first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
package memory
import (
"testing"
)
// TestCountByScopeEmpty returns an empty map for a fresh store with no entries.
func TestCountByScopeEmpty(t *testing.T) {
st := openTemp(t)
counts, err := st.CountByScope()
if err != nil {
t.Fatalf("CountByScope: %v", err)
}
if len(counts) != 0 {
t.Fatalf("counts = %v, want empty", counts)
}
}
// TestCountByScopeGroups reconciles files across scopes and asserts the counts
// are grouped per scope.
func TestCountByScopeGroups(t *testing.T) {
st, root, _ := openTempWithRoots(t)
writeFile(t, root, "g1", "global", "reference", "a.md")
writeFile(t, root, "g2", "global", "notes", "b.md")
writeFile(t, root, "p1", "projects", "proj1", "project", "m.md")
if _, err := st.Reconcile(); err != nil {
t.Fatalf("Reconcile: %v", err)
}
counts, err := st.CountByScope()
if err != nil {
t.Fatalf("CountByScope: %v", err)
}
if counts[ScopeGlobal] != 2 {
t.Fatalf("global count = %d, want 2 (counts=%v)", counts[ScopeGlobal], counts)
}
if counts[ScopeProjects] != 1 {
t.Fatalf("projects count = %d, want 1 (counts=%v)", counts[ScopeProjects], counts)
}
if _, ok := counts[ScopeSessions]; ok {
t.Fatalf("sessions should be absent, got %d", counts[ScopeSessions])
}
}
// TestCountByScopeNilStore is safe on a nil store and yields an empty map.
func TestCountByScopeNilStore(t *testing.T) {
var st *Store
counts, err := st.CountByScope()
if err != nil {
t.Fatalf("CountByScope on nil: %v", err)
}
if len(counts) != 0 {
t.Fatalf("counts = %v, want empty", counts)
}
}
+62
View File
@@ -0,0 +1,62 @@
package memory
import "regexp"
// ftsTokenRe matches contiguous runs of Unicode letters (incl. CJK), numbers,
// and underscore. Everything else — whitespace, punctuation, FTS5 operator
// characters — is treated as a separator. \p{L} deliberately includes CJK
// letters so queries like "配置文件" tokenize into a single searchable run.
var ftsTokenRe = regexp.MustCompile(`[\p{L}\p{N}_]+`)
// buildFtsQuery builds an FTS5 MATCH expression from a free-form user query.
//
// FTS5's MATCH grammar has its own operators and special characters
// (`"`, `(`, `)`, `*`, `:`, `^`, `-`, `.`, `{`, `}`). Passing a raw user string
// containing any of these crashes the parser. We tokenize on non-word runs,
// wrap each token in phrase quotes (which turn it into a literal-word search
// that ignores FTS5 special chars), and OR-join.
//
// OR (not AND): AND-join requires EVERY query word to appear in a document, so
// a single descriptive word the user added that is absent from the stored text
// zeroes the whole query even when most tokens match. OR lets BM25 rank by how
// many / how rare the matched tokens are; the caller applies a relative score
// floor to drop common-word-only noise (see Store.Search).
//
// Returns "" when no usable tokens are extracted. Callers treat that as "empty
// query, no results" and send no SQL.
func buildFtsQuery(raw string) string {
matches := ftsTokenRe.FindAllString(raw, -1)
if len(matches) == 0 {
return ""
}
quoted := make([]string, 0, len(matches))
for _, tok := range matches {
// Strip any embedded double quotes, then wrap the token as a phrase.
stripped := removeQuotes(tok)
if stripped == "" {
continue
}
quoted = append(quoted, `"`+stripped+`"`)
}
if len(quoted) == 0 {
return ""
}
out := quoted[0]
for _, q := range quoted[1:] {
out += " OR " + q
}
return out
}
// removeQuotes strips every double-quote character from s.
func removeQuotes(s string) string {
out := make([]rune, 0, len(s))
for _, r := range s {
if r != '"' {
out = append(out, r)
}
}
return string(out)
}
+55
View File
@@ -0,0 +1,55 @@
package memory
import "testing"
func TestBuildFtsQueryMultiWordOrJoin(t *testing.T) {
got := buildFtsQuery("permission deadlock retry")
want := `"permission" OR "deadlock" OR "retry"`
if got != want {
t.Fatalf("buildFtsQuery multi-word: got %q want %q", got, want)
}
}
func TestBuildFtsQuerySingleToken(t *testing.T) {
if got := buildFtsQuery("checkpoint"); got != `"checkpoint"` {
t.Fatalf("buildFtsQuery single: got %q", got)
}
}
func TestBuildFtsQueryCJKTokensKept(t *testing.T) {
// CJK letters are \p{L} and must survive tokenization. Whitespace splits
// them into separate tokens; punctuation is a separator.
got := buildFtsQuery("配置文件 端口")
want := `"配置文件" OR "端口"`
if got != want {
t.Fatalf("buildFtsQuery CJK: got %q want %q", got, want)
}
}
func TestBuildFtsQueryPunctuationStripped(t *testing.T) {
// FTS5 special chars and punctuation become separators; underscores and
// digits are word characters.
got := buildFtsQuery(`port: 5433 (postgres-db) foo_bar`)
want := `"port" OR "5433" OR "postgres" OR "db" OR "foo_bar"`
if got != want {
t.Fatalf("buildFtsQuery punctuation: got %q want %q", got, want)
}
}
func TestBuildFtsQueryStripsEmbeddedQuotes(t *testing.T) {
// A token can only contain word chars, so a raw double-quote is a
// separator; but guard the strip explicitly.
got := buildFtsQuery(`say "hello"`)
want := `"say" OR "hello"`
if got != want {
t.Fatalf("buildFtsQuery quotes: got %q want %q", got, want)
}
}
func TestBuildFtsQueryEmptyAndWhitespace(t *testing.T) {
for _, in := range []string{"", " ", "\t\n", "!!! ??? ... ---", "()[]{}"} {
if got := buildFtsQuery(in); got != "" {
t.Fatalf("buildFtsQuery(%q): got %q want empty", in, got)
}
}
}
+233
View File
@@ -0,0 +1,233 @@
package memory
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Locator is the parsed identity of a memory file on disk: which scope it
// belongs to, the scope id (project-id / session-id / slug; "" for global), the
// semantic type, and the absolute path of the file itself.
type Locator struct {
Scope Scope
ScopeID string
Type Type
Path string
}
// knownTypes maps a layout <type> directory segment (or a frontmatter
// metadata.type value) to its Type. Anything not present here resolves to
// TypeFree.
var knownTypes = map[string]Type{
string(TypeUser): TypeUser,
string(TypeFeedback): TypeFeedback,
string(TypeProject): TypeProject,
string(TypeReference): TypeReference,
string(TypeCheckpoint): TypeCheckpoint,
string(TypeProgress): TypeProgress,
string(TypeNotes): TypeNotes,
string(TypeFree): TypeFree,
}
// scopeForMarker maps a top-level layout directory name to its Scope.
var scopeForMarker = map[string]Scope{
"global": ScopeGlobal,
"projects": ScopeProjects,
"sessions": ScopeSessions,
}
// typeFromDir maps a <type> directory segment to a Type, defaulting to TypeFree
// for unknown segments.
func typeFromDir(seg string) Type {
if t, ok := knownTypes[strings.ToLower(seg)]; ok {
return t
}
return TypeFree
}
// parsePath parses an absolute path in the mimo memory layout, relative to root,
// into a Locator. Recognized shapes:
//
// <root>/global/<type>/*.md -> ScopeGlobal, ScopeID="", Type from <type>
// <root>/projects/<projectId>/<type>/*.md -> ScopeProjects, ScopeID=projectId, Type from <type>
// <root>/sessions/<sessionId>/<type>/*.md -> ScopeSessions, ScopeID=sessionId, Type from <type>
//
// A .md file directly under a scope dir (e.g. <root>/projects/<id>/MEMORY.md, or
// <root>/global/MEMORY.md) has no <type> segment and resolves to TypeFree.
// Unknown <type> segments also map to TypeFree. It returns (nil, false) for any
// path outside root or outside the layout.
func parsePath(root, absPath string) (*Locator, bool) {
rel, ok := relComponents(root, absPath)
if !ok || len(rel) == 0 {
return nil, false
}
scope, ok := scopeForMarker[rel[0]]
if !ok {
return nil, false
}
// rest is everything below the top-level scope marker.
rest := rel[1:]
var scopeID string
switch scope {
case ScopeGlobal:
// rest = [<type>/]<file>.md
case ScopeProjects, ScopeSessions:
// rest = <scopeId>/[<type>/]<file>.md
if len(rest) == 0 {
return nil, false
}
scopeID = rest[0]
rest = rest[1:]
default:
return nil, false
}
// A file component is mandatory and must be a Markdown file.
if len(rest) == 0 || !isMarkdown(rest[len(rest)-1]) {
return nil, false
}
typ := TypeFree
if len(rest) >= 2 {
// rest = <type>/.../<file>.md — the first segment names the type.
typ = typeFromDir(rest[0])
}
return &Locator{
Scope: scope,
ScopeID: scopeID,
Type: typ,
Path: filepath.Clean(absPath),
}, true
}
// parseCcPath parses a Claude Code layout path relative to ccBase:
//
// <ccBase>/<slug>/memory/**/*.md -> ScopeCC, ScopeID=<slug>, Type=TypeFree
//
// The real type is derived later from the file's YAML frontmatter via
// parseCcFrontmatterType. It returns (nil, false) for paths outside the layout.
func parseCcPath(ccBase, absPath string) (*Locator, bool) {
rel, ok := relComponents(ccBase, absPath)
if !ok {
return nil, false
}
// Need at least <slug>/memory/<file>.md.
if len(rel) < 3 || rel[1] != "memory" {
return nil, false
}
if !isMarkdown(rel[len(rel)-1]) {
return nil, false
}
return &Locator{
Scope: ScopeCC,
ScopeID: rel[0],
Type: TypeFree,
Path: filepath.Clean(absPath),
}, true
}
// ccFrontmatter is the minimal shape read from a Claude Code memory file's
// leading YAML frontmatter: either a nested metadata.type or a top-level type.
type ccFrontmatter struct {
Type string `yaml:"type"`
Metadata struct {
Type string `yaml:"type"`
} `yaml:"metadata"`
}
// parseCcFrontmatterType reads the semantic type from a leading YAML frontmatter
// block (a "---" fence at the very start of body). It prefers metadata.type,
// falling back to a top-level type. Absent, malformed, or unrecognized values
// yield TypeFree.
func parseCcFrontmatterType(body string) Type {
block, ok := frontmatterBlock(body)
if !ok {
return TypeFree
}
var fm ccFrontmatter
if err := yaml.Unmarshal([]byte(block), &fm); err != nil {
return TypeFree
}
candidate := fm.Metadata.Type
if candidate == "" {
candidate = fm.Type
}
if t, ok := knownTypes[strings.ToLower(strings.TrimSpace(candidate))]; ok {
return t
}
return TypeFree
}
// resolveProjectId derives a stable project id from an absolute repository path:
// the first 12 hex characters of sha256(absRepoPath). It is deterministic and is
// used as the scope_id for the projects scope.
func resolveProjectId(absRepoPath string) string {
sum := sha256.Sum256([]byte(absRepoPath))
return hex.EncodeToString(sum[:])[:12]
}
// assertSafeComponent rejects a caller-supplied path component that would escape
// the memory root: any ".." segment or a leading "/" (absolute path). Empty
// components are also rejected. The write path uses this to sanitize
// scope_id/type/filename before joining them under root.
func assertSafeComponent(name string) error {
if name == "" {
return fmt.Errorf("memory: empty path component")
}
if strings.HasPrefix(name, "/") {
return fmt.Errorf("memory: unsafe path component %q: leading %q", name, "/")
}
for _, seg := range strings.Split(filepath.ToSlash(name), "/") {
if seg == ".." {
return fmt.Errorf("memory: unsafe path component %q: %q segment", name, "..")
}
}
return nil
}
// relComponents returns absPath expressed relative to base, split into
// non-empty components. ok is false when absPath is not located under base.
func relComponents(base, absPath string) (parts []string, ok bool) {
rel, err := filepath.Rel(filepath.Clean(base), filepath.Clean(absPath))
if err != nil {
return nil, false
}
rel = filepath.ToSlash(rel)
if rel == "." || rel == "" {
return nil, false
}
// Escaping base (e.g. "../foo") means the path is outside the layout.
if rel == ".." || strings.HasPrefix(rel, "../") {
return nil, false
}
return strings.Split(rel, "/"), true
}
// isMarkdown reports whether name has a .md extension (case-insensitive).
func isMarkdown(name string) bool {
return strings.EqualFold(filepath.Ext(name), ".md")
}
// frontmatterBlock returns the raw YAML between a leading "---" fence and the
// next "---" line. ok is false when body has no opening fence at its very start.
func frontmatterBlock(body string) (string, bool) {
rest := strings.ReplaceAll(body, "\r\n", "\n")
if !strings.HasPrefix(rest, "---\n") {
return "", false
}
rest = strings.TrimPrefix(rest, "---\n")
end := strings.Index(rest, "\n---")
if end < 0 {
return "", false
}
return rest[:end], true
}
+203
View File
@@ -0,0 +1,203 @@
package memory
import (
"path/filepath"
"testing"
)
func TestParsePathScopesAndTypes(t *testing.T) {
root := "/mem/root"
cases := []struct {
name string
path string
scope Scope
scopeID string
typ Type
}{
{
name: "global with type dir",
path: filepath.Join(root, "global", "user", "profile.md"),
scope: ScopeGlobal, scopeID: "", typ: TypeUser,
},
{
name: "global unknown type dir -> free",
path: filepath.Join(root, "global", "whatever", "x.md"),
scope: ScopeGlobal, scopeID: "", typ: TypeFree,
},
{
name: "global file directly under scope -> free",
path: filepath.Join(root, "global", "MEMORY.md"),
scope: ScopeGlobal, scopeID: "", typ: TypeFree,
},
{
name: "projects with type dir",
path: filepath.Join(root, "projects", "abc123", "checkpoint", "c1.md"),
scope: ScopeProjects, scopeID: "abc123", typ: TypeCheckpoint,
},
{
name: "projects file directly under id -> free",
path: filepath.Join(root, "projects", "abc123", "MEMORY.md"),
scope: ScopeProjects, scopeID: "abc123", typ: TypeFree,
},
{
name: "sessions with type dir",
path: filepath.Join(root, "sessions", "sess-1", "notes", "n.md"),
scope: ScopeSessions, scopeID: "sess-1", typ: TypeNotes,
},
{
name: "sessions progress type",
path: filepath.Join(root, "sessions", "sess-1", "progress", "p.md"),
scope: ScopeSessions, scopeID: "sess-1", typ: TypeProgress,
},
{
name: "nested file under type dir keeps type",
path: filepath.Join(root, "projects", "abc123", "reference", "sub", "r.md"),
scope: ScopeProjects, scopeID: "abc123", typ: TypeReference,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
loc, ok := parsePath(root, tc.path)
if !ok {
t.Fatalf("parsePath(%q) returned ok=false", tc.path)
}
if loc.Scope != tc.scope {
t.Errorf("scope = %q, want %q", loc.Scope, tc.scope)
}
if loc.ScopeID != tc.scopeID {
t.Errorf("scopeID = %q, want %q", loc.ScopeID, tc.scopeID)
}
if loc.Type != tc.typ {
t.Errorf("type = %q, want %q", loc.Type, tc.typ)
}
if loc.Path != filepath.Clean(tc.path) {
t.Errorf("path = %q, want %q", loc.Path, filepath.Clean(tc.path))
}
})
}
}
func TestParsePathOutsideLayout(t *testing.T) {
root := "/mem/root"
bad := []string{
"/other/place/x.md", // outside root
filepath.Join(root, "unknownscope", "x.md"), // not a layout scope
filepath.Join(root, "global"), // no file component
filepath.Join(root, "projects", "abc123"), // scope id dir, no file
filepath.Join(root, "global", "user", "x.txt"), // not markdown
}
for _, p := range bad {
if loc, ok := parsePath(root, p); ok {
t.Errorf("parsePath(%q) = %+v, ok=true; want ok=false", p, loc)
}
}
}
func TestParseCcPath(t *testing.T) {
base := "/home/u/.claude/projects"
loc, ok := parseCcPath(base, filepath.Join(base, "my-slug", "memory", "some", "note.md"))
if !ok {
t.Fatalf("parseCcPath returned ok=false")
}
if loc.Scope != ScopeCC {
t.Errorf("scope = %q, want %q", loc.Scope, ScopeCC)
}
if loc.ScopeID != "my-slug" {
t.Errorf("scopeID = %q, want %q", loc.ScopeID, "my-slug")
}
if loc.Type != TypeFree {
t.Errorf("type = %q, want %q", loc.Type, TypeFree)
}
bad := []string{
filepath.Join(base, "my-slug", "note.md"), // no memory segment
filepath.Join(base, "my-slug", "notmemory", "n.md"), // wrong segment
filepath.Join(base, "my-slug", "memory", "note.txt"), // not markdown
"/elsewhere/x/memory/n.md", // outside base
}
for _, p := range bad {
if loc, ok := parseCcPath(base, p); ok {
t.Errorf("parseCcPath(%q) = %+v, ok=true; want ok=false", p, loc)
}
}
}
func TestParseCcFrontmatterType(t *testing.T) {
cases := []struct {
name string
body string
want Type
}{
{
name: "nested metadata.type present",
body: "---\nmetadata:\n type: reference\n other: x\n---\nbody here\n",
want: TypeReference,
},
{
name: "top-level type present",
body: "---\ntype: feedback\n---\nbody\n",
want: TypeFeedback,
},
{
name: "metadata.type wins over top-level",
body: "---\ntype: free\nmetadata:\n type: project\n---\n",
want: TypeProject,
},
{
name: "absent frontmatter",
body: "no frontmatter here\n",
want: TypeFree,
},
{
name: "empty frontmatter",
body: "---\n---\nbody\n",
want: TypeFree,
},
{
name: "unknown type value",
body: "---\nmetadata:\n type: bogus\n---\n",
want: TypeFree,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := parseCcFrontmatterType(tc.body); got != tc.want {
t.Errorf("parseCcFrontmatterType = %q, want %q", got, tc.want)
}
})
}
}
func TestResolveProjectId(t *testing.T) {
const p = "/Users/dev/repo"
a := resolveProjectId(p)
b := resolveProjectId(p)
if a != b {
t.Errorf("resolveProjectId not stable: %q != %q", a, b)
}
if len(a) != 12 {
t.Errorf("resolveProjectId length = %d, want 12", len(a))
}
if resolveProjectId("/Users/dev/other") == a {
t.Errorf("resolveProjectId collided for distinct paths")
}
}
func TestAssertSafeComponent(t *testing.T) {
ok := []string{"user", "abc123", "sub/dir/file.md", "checkpoint"}
for _, s := range ok {
if err := assertSafeComponent(s); err != nil {
t.Errorf("assertSafeComponent(%q) = %v, want nil", s, err)
}
}
bad := []string{"", "..", "../etc", "a/../b", "/etc/passwd", "sub/../../x"}
for _, s := range bad {
if err := assertSafeComponent(s); err == nil {
t.Errorf("assertSafeComponent(%q) = nil, want error", s)
}
}
}
+235
View File
@@ -0,0 +1,235 @@
package memory
import (
"fmt"
"os"
"path/filepath"
"time"
)
// Result reports what a Reconcile pass changed: Indexed counts rows inserted or
// updated (new or changed files), Pruned counts rows deleted because their file
// no longer exists on disk.
type Result struct {
Indexed int
Pruned int
}
// walkMemoryDir recursively collects every *.md file under root. A missing root
// (ENOENT) yields an empty slice and no error, so reconcile is safe to run
// before the memory directory has been created.
func walkMemoryDir(root string) ([]string, error) {
var out []string
var recurse func(dir string) error
recurse = func(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
full := filepath.Join(dir, entry.Name())
if entry.IsDir() {
if err := recurse(full); err != nil {
return err
}
} else if entry.Type().IsRegular() && isMarkdown(entry.Name()) {
out = append(out, full)
}
}
return nil
}
if err := recurse(root); err != nil {
return nil, err
}
return out, nil
}
// walkCcRoot collects every <base>/<slug>/memory/**/*.md file. A missing base
// (ENOENT) yields an empty slice; slugs without a memory subdirectory are
// silently skipped.
func walkCcRoot(base string) ([]string, error) {
slugs, err := os.ReadDir(base)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []string
for _, entry := range slugs {
if !entry.IsDir() {
continue
}
memoryDir := filepath.Join(base, entry.Name(), "memory")
info, err := os.Stat(memoryDir)
if err != nil || !info.IsDir() {
continue
}
files, err := walkMemoryDir(memoryDir)
if err != nil {
return nil, err
}
out = append(out, files...)
}
return out, nil
}
// Reconcile performs a lazy sync between the memory files on disk and the
// memory_index table. It walks both the mimo root and (when configured) the cc
// base, prunes rows whose file no longer exists, and indexes new or changed
// files. Unchanged files are skipped via a size-mtime fingerprint. The FTS
// index is kept consistent by the memory_ai/ad/au triggers, so only
// memory_index is touched here.
func (s *Store) Reconcile() (Result, error) {
var res Result
// Collect disk paths from BOTH roots BEFORE pruning. Pruning per-root would
// wrongly wipe the other root's rows, because each walk's set is missing the
// other root's paths.
mimoFiles, err := walkMemoryDir(s.root)
if err != nil {
return res, fmt.Errorf("memory: walk mimo root %q: %w", s.root, err)
}
var ccFiles []string
if s.ccBase != "" {
ccFiles, err = walkCcRoot(s.ccBase)
if err != nil {
return res, fmt.Errorf("memory: walk cc base %q: %w", s.ccBase, err)
}
}
diskPaths := make(map[string]struct{}, len(mimoFiles)+len(ccFiles))
for _, p := range mimoFiles {
diskPaths[filepath.Clean(p)] = struct{}{}
}
for _, p := range ccFiles {
diskPaths[filepath.Clean(p)] = struct{}{}
}
// Load existing {path -> fingerprint} from memory_index.
existing, err := s.loadFingerprints()
if err != nil {
return res, err
}
// PRUNE: delete rows whose path is no longer on disk.
for p := range existing {
if _, ok := diskPaths[p]; ok {
continue
}
if _, err := s.db.Exec(`DELETE FROM memory_index WHERE path = ?`, p); err != nil {
return res, fmt.Errorf("memory: prune %q: %w", p, err)
}
res.Pruned++
}
// INDEX: mimo files use parsePath and keep loc.Type.
for _, p := range mimoFiles {
loc, ok := parsePath(s.root, p)
if !ok {
continue
}
updated, err := s.indexFile(loc, loc.Type, false, existing[filepath.Clean(p)])
if err != nil {
return res, err
}
if updated {
res.Indexed++
}
}
// INDEX: cc files use parseCcPath; final type is derived from frontmatter.
for _, p := range ccFiles {
loc, ok := parseCcPath(s.ccBase, p)
if !ok {
continue
}
updated, err := s.indexFile(loc, loc.Type, true, existing[filepath.Clean(p)])
if err != nil {
return res, err
}
if updated {
res.Indexed++
}
}
return res, nil
}
// loadFingerprints returns the current {path -> fingerprint} map from
// memory_index.
func (s *Store) loadFingerprints() (map[string]string, error) {
rows, err := s.db.Query(`SELECT path, fingerprint FROM memory_index`)
if err != nil {
return nil, fmt.Errorf("memory: load fingerprints: %w", err)
}
defer rows.Close()
out := make(map[string]string)
for rows.Next() {
var path, fp string
if err := rows.Scan(&path, &fp); err != nil {
return nil, fmt.Errorf("memory: scan fingerprint: %w", err)
}
out[path] = fp
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: iterate fingerprints: %w", err)
}
return out, nil
}
// indexFile stats loc.Path, computes its size-mtime fingerprint, and upserts the
// row when the fingerprint differs from oldFingerprint. It returns true when a
// row was inserted or updated. A file that vanished between the walk and the
// stat (ENOENT) is silently skipped. For cc files (isCc) the semantic type is
// derived from the file's YAML frontmatter, falling back to defaultType.
func (s *Store) indexFile(loc *Locator, defaultType Type, isCc bool, oldFingerprint string) (bool, error) {
info, err := os.Stat(loc.Path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("memory: stat %q: %w", loc.Path, err)
}
fingerprint := fmt.Sprintf("%d-%d", info.Size(), info.ModTime().UnixNano())
if oldFingerprint == fingerprint {
return false, nil // hit: unchanged file
}
raw, err := os.ReadFile(loc.Path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("memory: read %q: %w", loc.Path, err)
}
body := string(raw)
finalType := defaultType
if isCc {
finalType = parseCcFrontmatterType(body)
}
now := time.Now().UnixNano()
const upsert = `
INSERT INTO memory_index (path, scope, scope_id, type, body, fingerprint, last_indexed_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
scope = excluded.scope,
scope_id = excluded.scope_id,
type = excluded.type,
body = excluded.body,
fingerprint = excluded.fingerprint,
last_indexed_at = excluded.last_indexed_at`
if _, err := s.db.Exec(upsert,
loc.Path, string(loc.Scope), loc.ScopeID, string(finalType), body, fingerprint, now,
); err != nil {
return false, fmt.Errorf("memory: upsert %q: %w", loc.Path, err)
}
return true, nil
}
+256
View File
@@ -0,0 +1,256 @@
package memory
import (
"os"
"path/filepath"
"testing"
"time"
)
// openTempWithRoots opens a Store backed by a temp-file DB with explicit mimo
// root and cc base directories (both created on disk).
func openTempWithRoots(t *testing.T) (st *Store, root, ccBase string) {
t.Helper()
base := t.TempDir()
root = filepath.Join(base, "mimo")
ccBase = filepath.Join(base, "cc")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatalf("mkdir root: %v", err)
}
if err := os.MkdirAll(ccBase, 0o755); err != nil {
t.Fatalf("mkdir ccBase: %v", err)
}
dbPath := filepath.Join(base, "sub", "memory.db")
st, err := Open(dbPath, root, ccBase)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { st.Close() })
return st, root, ccBase
}
// writeFile writes body to a mimo path built from segments under root, creating
// parent directories. It returns the absolute path (cleaned).
func writeFile(t *testing.T, base string, body string, segs ...string) string {
t.Helper()
full := filepath.Join(append([]string{base}, segs...)...)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir for %q: %v", full, err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatalf("write %q: %v", full, err)
}
return filepath.Clean(full)
}
// rowFor returns (scope, scopeId, type, fingerprint, body, found) for a path.
func rowFor(t *testing.T, st *Store, path string) (scope, scopeID, typ, fp, body string, found bool) {
t.Helper()
err := st.DB().QueryRow(
`SELECT scope, scope_id, type, fingerprint, body FROM memory_index WHERE path = ?`, path,
).Scan(&scope, &scopeID, &typ, &fp, &body)
if err != nil {
return "", "", "", "", "", false
}
return scope, scopeID, typ, fp, body, true
}
func countRows(t *testing.T, st *Store) int {
t.Helper()
var n int
if err := st.DB().QueryRow(`SELECT count(*) FROM memory_index`).Scan(&n); err != nil {
t.Fatalf("count rows: %v", err)
}
return n
}
// TestReconcileNewFileIndexed indexes a fresh mimo file and records its locator.
func TestReconcileNewFileIndexed(t *testing.T) {
st, root, _ := openTempWithRoots(t)
p := writeFile(t, root, "hello world", "global", "reference", "note.md")
res, err := st.Reconcile()
if err != nil {
t.Fatalf("Reconcile: %v", err)
}
if res.Indexed != 1 || res.Pruned != 0 {
t.Fatalf("Reconcile result = %+v, want {Indexed:1 Pruned:0}", res)
}
scope, scopeID, typ, fp, body, found := rowFor(t, st, p)
if !found {
t.Fatalf("row for %q not found", p)
}
if scope != string(ScopeGlobal) || scopeID != "" || typ != string(TypeReference) {
t.Fatalf("row = scope=%q scope_id=%q type=%q, want global/''/reference", scope, scopeID, typ)
}
if body != "hello world" {
t.Fatalf("body = %q, want %q", body, "hello world")
}
if fp == "" {
t.Fatalf("fingerprint empty")
}
}
// TestReconcileUnchangedFileHit re-runs reconcile with no changes and expects a
// fingerprint hit (no re-index).
func TestReconcileUnchangedFileHit(t *testing.T) {
st, root, _ := openTempWithRoots(t)
writeFile(t, root, "stable content", "global", "notes", "a.md")
if res, err := st.Reconcile(); err != nil || res.Indexed != 1 {
t.Fatalf("first Reconcile = %+v, err=%v, want Indexed:1", res, err)
}
res, err := st.Reconcile()
if err != nil {
t.Fatalf("second Reconcile: %v", err)
}
if res.Indexed != 0 || res.Pruned != 0 {
t.Fatalf("second Reconcile = %+v, want {Indexed:0 Pruned:0} (fingerprint hit)", res)
}
}
// TestReconcileChangedFileReindexed bumps a file's size and mtime and expects a
// re-index.
func TestReconcileChangedFileReindexed(t *testing.T) {
st, root, _ := openTempWithRoots(t)
p := writeFile(t, root, "v1", "global", "notes", "a.md")
if _, err := st.Reconcile(); err != nil {
t.Fatalf("first Reconcile: %v", err)
}
_, _, _, fp1, _, _ := rowFor(t, st, p)
// Rewrite with different size and force a later mtime to guarantee the
// fingerprint changes regardless of filesystem timestamp resolution.
if err := os.WriteFile(p, []byte("v2 longer body"), 0o644); err != nil {
t.Fatalf("rewrite: %v", err)
}
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(p, future, future); err != nil {
t.Fatalf("chtimes: %v", err)
}
res, err := st.Reconcile()
if err != nil {
t.Fatalf("second Reconcile: %v", err)
}
if res.Indexed != 1 || res.Pruned != 0 {
t.Fatalf("second Reconcile = %+v, want {Indexed:1 Pruned:0}", res)
}
_, _, _, fp2, body, _ := rowFor(t, st, p)
if fp1 == fp2 {
t.Fatalf("fingerprint unchanged after edit: %q", fp2)
}
if body != "v2 longer body" {
t.Fatalf("body = %q, want re-indexed content", body)
}
}
// TestReconcileDeletedFilePruned removes a file and expects its row pruned.
func TestReconcileDeletedFilePruned(t *testing.T) {
st, root, _ := openTempWithRoots(t)
p := writeFile(t, root, "temp", "global", "notes", "gone.md")
if _, err := st.Reconcile(); err != nil {
t.Fatalf("first Reconcile: %v", err)
}
if countRows(t, st) != 1 {
t.Fatalf("want 1 row after index")
}
if err := os.Remove(p); err != nil {
t.Fatalf("remove: %v", err)
}
res, err := st.Reconcile()
if err != nil {
t.Fatalf("second Reconcile: %v", err)
}
if res.Indexed != 0 || res.Pruned != 1 {
t.Fatalf("second Reconcile = %+v, want {Indexed:0 Pruned:1}", res)
}
if _, _, _, _, _, found := rowFor(t, st, p); found {
t.Fatalf("row for deleted file still present")
}
}
// TestReconcileCcFrontmatterType indexes a cc-root file and derives its type
// from YAML frontmatter.
func TestReconcileCcFrontmatterType(t *testing.T) {
st, _, ccBase := openTempWithRoots(t)
const body = "---\nmetadata:\n type: checkpoint\n---\ncc body text"
// <ccBase>/<slug>/memory/**/*.md
p := writeFile(t, ccBase, body, "my-project", "memory", "sub", "cp.md")
res, err := st.Reconcile()
if err != nil {
t.Fatalf("Reconcile: %v", err)
}
if res.Indexed != 1 || res.Pruned != 0 {
t.Fatalf("Reconcile = %+v, want {Indexed:1 Pruned:0}", res)
}
scope, scopeID, typ, _, _, found := rowFor(t, st, p)
if !found {
t.Fatalf("cc row not found")
}
if scope != string(ScopeCC) || scopeID != "my-project" || typ != string(TypeCheckpoint) {
t.Fatalf("cc row = scope=%q scope_id=%q type=%q, want cc/my-project/checkpoint", scope, scopeID, typ)
}
}
// TestReconcileBothRootsNoCrossPrune verifies that indexing both roots does not
// prune the other root's rows (the reconcile.ts correctness note).
func TestReconcileBothRootsNoCrossPrune(t *testing.T) {
st, root, ccBase := openTempWithRoots(t)
mimoP := writeFile(t, root, "mimo body", "projects", "proj1", "project", "m.md")
ccP := writeFile(t, ccBase, "cc body", "slug1", "memory", "c.md")
res, err := st.Reconcile()
if err != nil {
t.Fatalf("Reconcile: %v", err)
}
if res.Indexed != 2 || res.Pruned != 0 {
t.Fatalf("Reconcile = %+v, want {Indexed:2 Pruned:0}", res)
}
if _, _, _, _, _, ok := rowFor(t, st, mimoP); !ok {
t.Fatalf("mimo row missing")
}
if _, _, _, _, _, ok := rowFor(t, st, ccP); !ok {
t.Fatalf("cc row missing")
}
// A second no-op reconcile must not prune either row.
res, err = st.Reconcile()
if err != nil {
t.Fatalf("second Reconcile: %v", err)
}
if res.Pruned != 0 {
t.Fatalf("second Reconcile pruned %d, want 0", res.Pruned)
}
if countRows(t, st) != 2 {
t.Fatalf("row count = %d, want 2", countRows(t, st))
}
}
// TestReconcileMissingRoots verifies reconcile is a no-op when roots do not
// exist yet (ENOENT tolerated).
func TestReconcileMissingRoots(t *testing.T) {
base := t.TempDir()
dbPath := filepath.Join(base, "memory.db")
st, err := Open(dbPath, filepath.Join(base, "nope"), filepath.Join(base, "nocc"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { st.Close() })
res, err := st.Reconcile()
if err != nil {
t.Fatalf("Reconcile on missing roots: %v", err)
}
if res.Indexed != 0 || res.Pruned != 0 {
t.Fatalf("Reconcile = %+v, want zero", res)
}
}
+66
View File
@@ -0,0 +1,66 @@
package memory
// Scope identifies the memory dimension a file belongs to.
type Scope string
// Recognized scopes.
const (
ScopeGlobal Scope = "global"
ScopeProjects Scope = "projects"
ScopeSessions Scope = "sessions"
ScopeCC Scope = "cc"
)
// Type identifies the semantic kind of a memory file.
type Type string
// Recognized types.
const (
TypeUser Type = "user"
TypeFeedback Type = "feedback"
TypeProject Type = "project"
TypeReference Type = "reference"
TypeCheckpoint Type = "checkpoint"
TypeProgress Type = "progress"
TypeNotes Type = "notes"
TypeFree Type = "free"
)
// schemaDDL is the idempotent set of DDL statements that create the memory
// storage schema: the content table, its secondary indexes, the FTS5 virtual
// table (external-content mode), and the three sync triggers that keep the FTS
// index consistent with the content table. All statements use IF NOT EXISTS so
// running the migration repeatedly is safe.
const schemaDDL = `
CREATE TABLE IF NOT EXISTS memory_index (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL,
scope_id TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL,
body TEXT NOT NULL,
fingerprint TEXT NOT NULL,
last_indexed_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS memory_index_scope_idx ON memory_index (scope, scope_id);
CREATE INDEX IF NOT EXISTS memory_index_type_idx ON memory_index (type);
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
body, content='memory_index', content_rowid='id',
tokenize='unicode61 remove_diacritics 1'
);
CREATE TRIGGER IF NOT EXISTS memory_ai AFTER INSERT ON memory_index BEGIN
INSERT INTO memory_fts(rowid, body) VALUES (new.id, new.body);
END;
CREATE TRIGGER IF NOT EXISTS memory_ad AFTER DELETE ON memory_index BEGIN
INSERT INTO memory_fts(memory_fts, rowid, body) VALUES('delete', old.id, old.body);
END;
CREATE TRIGGER IF NOT EXISTS memory_au AFTER UPDATE ON memory_index BEGIN
INSERT INTO memory_fts(memory_fts, rowid, body) VALUES('delete', old.id, old.body);
INSERT INTO memory_fts(rowid, body) VALUES (new.id, new.body);
END;
`
+173
View File
@@ -0,0 +1,173 @@
package memory
import (
"database/sql"
"fmt"
"strings"
)
// SearchResult is a single ranked hit from Store.Search. Score is normalized so
// that higher = better (the raw FTS5 bm25 value, where lower is better, is
// negated).
type SearchResult struct {
Path string
Snippet string
Score float64
Scope Scope
ScopeID string
Type Type
}
// SearchOptions tunes a Store.Search call. All fields are optional; the zero
// value performs an unfiltered search with default limit and score floor.
type SearchOptions struct {
// Scope, ScopeID and Type, when non-empty, restrict results to rows whose
// corresponding memory_index column matches exactly.
Scope string
ScopeID string
Type string
// Limit caps the number of returned results; <=0 means the default of 10.
Limit int
// ReconcileFirst runs a lazy Store.Reconcile before searching so that
// off-tool writes are picked up. Its counts are ignored; hard errors
// propagate.
ReconcileFirst bool
// ScoreFloor is the relative floor ratio: trailing rows scoring below
// topScore*ScoreFloor are dropped. <=0 keeps all matches when 0, but the
// default of 0.15 is used when the field is left at its zero value; pass a
// negative value to explicitly disable the floor.
ScoreFloor float64
}
// defaultSearchLimit is the result count used when SearchOptions.Limit <= 0.
const defaultSearchLimit = 10
// maxFetchLimit caps the over-fetch used to feed the relative score floor.
const maxFetchLimit = 50
// defaultScoreFloor is the relative floor applied when SearchOptions.ScoreFloor
// is left at its zero value.
const defaultScoreFloor = 0.15
// Search runs a BM25 full-text query over the indexed memory bodies.
//
// The free-form query is tokenized and OR-joined by buildFtsQuery; an empty
// token set returns (nil, nil) without touching SQL. Results are ranked by
// BM25 (converted to higher = better), over-fetched 3x (capped at 50) so a
// relative score floor can trim common-word-only noise, then sliced to the
// requested limit. Optional scope/scope_id/type filters restrict the corpus.
func (s *Store) Search(query string, opts SearchOptions) ([]SearchResult, error) {
if opts.ReconcileFirst {
if _, err := s.Reconcile(); err != nil {
return nil, fmt.Errorf("memory: reconcile before search: %w", err)
}
}
match := buildFtsQuery(query)
if match == "" {
// No usable tokens: treat as empty query with no results, send no SQL.
return nil, nil
}
limit := opts.Limit
if limit <= 0 {
limit = defaultSearchLimit
}
fetchLimit := limit * 3
if fetchLimit > maxFetchLimit {
fetchLimit = maxFetchLimit
}
// The FTS5 table `memory_fts` is external-content over `memory_index`, so
// filter columns (scope/scope_id/type) and the display path live on
// memory_index and the join is memory_index.id = memory_fts.rowid. snippet()
// and bm25() operate on the FTS table; body is FTS column 0.
var sb strings.Builder
sb.WriteString(`
SELECT mi.path, mi.scope, mi.scope_id, mi.type,
snippet(memory_fts, 0, '<<', '>>', '...', 32) AS snippet,
bm25(memory_fts) AS score
FROM memory_fts
JOIN memory_index mi ON mi.id = memory_fts.rowid
WHERE memory_fts MATCH ?`)
// MATCH parameter is always first; filter params follow in order.
args := []any{match}
if opts.Scope != "" {
sb.WriteString(" AND mi.scope = ?")
args = append(args, opts.Scope)
}
if opts.ScopeID != "" {
sb.WriteString(" AND mi.scope_id = ?")
args = append(args, opts.ScopeID)
}
if opts.Type != "" {
sb.WriteString(" AND mi.type = ?")
args = append(args, opts.Type)
}
// bm25(): lower = better, so ascending order puts the best hit first.
sb.WriteString(" ORDER BY score ASC LIMIT ?")
args = append(args, fetchLimit)
rows, err := s.db.Query(sb.String(), args...)
if err != nil {
return nil, fmt.Errorf("memory: search query: %w", err)
}
defer rows.Close()
var results []SearchResult
for rows.Next() {
var (
path, scope, scopeID, typ string
snippet sql.NullString
bm25 float64
)
if err := rows.Scan(&path, &scope, &scopeID, &typ, &snippet, &bm25); err != nil {
return nil, fmt.Errorf("memory: scan search row: %w", err)
}
results = append(results, SearchResult{
Path: path,
Snippet: snippet.String,
Score: -bm25, // convert to higher = better
Scope: Scope(scope),
ScopeID: scopeID,
Type: Type(typ),
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: iterate search rows: %w", err)
}
if len(results) == 0 {
return nil, nil
}
// Relative score floor. BM25 magnitudes are corpus-size dependent (in a
// tiny corpus every score collapses toward 0 due to low IDF), so an
// absolute floor would wrongly wipe real hits. We keep results scoring at
// least topScore*floor. The #1 result is ALWAYS kept — a match is a match
// even when BM25 can't discriminate. Default 0.15; a negative floor
// disables the trimming entirely.
floor := opts.ScoreFloor
if floor == 0 {
floor = defaultScoreFloor
}
// Rows come back ORDER BY score ASC (best first after negation), so
// results[0] is the top hit.
if floor > 0 {
topScore := results[0].Score
cutoff := topScore * floor
kept := results[:1]
for _, r := range results[1:] {
if r.Score >= cutoff {
kept = append(kept, r)
}
}
results = kept
}
if len(results) > limit {
results = results[:limit]
}
return results, nil
}
+152
View File
@@ -0,0 +1,152 @@
package memory
import "testing"
// seedSearchCorpus writes a small memory corpus and indexes it. "checkpoint" is
// deliberately a common word (appears in many docs) while "permission" and
// "deadlock" are rare, so BM25 + the relative floor can be exercised.
func seedSearchCorpus(t *testing.T, st *Store, root string) map[string]string {
t.Helper()
paths := map[string]string{}
paths["rare"] = writeFile(t, root,
"permission deadlock encountered during checkpoint save then retry succeeded",
"projects", "proj1", "notes", "rare.md")
paths["c1"] = writeFile(t, root, "checkpoint state alpha", "global", "checkpoint", "c1.md")
paths["c2"] = writeFile(t, root, "checkpoint state beta", "global", "checkpoint", "c2.md")
paths["c3"] = writeFile(t, root, "checkpoint state gamma", "global", "checkpoint", "c3.md")
paths["c4"] = writeFile(t, root, "checkpoint state delta", "global", "checkpoint", "c4.md")
paths["user"] = writeFile(t, root, "unrelated grocery shopping list", "global", "user", "u1.md")
if _, err := st.Reconcile(); err != nil {
t.Fatalf("Reconcile: %v", err)
}
return paths
}
func resultPaths(rs []SearchResult) map[string]bool {
m := make(map[string]bool, len(rs))
for _, r := range rs {
m[r.Path] = true
}
return m
}
func TestSearchMultiWordOrRecall(t *testing.T) {
st, root, _ := openTempWithRoots(t)
p := seedSearchCorpus(t, st, root)
// OR recall: a query spanning a rare word (in one doc) and the common word
// (in several) should surface docs matching either. Disable the floor so we
// verify raw OR recall independent of trimming.
res, err := st.Search("permission checkpoint", SearchOptions{ScoreFloor: -1})
if err != nil {
t.Fatalf("Search: %v", err)
}
got := resultPaths(res)
if !got[p["rare"]] {
t.Fatalf("expected rare doc in recall results, got %v", got)
}
if !got[p["c1"]] {
t.Fatalf("expected a checkpoint doc in recall results, got %v", got)
}
}
func TestSearchScoreFloorDropsCommonWordOnly(t *testing.T) {
st, root, _ := openTempWithRoots(t)
p := seedSearchCorpus(t, st, root)
// The rare doc matches permission+deadlock+checkpoint; the c* docs match
// only the common "checkpoint". With the default floor the multi-rare doc
// ranks top and the common-word-only docs are trimmed.
res, err := st.Search("permission deadlock checkpoint", SearchOptions{})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(res) == 0 {
t.Fatal("expected at least the top result")
}
if res[0].Path != p["rare"] {
t.Fatalf("expected rare doc ranked top, got %q", res[0].Path)
}
// Higher = better after negation: the top score should be positive-most.
for _, r := range res[1:] {
if r.Score > res[0].Score {
t.Fatalf("result %q outscored the top hit", r.Path)
}
}
got := resultPaths(res)
for _, key := range []string{"c1", "c2", "c3", "c4"} {
if got[p[key]] {
t.Fatalf("common-word-only doc %s should have been dropped by floor, got %v", key, got)
}
}
}
func TestSearchScopeAndTypeFilters(t *testing.T) {
st, root, _ := openTempWithRoots(t)
p := seedSearchCorpus(t, st, root)
// scope filter: only the projects doc should match under scope=projects.
res, err := st.Search("checkpoint permission", SearchOptions{Scope: "projects", ScoreFloor: -1})
if err != nil {
t.Fatalf("Search scope: %v", err)
}
got := resultPaths(res)
if !got[p["rare"]] || len(got) != 1 {
t.Fatalf("scope=projects should return only the rare doc, got %v", got)
}
// type filter: only global/checkpoint docs, none of the projects/user docs.
res, err = st.Search("checkpoint permission", SearchOptions{Type: string(TypeCheckpoint), ScoreFloor: -1})
if err != nil {
t.Fatalf("Search type: %v", err)
}
got = resultPaths(res)
if got[p["rare"]] || got[p["user"]] {
t.Fatalf("type=checkpoint should exclude non-checkpoint docs, got %v", got)
}
if !got[p["c1"]] {
t.Fatalf("type=checkpoint should include checkpoint docs, got %v", got)
}
}
func TestSearchEmptyQueryReturnsNil(t *testing.T) {
st, root, _ := openTempWithRoots(t)
seedSearchCorpus(t, st, root)
for _, q := range []string{"", " ", "!!! ??? ---"} {
res, err := st.Search(q, SearchOptions{})
if err != nil {
t.Fatalf("Search(%q): unexpected error %v", q, err)
}
if res != nil {
t.Fatalf("Search(%q): expected nil results, got %v", q, res)
}
}
}
func TestSearchReconcileFirst(t *testing.T) {
st, root, _ := openTempWithRoots(t)
// Write a file but do NOT reconcile manually; ReconcileFirst should index it.
writeFile(t, root, "lazy reconciled permission deadlock content", "global", "notes", "lazy.md")
res, err := st.Search("permission deadlock", SearchOptions{ReconcileFirst: true})
if err != nil {
t.Fatalf("Search ReconcileFirst: %v", err)
}
if len(res) == 0 {
t.Fatal("ReconcileFirst should have indexed and matched the lazy doc")
}
}
func TestSearchLimit(t *testing.T) {
st, root, _ := openTempWithRoots(t)
seedSearchCorpus(t, st, root)
res, err := st.Search("checkpoint", SearchOptions{Limit: 2, ScoreFloor: -1})
if err != nil {
t.Fatalf("Search limit: %v", err)
}
if len(res) > 2 {
t.Fatalf("limit=2 should cap results, got %d", len(res))
}
}
+137
View File
@@ -0,0 +1,137 @@
// Package memory implements the persistent memory storage layer for pigo.
//
// It is backed by a pure-Go SQLite database (modernc.org/sqlite, no CGO) with
// an FTS5 full-text index over Markdown memory files. This file provides the
// Store type together with database opening and idempotent schema migration.
// Later nodes extend the package with path resolution, reconcile (lazy
// indexing/pruning) and BM25 search.
package memory
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite" // pure-Go SQLite driver, registers the "sqlite" driver
)
// Store is the handle to the memory database. Fields are unexported; later
// nodes access the underlying *sql.DB via DB() and the configured roots via the
// package-internal fields.
type Store struct {
db *sql.DB
root string // memory root directory (magic layout: global/projects/sessions)
ccBase string // optional Claude Code base dir for the "cc" scope; "" disables
}
// Open opens (creating if necessary) the SQLite database at dbPath and runs the
// idempotent schema migration. The parent directory of dbPath is created with
// os.MkdirAll before opening. root is the memory root directory and ccBase is
// the optional Claude Code base directory; both are retained for use by later
// nodes and are not required to exist here.
//
// Calling Open twice on the same file is safe: the migration uses
// CREATE ... IF NOT EXISTS throughout.
func Open(dbPath, root, ccBase string) (*Store, error) {
if dbPath == "" {
return nil, fmt.Errorf("memory: empty dbPath")
}
// modernc.org/sqlite understands the ":memory:" DSN; only create a parent
// directory for real file paths.
if dbPath != ":memory:" {
if dir := filepath.Dir(dbPath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("memory: create db dir %q: %w", dir, err)
}
}
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("memory: open db %q: %w", dbPath, err)
}
// A single global DB with a single connection: writes are serialized, which
// matches the low write frequency and avoids SQLITE_BUSY contention.
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("memory: ping db %q: %w", dbPath, err)
}
if err := migrate(db); err != nil {
db.Close()
return nil, fmt.Errorf("memory: migrate: %w", err)
}
return &Store{db: db, root: root, ccBase: ccBase}, nil
}
// migrate applies the idempotent schema DDL.
func migrate(db *sql.DB) error {
if _, err := db.Exec(schemaDDL); err != nil {
return err
}
return nil
}
// DB returns the underlying *sql.DB for use by later nodes and tests. It may be
// nil if the store was not successfully opened.
func (s *Store) DB() *sql.DB {
if s == nil {
return nil
}
return s.db
}
// Root returns the memory root directory the store was opened with (the magic
// layout root holding global/projects/sessions). It is the canonical source for
// the memory root used by checkpoint persistence and context rebuild
// (<root>/sessions/<id>/checkpoint.md); callers must resolve the memory root
// through this accessor rather than re-deriving it from another store's dir. It
// is "" for a nil store.
func (s *Store) Root() string {
if s == nil {
return ""
}
return s.root
}
// CountByScope returns the number of indexed memory entries grouped by scope.
// Scopes with no entries are omitted from the map. It reflects the current
// contents of memory_index; callers that want fresh counts should Reconcile
// first. A nil store or nil db yields an empty map.
func (s *Store) CountByScope() (map[Scope]int, error) {
out := make(map[Scope]int)
if s == nil || s.db == nil {
return out, nil
}
rows, err := s.db.Query(`SELECT scope, COUNT(*) FROM memory_index GROUP BY scope`)
if err != nil {
return nil, fmt.Errorf("memory: count by scope: %w", err)
}
defer rows.Close()
for rows.Next() {
var scope string
var n int
if err := rows.Scan(&scope, &n); err != nil {
return nil, fmt.Errorf("memory: scan scope count: %w", err)
}
out[Scope(scope)] = n
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: iterate scope counts: %w", err)
}
return out, nil
}
// Close closes the underlying database connection.
func (s *Store) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
+175
View File
@@ -0,0 +1,175 @@
package memory
import (
"path/filepath"
"testing"
)
// openTemp opens a Store backed by a temp-file DB and registers cleanup.
func openTemp(t *testing.T) *Store {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "sub", "memory.db") // sub/ must be created by Open
st, err := Open(dbPath, dir, "")
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
// tableExists reports whether a table or virtual table with the given name
// exists in sqlite_master.
func tableExists(t *testing.T, st *Store, name string) bool {
t.Helper()
var got string
err := st.DB().QueryRow(
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, name,
).Scan(&got)
if err != nil {
return false
}
return got == name
}
// TestFTS5SmokeTest de-risks the pure-Go SQLite FTS5 dependency: it asserts the
// FTS5 virtual table is created without error and is queryable.
func TestFTS5SmokeTest(t *testing.T) {
st := openTemp(t)
if !tableExists(t, st, "memory_fts") {
t.Fatalf("memory_fts virtual table not created")
}
// A MATCH query must run without error (proves FTS5 is compiled in).
rows, err := st.DB().Query(`SELECT rowid FROM memory_fts WHERE memory_fts MATCH ?`, "anything")
if err != nil {
t.Fatalf("FTS5 MATCH query failed (FTS5 not available?): %v", err)
}
rows.Close()
}
// TestSchemaObjectsCreated verifies the content table, indexes and triggers.
func TestSchemaObjectsCreated(t *testing.T) {
st := openTemp(t)
if !tableExists(t, st, "memory_index") {
t.Fatalf("memory_index table not created")
}
for _, idx := range []string{"memory_index_scope_idx", "memory_index_type_idx"} {
var n int
if err := st.DB().QueryRow(
`SELECT count(*) FROM sqlite_master WHERE type='index' AND name=?`, idx,
).Scan(&n); err != nil || n != 1 {
t.Fatalf("index %q missing (n=%d, err=%v)", idx, n, err)
}
}
for _, trg := range []string{"memory_ai", "memory_ad", "memory_au"} {
var n int
if err := st.DB().QueryRow(
`SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name=?`, trg,
).Scan(&n); err != nil || n != 1 {
t.Fatalf("trigger %q missing (n=%d, err=%v)", trg, n, err)
}
}
}
// insertRow inserts a memory_index row and returns its id.
func insertRow(t *testing.T, st *Store, path, body string) int64 {
t.Helper()
res, err := st.DB().Exec(
`INSERT INTO memory_index (path, scope, scope_id, type, body, fingerprint, last_indexed_at)
VALUES (?, 'global', '', 'free', ?, 'fp', 0)`, path, body)
if err != nil {
t.Fatalf("insert: %v", err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("last insert id: %v", err)
}
return id
}
// ftsMatchCount returns how many FTS rows match the given single-term query.
func ftsMatchCount(t *testing.T, st *Store, term string) int {
t.Helper()
var n int
if err := st.DB().QueryRow(
`SELECT count(*) FROM memory_fts WHERE memory_fts MATCH ?`, term,
).Scan(&n); err != nil {
t.Fatalf("fts match count %q: %v", term, err)
}
return n
}
// TestTriggerSyncInsertDeleteUpdate verifies the AFTER INSERT/DELETE/UPDATE
// triggers keep memory_fts in sync with memory_index.
func TestTriggerSyncInsertDeleteUpdate(t *testing.T) {
st := openTemp(t)
// INSERT -> searchable.
id := insertRow(t, st, "/mem/a.md", "alpha bravo charlie")
if got := ftsMatchCount(t, st, "bravo"); got != 1 {
t.Fatalf("after insert: MATCH bravo = %d, want 1", got)
}
// UPDATE -> re-synced (old term gone, new term present).
if _, err := st.DB().Exec(`UPDATE memory_index SET body=? WHERE id=?`, "delta echo", id); err != nil {
t.Fatalf("update: %v", err)
}
if got := ftsMatchCount(t, st, "bravo"); got != 0 {
t.Fatalf("after update: MATCH bravo = %d, want 0", got)
}
if got := ftsMatchCount(t, st, "echo"); got != 1 {
t.Fatalf("after update: MATCH echo = %d, want 1", got)
}
// DELETE -> removed from index.
if _, err := st.DB().Exec(`DELETE FROM memory_index WHERE id=?`, id); err != nil {
t.Fatalf("delete: %v", err)
}
if got := ftsMatchCount(t, st, "echo"); got != 0 {
t.Fatalf("after delete: MATCH echo = %d, want 0", got)
}
}
// TestOpenIdempotent verifies that running the migration twice on the same file
// is safe and preserves data.
func TestOpenIdempotent(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "memory.db")
st1, err := Open(dbPath, dir, "")
if err != nil {
t.Fatalf("first Open: %v", err)
}
insertRow(t, st1, "/mem/keep.md", "persistent needle")
if err := st1.Close(); err != nil {
t.Fatalf("close first: %v", err)
}
// Re-open: migration must not error and existing data must survive.
st2, err := Open(dbPath, dir, "")
if err != nil {
t.Fatalf("second Open (migration not idempotent?): %v", err)
}
t.Cleanup(func() { st2.Close() })
if got := ftsMatchCount(t, st2, "needle"); got != 1 {
t.Fatalf("after reopen: MATCH needle = %d, want 1", got)
}
}
// TestInMemoryOpen verifies the ":memory:" DSN works (no parent dir creation).
func TestInMemoryOpen(t *testing.T) {
st, err := Open(":memory:", "", "")
if err != nil {
t.Fatalf("Open in-memory: %v", err)
}
defer st.Close()
if !tableExists(t, st, "memory_fts") {
t.Fatalf("memory_fts not created in in-memory db")
}
}