remember / supersede / forget now reach the one Python writer from a terminal, from MCP, and from HTTP. The server, not the caller, decides who owns a memory. - cli: remember, supersede, forget. --holder falls back to settings.default_holder until the holder registry lands. Timestamps are parsed once with one clear message. - ingestion: import pymupdf as fitz. The deprecated fitz import printed a warning to stdout, so every CLI command's JSON answer was unparseable. - mcp: remember, supersede_memory and forget_memory forward to the Python writer over HTTP (ELY_EYE_API, default http://127.0.0.1:8765). SQLite stays read-only to the Go side, so the write rules live in one place. - mcp reads follow schema v2: atoms.time became ingested_at, and reads go through v_atoms_current, so a superseded memory stops being findable. holder, origin, scenario and event_time now show up in fetch_atom and search_atoms. - scripts/mcp_write_smoke.sh proves the loop end to end: remember, search, supersede, forget, then not found. Evidence: 92 pytest passed; go build, go vet and go test clean; mypy clean on the 10 touched source files with a fresh cache; ruff clean except a pre-existing B008 at cli.py:292, identical at HEAD.
85 lines
2.4 KiB
Go
85 lines
2.4 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"
|
|
)
|
|
|
|
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},
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|