A cartridge used to be an export: materialize it, hash it, and never let it
change behaviour again. The transactive-memory promise needs the opposite —
mounting the home cartridge must make the work cartridge's memories
unfindable, and appending to a cartridge must make a new memory findable
through it.
Cartridge scope (schema v2 -> v3):
- cartridge_atoms (which atoms are in which cartridge) and cartridge_mounts
(which cartridges this machine has mounted now); both rebuilt on reopen,
because a mount is a machine-local state, not a fact about the data.
- mount / switch / unmount / append refuse a cartridge that was built for a
different runtime, naming every differing field instead of a boolean.
- append rewrites atoms.parquet and sparse_index.bm25.zst, recomputes DNA and
the KV fingerprint, and keeps the cartridges row in one SQLite transaction;
the files are written first so a failed export cannot leave a half-cartridge
in the database.
- RetrievalService.search() now ranks inside the mounted scope by default, and
an explicit cartridge_ids filter overrides the mount. Nothing mounted means
the whole library, so an empty store behaves as it did before.
- forget() removes the forgotten atoms from every cartridge's membership:
forgetting must not leave a memory retrievable through a cartridge.
KV fingerprint (kv_fingerprint.py, stored in the manifest):
- model / tokenizer / chat-template hashes, the RoPE block (factor,
original_max_position_embeddings, mrope_interleaved, mrope_section,
partial_rotary_factor), the adapter hash, the context length, the engine
version, and the fingerprint format version.
- A snapshot we cannot read is recorded as unverifiable and listed in
unverifiable_fields; a blind machine refuses to mount rather than pretending
the runtime matches, because a wrong RoPE factor silently corrupts retrieval.
Two defects found by running the new tests, not by reading:
- atoms.parquet could not export a memory at all: pyarrow refuses a struct with
no child field, so metadata={} failed the whole table. The six JSON columns
are now written as JSON text and decoded by the cartridge reader.
- materialize() inserted cartridge membership before the cartridges row existed,
so the foreign key rejected every cartridge.
api.py crossed 500 lines, so the cartridge and mount routes moved to
api_cartridges.py. That module must not use `from __future__ import
annotations`: FastAPI then resolves Depends(settings_dep) against module
globals, where the injected dependency is only a parameter of register(), and
every route starts demanding settings as a query parameter.
Verification: 138 pytest passed (26 new: 10 fingerprint, 16 scope), Go
build/vet/gofmt/test clean, and scripts/mcp_write_smoke.sh now proves the whole
loop across two processes - mount narrows search, a memory written after the
upload is invisible until append_to_cartridge, unmount restores the library,
and rewriting the RoPE factor makes mount_cartridge refuse with "rope: factor:
cartridge 4.0 vs local 2.0". ruff clean on every touched file (the 20 remaining
package errors are in files this change did not touch; HEAD had 25). mypy: one
error, the pre-existing np.savez stub artifact at cartridge_assets.py:329
(HEAD:320). Repo pyright: 62 errors, unchanged from the p0-3 ledger.
112 lines
4.4 KiB
Go
112 lines
4.4 KiB
Go
package mcpserver
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
type CartridgeIDInput struct {
|
|
CartridgeID string `json:"cartridge_id" jsonschema:"exact cartridge id"`
|
|
}
|
|
|
|
type AppendInput struct {
|
|
CartridgeID string `json:"cartridge_id" jsonschema:"exact cartridge id"`
|
|
AtomIDs []string `json:"atom_ids" jsonschema:"atom ids to add to this cartridge"`
|
|
}
|
|
|
|
type MountOutput struct {
|
|
Manifest domain.CartridgeManifest `json:"manifest"`
|
|
}
|
|
|
|
type MountStateOutput struct {
|
|
State domain.MountState `json:"state"`
|
|
}
|
|
|
|
// registerMountTools exposes the cartridge as a retrieval scope: the point of a
|
|
// cartridge is that the model stops hunting the whole library and asks one cartridge.
|
|
func (s *Server) registerMountTools(server *mcp.Server) {
|
|
mcp.AddTool(server, &mcp.Tool{
|
|
Name: "mounted_cartridges",
|
|
Title: "Mounted cartridges",
|
|
Description: "Report which cartridges are mounted. A mounted cartridge narrows every later search; nothing mounted means the whole library is searchable.",
|
|
}, s.mountsTool)
|
|
mcp.AddTool(server, &mcp.Tool{
|
|
Name: "mount_cartridge",
|
|
Title: "Mount a cartridge",
|
|
Description: "Add one cartridge to the mounted set. The server refuses a cartridge whose KV fingerprint does not match this machine, because mismatched RoPE or quantization settings attend to wrong positions instead of failing.",
|
|
}, s.mountTool)
|
|
mcp.AddTool(server, &mcp.Tool{
|
|
Name: "switch_cartridge",
|
|
Title: "Switch to one cartridge",
|
|
Description: "Mount one cartridge and nothing else.",
|
|
}, s.switchTool)
|
|
mcp.AddTool(server, &mcp.Tool{
|
|
Name: "unmount_cartridge",
|
|
Title: "Unmount a cartridge",
|
|
Description: "Remove one cartridge from the mounted set and report what is left.",
|
|
}, s.unmountTool)
|
|
mcp.AddTool(server, &mcp.Tool{
|
|
Name: "append_to_cartridge",
|
|
Title: "Append memories to a cartridge",
|
|
Description: "Add atoms to an existing cartridge. The bundle and its DNA are rewritten, so the cartridge id keeps describing the bytes it holds.",
|
|
}, s.appendTool)
|
|
}
|
|
|
|
func (s *Server) mountsTool(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, MountStateOutput, error) {
|
|
var state domain.MountState
|
|
if err := s.api.Get(ctx, "/api/mounts", &state); err != nil {
|
|
return nil, MountStateOutput{}, err
|
|
}
|
|
return nil, MountStateOutput{State: state}, nil
|
|
}
|
|
|
|
func (s *Server) mountTool(ctx context.Context, _ *mcp.CallToolRequest, input CartridgeIDInput) (*mcp.CallToolResult, MountOutput, error) {
|
|
if input.CartridgeID == "" {
|
|
return nil, MountOutput{}, errors.New("cartridge_id is required")
|
|
}
|
|
var manifest domain.CartridgeManifest
|
|
if err := s.api.Post(ctx, "/api/cartridges/"+input.CartridgeID+"/mount", struct{}{}, &manifest); err != nil {
|
|
return nil, MountOutput{}, err
|
|
}
|
|
return nil, MountOutput{Manifest: manifest}, nil
|
|
}
|
|
|
|
func (s *Server) switchTool(ctx context.Context, _ *mcp.CallToolRequest, input CartridgeIDInput) (*mcp.CallToolResult, MountOutput, error) {
|
|
if input.CartridgeID == "" {
|
|
return nil, MountOutput{}, errors.New("cartridge_id is required")
|
|
}
|
|
var manifest domain.CartridgeManifest
|
|
if err := s.api.Post(ctx, "/api/cartridges/"+input.CartridgeID+"/switch", struct{}{}, &manifest); err != nil {
|
|
return nil, MountOutput{}, err
|
|
}
|
|
return nil, MountOutput{Manifest: manifest}, nil
|
|
}
|
|
|
|
func (s *Server) unmountTool(ctx context.Context, _ *mcp.CallToolRequest, input CartridgeIDInput) (*mcp.CallToolResult, MountStateOutput, error) {
|
|
if input.CartridgeID == "" {
|
|
return nil, MountStateOutput{}, errors.New("cartridge_id is required")
|
|
}
|
|
var state domain.MountState
|
|
if err := s.api.Post(ctx, "/api/cartridges/"+input.CartridgeID+"/unmount", struct{}{}, &state); err != nil {
|
|
return nil, MountStateOutput{}, err
|
|
}
|
|
return nil, MountStateOutput{State: state}, nil
|
|
}
|
|
|
|
func (s *Server) appendTool(ctx context.Context, _ *mcp.CallToolRequest, input AppendInput) (*mcp.CallToolResult, MountOutput, error) {
|
|
if input.CartridgeID == "" {
|
|
return nil, MountOutput{}, errors.New("cartridge_id is required")
|
|
}
|
|
if len(input.AtomIDs) == 0 {
|
|
return nil, MountOutput{}, errors.New("atom_ids needs at least one atom")
|
|
}
|
|
var manifest domain.CartridgeManifest
|
|
if err := s.api.Post(ctx, "/api/cartridges/"+input.CartridgeID+"/append", input, &manifest); err != nil {
|
|
return nil, MountOutput{}, err
|
|
}
|
|
return nil, MountOutput{Manifest: manifest}, nil
|
|
}
|