Skip to content

Commit d8b06bc

Browse files
author
codejunkie99
committed
feat(onboard): opt-in [BETA] toggle for memory search + fix PR #2 review
Written by Minimax-M2.7 in Claude Code Harness. Integrates sergi-rz's FTS5 memory search (#2) behind a beta feature flag. Default OFF; users opt in through the onboarding wizard or by editing .agent/memory/.features.json directly. Addresses the three codex review findings on that PR at the same time. Beta feature framework - onboard_features.py (new): read/write .agent/memory/.features.json with a simple {key: {enabled: bool, beta: bool}} schema. - onboard.py: adds a final "Optional features" step to the wizard. Default answer is No — beta features stay off unless the user explicitly opts in. --yes (non-interactive) path writes the features file too, all off. - memory_search.py gates search/rebuild behind feature_enabled(). --status still works regardless so users can see the toggle. Codex review fixes on PR #2 - [P1] needs_rebuild now compares the set of memory files currently on disk with the set already in the index; any previously-indexed file that no longer exists flags the index stale. Without this, deleted/renamed files kept appearing in results until some unrelated file bumped the index. - [P2] search_grep now passes explicit .md/.jsonl target paths to grep instead of scanning the whole .agent/memory/ tree. Source files (archive.py, auto_dream.py, etc.) no longer pollute fallback search results. - [P3] .gitignore now ignores .agent/memory/.index/ correctly. The previous rule was cancelled by the !.agent/memory/** negation placed below it; moved the .index/ exclusion AFTER the negation so the later rule wins. Verified with git status --ignored.
1 parent a012575 commit d8b06bc

4 files changed

Lines changed: 186 additions & 35 deletions

File tree

.agent/memory/memory_search.py

Lines changed: 109 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
#!/usr/bin/env python3
22
"""
3-
Memory Search — SQLite FTS5 full-text search over .agent/memory/ files.
3+
Memory Search [BETA] — SQLite FTS5 full-text search over .agent/memory/ files.
44
55
Indexes all .md and .jsonl files under .agent/memory/ and provides ranked
6-
keyword search. Falls back to grep if FTS5 is not available.
6+
keyword search. Falls back to grep (restricted to .md/.jsonl) if FTS5 is
7+
not available.
8+
9+
BETA + opt-in: disabled by default. Enable via onboarding
10+
(agentic-stack <harness> --reconfigure) or by setting
11+
{"memory_search_fts": {"enabled": true}}
12+
in .agent/memory/.features.json.
713
814
Usage:
915
python3 memory_search.py <query> Search memories by keyword
1016
python3 memory_search.py --rebuild Force rebuild the index
1117
python3 memory_search.py --status Show index status
1218
1319
The index is stored at .agent/memory/.index/memory.db and auto-rebuilds
14-
when any memory file changes. It is gitignored (derived data).
20+
when any memory file changes, is renamed, or is deleted.
1521
"""
1622
import json
1723
import sys
@@ -22,6 +28,33 @@
2228
MEMORY_DIR = Path(__file__).resolve().parent
2329
INDEX_DIR = MEMORY_DIR / ".index"
2430
INDEX_PATH = INDEX_DIR / "memory.db"
31+
FEATURES_PATH = MEMORY_DIR / ".features.json"
32+
33+
# Files we consider "memory documents" for both indexing and search.
34+
MEMORY_SUFFIXES = (".md", ".jsonl")
35+
36+
37+
def feature_enabled() -> bool:
38+
"""True iff `memory_search_fts` is opted in via .features.json.
39+
40+
Default OFF: beta features are explicit opt-in. Missing config file,
41+
missing key, or `enabled: false` all resolve to disabled.
42+
"""
43+
try:
44+
data = json.loads(FEATURES_PATH.read_text(encoding="utf-8"))
45+
except (FileNotFoundError, json.JSONDecodeError):
46+
return False
47+
entry = data.get("memory_search_fts") or {}
48+
return bool(entry.get("enabled"))
49+
50+
51+
def _memory_files():
52+
"""Yield memory document paths, skipping the .index/ side directory."""
53+
for f in MEMORY_DIR.rglob("*"):
54+
if ".index" in f.parts:
55+
continue
56+
if f.suffix in MEMORY_SUFFIXES and f.is_file():
57+
yield f
2558

2659

2760
def check_fts5() -> bool:
@@ -36,15 +69,36 @@ def check_fts5() -> bool:
3669

3770

3871
def needs_rebuild() -> bool:
39-
"""Check if index is stale (any source file newer than the index)."""
72+
"""True if the index is stale.
73+
74+
Stale means any of:
75+
- index file does not exist
76+
- a current memory file is newer than the index
77+
- a file that WAS indexed no longer exists (delete / rename)
78+
79+
Without the third check, deleted files keep showing up in search
80+
results until some unrelated edit bumps the index.
81+
"""
4082
if not INDEX_PATH.exists():
4183
return True
4284
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:
85+
86+
current_rel = set()
87+
for f in _memory_files():
88+
if f.stat().st_mtime > index_mtime:
4789
return True
90+
current_rel.add(str(f.relative_to(MEMORY_DIR)))
91+
92+
try:
93+
conn = sqlite3.connect(INDEX_PATH)
94+
indexed_rel = {row[0] for row in conn.execute("SELECT filename FROM memories")}
95+
conn.close()
96+
except sqlite3.OperationalError:
97+
return True # corrupt schema / unreadable — rebuild from scratch
98+
99+
# Any previously-indexed file no longer present? Rebuild to flush it.
100+
if indexed_rel - current_rel:
101+
return True
48102
return False
49103

