riskrails 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/README.md +246 -812
  5. package/agents/ai-reviewer.md.tmpl +6 -4
  6. package/ai/policies/review-economy.md +7 -2
  7. package/ai/using-riskrails.md +15 -0
  8. package/bin/claude-1m +15 -14
  9. package/bin/riskrails.js +108 -32
  10. package/codex/hooks.json +25 -2
  11. package/hooks/AGENTS.md +2 -2
  12. package/hooks/CLAUDE.md +2 -2
  13. package/hooks/ai-guard.py +29 -8
  14. package/hooks/ai-path-guard-defaults.json +3 -1
  15. package/hooks/codex-hooks.json +25 -2
  16. package/hooks/context-guard.py +251 -35
  17. package/hooks/dialog-gate.py +105 -0
  18. package/hooks/guard/common.py +5 -0
  19. package/hooks/guard/decision.py +123 -0
  20. package/hooks/guard/dialog.py +598 -0
  21. package/hooks/guard/git.py +49 -3
  22. package/hooks/guard/paths.py +62 -3
  23. package/hooks/hooks.json +37 -2
  24. package/hooks/memory-pre-push.py +28 -0
  25. package/hooks/runtime-gate.py +59 -12
  26. package/hooks/update_notice.py +135 -0
  27. package/instructions/routing.md +4 -4
  28. package/instructions/stub.md +2 -2
  29. package/package.json +1 -1
  30. package/profiles/max.json +3 -1
  31. package/profiles/pro.json +3 -1
  32. package/scripts/merge-codex-config.py +134 -0
  33. package/scripts/resolve-profile.py +15 -3
  34. package/settings.common.json +39 -2
  35. package/skills/AGENTS.md +2 -2
  36. package/skills/CLAUDE.md +2 -2
  37. package/skills/ai-init/templates/.ai/AGENTS.md +2 -2
  38. package/skills/ai-init/templates/.ai/VERSION +1 -1
  39. package/skills/ai-init/templates/.ai/policies/dialog.json +1 -0
  40. package/skills/ai-init/templates/.ai/policies/safety.md +22 -2
  41. package/skills/ai-init/templates/.ai/policies/weight.json +1 -0
  42. package/skills/ai-init/templates/.ai/policies/weight.md +2 -2
  43. package/skills/ai-init/templates/.ai/state/README.md +9 -6
  44. package/skills/ai-init/templates/AGENTS.block.md +1 -1
  45. package/skills/ai-init/templates/CLAUDE.block.md +1 -1
  46. package/skills/ai-init/templates/GEMINI.block.md +1 -1
  47. package/skills/ai-init/templates/junie-guidelines.block.md +1 -1
  48. package/skills/ai-task/memory_compact.py +968 -0
  49. package/skills/ai-task/memory_format.py +775 -0
  50. package/skills/ai-task/memory_store.py +528 -0
  51. package/skills/ai-task/memory_sync.py +1571 -0
  52. package/skills/ai-task/sensors.py +2 -2
  53. package/skills/ai-task/state.py +967 -70
  54. package/skills/ai-task/weight.py +3 -3
  55. package/skills/project-init/templates/constitution.md +1 -1
  56. package/skills/project-update/history/blobs/164cd34c182de88c33de7df35beb29200d792419e1c13a5f0b2104f944e57be7 +1 -0
  57. package/skills/project-update/history/blobs/1d1d907449ec74594d0ee923ac142ae746b9aedf0a1eda9469f075a44d23e3d7 +13 -0
  58. package/skills/project-update/history/blobs/23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5 +67 -0
  59. package/skills/project-update/history/blobs/5353ca529c46f2443e12947ec205a6887fb594a4de1f87f0eb1c0e6c0f826133 +49 -0
  60. package/skills/project-update/history/blobs/5f65bc7ab3fc3c4e75ae05d49eaf4331fafc398c661abe33c69f7380a720344c +32 -0
  61. package/skills/project-update/history/blobs/7be527cd648b27cca6e267de30f8e33a2ce17d5631a82694f23ae5c0623efe1f +15 -0
  62. package/skills/project-update/history/blobs/7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a +67 -0
  63. package/skills/project-update/history/blobs/8b3ef9586491a426767c01da5d01bdd6b0655c576c68c4cc6dfb093367817dc0 +67 -0
  64. package/skills/project-update/history/blobs/917df3320d778ddbaa5c5c7742bc4046bf803c36ed2b050f30844ed206783469 +1 -0
  65. package/skills/project-update/history/blobs/935af1019931058ec3a0b834fe230e127ec914f75edcc4a6c589cfe733b0b979 +13 -0
  66. package/skills/project-update/history/blobs/ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4 +84 -0
  67. package/skills/project-update/history/blobs/ca4499d829268c8766fb3b825a515e88dc55c237e9d202816b50959b4dac031f +13 -0
  68. package/skills/project-update/history/blobs/e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7 +74 -0
  69. package/skills/project-update/history/blobs/fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96 +71 -0
  70. package/skills/project-update/history/index.json +32 -13
  71. package/skills/project-update/migrations/0009_state_file.py +25 -0
  72. package/skills/project-update/migrations/0010_dialog_switch.py +30 -0
  73. package/skills/project-update/migrations/README.md +2 -1
  74. package/skills/project-update/update.py +12 -3
  75. package/skills/rr-build/SKILL.md +14 -5
  76. package/skills/rr-build-all/SKILL.md +190 -0
  77. package/skills/rr-plan/SKILL.md +13 -3
  78. package/skills/rr-review/SKILL.md +18 -8
  79. package/skills/rr-ship/SKILL.md +16 -8
  80. package/skills/rr-status/SKILL.md +2 -2
  81. package/skills/usage-report/usage-report.py +41 -8
