Files
ely-eye/ely-eye-mcp/internal/apiclient/client.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

147 lines
4.3 KiB
Go

// Package apiclient talks to a running Ely-Eye API server. The Python service
// owns every write rule, so the MCP server forwards mutations instead of
// duplicating them against SQLite.
package apiclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain"
)
const maxErrorBody = 4096
type Client struct {
BaseURL string
HTTP *http.Client
}
func New(baseURL string) *Client {
if strings.TrimSpace(baseURL) == "" {
baseURL = "http://127.0.0.1:8765"
}
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// Get fetches a JSON resource into out.
func (c *Client) Get(ctx context.Context, path string, out any) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return fmt.Errorf("cannot build the request for %s: %w", path, err)
}
return c.do(request, path, out)
}
func (c *Client) do(request *http.Request, path string, out any) error {
response, err := c.HTTP.Do(request)
if err != nil {
return fmt.Errorf("the Ely-Eye API at %s is unreachable; start it with `ely-eye serve`: %w", c.BaseURL, err)
}
defer response.Body.Close()
data, err := io.ReadAll(io.LimitReader(response.Body, maxErrorBody))
if err != nil {
return fmt.Errorf("%s returned an unreadable body: %w", path, err)
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return errors.New(path + " was rejected: " + serverReason(response.StatusCode, data))
}
if out == nil {
return nil
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("%s returned JSON that does not decode: %w", path, err)
}
return nil
}
// Post sends body as JSON and decodes the response into out. A rejected request
// carries the server's own reason, which is the rule the caller broke.
func (c *Client) Post(ctx context.Context, path string, body any, out any) error {
payload, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("cannot encode the request for %s: %w", path, err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("cannot build the request for %s: %w", path, err)
}
request.Header.Set("Content-Type", "application/json")
return c.do(request, path, out)
}
// serverReason prefers FastAPI's {"detail": "..."} so the caller sees the broken
// rule instead of an HTML error page.
func serverReason(status int, data []byte) string {
var envelope struct {
Detail string `json:"detail"`
}
if err := json.Unmarshal(data, &envelope); err == nil && strings.TrimSpace(envelope.Detail) != "" {
return envelope.Detail
}
trimmed := strings.TrimSpace(string(data))
if trimmed == "" {
return fmt.Sprintf("HTTP %d with no reason", status)
}
return trimmed
}
// SearchOptions is one ranked query. Empty optional fields are omitted, so the
// server's own defaults decide rather than a zero value invented here.
type SearchOptions struct {
Query string
Modality string
Holder string
Scenario string
Limit int
}
// SearchResult carries the server's own verdict: "no_match" is a claim about the
// corpus, an empty hit list is not.
type SearchResult struct {
Status string `json:"status"`
Hits []domain.AtomHit `json:"hits"`
NextCursor string `json:"next_cursor"`
}
// Search asks the running server to rank. The ranking lives in one place, so an
// MCP hit cannot disagree with an HTTP or CLI hit for the same query.
func (c *Client) Search(ctx context.Context, options SearchOptions) (*SearchResult, error) {
if strings.TrimSpace(options.Query) == "" {
return nil, errors.New("query is required")
}
body := map[string]any{"query": options.Query}
if options.Limit > 0 {
body["top_k"] = options.Limit
}
filters := map[string]string{}
for key, value := range map[string]string{
"modality": options.Modality,
"holder": options.Holder,
"scenario": options.Scenario,
} {
if value != "" {
filters[key] = value
}
}
if len(filters) > 0 {
body["filters"] = filters
}
var result SearchResult
if err := c.Post(ctx, "/api/search", body, &result); err != nil {
return nil, err
}
return &result, nil
}