Files
ely-eye/ely-eye-mcp/internal/mcpserver/mount_tools.go
T
ZacharyZhang-NY 9fe13fffb3 Make a cartridge a scope you mount, not only a bundle you export
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.
2026-10-07 16:40:27 -04:00

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
}