@@ -0,0 +1,528 @@
1
+ """The History interface over a project's record (spec §D7, §D8).
2
+
3
+ FileHistory reads the packages under .ai/memory/, every legacy
4
+ .ai/reports/<id>/ whose key no package seals (compacted in tracked mode),
5
+ and the sections of .ai/project/known-risks.md. SqliteHistory (the optional
6
+ local index) answers the same calls from .ai/local/memory/index.sqlite3.
7
+ Both hand their entries to the one ranker and the one matcher in
8
+ memory_format, so recall and search are byte-identical across backends (R11).
9
+ """
10
+ import json
11
+ import os
12
+ import re
13
+
14
+ import memory_compact as mc
15
+ import memory_format as mf
16
+
17
+ STATE_NAMES = ("state.json", "current.json")
18
+
19
+
20
+ def current_task(root):
21
+ """The task in flight, from the live state, or None."""
22
+ for name in STATE_NAMES:
23
+ try:
24
+ with open(os.path.join(root, ".ai", "state", name), encoding="utf-8") as fh:
25
+ state = json.load(fh)
26
+ except (OSError, ValueError):
27
+ continue
28
+ if isinstance(state, dict) and state.get("current_stage") != "done" and state.get("task_id"):
29
+ return state["task_id"]
30
+ return None
31
+
32
+
33
+ def risk_entries_from_md(text, src_name="known-risks.md"):
34
+ """known-risks.md sections as risk entries without a task (files mode)."""
35
+ risks, _ = mc.parse_known_risks(text)
36
+ out = []
37
+ for i, risk in enumerate(risks):
38
+ found = re.search(r"\b(\d{4}-\d{2}-\d{2})\b", risk["text"])
39
+ entry = {"v": mf.FORMAT, "task": None, "inst": 1, "seq": i, "kind": "risk",
40
+ "ts": "%sT00:00:00Z" % found.group(1) if found else "1970-01-01T00:00:00Z",
41
+ "actor": "unknown", "by": None, "runtime": "unknown", "src": ["%s#%s" % (src_name, risk["rkey"])]}
42
+ entry.update({k: risk[k] for k in ("rkey", "title", "where", "label", "what", "likelihood", "fix",
43
+ "status", "status_why", "found_by", "text")})
44
+ if risk["label_derived"]:
45
+ entry["label_derived"] = True
46
+ out.append(mf.with_rid(entry))
47
+ return out
48
+
49
+
50
+ class History:
51
+ """The interface: tasks, entries, raw_journal, task_window, recall,
52
+ search, risks. Subclasses provide load()."""
53
+
54
+ def __init__(self, root, cfg=None):
55
+ self.root = root
56
+ self.cfg = cfg
57
+ self._loaded = None
58
+
59
+ # ---------------------------------------------------------- loading
60
+ def load(self): # pragma: no cover - abstract
61
+ raise NotImplementedError
62
+
63
+ def data(self):
64
+ if self._loaded is None:
65
+ self._loaded = self.load()
66
+ return self._loaded
67
+
68
+ def all_entries(self):
69
+ return self.data()["entries"]
70
+
71
+ def seals(self):
72
+ """{key: seal}; for a conflicting key, the newest seal."""
73
+ out = {}
74
+ for e in self.all_entries():
75
+ if e.get("kind") == "task":
76
+ key = mf.key_of(e)
77
+ if key not in out or (e.get("ts") or "", e["rid"]) > (out[key].get("ts") or "", out[key]["rid"]):
78
+ out[key] = e
79
+ return out
80
+
81
+ def superseded(self):
82
+ return {e["supersedes"] for e in self.all_entries() if e.get("supersedes")}
83
+
84
+ # ---------------------------------------------------------- the calls
85
+ def tasks(self):
86
+ return sorted(self.seals().values(), key=lambda s: (s.get("closed_at") or s.get("ts") or "", s["rid"]))
87
+
88
+ def entries(self, key):
89
+ """A key is `<task>#<inst>` or a bare task id (every instance)."""
90
+ if "#" in key:
91
+ return mf.sort_entries([e for e in self.all_entries() if mf.key_of(e) == key])
92
+ return mf.sort_entries([e for e in self.all_entries() if e.get("task") == key])
93
+
94
+ def conflicts(self):
95
+ by_key = {}
96
+ for e in self.all_entries():
97
+ if e.get("kind") == "task":
98
+ by_key.setdefault(mf.key_of(e), set()).add(e["rid"])
99
+ chained = self.superseded()
100
+ return {k: sorted(v) for k, v in by_key.items() if len(v - chained) > 1}
101
+
102
+ def raw_journal(self, task_id):
103
+ for rel in (".ai/reports/%s/events.jsonl" % task_id, ".ai/state/logs/%s/report/events.jsonl" % task_id):
104
+ try:
105
+ with open(os.path.join(self.root, rel), "rb") as fh:
106
+ return fh.read()
107
+ except OSError:
108
+ continue
109
+ return None
110
+
111
+ def task_window(self, task_id):
112
+ """(started_at, closed_at) of the task's last instance, from its seal."""
113
+ found = [s for s in self.seals().values() if s.get("task") == task_id]
114
+ if not found:
115
+ return None
116
+ seal = max(found, key=lambda s: s.get("inst") or 0)
117
+ return seal.get("started_at"), seal.get("closed_at")
118
+
119
+ def risks(self, status="open"):
120
+ latest = {}
121
+ dead = self.superseded()
122
+ for e in self.all_entries():
123
+ if e.get("kind") != "risk" or e["rid"] in dead:
124
+ continue
125
+ have = latest.get(e.get("rkey"))
126
+ if have is None or (e.get("ts") or "", e["rid"]) > (have.get("ts") or "", have["rid"]):
127
+ latest[e.get("rkey")] = e
128
+ out = sorted(latest.values(), key=lambda e: (mf.desc_key(e.get("ts") or ""), e["rid"]))
129
+ return out if status == "all" else [e for e in out if e.get("status") == status]
130
+
131
+ def source(self):
132
+ d = self.data()
133
+ return {"packages": d["packages"], "legacy": d["legacy"], "index": d.get("index") or self.index_name(),
134
+ "fts5": False, "fallback": d.get("fallback", False)}
135
+
136
+ def index_name(self):
137
+ return "none"
138
+
139
+ def recall(self, files, budget=1500, max_items=40, kinds=None, all_risks=False, offset=0, exclude=None):
140
+ exclude = exclude if exclude is not None else current_task(self.root)
141
+ entries = [e for e in self.all_entries() if not (exclude and e.get("task") == exclude)]
142
+ items, _ = mf.candidate_items(entries, self.seals(), self.superseded(), all_risks)
143
+ if kinds:
144
+ wanted = set(kinds)
145
+ items = [i for i in items if KIND_GROUP.get(i["kind"]) in wanted]
146
+ return mf.rank_recall(items, files, budget, max_items, offset, self.source(), exclude)
147
+
148
+ def search(self, query, filters=None, limit=20, offset=0):
149
+ tokens = mf.tokenize(query)
150
+ seals = self.seals()
151
+ hits = [e for e in self.candidates(tokens, filters)
152
+ if mf.match_search(e, tokens, filters, seals.get(mf.key_of(e)))]
153
+ hits.sort(key=mf.search_order)
154
+ return hits[offset:offset + limit], len(hits)
155
+
156
+ def candidates(self, tokens, filters):
157
+ del tokens, filters
158
+ return self.all_entries()
159
+
160
+
161
+ KIND_GROUP = {"risk": "risk", "review": "review", "question": "decision", "override": "decision",
162
+ "approval": "decision", "note": "decision", "scope": "scope", "task": "task"}
163
+
164
+
165
+ class FileHistory(History):
166
+ """Packages, legacy report directories and known-risks.md, read each call."""
167
+
168
+ def load(self):
169
+ return load_files(self.root, self.cfg)
170
+
171
+
172
+ def package_files(root):
173
+ base = os.path.join(root, ".ai", "memory")
174
+ try:
175
+ return sorted(n for n in os.listdir(base) if n.endswith(".jsonl"))
176
+ except OSError:
177
+ return []
178
+
179
+
180
+ def load_files(root, cfg=None):
181
+ """Everything FileHistory reads, plus the problems it met on the way."""
182
+ entries, errors, sealed = [], [], set()
183
+ names = package_files(root)
184
+ for name in names:
185
+ try:
186
+ with open(os.path.join(root, ".ai", "memory", name), "rb") as fh:
187
+ data = fh.read()
188
+ except OSError as exc:
189
+ errors.append((name, 0, str(exc)))
190
+ continue
191
+ _, found, problems = mf.parse_package(data, name)
192
+ errors += [(name, line, what) for line, what in problems]
193
+ for e in found:
194
+ e = mf.strip_private(e)
195
+ entries.append(e)
196
+ if e.get("kind") == "task":
197
+ sealed.add(e.get("task"))
198
+ rids = set()
199
+ unique = []
200
+ for e in entries:
201
+ if e["rid"] not in rids:
202
+ rids.add(e["rid"])
203
+ unique.append(e)
204
+ entries = unique
205
+ legacy = legacy_entries(root, sealed, cfg)
206
+ entries += legacy["entries"]
207
+ md_risks = []
208
+ text = read_text(os.path.join(root, ".ai", "project", "known-risks.md"))
209
+ if text:
210
+ md_risks = risk_entries_from_md(text)
211
+ entries += [e for e in md_risks if e["rid"] not in rids]
212
+ entries += live_risks(root)
213
+ return {"entries": entries, "errors": errors, "packages": len(names), "legacy": legacy["count"],
214
+ "legacy_keys": legacy["keys"], "known_risks": len(md_risks)}
215
+
216
+
217
+ def read_text(path):
218
+ try:
219
+ with open(path, encoding="utf-8") as fh:
220
+ return fh.read()
221
+ except OSError:
222
+ return None
223
+
224
+
225
+ def legacy_entries(root, sealed, cfg=None):
226
+ """Closed report directories no package seals, compacted from their
227
+ tracked files only, so every clone sees the same rids."""
228
+ reports = os.path.join(root, ".ai", "reports")
229
+ reader = mc.FsReader(root)
230
+ out, keys, count = [], [], 0
231
+ excerpt_max = getattr(cfg, "excerpt_max_bytes", 1024) if cfg is not None else 1024
232
+ in_flight = current_task(root)
233
+ del reports
234
+ for name in legacy_names(root):
235
+ if name in sealed or name == in_flight:
236
+ continue
237
+ found = legacy_dir_entries(reader, name, excerpt_max)
238
+ if not found:
239
+ continue
240
+ count += 1
241
+ out += found
242
+ keys += sorted({mf.key_of(e) for e in found if e.get("kind") == "task"})
243
+ return {"entries": out, "keys": keys, "count": count}
244
+
245
+
246
+ def legacy_names(root):
247
+ reports = os.path.join(root, ".ai", "reports")
248
+ return [n for n in (sorted(os.listdir(reports)) if os.path.isdir(reports) else [])
249
+ if n.startswith("T-") and mf.is_task_id(n)]
250
+
251
+
252
+ def legacy_dir_entries(reader, name, excerpt_max=1024):
253
+ """One closed report directory, compacted from its tracked files; [] when
254
+ its last instance is still open."""
255
+ found = mc.compact_instances(reader, name, "tracked", excerpt_max)
256
+ closed = [c for c in found if c.seal and c.seal["result"] in ("closed", "abandoned", "restarted")]
257
+ if not closed or found[-1].seal["result"] == "open":
258
+ return []
259
+ return [e for c in closed for e in c.entries + [c.seal]]
260
+
261
+
262
+ def live_risks(root):
263
+ """risk_recorded lines of the task in flight: `memory risks` shows them
264
+ before close compacts them (§D9)."""
265
+ task = current_task(root)
266
+ if not task:
267
+ return []
268
+ data = mc.FsReader(root).read(".ai/reports/%s/events.jsonl" % task)
269
+ out = []
270
+ for number, event in mc.parse_journal(data):
271
+ if not event or event.get("event") != "risk_recorded":
272
+ continue
273
+ d = event.get("data") or {}
274
+ entry = {"v": mf.FORMAT, "task": task, "inst": 1, "seq": number, "kind": "risk",
275
+ "ts": mc.ts_ok(event.get("ts")), "actor": mc.actor_of(event), "by": d.get("by"),
276
+ "runtime": mc.runtime_of(event), "src": ["events.jsonl:%d" % number]}
277
+ entry.update({k: d.get(k) for k in ("rkey", "title", "where", "label", "what", "likelihood", "fix",
278
+ "status", "status_why")})
279
+ entry.update(found_by=task, text=d.get("text"))
280
+ if d.get("supersedes"):
281
+ entry["supersedes"] = d["supersedes"]
282
+ out.append(mf.with_rid(entry))
283
+ return out
284
+
285
+
286
+ def open_history(root, cfg=None):
287
+ """The index when the developer chose it and it answers; FileHistory otherwise."""
288
+ if cfg is not None and getattr(cfg, "index", "none") == "sqlite":
289
+ try:
290
+ history = SqliteHistory(root, cfg)
291
+ history.data()
292
+ return history
293
+ except Exception as exc: # pylint: disable=broad-except # any index failure falls back
294
+ history = FileHistory(root, cfg)
295
+ history.data()["fallback"] = True
296
+ history.data()["index"] = "sqlite"
297
+ history.data()["fallback_why"] = str(exc)
298
+ return history
299
+ return FileHistory(root, cfg)
300
+
301
+
302
+ # ------------------------------------------------------------------ the local index (§D7, §I3, M10)
303
+ INDEX_REL = os.path.join(".ai", "local", "memory", "index.sqlite3")
304
+ USER_VERSION = 1
305
+ DDL = """
306
+ CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
307
+ CREATE TABLE IF NOT EXISTS packages(path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER, sha256 TEXT,
308
+ author TEXT, period TEXT, entries INTEGER);
309
+ CREATE TABLE IF NOT EXISTS tasks(key TEXT PRIMARY KEY, task_id TEXT NOT NULL, inst INTEGER NOT NULL,
310
+ started_at TEXT, closed_at TEXT, result TEXT, mode TEXT, w INTEGER,
311
+ workflow TEXT, goal TEXT, seal_rid TEXT, source TEXT NOT NULL);
312
+ CREATE INDEX IF NOT EXISTS tasks_closed ON tasks(closed_at);
313
+ CREATE TABLE IF NOT EXISTS entries(rid TEXT PRIMARY KEY, key TEXT, seq INTEGER, kind TEXT NOT NULL,
314
+ ts TEXT NOT NULL, actor TEXT, by TEXT, runtime TEXT, status TEXT,
315
+ superseded INTEGER NOT NULL DEFAULT 0, text TEXT NOT NULL, json TEXT NOT NULL,
316
+ package TEXT, line INTEGER, origin TEXT NOT NULL);
317
+ CREATE INDEX IF NOT EXISTS entries_key ON entries(key, seq);
318
+ CREATE INDEX IF NOT EXISTS entries_kind_ts ON entries(kind, ts);
319
+ CREATE INDEX IF NOT EXISTS entries_by_ts ON entries(by, ts);
320
+ CREATE INDEX IF NOT EXISTS entries_package ON entries(package);
321
+ CREATE TABLE IF NOT EXISTS entry_files(rid TEXT NOT NULL REFERENCES entries(rid) ON DELETE CASCADE,
322
+ path TEXT NOT NULL, own INTEGER NOT NULL, PRIMARY KEY(rid, path));
323
+ CREATE INDEX IF NOT EXISTS entry_files_path ON entry_files(path);
324
+ CREATE TABLE IF NOT EXISTS conflicts(key TEXT, rid_a TEXT, rid_b TEXT, reason TEXT,
325
+ PRIMARY KEY(key, rid_a, rid_b));
326
+ """
327
+ FTS_DDL = ("CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(text, content='entries', "
328
+ "content_rowid='rowid', tokenize='unicode61 remove_diacritics 0')")
329
+ LEGACY_FILES = ("events.jsonl", "task.md", "questions.md", "state.json")
330
+
331
+
332
+ def fts5_available():
333
+ """FTS5, detected on :memory:; AI_MEMORY_NO_FTS=1 turns it off for tests."""
334
+ if os.environ.get("AI_MEMORY_NO_FTS"):
335
+ return False
336
+ import sqlite3 # pylint: disable=import-outside-toplevel
337
+ try:
338
+ con = sqlite3.connect(":memory:")
339
+ con.execute("CREATE VIRTUAL TABLE t USING fts5(x)")
340
+ con.close()
341
+ return True
342
+ except sqlite3.Error:
343
+ return False
344
+
345
+
346
+ def mark_stale(root):
347
+ """B5 failed: the next read refreshes the index or falls back to files."""
348
+ import sqlite3 # pylint: disable=import-outside-toplevel
349
+ path = os.path.join(root, INDEX_REL)
350
+ if os.path.exists(path):
351
+ con = sqlite3.connect(path, timeout=1)
352
+ try:
353
+ con.execute("INSERT OR REPLACE INTO meta(key, value) VALUES('stale', '1')")
354
+ con.commit()
355
+ finally:
356
+ con.close()
357
+
358
+
359
+ def source_signature(root, rels):
360
+ """(size, mtime_ns) of the files one source reads, as one string."""
361
+ parts = []
362
+ for rel in rels:
363
+ try:
364
+ st = os.stat(os.path.join(root, rel))
365
+ parts.append("%s:%d:%d" % (rel, st.st_size, st.st_mtime_ns))
366
+ except OSError:
367
+ parts.append("%s:-" % rel)
368
+ return "|".join(parts)
369
+
370
+
371
+ class SqliteHistory(History):
372
+ """The derived index, refreshed from the sources on every open and never
373
+ the only copy of anything. Any error is the caller's cue to fall back."""
374
+
375
+ def __init__(self, root, cfg=None):
376
+ super().__init__(root, cfg)
377
+ self.path = os.path.join(root, INDEX_REL)
378
+ self.fts = fts5_available()
379
+
380
+ def index_name(self):
381
+ return "sqlite"
382
+
383
+ def source(self):
384
+ out = super().source()
385
+ out["fts5"] = self.fts
386
+ return out
387
+
388
+ def connect(self):
389
+ import sqlite3 # pylint: disable=import-outside-toplevel
390
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
391
+ busy = int(os.environ.get("AI_MEMORY_BUSY_MS", "5000"))
392
+ con = sqlite3.connect(self.path, timeout=busy / 1000.0, isolation_level=None)
393
+ con.execute("PRAGMA busy_timeout=%d" % busy)
394
+ version = con.execute("PRAGMA user_version").fetchone()[0]
395
+ if version not in (0, USER_VERSION):
396
+ con.close() # a newer or older layout: rebuild from the sources
397
+ os.remove(self.path)
398
+ return self.connect()
399
+ con.execute("PRAGMA journal_mode=WAL")
400
+ con.execute("PRAGMA foreign_keys=ON")
401
+ if version == 0:
402
+ con.executescript(DDL)
403
+ con.execute("PRAGMA user_version=%d" % USER_VERSION)
404
+ has_fts = bool(con.execute("SELECT 1 FROM sqlite_master WHERE name='entries_fts'").fetchone())
405
+ if self.fts and not has_fts:
406
+ con.execute(FTS_DDL)
407
+ con.execute("INSERT INTO entries_fts(entries_fts) VALUES('rebuild')")
408
+ return con
409
+
410
+ def rebuild(self):
411
+ for suffix in ("", "-wal", "-shm"):
412
+ if os.path.exists(self.path + suffix):
413
+ os.remove(self.path + suffix)
414
+ return self.refresh()
415
+
416
+ def sources(self):
417
+ """{source key: (origin, signature, name)} for everything FileHistory reads."""
418
+ out = {}
419
+ for name in package_files(self.root):
420
+ out["memory/" + name] = ("package", source_signature(self.root, [os.path.join(".ai", "memory", name)]),
421
+ name)
422
+ for name in legacy_names(self.root):
423
+ rels = [os.path.join(".ai", "reports", name, f) for f in LEGACY_FILES]
424
+ out["legacy/" + name] = ("legacy", source_signature(self.root, rels), name)
425
+ rel = os.path.join(".ai", "project", "known-risks.md")
426
+ if os.path.exists(os.path.join(self.root, rel)):
427
+ out["risks/known-risks.md"] = ("risks", source_signature(self.root, [rel]), rel)
428
+ return out
429
+
430
+ def load_source(self, origin, name):
431
+ if origin == "package":
432
+ with open(os.path.join(self.root, ".ai", "memory", name), "rb") as fh:
433
+ _, entries, _ = mf.parse_package(fh.read(), name)
434
+ return [mf.strip_private(e) for e in entries]
435
+ if origin == "legacy":
436
+ excerpt_max = getattr(self.cfg, "excerpt_max_bytes", 1024) if self.cfg is not None else 1024
437
+ return legacy_dir_entries(mc.FsReader(self.root), name, excerpt_max)
438
+ return risk_entries_from_md(read_text(os.path.join(self.root, name)) or "")
439
+
440
+ def refresh(self):
441
+ """Import what changed since the last call, in one BEGIN IMMEDIATE. A
442
+ source that disappeared rebuilds: its rids may be another's too."""
443
+ con = self.connect()
444
+ try:
445
+ known = {row[0]: row[1] for row in con.execute("SELECT path, sha256 FROM packages")}
446
+ current = self.sources()
447
+ if set(known) - set(current):
448
+ con.close()
449
+ return self.rebuild()
450
+ changed = [k for k in sorted(current) if known.get(k) != current[k][1]]
451
+ if not changed:
452
+ return 0
453
+ con.execute("BEGIN IMMEDIATE")
454
+ for key in changed:
455
+ self._import(con, key, *current[key])
456
+ con.execute("INSERT OR REPLACE INTO meta VALUES('stale', '0')")
457
+ con.execute("INSERT OR REPLACE INTO meta VALUES('fts5', ?)", ("1" if self.fts else "0",))
458
+ con.execute("INSERT OR REPLACE INTO meta VALUES('keep_rules', ?)", (str(mf.KEEP_VERSION),))
459
+ con.execute("COMMIT")
460
+ return len(changed)
461
+ finally:
462
+ con.close()
463
+
464
+ def _import(self, con, key, origin, signature, name):
465
+ if self.fts:
466
+ con.execute("INSERT INTO entries_fts(entries_fts, rowid, text) SELECT 'delete', rowid, text "
467
+ "FROM entries WHERE package=?", (key,))
468
+ con.execute("DELETE FROM entries WHERE package=?", (key,))
469
+ entries = self.load_source(origin, name)
470
+ for line, e in enumerate(entries, 1):
471
+ cur = con.execute(
472
+ "INSERT OR IGNORE INTO entries(rid, key, seq, kind, ts, actor, by, runtime, status, text, json, "
473
+ "package, line, origin) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
474
+ (e["rid"], mf.key_of(e), e.get("seq"), e.get("kind"), e.get("ts") or "", e.get("actor"),
475
+ e.get("by"), e.get("runtime"), e.get("status") or e.get("decision") or e.get("result"),
476
+ mf.search_text(e), mf.canonical(e).decode("utf-8"), key, line, origin))
477
+ if not cur.rowcount:
478
+ continue
479
+ if self.fts:
480
+ con.execute("INSERT INTO entries_fts(rowid, text) SELECT rowid, text FROM entries WHERE rid=?",
481
+ (e["rid"],))
482
+ for path in mf.own_paths(e):
483
+ con.execute("INSERT OR IGNORE INTO entry_files(rid, path, own) VALUES(?,?,1)", (e["rid"], path))
484
+ if e.get("kind") == "task":
485
+ con.execute("INSERT OR REPLACE INTO tasks VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
486
+ (mf.key_of(e), e.get("task"), e.get("inst"), e.get("started_at"), e.get("closed_at"),
487
+ e.get("result"), e.get("mode"), e.get("w"), e.get("workflow"), e.get("goal"),
488
+ e["rid"], origin))
489
+ parts = mf.parse_package_name(name) if origin == "package" else None
490
+ con.execute("INSERT OR REPLACE INTO packages VALUES(?,?,?,?,?,?,?)",
491
+ (key, 0, 0, signature, (parts or {}).get("author"), (parts or {}).get("period"), len(entries)))
492
+
493
+ def load(self):
494
+ self.refresh()
495
+ con = self.connect()
496
+ try:
497
+ rows = con.execute("SELECT origin, json FROM entries").fetchall()
498
+ n_packages = con.execute("SELECT COUNT(*) FROM packages WHERE path LIKE 'memory/%'").fetchone()[0]
499
+ finally:
500
+ con.close()
501
+ by_origin = {"package": [], "legacy": [], "risks": []}
502
+ for origin, text in rows:
503
+ by_origin[origin].append(json.loads(text))
504
+ sealed = {e.get("task") for e in by_origin["package"] if e.get("kind") == "task"}
505
+ in_flight = current_task(self.root)
506
+ legacy = [e for e in by_origin["legacy"] if e.get("task") not in sealed and e.get("task") != in_flight]
507
+ rids = {e["rid"] for e in by_origin["package"]}
508
+ entries = by_origin["package"] + legacy + [e for e in by_origin["risks"] if e["rid"] not in rids]
509
+ entries += live_risks(self.root)
510
+ return {"entries": entries, "errors": [], "packages": n_packages,
511
+ "legacy": len({e.get("task") for e in legacy}),
512
+ "legacy_keys": sorted({mf.key_of(e) for e in legacy if e.get("kind") == "task"}),
513
+ "known_risks": len(by_origin["risks"])}
514
+
515
+ def candidates(self, tokens, filters):
516
+ """FTS5 only narrows, on ASCII prefix tokens; the Python matcher decides."""
517
+ del filters
518
+ if not self.fts or not tokens or not all(re.match(r"^[a-z0-9]+$", t) for t in tokens):
519
+ return self.all_entries()
520
+ con = self.connect()
521
+ try:
522
+ rids = {r[0] for r in con.execute(
523
+ "SELECT e.rid FROM entries_fts JOIN entries e ON e.rowid = entries_fts.rowid "
524
+ "WHERE entries_fts MATCH ?", (" AND ".join('"%s"*' % t for t in tokens),))}
525
+ finally:
526
+ con.close()
527
+ live = {e["rid"] for e in live_risks(self.root)}
528
+ return [e for e in self.all_entries() if e["rid"] in rids or e["rid"] in live]