50104

@@ -79,9 +133,7 @@ def build_index() -> int:
79133
USING fts5(filename, content, tokenize='porter unicode61')
80134
""")
81135
indexed = 0
82-
for f in MEMORY_DIR.rglob("*"):
83-
if ".index" in str(f):
84-
continue
136+
for f in _memory_files():
85137
try:
86138
if f.suffix == ".md":
87139
content = f.read_text(encoding="utf-8")
@@ -90,7 +142,8 @@ def build_index() -> int:
90142
else:
91143
continue
92144
rel_path = f.relative_to(MEMORY_DIR)
93-
conn.execute("INSERT INTO memories VALUES (?, ?)", (str(rel_path), content))
145+
conn.execute("INSERT INTO memories VALUES (?, ?)",
146+
(str(rel_path), content))
94147
indexed += 1
95148
except Exception:
96149
pass
@@ -99,7 +152,7 @@ def build_index() -> int:
99152
return indexed
100153

101154

102-
def search_fts5(query: str) -> list[tuple[str, str]]:
155+
def search_fts5(query: str):
103156
"""Search the FTS5 index. Returns (filename, snippet) pairs."""
104157
if needs_rebuild():
105158
build_index()
@@ -123,19 +176,26 @@ def search_fts5(query: str) -> list[tuple[str, str]]:
123176
return rows
124177

125178

126-
def search_grep(query: str) -> list[tuple[str, str]]:
127-
"""Fallback search using grep when FTS5 is not available."""
179+
def search_grep(query: str):
180+
"""Fallback search using grep, restricted to memory document files.
181+
182+
Passing explicit target paths (not the whole directory) ensures we
183+
don't match implementation files like archive.py or auto_dream.py —
184+
keyword retrieval must only surface .md / .jsonl memory content.
185+
"""
186+
targets = [str(f) for f in _memory_files()]
187+
if not targets:
188+
return []
128189
result = subprocess.run(
129-
["grep", "-ril", query, str(MEMORY_DIR)],
190+
["grep", "-ril", query, *targets],
130191
capture_output=True,
131192
text=True,
132193
)
133-
files = [
134-
f
135-
for f in result.stdout.strip().split("\n")
136-
if f and ".index" not in f
194+
files = [f for f in result.stdout.strip().split("\n") if f]
195+
return [
196+
(Path(f).relative_to(MEMORY_DIR), f"(match in {Path(f).name})")
197+
for f in files
137198
]
138-
return [(Path(f).relative_to(MEMORY_DIR), f"(match in {Path(f).name})") for f in files]
139199

140200

141201
def cmd_rebuild():
@@ -147,6 +207,13 @@ def cmd_rebuild():
147207

148208

149209
def cmd_status():
210+
enabled = feature_enabled()
211+
tag = "ENABLED" if enabled else "DISABLED (beta, opt-in)"
212+
print(f"Feature: memory_search_fts [BETA] — {tag}")
213+
if not enabled:
214+
print("Enable via: agentic-stack <harness> --reconfigure")
215+
print("Or edit .agent/memory/.features.json directly.")
216+
return
150217
if not check_fts5():
151218
print("Mode: BASIC (grep fallback)")
152219
print("Reason: SQLite FTS5 not available in this Python build.")
@@ -163,24 +230,40 @@ def cmd_status():
163230
print(f"Location: {INDEX_PATH}")
164231

165232

233+
def _refuse_disabled():
234+
print(
235+
"memory_search [BETA] is disabled — opt-in only.\n"
236+
"Enable via onboarding: agentic-stack <harness> --reconfigure\n"
237+
"Or set enabled=true for memory_search_fts in "
238+
".agent/memory/.features.json",
239+
file=sys.stderr,
240+
)
241+
sys.exit(2)
242+
243+
166244
def main():
167245
args = sys.argv[1:]
168246

169247
if not args or args[0] in ("-h", "--help"):
170-
print("Usage:")
248+
print("Usage [BETA, opt-in]:")
171249
print(" memory_search.py <query> Search memories by keyword")
172250
print(" memory_search.py --rebuild Force rebuild index")
173251
print(" memory_search.py --status Show index status")
174252
sys.exit(0)
175253

176-
if args[0] == "--rebuild":
177-
cmd_rebuild()
178-
return
179-
254+
# --status always works (lets the user see whether the feature is on).
255+
# All other commands require the opt-in flag.
180256
if args[0] == "--status":
181257
cmd_status()
182258
return
183259

260+
if not feature_enabled():
261+
_refuse_disabled()
262+
263+
if args[0] == "--rebuild":
264+
cmd_rebuild()
265+
return
266+
184267
query = " ".join(args)
185268
use_fts5 = check_fts5()
186269

.gitignore

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

22-
# derived search index (auto-rebuilt)
23-
.agent/memory/.index/
24-
2522
# do NOT ignore memory files — they are the point
2623
!.agent/memory/
2724
!.agent/memory/**
25+
26+
# ...EXCEPT the derived search index (auto-rebuilt, per-machine).
27+
# Placed AFTER the negation above so the later rule wins.
28+
.agent/memory/.index/
29+
.agent/memory/.index/**

onboard.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
import sys, os
44
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
55

6-
from onboard_ui import print_banner, intro, note, outro, step_done, BAR, R, MUTED, GREEN, PURPLE, WHITE, B
7-
from onboard_widgets import ask_text, ask_select, ask_confirm
8-
from onboard_render import render
9-
from onboard_write import write_prefs, is_customized, REL
6+
from onboard_ui import print_banner, intro, note, outro, step_done, BAR, R, MUTED, GREEN, PURPLE, WHITE, B, ORANGE
7+
from onboard_widgets import ask_text, ask_select, ask_confirm
8+
from onboard_render import render
9+
from onboard_write import write_prefs, is_customized, REL
10+
from onboard_features import write_features
1011

1112
_CI_VARS = ("CI", "GITHUB_ACTIONS", "CIRCLECI", "BUILDKITE", "JENKINS_URL", "TRAVIS")
1213

@@ -57,6 +58,16 @@ def _wizard(target, force):
5758
["conventional commits", "free-form", "emoji"])
5859
a["review"] = ask_select("Code review depth?",
5960
["critical issues only", "everything"])
61+
62+
# ── Optional features (beta, opt-in) ───────────────────────────────
63+
note("Optional features", [
64+
f"{ORANGE}[BETA]{R}{MUTED} features — off by default, opt-in only.",
65+
"You can change this later with agentic-stack <harness> --reconfigure.",
66+
])
67+
a["feature_memory_search"] = ask_confirm(
68+
f"Enable FTS memory search {ORANGE}[BETA]{R}?",
69+
default=False,
70+
)
6071
return a
6172

6273

@@ -71,19 +82,32 @@ def main():
7182

7283
if yes:
7384
path = write_prefs(target, render({}), force=True)
85+
# --yes defaults all optional beta features to off
86+
features_file = write_features(target, {
87+
"memory_search_fts": {"enabled": False, "beta": True},
88+
})
7489
print(f"{GREEN}{R} {WHITE}{B}PREFERENCES.md{R} written with defaults")
75-
print(f"{MUTED} {path}{R}\n")
90+
print(f"{MUTED} {path}{R}")
91+
print(f"{MUTED} {features_file} (all beta features off){R}\n")
7692
sys.exit(0)
7793

7894
try:
7995
answers = _wizard(target, force=reconf)
8096
if answers is None:
8197
sys.exit(0)
8298
path = write_prefs(target, render(answers), force=reconf)
99+
features = {
100+
"memory_search_fts": {
101+
"enabled": bool(answers.get("feature_memory_search")),
102+
"beta": True,
103+
},
104+
}
105+
features_file = write_features(target, features)
83106
outro([
84107
f"PREFERENCES.md written",
85108
f"{path}",
86-
"Edit it any time — your AI re-reads it every session.",
109+
f"Features: {features_file}",
110+
"Edit either file any time — your AI re-reads them every session.",
87111
"Tip: git add .agent/memory/ to track your brain.",
88112
])
89113
except KeyboardInterrupt:

onboard_features.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Optional feature toggles written by onboarding.
2+
3+
Stored at `.agent/memory/.features.json` as a simple JSON map. Keeps the
4+
data separate from PREFERENCES.md so toggling a feature doesn't require
5+
regenerating the whole preferences file, and so downstream code can read
6+
it without parsing markdown.
7+
8+
All features default to OFF. Users opt in explicitly during onboarding
9+
or by editing .features.json directly.
10+
"""
11+
import json
12+
import os
13+
14+
FEATURES_REL = ".agent/memory/.features.json"
15+
16+
17+
def features_path(target_dir: str) -> str:
18+
return os.path.join(target_dir, FEATURES_REL)
19+
20+
21+
def load_features(target_dir: str) -> dict:
22+
try:
23+
with open(features_path(target_dir), encoding="utf-8") as f:
24+
return json.load(f)
25+
except (FileNotFoundError, json.JSONDecodeError):
26+
return {}
27+
28+
29+
def write_features(target_dir: str, features: dict) -> str:
30+
path = features_path(target_dir)
31+
os.makedirs(os.path.dirname(path), exist_ok=True)
32+
with open(path, "w", encoding="utf-8") as f:
33+
json.dump(features, f, indent=2)
34+
f.write("\n")
35+
return path
36+
37+
38+
def is_enabled(target_dir: str, key: str) -> bool:
39+
"""True iff the feature is explicitly enabled. Off when file missing
40+
or key absent — opt-in model."""
41+
entry = load_features(target_dir).get(key) or {}
42+
return bool(entry.get("enabled"))

0 commit comments

Comments
 (0)