first commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user