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.
147 lines
4.3 KiB
Go
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
|
|
}
|