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.
213 lines
5.0 KiB
Go
213 lines
5.0 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"math"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain"
|
|
)
|
|
|
|
var tokenPattern = regexp.MustCompile(`[\p{L}\p{N}_]+`)
|
|
|
|
func (s *Store) FetchAtom(ctx context.Context, atomID string) (domain.Atom, error) {
|
|
if strings.TrimSpace(atomID) == "" {
|
|
return domain.Atom{}, errors.New("atom_id is required")
|
|
}
|
|
row := s.db.QueryRowContext(ctx, `
|
|
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 v_atoms_current
|
|
WHERE atom_id = ?`, atomID)
|
|
atom, err := scanAtom(row)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return domain.Atom{}, errors.New("atom not found")
|
|
}
|
|
return domain.Atom{}, err
|
|
}
|
|
return atom, nil
|
|
}
|
|
|
|
func (s *Store) SearchAtoms(ctx context.Context, query string, modality string, limit int) ([]domain.AtomHit, error) {
|
|
matchQuery, err := ftsQuery(query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 24
|
|
}
|
|
args := []any{matchQuery}
|
|
filter := ""
|
|
if modality != "" {
|
|
filter = "AND a.modality = ?"
|
|
args = append(args, modality)
|
|
}
|
|
args = append(args, limit)
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
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 v_atoms_current a ON a.atom_id = atom_fts.atom_id
|
|
WHERE atom_fts MATCH ? `+filter+`
|
|
ORDER BY score
|
|
LIMIT ?`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var hits []domain.AtomHit
|
|
var bestScore float64
|
|
for rows.Next() {
|
|
atom, score, err := scanAtomHit(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sparseScore := -score
|
|
if math.IsNaN(sparseScore) || math.IsInf(sparseScore, 0) {
|
|
sparseScore = 0
|
|
}
|
|
if sparseScore > bestScore {
|
|
bestScore = sparseScore
|
|
}
|
|
hits = append(hits, domain.AtomHit{Atom: atom, SparseScore: sparseScore, FinalScore: sparseScore})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if bestScore > 0 {
|
|
for index := range hits {
|
|
hits[index].SparseScore = hits[index].SparseScore / bestScore
|
|
hits[index].FinalScore = hits[index].SparseScore
|
|
}
|
|
}
|
|
return hits, nil
|
|
}
|
|
|
|
type atomScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanAtom(scanner atomScanner) (domain.Atom, error) {
|
|
var atom domain.Atom
|
|
var layout string
|
|
var relations string
|
|
var trust string
|
|
var metadata string
|
|
if err := scanner.Scan(
|
|
&atom.AtomID,
|
|
&atom.SourceID,
|
|
&atom.Modality,
|
|
&atom.Source,
|
|
&atom.IngestedAt,
|
|
&atom.HolderID,
|
|
&atom.Origin,
|
|
&atom.Scenario,
|
|
&atom.EventTime,
|
|
&atom.Text,
|
|
&atom.ImageRef,
|
|
&layout,
|
|
&relations,
|
|
&trust,
|
|
&atom.TokenEquivalent,
|
|
&metadata,
|
|
); err != nil {
|
|
return domain.Atom{}, err
|
|
}
|
|
var err error
|
|
atom.Layout, err = optionalJSONValue(layout)
|
|
if err != nil {
|
|
return domain.Atom{}, err
|
|
}
|
|
atom.Relations, err = requiredJSONValue(relations)
|
|
if err != nil {
|
|
return domain.Atom{}, err
|
|
}
|
|
atom.Trust, err = requiredJSONValue(trust)
|
|
if err != nil {
|
|
return domain.Atom{}, err
|
|
}
|
|
atom.Metadata, err = requiredJSONValue(metadata)
|
|
if err != nil {
|
|
return domain.Atom{}, err
|
|
}
|
|
return atom, nil
|
|
}
|
|
|
|
func scanAtomHit(scanner atomScanner) (domain.Atom, float64, error) {
|
|
var atom domain.Atom
|
|
var layout string
|
|
var relations string
|
|
var trust string
|
|
var metadata string
|
|
var score float64
|
|
if err := scanner.Scan(
|
|
&atom.AtomID,
|
|
&atom.SourceID,
|
|
&atom.Modality,
|
|
&atom.Source,
|
|
&atom.IngestedAt,
|
|
&atom.HolderID,
|
|
&atom.Origin,
|
|
&atom.Scenario,
|
|
&atom.EventTime,
|
|
&atom.Text,
|
|
&atom.ImageRef,
|
|
&layout,
|
|
&relations,
|
|
&trust,
|
|
&atom.TokenEquivalent,
|
|
&metadata,
|
|
&score,
|
|
); err != nil {
|
|
return domain.Atom{}, 0, err
|
|
}
|
|
var err error
|
|
atom.Layout, err = optionalJSONValue(layout)
|
|
if err != nil {
|
|
return domain.Atom{}, 0, err
|
|
}
|
|
atom.Relations, err = requiredJSONValue(relations)
|
|
if err != nil {
|
|
return domain.Atom{}, 0, err
|
|
}
|
|
atom.Trust, err = requiredJSONValue(trust)
|
|
if err != nil {
|
|
return domain.Atom{}, 0, err
|
|
}
|
|
atom.Metadata, err = requiredJSONValue(metadata)
|
|
if err != nil {
|
|
return domain.Atom{}, 0, err
|
|
}
|
|
return atom, score, nil
|
|
}
|
|
|
|
func ftsQuery(query string) (string, error) {
|
|
tokens := tokenPattern.FindAllString(strings.ToLower(query), -1)
|
|
if len(tokens) == 0 {
|
|
return "", errors.New("query must contain searchable text")
|
|
}
|
|
unique := make([]string, 0, len(tokens))
|
|
seen := map[string]bool{}
|
|
for _, token := range tokens {
|
|
if seen[token] {
|
|
continue
|
|
}
|
|
seen[token] = true
|
|
unique = append(unique, `"`+strings.ReplaceAll(token, `"`, `""`)+`"`)
|
|
if len(unique) == 16 {
|
|
break
|
|
}
|
|
}
|
|
return strings.Join(unique, " OR "), nil
|
|
}
|