Give the write path a CLI and an MCP face
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.
This commit is contained in:
1 parent
181f6bf57e
commit
3d792417cf
20 files changed
+1425
-33
No files matched your search
@@ -0,0 +1,84 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPostSendsJSONAndDecodesTheAnswer(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotBody map[string]any
|
||||
var gotType string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotType = r.Header.Get("Content-Type")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &gotBody)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"atom_id":"mem-1:text","holder_id":"wife"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var out struct {
|
||||
AtomID string `json:"atom_id"`
|
||||
HolderID string `json:"holder_id"`
|
||||
}
|
||||
client := New(server.URL)
|
||||
err := client.Post(context.Background(), "/api/memory", map[string]any{"text": "钥匙在玄关"}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("Post failed: %v", err)
|
||||
}
|
||||
if gotPath != "/api/memory" {
|
||||
t.Errorf("path = %q, want /api/memory", gotPath)
|
||||
}
|
||||
if gotType != "application/json" {
|
||||
t.Errorf("content type = %q, want application/json", gotType)
|
||||
}
|
||||
if gotBody["text"] != "钥匙在玄关" {
|
||||
t.Errorf("body = %v, want the statement to survive the trip", gotBody)
|
||||
}
|
||||
if out.AtomID != "mem-1:text" || out.HolderID != "wife" {
|
||||
t.Errorf("decoded = %+v, want the server's stamped atom", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedWriteCarriesTheServersReason(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = w.Write([]byte(`{"detail":"remember needs a holder; a memory nobody owns cannot be referred to"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := New(server.URL).Post(context.Background(), "/api/memory", map[string]any{}, &struct{}{})
|
||||
if err == nil {
|
||||
t.Fatal("a rejected write must not look like success")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "remember needs a holder") {
|
||||
t.Errorf("error = %v, want the rule the caller broke", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreachableAPIPointsAtTheServerThatShouldBeRunning(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
url := server.URL
|
||||
server.Close()
|
||||
|
||||
err := New(url).Post(context.Background(), "/api/memory", map[string]any{}, &struct{}{})
|
||||
if err == nil {
|
||||
t.Fatal("an unreachable API must fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ely-eye serve") {
|
||||
t.Errorf("error = %v, want the fix the operator can run", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefaultsToLocalServe(t *testing.T) {
|
||||
if got := New("").BaseURL; got != "http://127.0.0.1:8765" {
|
||||
t.Errorf("BaseURL = %q, want the default serve address", got)
|
||||
}
|
||||
if got := New("http://127.0.0.1:9000/").BaseURL; got != "http://127.0.0.1:9000" {
|
||||
t.Errorf("BaseURL = %q, want the trailing slash trimmed", got)
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,14 @@ type Config struct {
|
||||
ElyEyeHome string `json:"ely_eye_home"`
|
||||
Database string `json:"database"`
|
||||
Binary string `json:"binary"`
|
||||
APIBaseURL string `json:"api_base_url"`
|
||||
}
|
||||
|
||||
// Resolve locates the Ely-Eye data home and its SQLite database. The server is
|
||||
// a read-only mirror of any local .ely_eye store, so identity is anchored on the
|
||||
// database itself rather than on Ely-Eye repository layout. This lets the MCP
|
||||
// server be deployed for any project that owns an .ely_eye/data/ely_eye.sqlite.
|
||||
// Resolve locates the Ely-Eye data home and its SQLite database. Reads mirror
|
||||
// any local .ely_eye store directly, so identity is anchored on the database
|
||||
// itself rather than on Ely-Eye repository layout. This lets the MCP server be
|
||||
// deployed for any project that owns an .ely_eye/data/ely_eye.sqlite. Writes go
|
||||
// through the Ely-Eye API, which owns the memory rules.
|
||||
func Resolve(projectRoot string, elyEyeHome string, binary string) (Config, error) {
|
||||
home, err := resolveHome(projectRoot, elyEyeHome)
|
||||
if err != nil {
|
||||
@@ -48,9 +50,18 @@ func Resolve(projectRoot string, elyEyeHome string, binary string) (Config, erro
|
||||
ElyEyeHome: home,
|
||||
Database: db,
|
||||
Binary: binary,
|
||||
APIBaseURL: apiBaseURL(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// apiBaseURL points at the Ely-Eye API that owns every write rule.
|
||||
func apiBaseURL() string {
|
||||
if fromEnv := os.Getenv("ELY_EYE_API"); fromEnv != "" {
|
||||
return fromEnv
|
||||
}
|
||||
return "http://127.0.0.1:8765"
|
||||
}
|
||||
|
||||
func resolveHome(projectRoot string, elyEyeHome string) (string, error) {
|
||||
if elyEyeHome == "" {
|
||||
elyEyeHome = os.Getenv("ELY_EYE_HOME")
|
||||
|
||||
@@ -77,7 +77,9 @@ func summarizeAtom(atom domain.Atom, maxRunes int) domain.AtomSummary {
|
||||
SourceID: atom.SourceID,
|
||||
Modality: atom.Modality,
|
||||
Source: atom.Source,
|
||||
Time: atom.Time,
|
||||
IngestedAt: atom.IngestedAt,
|
||||
HolderID: atom.HolderID,
|
||||
Scenario: atom.Scenario,
|
||||
TextPreview: trimRunes(atom.Text, maxRunes),
|
||||
ImageRef: atom.ImageRef,
|
||||
TokenEquivalent: atom.TokenEquivalent,
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
package domain
|
||||
|
||||
// Memory mirrors the Evidence Atom the Ely-Eye API returns for a write. The
|
||||
// server stamps holder, origin and ingestion time, so the MCP side never
|
||||
// invents them.
|
||||
type Memory struct {
|
||||
AtomID string `json:"atom_id"`
|
||||
SourceID string `json:"source_id"`
|
||||
Source string `json:"source"`
|
||||
HolderID string `json:"holder_id"`
|
||||
Origin string `json:"origin"`
|
||||
Scenario string `json:"scenario"`
|
||||
Text string `json:"text"`
|
||||
IngestedAt string `json:"ingested_at"`
|
||||
EventTime string `json:"event_time,omitempty"`
|
||||
EventTimePrecision string `json:"event_time_precision,omitempty"`
|
||||
ValidFrom string `json:"valid_from,omitempty"`
|
||||
ValidTo string `json:"valid_to,omitempty"`
|
||||
RetentionUntil string `json:"retention_until,omitempty"`
|
||||
Supersedes []string `json:"supersedes"`
|
||||
TokenEquivalent int64 `json:"token_equivalent"`
|
||||
}
|
||||
|
||||
type ForgetReport struct {
|
||||
SourceID string `json:"source_id"`
|
||||
AtomIDs []string `json:"atom_ids"`
|
||||
AtomsDeleted int `json:"atoms_deleted"`
|
||||
EdgesDeleted int `json:"edges_deleted"`
|
||||
CacheEntriesCleared int `json:"cache_entries_cleared"`
|
||||
VectorIndexDropped bool `json:"vector_index_dropped"`
|
||||
AffectedCartridges []any `json:"affected_cartridges"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type CacheLayer struct {
|
||||
Layer string `json:"layer"`
|
||||
Entries int `json:"entries"`
|
||||
@@ -44,7 +76,11 @@ type Atom struct {
|
||||
SourceID string `json:"source_id"`
|
||||
Modality string `json:"modality"`
|
||||
Source string `json:"source"`
|
||||
Time string `json:"time"`
|
||||
IngestedAt string `json:"ingested_at"`
|
||||
HolderID string `json:"holder_id,omitempty"`
|
||||
Origin string `json:"origin,omitempty"`
|
||||
Scenario string `json:"scenario,omitempty"`
|
||||
EventTime string `json:"event_time,omitempty"`
|
||||
Text string `json:"text"`
|
||||
ImageRef string `json:"image_ref,omitempty"`
|
||||
Layout any `json:"layout,omitempty"`
|
||||
@@ -65,7 +101,9 @@ type AtomSummary struct {
|
||||
SourceID string `json:"source_id"`
|
||||
Modality string `json:"modality"`
|
||||
Source string `json:"source"`
|
||||
Time string `json:"time"`
|
||||
IngestedAt string `json:"ingested_at"`
|
||||
HolderID string `json:"holder_id,omitempty"`
|
||||
Scenario string `json:"scenario,omitempty"`
|
||||
TextPreview string `json:"text_preview"`
|
||||
ImageRef string `json:"image_ref,omitempty"`
|
||||
TokenEquivalent int64 `json:"token_equivalent"`
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/apiclient"
|
||||
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/config"
|
||||
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/store"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
store *store.Store
|
||||
api *apiclient.Client
|
||||
version string
|
||||
}
|
||||
|
||||
@@ -25,15 +27,16 @@ func Run(ctx context.Context, cfg config.Config, version string) error {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
server := &Server{cfg: cfg, store: db, version: version}
|
||||
server := &Server{cfg: cfg, store: db, api: apiclient.New(cfg.APIBaseURL), version: version}
|
||||
mcpServer := mcp.NewServer(&mcp.Implementation{
|
||||
Name: "ely-eye",
|
||||
Title: "Ely-Eye Context Cartridge MCP",
|
||||
Version: version,
|
||||
}, &mcp.ServerOptions{
|
||||
Instructions: "Use Ely-Eye tools to retrieve local cartridge context, evidence atoms, proof suites, and compiled evidence packs. Tools read local Ely-Eye state and do not mutate the production database.",
|
||||
Instructions: "Use Ely-Eye tools to retrieve local cartridge context, evidence atoms, proof suites, and compiled evidence packs. Read tools mirror local Ely-Eye state without writing. remember, supersede_memory and forget_memory mutate memory through the Ely-Eye API, which must be running (`ely-eye serve`); the server, not the caller, decides who owns a memory.",
|
||||
})
|
||||
server.registerTools(mcpServer)
|
||||
server.registerWriteTools(mcpServer)
|
||||
server.registerResources(mcpServer)
|
||||
if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil {
|
||||
if errors.Is(err, io.EOF) || strings.HasSuffix(err.Error(), "EOF") {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package mcpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
type RememberInput struct {
|
||||
Text string `json:"text" jsonschema:"the statement to remember, kept verbatim"`
|
||||
Scenario string `json:"scenario,omitempty" jsonschema:"optional context label such as home or work"`
|
||||
EventTime string `json:"event_time,omitempty" jsonschema:"when it was true, ISO 8601 with offset, e.g. 2026-05-01T12:00:00+08:00"`
|
||||
EventTimePrecision string `json:"event_time_precision,omitempty" jsonschema:"instant, minute, hour, day, month, or year; required with event_time"`
|
||||
RetentionUntil string `json:"retention_until,omitempty" jsonschema:"ISO 8601 with offset; the memory stops being retrievable after this"`
|
||||
}
|
||||
|
||||
type SupersedeInput struct {
|
||||
AtomID string `json:"atom_id" jsonschema:"exact Evidence Atom id being replaced"`
|
||||
Text string `json:"text" jsonschema:"the newer statement, kept verbatim"`
|
||||
Scenario string `json:"scenario,omitempty" jsonschema:"optional context label such as home or work"`
|
||||
EventTime string `json:"event_time,omitempty" jsonschema:"when the new statement became true, ISO 8601 with offset"`
|
||||
EventTimePrecision string `json:"event_time_precision,omitempty" jsonschema:"instant, minute, hour, day, month, or year; required with event_time"`
|
||||
}
|
||||
|
||||
type ForgetInput struct {
|
||||
AtomID string `json:"atom_id,omitempty" jsonschema:"exact Evidence Atom id to forget"`
|
||||
SourceID string `json:"source_id,omitempty" jsonschema:"exact source id to forget, removing every atom of that source"`
|
||||
Reason string `json:"reason,omitempty" jsonschema:"why this memory is gone"`
|
||||
}
|
||||
|
||||
type RememberOutput struct {
|
||||
Atom domain.Memory `json:"atom"`
|
||||
}
|
||||
|
||||
type ForgetOutput struct {
|
||||
Report domain.ForgetReport `json:"report"`
|
||||
}
|
||||
|
||||
func (s *Server) registerWriteTools(server *mcp.Server) {
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "remember",
|
||||
Title: "Remember",
|
||||
Description: "Store a new memory. The Ely-Eye server stamps the holder, origin and ingestion time, so a caller cannot claim to be somebody else.",
|
||||
}, s.rememberTool)
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "supersede_memory",
|
||||
Title: "Supersede a memory",
|
||||
Description: "Replace a memory with a newer statement. The old one stops being retrievable but keeps its record, so the timeline stays readable.",
|
||||
}, s.supersedeTool)
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "forget_memory",
|
||||
Title: "Forget a memory",
|
||||
Description: "Delete a memory by atom_id or source_id and drop the derived indexes that would keep surfacing it.",
|
||||
}, s.forgetTool)
|
||||
}
|
||||
|
||||
func (s *Server) rememberTool(ctx context.Context, _ *mcp.CallToolRequest, input RememberInput) (*mcp.CallToolResult, RememberOutput, error) {
|
||||
if input.Text == "" {
|
||||
return nil, RememberOutput{}, errors.New("text is required")
|
||||
}
|
||||
var atom domain.Memory
|
||||
if err := s.api.Post(ctx, "/api/memory", input, &atom); err != nil {
|
||||
return nil, RememberOutput{}, err
|
||||
}
|
||||
return nil, RememberOutput{Atom: atom}, nil
|
||||
}
|
||||
|
||||
func (s *Server) supersedeTool(ctx context.Context, _ *mcp.CallToolRequest, input SupersedeInput) (*mcp.CallToolResult, RememberOutput, error) {
|
||||
if input.AtomID == "" {
|
||||
return nil, RememberOutput{}, errors.New("atom_id is required")
|
||||
}
|
||||
if input.Text == "" {
|
||||
return nil, RememberOutput{}, errors.New("text is required")
|
||||
}
|
||||
var atom domain.Memory
|
||||
if err := s.api.Post(ctx, "/api/memory/supersede", input, &atom); err != nil {
|
||||
return nil, RememberOutput{}, err
|
||||
}
|
||||
return nil, RememberOutput{Atom: atom}, nil
|
||||
}
|
||||
|
||||
func (s *Server) forgetTool(ctx context.Context, _ *mcp.CallToolRequest, input ForgetInput) (*mcp.CallToolResult, ForgetOutput, error) {
|
||||
if (input.AtomID == "") == (input.SourceID == "") {
|
||||
return nil, ForgetOutput{}, errors.New("forget takes exactly one of atom_id or source_id")
|
||||
}
|
||||
var report domain.ForgetReport
|
||||
if err := s.api.Post(ctx, "/api/memory/forget", input, &report); err != nil {
|
||||
return nil, ForgetOutput{}, err
|
||||
}
|
||||
return nil, ForgetOutput{Report: report}, nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package mcpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/apiclient"
|
||||
)
|
||||
|
||||
func recordingServer(t *testing.T, status int, body string, seen *[]string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
payload, _ := io.ReadAll(r.Body)
|
||||
var decoded map[string]any
|
||||
_ = json.Unmarshal(payload, &decoded)
|
||||
*seen = append(*seen, r.URL.Path+" "+string(payload)+" "+func() string {
|
||||
if decoded["text"] == nil {
|
||||
return "<no text>"
|
||||
}
|
||||
return decoded["text"].(string)
|
||||
}())
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
}
|
||||
|
||||
func TestRememberSendsTheStatementAndReturnsTheStampedMemory(t *testing.T) {
|
||||
var seen []string
|
||||
server := recordingServer(t, http.StatusCreated,
|
||||
`{"atom_id":"mem-1:text","source_id":"mem-1","holder_id":"husband","origin":"self","text":"钥匙在玄关抽屉"}`, &seen)
|
||||
defer server.Close()
|
||||
|
||||
s := &Server{api: apiclient.New(server.URL)}
|
||||
_, out, err := s.rememberTool(context.Background(), nil, RememberInput{Text: "钥匙在玄关抽屉", Scenario: "home"})
|
||||
if err != nil {
|
||||
t.Fatalf("remember failed: %v", err)
|
||||
}
|
||||
if out.Atom.HolderID != "husband" || out.Atom.Origin != "self" {
|
||||
t.Errorf("stamped memory = %+v, want the server's holder and origin", out.Atom)
|
||||
}
|
||||
if len(seen) != 1 || !strings.Contains(seen[0], "/api/memory") {
|
||||
t.Fatalf("requests = %v, want one POST to /api/memory", seen)
|
||||
}
|
||||
if !strings.Contains(seen[0], "钥匙在玄关抽屉") {
|
||||
t.Errorf("request = %s, want the statement verbatim", seen[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberRefusesAnEmptyStatement(t *testing.T) {
|
||||
var seen []string
|
||||
server := recordingServer(t, http.StatusCreated, `{}`, &seen)
|
||||
defer server.Close()
|
||||
|
||||
if _, _, err := (&Server{api: apiclient.New(server.URL)}).rememberTool(context.Background(), nil, RememberInput{}); err == nil {
|
||||
t.Fatal("an empty memory must not be stored")
|
||||
}
|
||||
if len(seen) != 0 {
|
||||
t.Errorf("requests = %v, want the empty write rejected before the network", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupersedeNamesTheAtomItReplaces(t *testing.T) {
|
||||
var seen []string
|
||||
server := recordingServer(t, http.StatusOK,
|
||||
`{"atom_id":"mem-2:text","supersedes":["mem-1:text"],"valid_from":"2026-05-02T00:00:00+00:00"}`, &seen)
|
||||
defer server.Close()
|
||||
|
||||
s := &Server{api: apiclient.New(server.URL)}
|
||||
_, out, err := s.supersedeTool(context.Background(), nil, SupersedeInput{AtomID: "mem-1:text", Text: "钥匙在客厅茶几"})
|
||||
if err != nil {
|
||||
t.Fatalf("supersede failed: %v", err)
|
||||
}
|
||||
if len(out.Atom.Supersedes) != 1 || out.Atom.Supersedes[0] != "mem-1:text" {
|
||||
t.Errorf("supersedes = %v, want the replaced atom", out.Atom.Supersedes)
|
||||
}
|
||||
if !strings.Contains(seen[0], "/api/memory/supersede") {
|
||||
t.Errorf("request = %s, want the supersede route", seen[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupersedeRefusesAMissingAtomID(t *testing.T) {
|
||||
var seen []string
|
||||
server := recordingServer(t, http.StatusOK, `{}`, &seen)
|
||||
defer server.Close()
|
||||
|
||||
if _, _, err := (&Server{api: apiclient.New(server.URL)}).supersedeTool(context.Background(), nil, SupersedeInput{Text: "新的说法"}); err == nil {
|
||||
t.Fatal("supersede must name the atom it replaces")
|
||||
}
|
||||
if len(seen) != 0 {
|
||||
t.Errorf("requests = %v, want no write attempted", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgetTakesExactlyOneID(t *testing.T) {
|
||||
var seen []string
|
||||
server := recordingServer(t, http.StatusOK, `{"source_id":"mem-1","atoms_deleted":1}`, &seen)
|
||||
defer server.Close()
|
||||
|
||||
s := &Server{api: apiclient.New(server.URL)}
|
||||
for _, input := range []ForgetInput{{}, {AtomID: "a:text", SourceID: "b"}} {
|
||||
if _, _, err := s.forgetTool(context.Background(), nil, input); err == nil {
|
||||
t.Errorf("input %+v accepted, want exactly one id", input)
|
||||
}
|
||||
}
|
||||
_, out, err := s.forgetTool(context.Background(), nil, ForgetInput{SourceID: "mem-1", Reason: "moved house"})
|
||||
if err != nil {
|
||||
t.Fatalf("forget failed: %v", err)
|
||||
}
|
||||
if out.Report.AtomsDeleted != 1 {
|
||||
t.Errorf("report = %+v, want the deleted count back", out.Report)
|
||||
}
|
||||
if len(seen) != 1 || !strings.Contains(seen[0], "/api/memory/forget") {
|
||||
t.Errorf("requests = %v, want one POST to the forget route", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARejectedWriteReachesTheCallerAsAnError(t *testing.T) {
|
||||
var seen []string
|
||||
server := recordingServer(t, http.StatusUnprocessableEntity,
|
||||
`{"detail":"mem-1:text was already closed"}`, &seen)
|
||||
defer server.Close()
|
||||
|
||||
_, _, err := (&Server{api: apiclient.New(server.URL)}).supersedeTool(context.Background(), nil, SupersedeInput{AtomID: "mem-1:text", Text: "再说一次"})
|
||||
if err == nil {
|
||||
t.Fatal("a closed memory must not supersede silently")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already closed") {
|
||||
t.Errorf("error = %v, want the server's reason", err)
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,11 @@ func (s *Store) FetchAtom(ctx context.Context, atomID string) (domain.Atom, erro
|
||||
return domain.Atom{}, errors.New("atom_id is required")
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT atom_id, source_id, modality, source, time, text, COALESCE(image_ref, ''),
|
||||
SELECT atom_id, source_id, modality, source, ingested_at,
|
||||
COALESCE(holder_id, ''), COALESCE(origin, ''), COALESCE(scenario, ''),
|
||||
COALESCE(event_time, ''), text, COALESCE(image_ref, ''),
|
||||
COALESCE(layout_json, ''), relations_json, trust_json, token_equivalent, metadata_json
|
||||
FROM atoms
|
||||
FROM v_atoms_current
|
||||
WHERE atom_id = ?`, atomID)
|
||||
atom, err := scanAtom(row)
|
||||
if err != nil {
|
||||
@@ -48,12 +50,14 @@ func (s *Store) SearchAtoms(ctx context.Context, query string, modality string,
|
||||
}
|
||||
args = append(args, limit)
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT a.atom_id, a.source_id, a.modality, a.source, a.time, a.text,
|
||||
SELECT a.atom_id, a.source_id, a.modality, a.source, a.ingested_at,
|
||||
COALESCE(a.holder_id, ''), COALESCE(a.origin, ''), COALESCE(a.scenario, ''),
|
||||
COALESCE(a.event_time, ''), a.text,
|
||||
COALESCE(a.image_ref, ''), COALESCE(a.layout_json, ''),
|
||||
a.relations_json, a.trust_json, a.token_equivalent, a.metadata_json,
|
||||
bm25(atom_fts) AS score
|
||||
FROM atom_fts
|
||||
JOIN atoms a ON a.atom_id = atom_fts.atom_id
|
||||
JOIN v_atoms_current a ON a.atom_id = atom_fts.atom_id
|
||||
WHERE atom_fts MATCH ? `+filter+`
|
||||
ORDER BY score
|
||||
LIMIT ?`, args...)
|
||||
@@ -104,7 +108,11 @@ func scanAtom(scanner atomScanner) (domain.Atom, error) {
|
||||
&atom.SourceID,
|
||||
&atom.Modality,
|
||||
&atom.Source,
|
||||
&atom.Time,
|
||||
&atom.IngestedAt,
|
||||
&atom.HolderID,
|
||||
&atom.Origin,
|
||||
&atom.Scenario,
|
||||
&atom.EventTime,
|
||||
&atom.Text,
|
||||
&atom.ImageRef,
|
||||
&layout,
|
||||
@@ -147,7 +155,11 @@ func scanAtomHit(scanner atomScanner) (domain.Atom, float64, error) {
|
||||
&atom.SourceID,
|
||||
&atom.Modality,
|
||||
&atom.Source,
|
||||
&atom.Time,
|
||||
&atom.IngestedAt,
|
||||
&atom.HolderID,
|
||||
&atom.Origin,
|
||||
&atom.Scenario,
|
||||
&atom.EventTime,
|
||||
&atom.Text,
|
||||
&atom.ImageRef,
|
||||
&layout,
|
||||
|
||||
Reference in new issue
Block a user