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.
- agi_memory-0.7.2/PKG-INFO +110 -0
- agi_memory-0.7.2/README.md +88 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/pyproject.toml +1 -1
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/hooks.py +115 -13
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/layers/code_layer.py +65 -7
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/layers/session_layer.py +5 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/mcp_server.py +41 -6
- agi_memory-0.7.2/src/agi_memory/redact.py +60 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/vault.py +73 -6
- agi_memory-0.7.2/src/agi_memory.egg-info/PKG-INFO +110 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory.egg-info/SOURCES.txt +1 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/tests/test_offline.py +132 -6
- agi_memory-0.7.0/PKG-INFO +0 -135
- agi_memory-0.7.0/README.md +0 -113
- agi_memory-0.7.0/src/agi_memory.egg-info/PKG-INFO +0 -135
- {agi_memory-0.7.0 → agi_memory-0.7.2}/LICENSE +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/setup.cfg +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/__init__.py +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/analyze.py +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/bootstrap.py +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/config.py +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/init_command.py +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/integrate.py +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/layers/__init__.py +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/layers/base.py +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/layers/episodic_layer.py +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/layers/graph_layer.py +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/promote.py +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/recall.py +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory/sync.py +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory.egg-info/dependency_links.txt +0 -0
- {agi_memory-0.7.0 → agi_memory-0.7.2}/src/agi_memory.egg-info/entry_points.txt +0 -0
- {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
|
+
[](https://github.com/kdbhalala/agi-memory/actions)
|
|
26
|
+
[](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
|
+
[](https://github.com/kdbhalala/agi-memory/actions)
|
|
4
|
+
[](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.
|
|
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
|
-
|
|
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
|
|
534
|
-
//
|
|
535
|
-
//
|
|
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
|
-
|
|
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))
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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()
|