agi-memory 0.7.0__tar.gz → 0.7.2__tar.gz

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 (33) hide show
  1. agi_memory-0.7.2/PKG-INFO +110 -0
  2. agi_memory-0.7.2/README.md +88 -0
  3. {agi_memory-0.7.0 → agi_memory-0.7.2}/pyproject.toml +1 -1
  4. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/hooks.py +115 -13
  5. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/layers/code_layer.py +65 -7
  6. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/layers/session_layer.py +5 -0
  7. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/mcp_server.py +41 -6
  8. agi_memory-0.7.2/src/agi_memory/redact.py +60 -0
  9. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/vault.py +73 -6
  10. agi_memory-0.7.2/src/agi_memory.egg-info/PKG-INFO +110 -0
  11. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory.egg-info/SOURCES.txt +1 -0
  12. {agi_memory-0.7.0 → agi_memory-0.7.2}/tests/test_offline.py +132 -6
  13. agi_memory-0.7.0/PKG-INFO +0 -135
  14. agi_memory-0.7.0/README.md +0 -113
  15. agi_memory-0.7.0/src/agi_memory.egg-info/PKG-INFO +0 -135
  16. {agi_memory-0.7.0 → agi_memory-0.7.2}/LICENSE +0 -0
  17. {agi_memory-0.7.0 → agi_memory-0.7.2}/setup.cfg +0 -0
  18. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/__init__.py +0 -0
  19. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/analyze.py +0 -0
  20. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/bootstrap.py +0 -0
  21. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/config.py +0 -0
  22. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/init_command.py +0 -0
  23. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/integrate.py +0 -0
  24. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/layers/__init__.py +0 -0
  25. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/layers/base.py +0 -0
  26. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/layers/episodic_layer.py +0 -0
  27. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/layers/graph_layer.py +0 -0
  28. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/promote.py +0 -0
  29. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/recall.py +0 -0
  30. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/sync.py +0 -0
  31. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory.egg-info/dependency_links.txt +0 -0
  32. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory.egg-info/entry_points.txt +0 -0
  33. {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory.egg-info/top_level.txt +0 -0
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: agi-memory
3
+ Version: 0.7.2
4
+ Summary: Turnkey zero-dependency four-pillar cognitive memory framework (Epistemic, Semantic, Episodic, Structural Code Graph) with MCP server for Claude Code, Cursor, Codex, OpenCode, Antigravity, and more
5
+ Author: Krunal Bhalala
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kdbhalala/agi-memory
8
+ Project-URL: Repository, https://github.com/kdbhalala/agi-memory
9
+ Project-URL: Issues, https://github.com/kdbhalala/agi-memory/issues
10
+ Keywords: mcp,agi-memory,ai-memory,knowledge-graph,claude-code,cursor,codex,opencode,llm,sqlite
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # agi-memory
24
+
25
+ [![CI](https://github.com/kdbhalala/agi-memory/actions/workflows/ci.yml/badge.svg)](https://github.com/kdbhalala/agi-memory/actions)
26
+ [![PyPI](https://img.shields.io/pypi/v/agi-memory.svg)](https://pypi.org/project/agi-memory/)
27
+
28
+ Coding assistants forget everything when a session ends. agi-memory is a small
29
+ local memory they can all read and write, so you don't have to re-explain your
30
+ project every time you open a new session or switch tools.
31
+
32
+ ## A basic example
33
+
34
+ On Monday you tell Claude Code why webhook retries are capped, and it saves that:
35
+
36
+ ```
37
+ memory_record(
38
+ title="Webhook retry limit",
39
+ text="Payment webhooks retry at most 5 times with exponential backoff. "
40
+ "Do not raise the limit: the provider bans endpoints that retry more.",
41
+ rationale="Stripe disables endpoints after repeated failed retries",
42
+ )
43
+ ```
44
+
45
+ On Thursday you're in Cursor, in a fresh session, and ask it to make webhooks
46
+ more reliable. Before touching the code it checks memory:
47
+
48
+ ```
49
+ $ agi-memory recall "webhook retries" --project shop
50
+ #1 [shop] Webhook retry limit: Payment webhooks retry at most 5 times with
51
+ exponential backoff. Do not raise the limit: the provider bans endpoints that
52
+ retry more. | Why: Stripe disables endpoints after repeated failed retries
53
+ ```
54
+
55
+ So it doesn't "fix" reliability by raising the retry count. That's the whole
56
+ idea: decisions, bugs you already fixed, and what you tried last time stay
57
+ available across sessions and across tools.
58
+
59
+ ## How it works
60
+
61
+ - Memories are stored in SQLite on your machine, and also written to plain
62
+ append-only text files you can read, diff, and keep in git.
63
+ - Each assistant talks to it through a local MCP server. It works in Claude
64
+ Code, Cursor, Windsurf, Codex, OpenCode, Antigravity, Aider, Goose, Cline,
65
+ Roo Code, Crush, Pi, and Hermes Agent.
66
+ - Nothing leaves your machine unless you point the memory files at a git
67
+ remote to sync them between computers.
68
+ - It's pure Python with no dependencies: no vector database, no model
69
+ download, no background service.
70
+
71
+ Besides notes and decisions, it also keeps a short history of past sessions and
72
+ an index of your code (functions and who calls them), so an assistant can ask
73
+ "what calls this?" before changing it.
74
+
75
+ ## What it won't do
76
+
77
+ Search is by keyword, not meaning. If you saved "authentication" and later ask
78
+ about "login", it can miss. It helps to use the words you'd search for when
79
+ saving something. The measured hit rates, including the misses, are in
80
+ [Testing & Evals](docs/testing.md).
81
+
82
+ ## Install
83
+
84
+ ```bash
85
+ pipx install agi-memory # or: brew tap kdbhalala/agi-memory https://github.com/kdbhalala/agi-memory && brew install agi-memory
86
+ agi-integrate install all # connect every assistant it finds on your machine
87
+ agi-integrate status # see what got connected
88
+ ```
89
+
90
+ Other install options, including a one-line script and running from source, are
91
+ in [Installation](docs/installation.md).
92
+
93
+ ## Documentation
94
+
95
+ | Guide | What's in it |
96
+ |---|---|
97
+ | [Installation](docs/installation.md) | Install options, upgrading, lifecycle hooks |
98
+ | [CLI Usage](docs/cli.md) | Every `agi-memory` and `agi-integrate` command |
99
+ | [Supported Assistants](docs/assistants.md) | Where each assistant's config lives, and the 16 MCP tools |
100
+ | [How memory is organised](docs/pillars.md) | Notes, knowledge graph, session history, code index |
101
+ | [Architecture](docs/architecture.md) | How the pieces fit together |
102
+ | [Vault & Git Sync](docs/sync.md) | The memory files, syncing between machines, compaction |
103
+ | [Python API](docs/python-api.md) | Using it directly from Python |
104
+ | [Testing & Evals](docs/testing.md) | How it's tested, and where it falls short |
105
+ | [Benchmarks](docs/benchmarks.md) | Speed and memory measurements |
106
+ | [Integrations](INTEGRATIONS.md) | Manual setup snippets for each assistant |
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,88 @@
1
+ # agi-memory
2
+
3
+ [![CI](https://github.com/kdbhalala/agi-memory/actions/workflows/ci.yml/badge.svg)](https://github.com/kdbhalala/agi-memory/actions)
4
+ [![PyPI](https://img.shields.io/pypi/v/agi-memory.svg)](https://pypi.org/project/agi-memory/)
5
+
6
+ Coding assistants forget everything when a session ends. agi-memory is a small
7
+ local memory they can all read and write, so you don't have to re-explain your
8
+ project every time you open a new session or switch tools.
9
+
10
+ ## A basic example
11
+
12
+ On Monday you tell Claude Code why webhook retries are capped, and it saves that:
13
+
14
+ ```
15
+ memory_record(
16
+ title="Webhook retry limit",
17
+ text="Payment webhooks retry at most 5 times with exponential backoff. "
18
+ "Do not raise the limit: the provider bans endpoints that retry more.",
19
+ rationale="Stripe disables endpoints after repeated failed retries",
20
+ )
21
+ ```
22
+
23
+ On Thursday you're in Cursor, in a fresh session, and ask it to make webhooks
24
+ more reliable. Before touching the code it checks memory:
25
+
26
+ ```
27
+ $ agi-memory recall "webhook retries" --project shop
28
+ #1 [shop] Webhook retry limit: Payment webhooks retry at most 5 times with
29
+ exponential backoff. Do not raise the limit: the provider bans endpoints that
30
+ retry more. | Why: Stripe disables endpoints after repeated failed retries
31
+ ```
32
+
33
+ So it doesn't "fix" reliability by raising the retry count. That's the whole
34
+ idea: decisions, bugs you already fixed, and what you tried last time stay
35
+ available across sessions and across tools.
36
+
37
+ ## How it works
38
+
39
+ - Memories are stored in SQLite on your machine, and also written to plain
40
+ append-only text files you can read, diff, and keep in git.
41
+ - Each assistant talks to it through a local MCP server. It works in Claude
42
+ Code, Cursor, Windsurf, Codex, OpenCode, Antigravity, Aider, Goose, Cline,
43
+ Roo Code, Crush, Pi, and Hermes Agent.
44
+ - Nothing leaves your machine unless you point the memory files at a git
45
+ remote to sync them between computers.
46
+ - It's pure Python with no dependencies: no vector database, no model
47
+ download, no background service.
48
+
49
+ Besides notes and decisions, it also keeps a short history of past sessions and
50
+ an index of your code (functions and who calls them), so an assistant can ask
51
+ "what calls this?" before changing it.
52
+
53
+ ## What it won't do
54
+
55
+ Search is by keyword, not meaning. If you saved "authentication" and later ask
56
+ about "login", it can miss. It helps to use the words you'd search for when
57
+ saving something. The measured hit rates, including the misses, are in
58
+ [Testing & Evals](docs/testing.md).
59
+
60
+ ## Install
61
+
62
+ ```bash
63
+ pipx install agi-memory # or: brew tap kdbhalala/agi-memory https://github.com/kdbhalala/agi-memory && brew install agi-memory
64
+ agi-integrate install all # connect every assistant it finds on your machine
65
+ agi-integrate status # see what got connected
66
+ ```
67
+
68
+ Other install options, including a one-line script and running from source, are
69
+ in [Installation](docs/installation.md).
70
+
71
+ ## Documentation
72
+
73
+ | Guide | What's in it |
74
+ |---|---|
75
+ | [Installation](docs/installation.md) | Install options, upgrading, lifecycle hooks |
76
+ | [CLI Usage](docs/cli.md) | Every `agi-memory` and `agi-integrate` command |
77
+ | [Supported Assistants](docs/assistants.md) | Where each assistant's config lives, and the 16 MCP tools |
78
+ | [How memory is organised](docs/pillars.md) | Notes, knowledge graph, session history, code index |
79
+ | [Architecture](docs/architecture.md) | How the pieces fit together |
80
+ | [Vault & Git Sync](docs/sync.md) | The memory files, syncing between machines, compaction |
81
+ | [Python API](docs/python-api.md) | Using it directly from Python |
82
+ | [Testing & Evals](docs/testing.md) | How it's tested, and where it falls short |
83
+ | [Benchmarks](docs/benchmarks.md) | Speed and memory measurements |
84
+ | [Integrations](INTEGRATIONS.md) | Manual setup snippets for each assistant |
85
+
86
+ ## License
87
+
88
+ MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agi-memory"
3
- version = "0.7.0"
3
+ version = "0.7.2"
4
4
  description = "Turnkey zero-dependency four-pillar cognitive memory framework (Epistemic, Semantic, Episodic, Structural Code Graph) with MCP server for Claude Code, Cursor, Codex, OpenCode, Antigravity, and more"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -180,6 +180,76 @@ def hook_session_start(project: Optional[str] = None) -> None:
180
180
  print("\n".join(output))
181
181
 
182
182
 
183
+ _PROMPT_RECALL_LIMIT = 3
184
+ _HARNESS_TURN = re.compile(r"\s*<(task-notification|system-reminder|local-command|command-name|command-message)\b")
185
+ # Conversational words that match thousands of memories and mean nothing here:
186
+ # "can you explain how this works" pulled in two unrelated commits without this.
187
+ _PROMPT_FILLER = frozenset(
188
+ "explain works work this that what make does help please want need like thing things "
189
+ "code file files just able could would should tell show look check about into with from "
190
+ "have your there here them they then than when".split())
191
+
192
+
193
+ def prompt_recall(prompt: str, project: Optional[str] = None, db_path: Optional[Path] = None) -> str:
194
+ """Memories that match a user's prompt closely enough to show unasked.
195
+
196
+ Agents almost never call memory_recall themselves: 0 calls in the 146
197
+ Claude Code sessions that had the tool, which is deferred there and so
198
+ costs a lookup before it can even be called. Hook-capable assistants get
199
+ the matches pushed when the prompt arrives instead. The search ORs terms,
200
+ so on a large store nearly any prompt matches something; a hit is shown
201
+ only when it shares several of the prompt's words.
202
+ """
203
+ try:
204
+ from agi_memory.layers.session_layer import SessionLayer, STOPWORDS
205
+ except ImportError:
206
+ from layers.session_layer import SessionLayer, STOPWORDS
207
+ text = prompt or ""
208
+ # ponytail: 5-char prefixes stand in for stemming; FTS already stems the search itself.
209
+ terms = {t[:5] for t in re.findall(r"[a-z0-9]+", text.lower())
210
+ if len(t) > 3 and t not in STOPWORDS and t not in _PROMPT_FILLER}
211
+ # Harness-generated turns (a background task finishing, a local command's
212
+ # output) arrive through the same prompt event; matching them injected
213
+ # unrelated memories into a turn nobody typed.
214
+ if len(terms) < 2 or text.lstrip().startswith("/") or _HARNESS_TURN.match(text):
215
+ return ""
216
+ need = min(4, max(2, -(-len(terms) // 4)))
217
+ proj = project or detect_project()
218
+ try:
219
+ hits = SessionLayer(db_path=db_path, project=proj).search(text, limit=10)
220
+ except Exception:
221
+ return ""
222
+ shown: List[str] = []
223
+ for h in hits:
224
+ if h.text.startswith("[approximate") or "[SUPERSEDED" in h.text:
225
+ continue
226
+ if len(terms & {w[:5] for w in re.findall(r"[a-z0-9]+", h.text.lower())}) >= need:
227
+ shown.append(f"- {_clip(h.text, 300)}")
228
+ if len(shown) == _PROMPT_RECALL_LIMIT:
229
+ break
230
+ if not shown:
231
+ return ""
232
+ return "\n".join([
233
+ "<!-- AGENT_MEMORY_PROMPT_RECALL -->",
234
+ f"Memories from agent-memory ({proj}) that match this prompt. "
235
+ "Check them before acting; call memory_recall for more.",
236
+ *shown,
237
+ ])
238
+
239
+
240
+ def hook_user_prompt_submit(project: Optional[str] = None) -> None:
241
+ """UserPromptSubmit: push memories matching the prompt into context."""
242
+ sys.path.insert(0, str(REPO_DIR))
243
+ raw = "" if sys.stdin.isatty() else sys.stdin.read()
244
+ try:
245
+ prompt = json.loads(raw).get("prompt") or ""
246
+ except (json.JSONDecodeError, AttributeError):
247
+ prompt = raw # a harness that pipes the prompt text itself
248
+ out = prompt_recall(prompt, project)
249
+ if out:
250
+ print(out)
251
+
252
+
183
253
  def hook_pre_compact(project: Optional[str] = None) -> None:
184
254
  """PreCompact: Curate high-signal working memory into L2 knowledge graph before context loss."""
185
255
  proj = project or detect_project()
@@ -269,8 +339,6 @@ def hook_post_commit(project: Optional[str] = None) -> None:
269
339
  return
270
340
 
271
341
  text = f"Commit {cid}: {subject}"
272
- if body:
273
- text += f"\n{body}"
274
342
 
275
343
  category = "decision"
276
344
  if re.search(r"\b(fix|bug|resolve|patch)\b", subject, re.I):
@@ -290,7 +358,10 @@ def hook_post_commit(project: Optional[str] = None) -> None:
290
358
  from layers.code_layer import CodeLayer
291
359
 
292
360
  l1 = SessionLayer(project=proj)
293
- l1.record(text=text, title=f"Git commit: {subject[:50]}", project=proj, category=category)
361
+ # The commit body is where a change's reason is written, so it is the
362
+ # rationale rather than more text.
363
+ l1.record(text=text, title=f"Git commit: {subject[:50]}", project=proj, category=category,
364
+ rationale=body or None)
294
365
 
295
366
  # Record into episodic history
296
367
  try:
@@ -320,6 +391,9 @@ def hook_post_commit(project: Optional[str] = None) -> None:
320
391
  f_path = Path(df.strip())
321
392
  if f_path.is_file():
322
393
  cl.index_file(f_path, project=proj)
394
+ else:
395
+ # Deleted or moved away: keep its symbols out of results.
396
+ cl.remove_file(f_path.as_posix(), project=proj)
323
397
  except Exception:
324
398
  pass
325
399
 
@@ -387,6 +461,7 @@ def install_claude_hooks(scope: str = "user", py_path: str = None) -> Tuple[bool
387
461
  cmd_start = f'"{py}" "{hooks_py}" session-start'
388
462
  cmd_compact = f'"{py}" "{hooks_py}" pre-compact'
389
463
  cmd_end = f'"{py}" "{hooks_py}" session-end'
464
+ cmd_prompt = f'"{py}" "{hooks_py}" user-prompt-submit'
390
465
 
391
466
  def _is_memory_hook(entry: Dict[str, Any]) -> bool:
392
467
  return any(
@@ -398,7 +473,7 @@ def install_claude_hooks(scope: str = "user", py_path: str = None) -> Tuple[bool
398
473
  )
399
474
 
400
475
  # Purge any previous or stale memory hooks to eliminate duplicates or broken paths
401
- for ev in ("SessionStart", "PreCompact", "SessionEnd"):
476
+ for ev in ("SessionStart", "PreCompact", "SessionEnd", "UserPromptSubmit"):
402
477
  if ev in hooks:
403
478
  hooks[ev] = [e for e in hooks[ev] if not _is_memory_hook(e)]
404
479
 
@@ -419,6 +494,7 @@ def install_claude_hooks(scope: str = "user", py_path: str = None) -> Tuple[bool
419
494
  _ensure_hook("SessionStart", cmd_start, matcher="startup|resume|clear|compact")
420
495
  _ensure_hook("PreCompact", cmd_compact)
421
496
  _ensure_hook("SessionEnd", cmd_end)
497
+ _ensure_hook("UserPromptSubmit", cmd_prompt)
422
498
 
423
499
  # Sanitize and ensure Claude Code permission format (mcp__<server>__*)
424
500
  perms = data.get("permissions", {})
@@ -530,9 +606,11 @@ OPENCODE_PLUGIN_TEMPLATE = """\
530
606
  // Zero npm dependencies: shells out to the stdlib-only hooks CLI.
531
607
  //
532
608
  // Mapping:
533
- // session-start -> experimental.chat.system.transform (once per session)
534
- // pre-compact -> experimental.session.compacting (output.context)
535
- // session-end -> event session.idle (fire-and-forget; sync is idempotent)
609
+ // session-start -> experimental.chat.system.transform (once per session)
610
+ // user-prompt-submit -> chat.message reads the typed text; the matches are
611
+ // pushed through system.transform for that turn
612
+ // pre-compact -> experimental.session.compacting (output.context)
613
+ // session-end -> event session.idle (fire-and-forget; sync is idempotent)
536
614
  //
537
615
  // Known gaps: --continue/--resume fires no bus event, so a resumed session
538
616
  // keeps whatever context it already has. session.idle fires per turn; the
@@ -541,24 +619,43 @@ export const AgentMemoryPlugin = async ({ $ }) => {
541
619
  const PY = "@@PY@@";
542
620
  const HOOKS_PY = "@@HOOKS_PY@@";
543
621
  const seen = new Set();
622
+ const recalled = new Map();
544
623
 
545
- const run = async (verb) => {
624
+ const run = async (verb, stdin) => {
546
625
  try {
547
- return (await $`${PY} ${HOOKS_PY} ${verb}`.text()).trim();
626
+ const cmd = stdin === undefined
627
+ ? $`${PY} ${HOOKS_PY} ${verb}`
628
+ : $`${PY} ${HOOKS_PY} ${verb} < ${new Response(stdin)}`;
629
+ return (await cmd.text()).trim();
548
630
  } catch {
549
631
  return "";
550
632
  }
551
633
  };
552
634
 
553
635
  return {
636
+ // Read-only here: the parts are persisted after this hook, so the matches
637
+ // travel through the system prompt instead of as synthetic message parts.
638
+ "chat.message": async (input, output) => {
639
+ const id = (input && input.sessionID) || "default";
640
+ const text = ((output && output.parts) || [])
641
+ .filter((p) => p && p.type === "text" && !p.synthetic)
642
+ .map((p) => p.text)
643
+ .join("\\n");
644
+ const ctx = text ? await run("user-prompt-submit", JSON.stringify({ prompt: text })) : "";
645
+ if (ctx) recalled.set(id, ctx);
646
+ else recalled.delete(id);
647
+ },
554
648
  // Mutate output.system IN PLACE (push/splice); reassigning the array is
555
649
  // a silent no-op on the OpenCode side.
556
650
  "experimental.chat.system.transform": async (input, output) => {
557
651
  const id = (input && input.sessionID) || "default";
558
- if (seen.has(id)) return;
559
- seen.add(id);
560
- const ctx = await run("session-start");
561
- if (ctx) output.system.push(ctx);
652
+ if (!seen.has(id)) {
653
+ seen.add(id);
654
+ const ctx = await run("session-start");
655
+ if (ctx) output.system.push(ctx);
656
+ }
657
+ const recall = recalled.get(id);
658
+ if (recall) output.system.push(recall);
562
659
  },
563
660
  "experimental.session.compacting": async (input, output) => {
564
661
  const msg = await run("pre-compact");
@@ -721,6 +818,9 @@ def main() -> None:
721
818
  p_start = subparsers.add_parser("session-start", help="Inject active context and precedents")
722
819
  p_start.add_argument("--project", "-p", help="Project name override")
723
820
 
821
+ p_prompt = subparsers.add_parser("user-prompt-submit", help="Inject memories matching the prompt (JSON or text on stdin)")
822
+ p_prompt.add_argument("--project", "-p", help="Project name override")
823
+
724
824
  p_compact = subparsers.add_parser("pre-compact", help="Promote high-signal L1 memories before compaction")
725
825
  p_compact.add_argument("--project", "-p", help="Project name override")
726
826
 
@@ -746,6 +846,8 @@ def main() -> None:
746
846
 
747
847
  if args.action == "session-start":
748
848
  hook_session_start(getattr(args, "project", None))
849
+ elif args.action == "user-prompt-submit":
850
+ hook_user_prompt_submit(getattr(args, "project", None))
749
851
  elif args.action == "pre-compact":
750
852
  hook_pre_compact(getattr(args, "project", None))
751
853
  elif args.action == "session-end":
@@ -655,6 +655,9 @@ def _dedupe_paths(rows: list) -> list:
655
655
  return out
656
656
 
657
657
 
658
+ _STALE_NOTE = " [STALE: file no longer exists, re-run code_index]"
659
+
660
+
658
661
  class CodeLayer(MemoryLayer):
659
662
  """Native SQLite Structural Code Graph layer."""
660
663
  name = "code"
@@ -696,6 +699,10 @@ class CodeLayer(MemoryLayer):
696
699
  UNIQUE(project, file_path)
697
700
  )
698
701
  """)
702
+ # Where each file was indexed from. Paths are stored relative to that
703
+ # directory, so without it a result cannot be checked against the disk.
704
+ if "root_dir" not in {r[1] for r in cur.execute("PRAGMA table_info(code_files)")}:
705
+ cur.execute("ALTER TABLE code_files ADD COLUMN root_dir TEXT")
699
706
 
700
707
  cur.execute("""
701
708
  CREATE TABLE IF NOT EXISTS code_symbols (
@@ -826,9 +833,9 @@ class CodeLayer(MemoryLayer):
826
833
  cur.execute("DELETE FROM code_files WHERE id = ?", (f_row[0],))
827
834
 
828
835
  cur.execute("""
829
- INSERT INTO code_files (project, file_path, language, content_hash, symbol_count, lines_of_code)
830
- VALUES (?, ?, ?, ?, ?, ?)
831
- """, (proj, rel_path, lang, chash, len(symbols), loc))
836
+ INSERT INTO code_files (project, file_path, language, content_hash, symbol_count, lines_of_code, root_dir)
837
+ VALUES (?, ?, ?, ?, ?, ?, ?)
838
+ """, (proj, rel_path, lang, chash, len(symbols), loc, str(root.resolve())))
832
839
  file_id = cur.lastrowid
833
840
 
834
841
  for sym in symbols:
@@ -909,6 +916,51 @@ class CodeLayer(MemoryLayer):
909
916
  "elapsed_ms": round(elapsed_ms, 2)
910
917
  }
911
918
 
919
+ def remove_file(self, rel_path: str, project: Optional[str] = None) -> None:
920
+ """Drop a file's symbols and edges, e.g. after a commit deleted it."""
921
+ proj = project or self.project or "global"
922
+ con = self._get_con(mode="rw")
923
+ try:
924
+ for table in ("code_symbols", "code_edges", "code_files"):
925
+ con.execute(f"DELETE FROM {table} WHERE project = ? AND file_path = ?", (proj, rel_path))
926
+ con.commit()
927
+ finally:
928
+ con.close()
929
+
930
+ def missing_paths(self, paths, project: Optional[str] = None) -> Set[str]:
931
+ """Indexed paths whose file no longer exists where it was indexed from.
932
+
933
+ Checked at query time: an agent acting on a dead path is worse off than
934
+ one told the index is out of date. Files indexed before root_dir was
935
+ recorded cannot be checked, so they are never reported.
936
+ """
937
+ wanted = {p for p in paths if p}
938
+ if not wanted:
939
+ return set()
940
+ proj = project or self.project
941
+ sql = "SELECT file_path, root_dir FROM code_files WHERE root_dir IS NOT NULL"
942
+ args: list = []
943
+ if proj:
944
+ sql += " AND project = ?"
945
+ args.append(proj)
946
+ con = self._get_con(mode="ro")
947
+ try:
948
+ rows = con.execute(sql, args).fetchall()
949
+ finally:
950
+ con.close()
951
+ checked, present = set(), set()
952
+ for fp, root in rows:
953
+ if fp in wanted:
954
+ checked.add(fp)
955
+ if (Path(root) / fp).exists():
956
+ present.add(fp)
957
+ return checked - present
958
+
959
+ def mark_stale(self, rows: List[Dict[str, Any]], project: Optional[str] = None) -> List[Dict[str, Any]]:
960
+ """Copy result rows with `stale` set where the file is gone."""
961
+ gone = self.missing_paths({r.get("file_path") for r in rows}, project)
962
+ return [{**r, "stale": r.get("file_path") in gone} for r in rows]
963
+
912
964
  def search_symbols(self, query: str, project: Optional[str] = None, limit: int = 10) -> List[Dict[str, Any]]:
913
965
  """Microsecond symbol search via SQLite FTS5 with fallback to prefix match."""
914
966
  proj = project or self.project
@@ -1248,12 +1300,14 @@ class CodeLayer(MemoryLayer):
1248
1300
 
1249
1301
  def search(self, query: str, limit: int = 5) -> List[Hit]:
1250
1302
  """Search code symbols and return as standard MemoryLayer Hits."""
1251
- syms = self.search_symbols(query, limit=limit)
1303
+ syms = self.mark_stale(self.search_symbols(query, limit=limit))
1252
1304
  hits = []
1253
1305
  for s in syms:
1254
1306
  txt = f"{s['kind'].upper()} {s['qualified_name']} ({s['file_path']}:{s['start_line']}-{s['end_line']})"
1255
1307
  if s.get("signature"):
1256
1308
  txt += f" :: {s['signature']}"
1309
+ if s["stale"]:
1310
+ txt += _STALE_NOTE
1257
1311
  hits.append(Hit(text=txt, source=self.name, ref=s["file_path"], score=1.0))
1258
1312
  return hits
1259
1313
 
@@ -1269,7 +1323,7 @@ class CodeLayer(MemoryLayer):
1269
1323
 
1270
1324
  lines = []
1271
1325
  for fp, sym_list in by_file.items():
1272
- lines.append(f"### `{fp}` ({len(sym_list)} symbols)")
1326
+ lines.append(f"### `{fp}` ({len(sym_list)} symbols)" + (_STALE_NOTE if sym_list[0].get("stale") else ""))
1273
1327
  for s in sym_list:
1274
1328
  prefix = " - " if s.get("parent_symbol") else "- "
1275
1329
  sig = f"`{s['signature']}`" if s.get("signature") else f"`{s['qualified_name']}`"
@@ -1307,7 +1361,8 @@ class CodeLayer(MemoryLayer):
1307
1361
  lines = [f"### Callers & Inbound References for `{symbol}` ({len(callers)} found"
1308
1362
  + (f", showing first {limit} by depth)" if len(callers) > limit else ")")]
1309
1363
  for c in callers[:limit]:
1310
- lines.append(f"- **{c['caller']}** [{c['relation']}] (L{c['line_number']} in `{c['file_path']}`) [depth {c['depth']}]")
1364
+ lines.append(f"- **{c['caller']}** [{c['relation']}] (L{c['line_number']} in `{c['file_path']}`) [depth {c['depth']}]"
1365
+ + (_STALE_NOTE if c.get("stale") else ""))
1311
1366
  lines.append(f" *Chain*: {c['call_chain']}")
1312
1367
  lines += CodeLayer._render_tail(callers, limit, "callers")
1313
1368
  return "\n".join(lines)
@@ -1322,7 +1377,8 @@ class CodeLayer(MemoryLayer):
1322
1377
  lines = [f"### Outbound Dependencies & Calls for `{symbol}` ({len(deps)} found"
1323
1378
  + (f", showing first {limit} by depth)" if len(deps) > limit else ")")]
1324
1379
  for d in deps[:limit]:
1325
- lines.append(f"- **{d['target']}** [{d['relation']}] (L{d['line_number']} in `{d['file_path']}`) [depth {d['depth']}]")
1380
+ lines.append(f"- **{d['target']}** [{d['relation']}] (L{d['line_number']} in `{d['file_path']}`) [depth {d['depth']}]"
1381
+ + (_STALE_NOTE if d.get("stale") else ""))
1326
1382
  lines.append(f" *Chain*: {d['dependency_chain']}")
1327
1383
  lines += CodeLayer._render_tail(deps, limit, "dependencies")
1328
1384
  return "\n".join(lines)
@@ -1338,6 +1394,8 @@ class CodeLayer(MemoryLayer):
1338
1394
  ]
1339
1395
  if impact.get("impacted_files"):
1340
1396
  lines.append(f"- **Files Affected**: {', '.join(impact['impacted_files'][:10])}")
1397
+ if impact.get("stale_files"):
1398
+ lines.append(f"- **Stale (file no longer exists, re-run code_index)**: {', '.join(impact['stale_files'][:10])}")
1341
1399
  if impact.get("impacted_symbols"):
1342
1400
  lines.append(f"- **Symbols Affected**: {', '.join(impact['impacted_symbols'][:15])}")
1343
1401
  if impact.get("dependency_chains"):
@@ -17,11 +17,14 @@ from .base import Hit, MemoryLayer, open_db
17
17
 
18
18
  try:
19
19
  from agi_memory.config import DEFAULT_DB, get_default_db
20
+ from agi_memory.redact import redact
20
21
  except ImportError:
21
22
  try:
22
23
  from ..config import DEFAULT_DB, get_default_db
24
+ from ..redact import redact
23
25
  except (ImportError, ValueError):
24
26
  from config import DEFAULT_DB, get_default_db
27
+ from redact import redact
25
28
 
26
29
  DB = get_default_db()
27
30
 
@@ -553,6 +556,8 @@ class SessionLayer(MemoryLayer):
553
556
  category: str = "decision", supersedes: str | None = None,
554
557
  rationale: str | None = None, origin: str | None = None) -> dict:
555
558
  """Record an observation/decision into L1 memory via direct SQLite FTS5 insertion."""
559
+ # Before anything is hashed, inserted or mirrored to the vault's git remote.
560
+ text, title, rationale = redact(text), redact(title), redact(rationale)
556
561
  proj = project or self.project or "global"
557
562
  tit = title or (text[:60].strip() + ("..." if len(text) > 60 else ""))
558
563
  cat = (category or "decision").strip().lower()