Skip to content

Commit db55b5d

Browse files
SERGIO RUIZ RODRIGUEZclaude
authored andcommitted
feat: add FTS5 full-text search for memory retrieval
Currently the memory system can only surface top-k entries by salience score, but cannot search by topic or keyword. This adds a lightweight SQLite FTS5 search tool that indexes all .md and .jsonl files under .agent/memory/ and returns ranked results with context snippets. - memory_search.py: FTS5 index with auto-rebuild and grep fallback - .gitignore: exclude derived .index/ directory - memory-manager SKILL.md: document the search command Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6f8cfbd commit db55b5d

3 files changed

Lines changed: 221 additions & 0 deletions

File tree

.agent/memory/memory_search.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Memory Search — SQLite FTS5 full-text search over .agent/memory/ files.
4+
5+
Indexes all .md and .jsonl files under .agent/memory/ and provides ranked
6+
keyword search. Falls back to grep if FTS5 is not available.
7+
8+
Usage:
9+
python3 memory_search.py <query> Search memories by keyword
10+
python3 memory_search.py --rebuild Force rebuild the index
11+
python3 memory_search.py --status Show index status
12+
13+
The index is stored at .agent/memory/.index/memory.db and auto-rebuilds
14+
when any memory file changes. It is gitignored (derived data).
15+
"""
16+
import json
17+
import sys
18+
import sqlite3
19+
import subprocess
20+
from pathlib import Path
21+
22+
MEMORY_DIR = Path(__file__).resolve().parent
23+
INDEX_DIR = MEMORY_DIR / ".index"
24+
INDEX_PATH = INDEX_DIR / "memory.db"
25+
26+
27+
def check_fts5() -> bool:
28+
"""Check if SQLite FTS5 extension is available."""
29+
try:
30+
conn = sqlite3.connect(":memory:")
31+
conn.execute("CREATE VIRTUAL TABLE _t USING fts5(c)")
32+
conn.close()
33+
return True
34+
except Exception:
35+
return False
36+
37+
38+
def needs_rebuild() -> bool:
39+
"""Check if index is stale (any source file newer than the index)."""
40+
if not INDEX_PATH.exists():
41+
return True
42+
index_mtime = INDEX_PATH.stat().st_mtime
43+
for f in MEMORY_DIR.rglob("*"):
44+
if ".index" in str(f):
45+
continue
46+
if f.suffix in (".md", ".jsonl") and f.stat().st_mtime > index_mtime:
47+
return True
48+
return False
49+
50+
51+
def _read_jsonl(path: Path) -> str:
52+
"""Read a .jsonl file and return a searchable text representation."""
53+
lines = []
54+
for raw in path.read_text(encoding="utf-8").splitlines():
55+
raw = raw.strip()
56+
if not raw:
57+
continue
58+
try:
59+
entry = json.loads(raw)
60+
parts = [
61+
entry.get("action", ""),
62+
entry.get("reflection", ""),
63+
entry.get("detail", ""),
64+
entry.get("skill", ""),
65+
]
66+
lines.append(" ".join(p for p in parts if p))
67+
except json.JSONDecodeError:
68+
continue
69+
return "\n".join(lines)
70+
71+
72+
def build_index() -> int:
73+
"""Build or rebuild the FTS5 index from all memory files."""
74+
INDEX_DIR.mkdir(exist_ok=True)
75+
conn = sqlite3.connect(INDEX_PATH)
76+
conn.execute("DROP TABLE IF EXISTS memories")
77+
conn.execute("""
78+
CREATE VIRTUAL TABLE memories
79+
USING fts5(filename, content, tokenize='porter unicode61')
80+
""")
81+
indexed = 0
82+
for f in MEMORY_DIR.rglob("*"):
83+
if ".index" in str(f):
84+
continue
85+
try:
86+
if f.suffix == ".md":
87+
content = f.read_text(encoding="utf-8")
88+
elif f.suffix == ".jsonl":
89+
content = _read_jsonl(f)
90+
else:
91+
continue
92+
rel_path = f.relative_to(MEMORY_DIR)
93+
conn.execute("INSERT INTO memories VALUES (?, ?)", (str(rel_path), content))
94+
indexed += 1
95+
except Exception:
96+
pass
97+
conn.commit()
98+
conn.close()
99+
return indexed
100+
101+
102+
def search_fts5(query: str) -> list[tuple[str, str]]:
103+
"""Search the FTS5 index. Returns (filename, snippet) pairs."""
104+
if needs_rebuild():
105+
build_index()
106+
conn = sqlite3.connect(INDEX_PATH)
107+
try:
108+
rows = conn.execute(
109+
"""SELECT filename,
110+
snippet(memories, 1, '>>>', '<<<', '...', 30)
111+
FROM memories
112+
WHERE memories MATCH ?
113+
ORDER BY rank""",
114+
(query,),
115+
).fetchall()
116+
except sqlite3.OperationalError:
117+
# Query syntax error — fall back to LIKE
118+
rows = conn.execute(
119+
"SELECT filename, substr(content, 1, 300) FROM memories WHERE content LIKE ?",
120+
(f"%{query}%",),
121+
).fetchall()
122+
conn.close()
123+
return rows
124+
125+
126+
def search_grep(query: str) -> list[tuple[str, str]]:
127+
"""Fallback search using grep when FTS5 is not available."""
128+
result = subprocess.run(
129+
["grep", "-ril", query, str(MEMORY_DIR)],
130+
capture_output=True,
131+
text=True,
132+
)
133+
files = [
134+
f
135+
for f in result.stdout.strip().split("\n")
136+
if f and ".index" not in f
137+
]
138+
return [(Path(f).relative_to(MEMORY_DIR), f"(match in {Path(f).name})") for f in files]
139+
140+
141+
def cmd_rebuild():
142+
if not check_fts5():
143+
print("FTS5 not available — cannot build index.")
144+
return
145+
count = build_index()
146+
print(f"Index rebuilt: {count} files indexed.")
147+
148+
149+
def cmd_status():
150+
if not check_fts5():
151+
print("Mode: BASIC (grep fallback)")
152+
print("Reason: SQLite FTS5 not available in this Python build.")
153+
return
154+
if not INDEX_PATH.exists():
155+
print("Mode: FTS5 (index not built yet — auto-builds on first search)")
156+
return
157+
conn = sqlite3.connect(INDEX_PATH)
158+
count = conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
159+
conn.close()
160+
size_kb = INDEX_PATH.stat().st_size // 1024
161+
print(f"Mode: FTS5")
162+
print(f"Index: {count} files indexed ({size_kb} KB)")
163+
print(f"Location: {INDEX_PATH}")
164+
165+
166+
def main():
167+
args = sys.argv[1:]
168+
169+
if not args or args[0] in ("-h", "--help"):
170+
print("Usage:")
171+
print(" memory_search.py <query> Search memories by keyword")
172+
print(" memory_search.py --rebuild Force rebuild index")
173+
print(" memory_search.py --status Show index status")
174+
sys.exit(0)
175+
176+
if args[0] == "--rebuild":
177+
cmd_rebuild()
178+
return
179+
180+
if args[0] == "--status":
181+
cmd_status()
182+
return
183+
184+
query = " ".join(args)
185+
use_fts5 = check_fts5()
186+
187+
if use_fts5:
188+
results = search_fts5(query)
189+
mode = "FTS5"
190+
else:
191+
results = search_grep(query)
192+
mode = "grep"
193+
194+
if not results:
195+
print(f"No results for: '{query}' [mode: {mode}]")
196+
return
197+
198+
print(f"Results for: '{query}' [mode: {mode}]\n")
199+
for filename, snippet in results:
200+
print(f" {filename}")
201+
print(f" {snippet}\n")
202+
203+
204+
if __name__ == "__main__":
205+
main()

.agent/skills/memory-manager/SKILL.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ happened. Before important decisions, read the top entries from
2626
5. Archive resolved working context to `memory/episodic/snapshots/`.
2727
6. Commit via git so history is preserved: `git log memory/` is the agent's autobiography.
2828

29+
## Searching memory by keyword
30+
31+
When you need to find a specific memory by topic or keyword (not just the
32+
top-salience entries), use the FTS5 search tool:
33+
34+
```bash
35+
python3 .agent/memory/memory_search.py <query>
36+
```
37+
38+
This indexes all `.md` and `.jsonl` files under `.agent/memory/` and returns
39+
ranked results with context snippets. Falls back to grep if FTS5 is not
40+
available. The index auto-rebuilds when files change.
41+
2942
## Anti-patterns
3043
- Do not auto-merge `personal/` into `semantic/` — user preferences are not general knowledge.
3144
- Do not delete entries to "clean up" memory. Archive them.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ venv/
1919
.agent/memory/dream.log
2020
*.log
2121

22+
# derived search index (auto-rebuilt)
23+
.agent/memory/.index/
24+
2225
# do NOT ignore memory files — they are the point
2326
!.agent/memory/
2427
!.agent/memory/**

0 commit comments

Comments
 (0)