graph-mind-memory 0.1.5__tar.gz → 0.2.1__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.
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/PKG-INFO +28 -14
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/README.md +27 -13
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/brain_log.py +11 -2
- graph_mind_memory-0.2.1/forget.py +166 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_mcp_server.py +47 -243
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/PKG-INFO +28 -14
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/SOURCES.txt +1 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/entry_points.txt +1 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/top_level.txt +1 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/pyproject.toml +3 -1
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/LICENSE +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/associative_memory.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/automatic_capture.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/automatic_capture_cli.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/clock.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/coding_memory.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/conversation_memory.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/development_memory.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/development_paths.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/embedding_warmup.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/entity_timeline.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/dependency_links.txt +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/requires.txt +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/install.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/local_brain.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/local_embedder.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/semantic_llm_extractor.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/semantic_memory.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/semantic_recall.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/setup.cfg +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/temporal_resolver.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/universal_personal_memory.py +0 -0
- {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/vector_cache.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graph-mind-memory
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Local-first memory for AI coding assistants: verbatim, on your own PC, no LLM calls at write time.
|
|
5
5
|
Author-email: Yohan Ko <goyohan0611@gmail.com>
|
|
6
6
|
License-Expression: AGPL-3.0-only
|
|
@@ -41,6 +41,10 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
|
|
|
41
41
|
|
|
42
42
|
*Switch models; keep the memory.*
|
|
43
43
|
|
|
44
|
+
<img src="demo/demo.gif" alt="A decision made in Claude Code on Monday, recalled from Codex on Wednesday" width="760">
|
|
45
|
+
|
|
46
|
+
<sub>A real run, not a mock-up: <code>python demo/record_demo.py</code> records it from a fresh store.</sub>
|
|
47
|
+
|
|
44
48
|
</div>
|
|
45
49
|
|
|
46
50
|
> [!NOTE]
|
|
@@ -162,25 +166,33 @@ Then restart your AI apps. The server is also listed in the
|
|
|
162
166
|
Before anything is stored, these are masked: API keys, tokens from GitHub, AWS, Google and Slack,
|
|
163
167
|
private keys, and passwords inside URLs.
|
|
164
168
|
|
|
169
|
+
Only what you actually send is captured: the service reads each app's transcript, which is written
|
|
170
|
+
after you press Enter, so a paste you delete before sending never reaches it. A plain password
|
|
171
|
+
like `hunter2` has no recognizable format and is **not** masked. To remove something:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
graph-mind-forget # asks for the phrase without showing it, then confirms
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
It deletes every captured turn and memory containing the phrase from this PC and its indexes,
|
|
178
|
+
removes their lines from the shared memory, and tells your other PCs to delete their copies on
|
|
179
|
+
their next sync. Only ids are shared for that, never the phrase. Your AI app's own history
|
|
180
|
+
(for Claude Code, `~/.claude/projects`) is separate and is not touched.
|
|
181
|
+
|
|
165
182
|
---
|
|
166
183
|
|
|
167
184
|
## MCP tools
|
|
168
185
|
|
|
169
186
|
| tool | what it does |
|
|
170
187
|
|---|---|
|
|
171
|
-
| `brain_context` | a bounded packet of the
|
|
172
|
-
| `brain_recall` |
|
|
173
|
-
| `brain_remember` | save a sourced memory (secrets masked) |
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
| `code_activity` | what changed in a project, file or symbol, and when |
|
|
180
|
-
| `memory_record` | record a development event or decision |
|
|
181
|
-
| `project_status` | current state of a project |
|
|
182
|
-
| `resume_project` | everything needed to pick a project back up |
|
|
183
|
-
| `explain_decision` | a decision, its reason and its history |
|
|
188
|
+
| `brain_context` | a bounded packet of the past turns and memories that answer a request; `recent=true` for "where did we leave off?" |
|
|
189
|
+
| `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
|
|
190
|
+
| `brain_remember` | save a sourced memory or decision (secrets masked) |
|
|
191
|
+
| `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
|
|
192
|
+
| `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
|
|
193
|
+
|
|
194
|
+
Five tools on purpose: every tool's description is read by the model on every turn, and similar
|
|
195
|
+
tools get confused with each other. Version 0.1 had thirteen.
|
|
184
196
|
|
|
185
197
|
---
|
|
186
198
|
|
|
@@ -228,6 +240,8 @@ python -m unittest discover -p "test_*.py"
|
|
|
228
240
|
|
|
229
241
|
## Known limits
|
|
230
242
|
|
|
243
|
+
- **The first question after an AI app starts waits for the embedding model to load** (a few
|
|
244
|
+
seconds; 30-50 s on a slow or synced disk). Later questions take well under a second.
|
|
231
245
|
- **Daily use on Windows only so far.** macOS and Linux pass the tests in CI but have not seen real
|
|
232
246
|
use.
|
|
233
247
|
- **Capture follows each app's transcript format,** which is not a public interface. An app update
|
|
@@ -15,6 +15,10 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
|
|
|
15
15
|
|
|
16
16
|
*Switch models; keep the memory.*
|
|
17
17
|
|
|
18
|
+
<img src="demo/demo.gif" alt="A decision made in Claude Code on Monday, recalled from Codex on Wednesday" width="760">
|
|
19
|
+
|
|
20
|
+
<sub>A real run, not a mock-up: <code>python demo/record_demo.py</code> records it from a fresh store.</sub>
|
|
21
|
+
|
|
18
22
|
</div>
|
|
19
23
|
|
|
20
24
|
> [!NOTE]
|
|
@@ -136,25 +140,33 @@ Then restart your AI apps. The server is also listed in the
|
|
|
136
140
|
Before anything is stored, these are masked: API keys, tokens from GitHub, AWS, Google and Slack,
|
|
137
141
|
private keys, and passwords inside URLs.
|
|
138
142
|
|
|
143
|
+
Only what you actually send is captured: the service reads each app's transcript, which is written
|
|
144
|
+
after you press Enter, so a paste you delete before sending never reaches it. A plain password
|
|
145
|
+
like `hunter2` has no recognizable format and is **not** masked. To remove something:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
graph-mind-forget # asks for the phrase without showing it, then confirms
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
It deletes every captured turn and memory containing the phrase from this PC and its indexes,
|
|
152
|
+
removes their lines from the shared memory, and tells your other PCs to delete their copies on
|
|
153
|
+
their next sync. Only ids are shared for that, never the phrase. Your AI app's own history
|
|
154
|
+
(for Claude Code, `~/.claude/projects`) is separate and is not touched.
|
|
155
|
+
|
|
139
156
|
---
|
|
140
157
|
|
|
141
158
|
## MCP tools
|
|
142
159
|
|
|
143
160
|
| tool | what it does |
|
|
144
161
|
|---|---|
|
|
145
|
-
| `brain_context` | a bounded packet of the
|
|
146
|
-
| `brain_recall` |
|
|
147
|
-
| `brain_remember` | save a sourced memory (secrets masked) |
|
|
148
|
-
| `
|
|
149
|
-
| `
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
| `code_activity` | what changed in a project, file or symbol, and when |
|
|
154
|
-
| `memory_record` | record a development event or decision |
|
|
155
|
-
| `project_status` | current state of a project |
|
|
156
|
-
| `resume_project` | everything needed to pick a project back up |
|
|
157
|
-
| `explain_decision` | a decision, its reason and its history |
|
|
162
|
+
| `brain_context` | a bounded packet of the past turns and memories that answer a request; `recent=true` for "where did we leave off?" |
|
|
163
|
+
| `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
|
|
164
|
+
| `brain_remember` | save a sourced memory or decision (secrets masked) |
|
|
165
|
+
| `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
|
|
166
|
+
| `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
|
|
167
|
+
|
|
168
|
+
Five tools on purpose: every tool's description is read by the model on every turn, and similar
|
|
169
|
+
tools get confused with each other. Version 0.1 had thirteen.
|
|
158
170
|
|
|
159
171
|
---
|
|
160
172
|
|
|
@@ -202,6 +214,8 @@ python -m unittest discover -p "test_*.py"
|
|
|
202
214
|
|
|
203
215
|
## Known limits
|
|
204
216
|
|
|
217
|
+
- **The first question after an AI app starts waits for the embedding model to load** (a few
|
|
218
|
+
seconds; 30-50 s on a slow or synced disk). Later questions take well under a second.
|
|
205
219
|
- **Daily use on Windows only so far.** macOS and Linux pass the tests in CI but have not seen real
|
|
206
220
|
use.
|
|
207
221
|
- **Capture follows each app's transcript format,** which is not a public interface. An app update
|
|
@@ -36,7 +36,7 @@ import platform
|
|
|
36
36
|
import re
|
|
37
37
|
import sqlite3
|
|
38
38
|
|
|
39
|
-
KINDS = ("memory", "turn")
|
|
39
|
+
KINDS = ("memory", "turn", "forget") # forget: ids another PC erased (forget.py)
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def device_name() -> str:
|
|
@@ -48,7 +48,10 @@ def device_name() -> str:
|
|
|
48
48
|
def config_path() -> Path:
|
|
49
49
|
"""Where the chosen brain folder is remembered for every client on this machine."""
|
|
50
50
|
override = os.environ.get("GRAPH_MIND_CONFIG")
|
|
51
|
-
|
|
51
|
+
if override:
|
|
52
|
+
return Path(override)
|
|
53
|
+
from development_paths import graph_mind_home
|
|
54
|
+
return graph_mind_home() / "config.json" # ~/.graph-mind unless GRAPH_MIND_HOME
|
|
52
55
|
|
|
53
56
|
|
|
54
57
|
def _config() -> dict:
|
|
@@ -423,6 +426,8 @@ def sync(shared: Path, index: Path) -> dict:
|
|
|
423
426
|
except ValueError:
|
|
424
427
|
continue # a corrupt line is skipped, not fatal
|
|
425
428
|
items.sort(key=lambda pair: pair[0].get("written_at", ""))
|
|
429
|
+
forgets = [item["record"] for item, _ in items if item.get("kind") == "forget"]
|
|
430
|
+
items = [(item, line) for item, line in items if item.get("kind") != "forget"]
|
|
426
431
|
imported, conflicts, waiting = 0, 0, []
|
|
427
432
|
with LocalBrainStore(index) as brain, ConversationMemoryStore(index) as turns:
|
|
428
433
|
# A supersession can arrive before the memory it replaces when two devices' logs are
|
|
@@ -447,6 +452,10 @@ def sync(shared: Path, index: Path) -> dict:
|
|
|
447
452
|
if not progress:
|
|
448
453
|
waiting = [line for _, line in retry]
|
|
449
454
|
break
|
|
455
|
+
if forgets: # after the imports: a forgotten turn may be in them
|
|
456
|
+
from forget import erase
|
|
457
|
+
for record in forgets:
|
|
458
|
+
erase(index, record)
|
|
450
459
|
with db:
|
|
451
460
|
db.execute("DELETE FROM brain_log_pending")
|
|
452
461
|
db.executemany("INSERT OR IGNORE INTO brain_log_pending VALUES(?)",
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Forget everything that contains a phrase: on this PC, in the shared log, and on the other PCs.
|
|
2
|
+
|
|
3
|
+
graph-mind-forget # asks for the phrase without echoing it (keeps it out of
|
|
4
|
+
# the shell history), shows what matches, asks to confirm
|
|
5
|
+
graph-mind-forget "hunter2" --yes
|
|
6
|
+
|
|
7
|
+
A captured turn or memory lives in its store, in that store's word index, and as an engram in the
|
|
8
|
+
associative index; all three go. In the shared log its lines go too: Postgres rows are deleted, a
|
|
9
|
+
folder's lines are blanked in place (same length, so other PCs' read offsets stay valid). Then a
|
|
10
|
+
"forget" record listing the ids, never the phrase, tells every other PC to erase its own copies.
|
|
11
|
+
|
|
12
|
+
Not covered: the AI app's own transcript (Claude Code keeps ~/.claude/projects/*.jsonl) and the
|
|
13
|
+
embedding vectors, which are numbers keyed by a hash and hold no text.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
import argparse
|
|
19
|
+
import getpass
|
|
20
|
+
import json
|
|
21
|
+
import sqlite3
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find(index: Path, phrase: str) -> dict:
|
|
26
|
+
"""Ids of the turns and memories whose text contains the phrase (exact, case-sensitive)."""
|
|
27
|
+
db = sqlite3.connect(index, timeout=30)
|
|
28
|
+
try:
|
|
29
|
+
tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master")}
|
|
30
|
+
turns = [row[0] for row in db.execute(
|
|
31
|
+
"SELECT turn_id FROM conversation_turns WHERE instr(content_redacted, ?) > 0",
|
|
32
|
+
(phrase,))] if "conversation_turns" in tables else []
|
|
33
|
+
memories = [row[0] for row in db.execute(
|
|
34
|
+
"SELECT memory_id FROM local_brain_memories "
|
|
35
|
+
"WHERE instr(content, ?) > 0 OR instr(title, ?) > 0",
|
|
36
|
+
(phrase, phrase))] if "local_brain_memories" in tables else []
|
|
37
|
+
finally:
|
|
38
|
+
db.close()
|
|
39
|
+
return {"turn_ids": turns, "memory_ids": memories}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def erase(index: Path, ids: dict) -> dict:
|
|
43
|
+
"""Delete these turns and memories, with every index entry derived from them."""
|
|
44
|
+
turns, memories = list(ids.get("turn_ids", [])), list(ids.get("memory_ids", []))
|
|
45
|
+
db = sqlite3.connect(index, timeout=30)
|
|
46
|
+
try:
|
|
47
|
+
tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master")}
|
|
48
|
+
with db:
|
|
49
|
+
if turns and "conversation_turns" in tables:
|
|
50
|
+
marks = ",".join("?" * len(turns))
|
|
51
|
+
rows = db.execute(f"SELECT revision, content_redacted FROM conversation_turns "
|
|
52
|
+
f"WHERE turn_id IN ({marks})", turns).fetchall()
|
|
53
|
+
# an external-content FTS table keeps its words until told to drop them
|
|
54
|
+
db.executemany("INSERT INTO conversation_turns_fts(conversation_turns_fts, rowid, "
|
|
55
|
+
"content_redacted) VALUES('delete', ?, ?)", rows)
|
|
56
|
+
db.execute(f"DELETE FROM conversation_turns WHERE turn_id IN ({marks})", turns)
|
|
57
|
+
if memories and "local_brain_memories" in tables:
|
|
58
|
+
marks = ",".join("?" * len(memories))
|
|
59
|
+
db.execute(f"DELETE FROM local_brain_memories WHERE memory_id IN ({marks})",
|
|
60
|
+
memories)
|
|
61
|
+
if "local_brain_memory_profiles" in tables:
|
|
62
|
+
db.execute(f"DELETE FROM local_brain_memory_profiles "
|
|
63
|
+
f"WHERE memory_id IN ({marks})", memories)
|
|
64
|
+
sources = turns + memories
|
|
65
|
+
if sources and "associative_engrams" in tables:
|
|
66
|
+
marks = ",".join("?" * len(sources))
|
|
67
|
+
engrams = [row[0] for row in db.execute(
|
|
68
|
+
f"SELECT engram_id FROM associative_engrams WHERE source_id IN ({marks})",
|
|
69
|
+
sources)]
|
|
70
|
+
if engrams:
|
|
71
|
+
em = ",".join("?" * len(engrams))
|
|
72
|
+
for table, column in (("associative_engrams_fts", "engram_id"),
|
|
73
|
+
("associative_terms", "engram_id"),
|
|
74
|
+
("associative_edges", "source_engram_id"),
|
|
75
|
+
("associative_edges", "target_engram_id"),
|
|
76
|
+
("associative_pending_edges", "source_engram_id"),
|
|
77
|
+
("associative_pending_edges", "target_engram_id"),
|
|
78
|
+
("associative_engrams", "engram_id")):
|
|
79
|
+
if table in tables:
|
|
80
|
+
db.execute(f"DELETE FROM {table} WHERE {column} IN ({em})", engrams)
|
|
81
|
+
finally:
|
|
82
|
+
db.close()
|
|
83
|
+
return {"turns": len(turns), "memories": len(memories)}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _keys(ids: dict) -> list[str]:
|
|
87
|
+
"""How a target's log line spells its id (brain_log writes sorted keys, ": " separators)."""
|
|
88
|
+
return ([f'"turn_id": {json.dumps(t)}' for t in ids.get("turn_ids", [])]
|
|
89
|
+
+ [f'"memory_id": {json.dumps(m)}' for m in ids.get("memory_ids", [])])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def scrub_shared(shared, ids: dict) -> int:
|
|
93
|
+
"""Remove the targets' lines from the shared log. Returns how many lines went."""
|
|
94
|
+
from brain_log import _pg, is_database
|
|
95
|
+
keys = _keys(ids)
|
|
96
|
+
if not keys:
|
|
97
|
+
return 0
|
|
98
|
+
if is_database(shared):
|
|
99
|
+
with _pg(shared) as connection:
|
|
100
|
+
removed = 0
|
|
101
|
+
for key in keys:
|
|
102
|
+
removed += connection.execute(
|
|
103
|
+
"DELETE FROM brain_log WHERE strpos(line, %s) > 0", (key,)).rowcount
|
|
104
|
+
connection.commit()
|
|
105
|
+
return removed
|
|
106
|
+
removed = 0
|
|
107
|
+
for log in (Path(shared) / "log").glob("*.jsonl"):
|
|
108
|
+
data = log.read_bytes()
|
|
109
|
+
lines = data.split(b"\n")
|
|
110
|
+
changed = False
|
|
111
|
+
for n, line in enumerate(lines):
|
|
112
|
+
text = line.decode("utf-8", "replace")
|
|
113
|
+
if any(key in text for key in keys) and '"kind": "forget"' not in text:
|
|
114
|
+
lines[n] = b" " * len(line) # same length: readers' offsets stay valid
|
|
115
|
+
changed, removed = True, removed + 1
|
|
116
|
+
if changed:
|
|
117
|
+
with log.open("r+b") as stream: # in place: the file is never shorter
|
|
118
|
+
stream.write(b"\n".join(lines))
|
|
119
|
+
return removed
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def forget(phrase: str, index: Path, shared=None) -> dict:
|
|
123
|
+
"""Find, erase here, scrub the shared log, and tell the other PCs."""
|
|
124
|
+
from brain_log import append
|
|
125
|
+
ids = find(index, phrase)
|
|
126
|
+
if not (ids["turn_ids"] or ids["memory_ids"]):
|
|
127
|
+
return {"turns": 0, "memories": 0, "shared_lines_removed": 0}
|
|
128
|
+
result = erase(index, ids)
|
|
129
|
+
if shared:
|
|
130
|
+
result["shared_lines_removed"] = scrub_shared(shared, ids)
|
|
131
|
+
append(shared, "forget", ids) # ids only: the phrase never leaves here
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main(argv=None):
|
|
136
|
+
from brain_log import folder
|
|
137
|
+
from development_paths import default_development_db
|
|
138
|
+
parser = argparse.ArgumentParser(description="Forget every captured turn and memory "
|
|
139
|
+
"that contains a phrase, on every PC.")
|
|
140
|
+
parser.add_argument("phrase", nargs="?", help="omit it to type it without echo")
|
|
141
|
+
parser.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
142
|
+
args = parser.parse_args(argv)
|
|
143
|
+
phrase = args.phrase or getpass.getpass("Phrase to forget (not shown): ")
|
|
144
|
+
if len(phrase.strip()) < 4:
|
|
145
|
+
sys.exit("Use at least 4 characters, so a short word does not wipe half the memory.")
|
|
146
|
+
index = default_development_db()
|
|
147
|
+
ids = find(index, phrase)
|
|
148
|
+
count = len(ids["turn_ids"]) + len(ids["memory_ids"])
|
|
149
|
+
if not count:
|
|
150
|
+
print("Nothing in this PC's memory contains that phrase.")
|
|
151
|
+
return
|
|
152
|
+
print(f"{len(ids['turn_ids'])} conversation turns and {len(ids['memory_ids'])} memories "
|
|
153
|
+
"contain it.")
|
|
154
|
+
if not args.yes and input("Forget them here and on your other PCs? [y/N] ").lower() != "y":
|
|
155
|
+
print("Nothing changed.")
|
|
156
|
+
return
|
|
157
|
+
shared = folder()
|
|
158
|
+
result = forget(phrase, index, shared)
|
|
159
|
+
print(f"Forgotten: {result['turns']} turns, {result['memories']} memories"
|
|
160
|
+
+ (f"; {result['shared_lines_removed']} lines removed from the shared memory, "
|
|
161
|
+
"other PCs follow on their next sync" if shared else "") + ".")
|
|
162
|
+
print("Your AI app may still have it in its own history (Claude Code: ~/.claude/projects).")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
main()
|
|
@@ -15,7 +15,6 @@ from mcp.server import MCPServer
|
|
|
15
15
|
from associative_memory import AssociativeMemoryIndex
|
|
16
16
|
from coding_memory import CodingMemoryStore
|
|
17
17
|
from conversation_memory import ConversationMemoryStore
|
|
18
|
-
from development_memory import DevelopmentMemoryStore
|
|
19
18
|
from development_paths import default_development_db
|
|
20
19
|
from local_brain import LocalBrainStore
|
|
21
20
|
from universal_personal_memory import build_context_pack, decide_recall
|
|
@@ -26,14 +25,6 @@ if hasattr(sys.stderr, "reconfigure"):
|
|
|
26
25
|
|
|
27
26
|
|
|
28
27
|
SERVER_VERSION = "0.5.0"
|
|
29
|
-
RecordableEventType = Literal[
|
|
30
|
-
"OBJECTIVE_SET", "TASK_STARTED", "TASK_COMPLETED", "TASK_BLOCKED",
|
|
31
|
-
"TASK_CANCELLED", "FILE_CHANGED", "DECISION_RECORDED", "TEST_RECORDED",
|
|
32
|
-
"NEXT_ACTION_SET", "NEXT_ACTION_COMPLETED", "SESSION_SUMMARY",
|
|
33
|
-
"EXPERIMENT_STARTED", "EXPERIMENT_STOPPED",
|
|
34
|
-
]
|
|
35
|
-
|
|
36
|
-
|
|
37
28
|
# The embedding model is loaded from the local Hugging Face cache, but the library still asks the
|
|
38
29
|
# hub over the network whether a newer revision exists, every time it loads. That made the first
|
|
39
30
|
# save of a session take ~95 s and sent requests off the machine for no reason. Once the model is
|
|
@@ -346,17 +337,12 @@ def build_server(db_path=None):
|
|
|
346
337
|
"stored or noted something, and do not add lines like 'I saved that to memory'. "
|
|
347
338
|
"Mention memory only when the user asks what you remember or whether it was saved. "
|
|
348
339
|
"Call brain_context first when a request may depend on the user's prior conversations, "
|
|
349
|
-
"facts, preferences,
|
|
350
|
-
"returns a bounded evidence packet instead of
|
|
351
|
-
"
|
|
352
|
-
"
|
|
353
|
-
"or
|
|
354
|
-
"
|
|
355
|
-
"the wording into code_activity filters and use its indexed evidence. "
|
|
356
|
-
"Call project_status or resume_project when an exact project ID is already known. "
|
|
357
|
-
"Treat scope_completeness=UNATTESTED as partial observed history. "
|
|
358
|
-
"Use brain_remember for general sourced memories and memory_record for sourced "
|
|
359
|
-
"development events. Preserve provenance."
|
|
340
|
+
"facts, preferences, decisions, projects or work, including 'where did we leave off?'. "
|
|
341
|
+
"It skips unrelated requests and returns a bounded evidence packet instead of "
|
|
342
|
+
"replaying full history. Use brain_recall to look memories up directly, or with "
|
|
343
|
+
"`entity` for the history of one thing in order. For code changed in a time range, "
|
|
344
|
+
"project, file or symbol, use code_activity. brain_folder shows or changes where the "
|
|
345
|
+
"memory lives and shares it with the user's other PCs. Preserve provenance."
|
|
360
346
|
),
|
|
361
347
|
version=SERVER_VERSION,
|
|
362
348
|
)
|
|
@@ -395,6 +381,8 @@ def build_server(db_path=None):
|
|
|
395
381
|
required: put the user's own sentence there verbatim. Add `entities` (who or what it is
|
|
396
382
|
about) and `effective_at` (when it happened, ISO time) when you know them. If it updates
|
|
397
383
|
something remembered earlier, pass that memory's id as `supersedes_memory_id`.
|
|
384
|
+
Decisions and their reasons are memories too: `memory_type="decision"`, the reason in
|
|
385
|
+
`content`, the project or component in `entities`.
|
|
398
386
|
|
|
399
387
|
Save silently. Do not tell the user you saved it ("I've noted that", "저장해 뒀어"); just
|
|
400
388
|
carry on with the conversation. Talk about memory only if the user asks what you remember
|
|
@@ -443,14 +431,21 @@ def build_server(db_path=None):
|
|
|
443
431
|
openWorldHint=False,
|
|
444
432
|
),
|
|
445
433
|
)
|
|
446
|
-
def brain_recall(query: str,
|
|
434
|
+
def brain_recall(query: str = "", entity: str | None = None,
|
|
435
|
+
scopes: list[str] | None = None,
|
|
447
436
|
as_of: str | None = None,
|
|
448
437
|
knowledge_cutoff: str | None = None,
|
|
449
438
|
limit: int = 8, recent: bool = False,
|
|
450
439
|
since: str | None = None,
|
|
451
440
|
about_assistant: bool = False,
|
|
452
441
|
around: str | None = None) -> dict[str, Any]:
|
|
453
|
-
"""
|
|
442
|
+
"""Look up memories and captured conversation turns directly, from natural wording.
|
|
443
|
+
|
|
444
|
+
`entity` instead answers "what happened to X, in order": every memory about that one
|
|
445
|
+
thing (the auth module, this project, my address), oldest first, with replaced entries
|
|
446
|
+
marked and the current one picked out. Similarity search returns only the nearest one or
|
|
447
|
+
two of four decisions made about one thing in four sessions; this returns all four. With
|
|
448
|
+
neither `query` nor `entity`, it lists what the store knows about, busiest first.
|
|
454
449
|
|
|
455
450
|
Set `recent=true` whenever the question is about what happened lately or what the user was
|
|
456
451
|
just doing: "what was I working on?", "where did we leave off?", "what did I do
|
|
@@ -467,6 +462,21 @@ def build_server(db_path=None):
|
|
|
467
462
|
mention events days or weeks before or after, so that date misleads (measured: on dev2
|
|
468
463
|
it pushed the answering turn out in 2 of 11 questions and helped 2).
|
|
469
464
|
"""
|
|
465
|
+
if entity or not query:
|
|
466
|
+
import sqlite3
|
|
467
|
+
|
|
468
|
+
import entity_timeline as timeline_index
|
|
469
|
+
shared_sync(database) # every device's memories are in this one index
|
|
470
|
+
db = sqlite3.connect(database)
|
|
471
|
+
try:
|
|
472
|
+
if not entity:
|
|
473
|
+
return {"entities": timeline_index.entities(db, limit=max(limit, 50))}
|
|
474
|
+
rows = timeline_index.timeline(db, entity, limit=max(limit, 50))
|
|
475
|
+
finally:
|
|
476
|
+
db.close()
|
|
477
|
+
current = [r for r in rows if not r["superseded"]]
|
|
478
|
+
return {"entity": entity, "entries": rows, "count": len(rows),
|
|
479
|
+
"current": current[-1] if current else None}
|
|
470
480
|
return gather(database, query, scopes=scopes, as_of=as_of or _now(),
|
|
471
481
|
knowledge_cutoff=knowledge_cutoff, limit=limit, recent=recent, since=since,
|
|
472
482
|
about_assistant=about_assistant, around=around)
|
|
@@ -526,75 +536,6 @@ def build_server(db_path=None):
|
|
|
526
536
|
"STRUCTURED_EXECUTION_REQUIRED_FOR_COMPLETE_SCOPE_CLAIMS")
|
|
527
537
|
return packet
|
|
528
538
|
|
|
529
|
-
@server.tool(
|
|
530
|
-
name="brain_associate",
|
|
531
|
-
structured_output=True,
|
|
532
|
-
annotations=types.ToolAnnotations(
|
|
533
|
-
readOnlyHint=True,
|
|
534
|
-
destructiveHint=False,
|
|
535
|
-
idempotentHint=True,
|
|
536
|
-
openWorldHint=False,
|
|
537
|
-
),
|
|
538
|
-
)
|
|
539
|
-
def brain_associate(query: str, scopes: list[str] | None = None,
|
|
540
|
-
concept_cues: list[str] | None = None,
|
|
541
|
-
seed_limit: int = 12, hops: int = 2,
|
|
542
|
-
fanout: int = 12, max_nodes: int = 40,
|
|
543
|
-
limit: int = 8,
|
|
544
|
-
minimum_concept_coverage: float = 0.75,
|
|
545
|
-
grounding: str = "semantic",
|
|
546
|
-
semantic_threshold: float = 0.35) -> dict[str, Any]:
|
|
547
|
-
"""Recall indirect candidate memories through bounded associative activation.
|
|
548
|
-
|
|
549
|
-
grounding="semantic" (default, v0.7.3): on-device embedding pipeline — FTS seed ->
|
|
550
|
-
local-embedding semantic gate -> query re-ranking. On untouched production evals it
|
|
551
|
-
recovers Korean paraphrase recall (Vague Hit@5 0%->57%) at 0% control false association,
|
|
552
|
-
and matches or beats literal on exact. Loads a local model lazily; all text stays on-device.
|
|
553
|
-
grounding="auto": cheap literal gate first, semantic fallback only when literal abstains.
|
|
554
|
-
grounding="literal": the deterministic token-overlap coverage gate only.
|
|
555
|
-
"""
|
|
556
|
-
with AssociativeMemoryIndex(database) as index:
|
|
557
|
-
if grounding in ("semantic", "auto"):
|
|
558
|
-
from semantic_recall import (auto_associate, default_embedder,
|
|
559
|
-
semantic_associate)
|
|
560
|
-
embedder = default_embedder(database)
|
|
561
|
-
if grounding == "auto":
|
|
562
|
-
result = auto_associate(index, query, concept_cues, embedder=embedder,
|
|
563
|
-
literal_gate=minimum_concept_coverage,
|
|
564
|
-
semantic_threshold=semantic_threshold, scopes=scopes)
|
|
565
|
-
else:
|
|
566
|
-
result = semantic_associate(index, query, concept_cues,
|
|
567
|
-
embedder=embedder, scopes=scopes, threshold=semantic_threshold)
|
|
568
|
-
else:
|
|
569
|
-
result = index.activate(query, scopes=scopes, concept_cues=concept_cues,
|
|
570
|
-
seed_limit=seed_limit, hops=hops, fanout=fanout,
|
|
571
|
-
max_nodes=max_nodes, limit=limit,
|
|
572
|
-
minimum_concept_coverage=minimum_concept_coverage)
|
|
573
|
-
result["evidence_status"] = "CANDIDATES_REQUIRE_SOURCE_VERIFICATION"
|
|
574
|
-
return result
|
|
575
|
-
|
|
576
|
-
@server.tool(
|
|
577
|
-
name="brain_index",
|
|
578
|
-
structured_output=True,
|
|
579
|
-
annotations=types.ToolAnnotations(
|
|
580
|
-
readOnlyHint=False,
|
|
581
|
-
destructiveHint=False,
|
|
582
|
-
idempotentHint=True,
|
|
583
|
-
openWorldHint=False,
|
|
584
|
-
),
|
|
585
|
-
)
|
|
586
|
-
def brain_index(limit: int = 1000) -> dict[str, Any]:
|
|
587
|
-
"""Bring the search index up to date: link new memories, then embed the unembedded ones.
|
|
588
|
-
|
|
589
|
-
brain_remember already does this for what it writes; run this after importing a backlog, or
|
|
590
|
-
on a store that was written by an older version.
|
|
591
|
-
"""
|
|
592
|
-
from embedding_warmup import warm
|
|
593
|
-
with AssociativeMemoryIndex(database) as index:
|
|
594
|
-
synced = index.sync_sources(limit=limit)
|
|
595
|
-
return {"synced": synced, "vectors_warmed": warm(database, limit=limit),
|
|
596
|
-
"store": str(database)}
|
|
597
|
-
|
|
598
539
|
@server.tool(
|
|
599
540
|
name="brain_folder",
|
|
600
541
|
structured_output=True,
|
|
@@ -605,7 +546,8 @@ def build_server(db_path=None):
|
|
|
605
546
|
openWorldHint=False,
|
|
606
547
|
),
|
|
607
548
|
)
|
|
608
|
-
def brain_folder(path: str | None = None, share_existing: bool = False
|
|
549
|
+
def brain_folder(path: str | None = None, share_existing: bool = False,
|
|
550
|
+
reindex: bool = False) -> dict[str, Any]:
|
|
609
551
|
"""Show or choose the folder that holds the user's brain.
|
|
610
552
|
|
|
611
553
|
Call it with `path` when the user names a folder for their memory, e.g. "use my Google
|
|
@@ -624,7 +566,16 @@ def build_server(db_path=None):
|
|
|
624
566
|
|
|
625
567
|
`share_existing=true` copies what this PC already remembered into the folder, which uploads
|
|
626
568
|
it to wherever the folder syncs. Set it only when the user has explicitly agreed to that.
|
|
569
|
+
|
|
570
|
+
`reindex=true` brings the search index up to date (links new memories, embeds unembedded
|
|
571
|
+
ones). Saving already does this; it is for a large imported backlog.
|
|
627
572
|
"""
|
|
573
|
+
if reindex:
|
|
574
|
+
from embedding_warmup import warm
|
|
575
|
+
with AssociativeMemoryIndex(database) as index:
|
|
576
|
+
synced = index.sync_sources(limit=1000)
|
|
577
|
+
return {"synced": synced, "vectors_warmed": warm(database, limit=1000),
|
|
578
|
+
"store": str(database)}
|
|
628
579
|
from brain_log import device_name, devices, export_history, folder, set_folder, share, sync
|
|
629
580
|
shared_out = None
|
|
630
581
|
if path == "share":
|
|
@@ -654,62 +605,6 @@ def build_server(db_path=None):
|
|
|
654
605
|
"existing_memories_shared": exported or "not shared; ask the user before "
|
|
655
606
|
"setting share_existing=true"}
|
|
656
607
|
|
|
657
|
-
@server.tool(
|
|
658
|
-
name="brain_timeline",
|
|
659
|
-
structured_output=True,
|
|
660
|
-
annotations=types.ToolAnnotations(
|
|
661
|
-
readOnlyHint=True,
|
|
662
|
-
destructiveHint=False,
|
|
663
|
-
idempotentHint=True,
|
|
664
|
-
openWorldHint=False,
|
|
665
|
-
),
|
|
666
|
-
)
|
|
667
|
-
def brain_timeline(entity: str | None = None, limit: int = 50) -> dict[str, Any]:
|
|
668
|
-
"""Every memory about ONE thing, oldest first, with replaced entries marked.
|
|
669
|
-
|
|
670
|
-
Similarity search answers "what relates to this question"; this answers "what happened to
|
|
671
|
-
the auth module / this project / this device, in order". Those are the questions that keep
|
|
672
|
-
failing otherwise, because the four decisions about one thing sit in four different
|
|
673
|
-
sessions and only the nearest one or two come back. Call it with no entity to list what the
|
|
674
|
-
store knows about, busiest first.
|
|
675
|
-
"""
|
|
676
|
-
import sqlite3
|
|
677
|
-
|
|
678
|
-
import entity_timeline as timeline_index
|
|
679
|
-
|
|
680
|
-
shared_sync(database) # every device's memories are in this one index
|
|
681
|
-
db = sqlite3.connect(database)
|
|
682
|
-
try:
|
|
683
|
-
if not entity:
|
|
684
|
-
return {"entities": timeline_index.entities(db, limit=limit)}
|
|
685
|
-
rows = timeline_index.timeline(db, entity, limit=limit)
|
|
686
|
-
finally:
|
|
687
|
-
db.close()
|
|
688
|
-
current = [r for r in rows if not r["superseded"]]
|
|
689
|
-
return {"entity": entity, "entries": rows[:limit], "count": len(rows),
|
|
690
|
-
"current": current[-1] if current else None}
|
|
691
|
-
|
|
692
|
-
@server.tool(
|
|
693
|
-
name="conversation_recall",
|
|
694
|
-
structured_output=True,
|
|
695
|
-
annotations=types.ToolAnnotations(
|
|
696
|
-
readOnlyHint=True,
|
|
697
|
-
destructiveHint=False,
|
|
698
|
-
idempotentHint=True,
|
|
699
|
-
openWorldHint=False,
|
|
700
|
-
),
|
|
701
|
-
)
|
|
702
|
-
def conversation_recall(query: str, scopes: list[str] | None = None,
|
|
703
|
-
limit: int = 10) -> dict[str, Any]:
|
|
704
|
-
"""Search redacted conversation turns captured after Local Brain activation."""
|
|
705
|
-
try:
|
|
706
|
-
with ConversationMemoryStore(database) as store:
|
|
707
|
-
return store.search(query, scopes=scopes, limit=limit)
|
|
708
|
-
except ValueError as error:
|
|
709
|
-
return {"status": "UNKNOWN", "reason": "INVALID_CONVERSATION_QUERY",
|
|
710
|
-
"detail": str(error), "scope_completeness": "UNATTESTED",
|
|
711
|
-
"turns": []}
|
|
712
|
-
|
|
713
608
|
@server.tool(
|
|
714
609
|
name="code_activity",
|
|
715
610
|
structured_output=True,
|
|
@@ -728,7 +623,10 @@ def build_server(db_path=None):
|
|
|
728
623
|
change_kind: str | None = None,
|
|
729
624
|
limit: int = 20,
|
|
730
625
|
max_diff_chars: int = 6000) -> dict[str, Any]:
|
|
731
|
-
"""
|
|
626
|
+
"""Code changes the capture service recorded, with diff excerpts, filtered by project,
|
|
627
|
+
time range (`happened_from`/`happened_to`, ISO), file, symbol or kind. For "what did I
|
|
628
|
+
change in auth.py last week?" or "when did the parser change?". Empty unless a code folder
|
|
629
|
+
is being watched (automatic-capture.json, code_workspaces)."""
|
|
732
630
|
try:
|
|
733
631
|
bounded_diff_chars = max(200, min(max_diff_chars, 20000))
|
|
734
632
|
with CodingMemoryStore(database) as store:
|
|
@@ -741,100 +639,6 @@ def build_server(db_path=None):
|
|
|
741
639
|
"detail": str(error), "scope_completeness": "UNATTESTED",
|
|
742
640
|
"changes": []}
|
|
743
641
|
|
|
744
|
-
@server.tool(
|
|
745
|
-
name="memory_record",
|
|
746
|
-
structured_output=True,
|
|
747
|
-
annotations=types.ToolAnnotations(
|
|
748
|
-
readOnlyHint=False,
|
|
749
|
-
destructiveHint=False,
|
|
750
|
-
idempotentHint=False,
|
|
751
|
-
openWorldHint=False,
|
|
752
|
-
),
|
|
753
|
-
)
|
|
754
|
-
def memory_record(project_id: str, session_id: str, event_type: RecordableEventType,
|
|
755
|
-
subject_id: str, payload: dict[str, Any], source_ref: str,
|
|
756
|
-
effective_at: str | None = None,
|
|
757
|
-
source_type: str = "mcp-client",
|
|
758
|
-
supersedes_event_id: str | None = None,
|
|
759
|
-
event_id: str | None = None) -> dict[str, Any]:
|
|
760
|
-
"""Append one sourced development event without overwriting prior history."""
|
|
761
|
-
known_at = _now()
|
|
762
|
-
event = {"event_id": event_id or "mcp-" + uuid.uuid4().hex,
|
|
763
|
-
"project_id": project_id, "session_id": session_id,
|
|
764
|
-
"event_type": event_type, "effective_at": effective_at or known_at,
|
|
765
|
-
"known_at": known_at, "actor": "assistant", "subject_id": subject_id,
|
|
766
|
-
"payload": payload,
|
|
767
|
-
"provenance": {"source_type": source_type, "source_ref": source_ref},
|
|
768
|
-
"supersedes_event_id": supersedes_event_id}
|
|
769
|
-
with DevelopmentMemoryStore(database) as store:
|
|
770
|
-
result = store.record_event(event)
|
|
771
|
-
return {**result, "project_id": project_id, "store": str(database)}
|
|
772
|
-
|
|
773
|
-
@server.tool(
|
|
774
|
-
name="project_status",
|
|
775
|
-
structured_output=True,
|
|
776
|
-
annotations=types.ToolAnnotations(
|
|
777
|
-
readOnlyHint=True,
|
|
778
|
-
destructiveHint=False,
|
|
779
|
-
idempotentHint=True,
|
|
780
|
-
openWorldHint=False,
|
|
781
|
-
),
|
|
782
|
-
)
|
|
783
|
-
def project_status(project_id: str, as_of: str | None = None,
|
|
784
|
-
knowledge_cutoff: str | None = None) -> dict[str, Any]:
|
|
785
|
-
"""Return the current objective, active work and next actions for a project."""
|
|
786
|
-
when = as_of or _now()
|
|
787
|
-
with DevelopmentMemoryStore(database) as store:
|
|
788
|
-
resumed = store.resume_project(project_id, as_of=when,
|
|
789
|
-
knowledge_cutoff=knowledge_cutoff)
|
|
790
|
-
if resumed["status"] != "KNOWN":
|
|
791
|
-
return resumed
|
|
792
|
-
return {key: resumed[key] for key in (
|
|
793
|
-
"status", "reason", "project_id", "as_of", "knowledge_cutoff",
|
|
794
|
-
"scope_completeness", "warnings", "objective", "current_task",
|
|
795
|
-
"open_tasks", "next_actions", "active_experiments", "last_session",
|
|
796
|
-
"evidence_event_ids")}
|
|
797
|
-
|
|
798
|
-
@server.tool(
|
|
799
|
-
name="resume_project",
|
|
800
|
-
structured_output=True,
|
|
801
|
-
annotations=types.ToolAnnotations(
|
|
802
|
-
readOnlyHint=True,
|
|
803
|
-
destructiveHint=False,
|
|
804
|
-
idempotentHint=True,
|
|
805
|
-
openWorldHint=False,
|
|
806
|
-
),
|
|
807
|
-
)
|
|
808
|
-
def resume_project(project_id: str, as_of: str | None = None,
|
|
809
|
-
knowledge_cutoff: str | None = None,
|
|
810
|
-
recent_file_limit: int = 10,
|
|
811
|
-
recent_test_limit: int = 10) -> dict[str, Any]:
|
|
812
|
-
"""Build a full evidence-backed packet for continuing work in a new session."""
|
|
813
|
-
when = as_of or _now()
|
|
814
|
-
with DevelopmentMemoryStore(database) as store:
|
|
815
|
-
return store.resume_project(project_id, as_of=when,
|
|
816
|
-
knowledge_cutoff=knowledge_cutoff, recent_file_limit=recent_file_limit,
|
|
817
|
-
recent_test_limit=recent_test_limit)
|
|
818
|
-
|
|
819
|
-
@server.tool(
|
|
820
|
-
name="explain_decision",
|
|
821
|
-
structured_output=True,
|
|
822
|
-
annotations=types.ToolAnnotations(
|
|
823
|
-
readOnlyHint=True,
|
|
824
|
-
destructiveHint=False,
|
|
825
|
-
idempotentHint=True,
|
|
826
|
-
openWorldHint=False,
|
|
827
|
-
),
|
|
828
|
-
)
|
|
829
|
-
def explain_decision(project_id: str, decision_id: str,
|
|
830
|
-
as_of: str | None = None,
|
|
831
|
-
knowledge_cutoff: str | None = None) -> dict[str, Any]:
|
|
832
|
-
"""Return the active decision, reason, correction history and provenance."""
|
|
833
|
-
when = as_of or _now()
|
|
834
|
-
with DevelopmentMemoryStore(database) as store:
|
|
835
|
-
return store.explain_decision(project_id, decision_id, as_of=when,
|
|
836
|
-
knowledge_cutoff=knowledge_cutoff)
|
|
837
|
-
|
|
838
642
|
return server
|
|
839
643
|
|
|
840
644
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graph-mind-memory
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Local-first memory for AI coding assistants: verbatim, on your own PC, no LLM calls at write time.
|
|
5
5
|
Author-email: Yohan Ko <goyohan0611@gmail.com>
|
|
6
6
|
License-Expression: AGPL-3.0-only
|
|
@@ -41,6 +41,10 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
|
|
|
41
41
|
|
|
42
42
|
*Switch models; keep the memory.*
|
|
43
43
|
|
|
44
|
+
<img src="demo/demo.gif" alt="A decision made in Claude Code on Monday, recalled from Codex on Wednesday" width="760">
|
|
45
|
+
|
|
46
|
+
<sub>A real run, not a mock-up: <code>python demo/record_demo.py</code> records it from a fresh store.</sub>
|
|
47
|
+
|
|
44
48
|
</div>
|
|
45
49
|
|
|
46
50
|
> [!NOTE]
|
|
@@ -162,25 +166,33 @@ Then restart your AI apps. The server is also listed in the
|
|
|
162
166
|
Before anything is stored, these are masked: API keys, tokens from GitHub, AWS, Google and Slack,
|
|
163
167
|
private keys, and passwords inside URLs.
|
|
164
168
|
|
|
169
|
+
Only what you actually send is captured: the service reads each app's transcript, which is written
|
|
170
|
+
after you press Enter, so a paste you delete before sending never reaches it. A plain password
|
|
171
|
+
like `hunter2` has no recognizable format and is **not** masked. To remove something:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
graph-mind-forget # asks for the phrase without showing it, then confirms
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
It deletes every captured turn and memory containing the phrase from this PC and its indexes,
|
|
178
|
+
removes their lines from the shared memory, and tells your other PCs to delete their copies on
|
|
179
|
+
their next sync. Only ids are shared for that, never the phrase. Your AI app's own history
|
|
180
|
+
(for Claude Code, `~/.claude/projects`) is separate and is not touched.
|
|
181
|
+
|
|
165
182
|
---
|
|
166
183
|
|
|
167
184
|
## MCP tools
|
|
168
185
|
|
|
169
186
|
| tool | what it does |
|
|
170
187
|
|---|---|
|
|
171
|
-
| `brain_context` | a bounded packet of the
|
|
172
|
-
| `brain_recall` |
|
|
173
|
-
| `brain_remember` | save a sourced memory (secrets masked) |
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
| `code_activity` | what changed in a project, file or symbol, and when |
|
|
180
|
-
| `memory_record` | record a development event or decision |
|
|
181
|
-
| `project_status` | current state of a project |
|
|
182
|
-
| `resume_project` | everything needed to pick a project back up |
|
|
183
|
-
| `explain_decision` | a decision, its reason and its history |
|
|
188
|
+
| `brain_context` | a bounded packet of the past turns and memories that answer a request; `recent=true` for "where did we leave off?" |
|
|
189
|
+
| `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
|
|
190
|
+
| `brain_remember` | save a sourced memory or decision (secrets masked) |
|
|
191
|
+
| `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
|
|
192
|
+
| `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
|
|
193
|
+
|
|
194
|
+
Five tools on purpose: every tool's description is read by the model on every turn, and similar
|
|
195
|
+
tools get confused with each other. Version 0.1 had thirteen.
|
|
184
196
|
|
|
185
197
|
---
|
|
186
198
|
|
|
@@ -228,6 +240,8 @@ python -m unittest discover -p "test_*.py"
|
|
|
228
240
|
|
|
229
241
|
## Known limits
|
|
230
242
|
|
|
243
|
+
- **The first question after an AI app starts waits for the embedding model to load** (a few
|
|
244
|
+
seconds; 30-50 s on a slow or synced disk). Later questions take well under a second.
|
|
231
245
|
- **Daily use on Windows only so far.** macOS and Linux pass the tests in CI but have not seen real
|
|
232
246
|
use.
|
|
233
247
|
- **Capture follows each app's transcript format,** which is not a public interface. An app update
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graph-mind-memory"
|
|
7
|
-
version = "0.1
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Local-first memory for AI coding assistants: verbatim, on your own PC, no LLM calls at write time."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -39,6 +39,7 @@ Issues = "https://github.com/goyohan0611-png/graph-mind/issues"
|
|
|
39
39
|
graph-mind = "graph_mind_mcp_server:main"
|
|
40
40
|
graph-mind-install = "install:main"
|
|
41
41
|
graph-mind-capture = "automatic_capture_cli:main"
|
|
42
|
+
graph-mind-forget = "forget:main"
|
|
42
43
|
|
|
43
44
|
[tool.setuptools]
|
|
44
45
|
# ponytail: flat modules, as in the repository; a graph_mind/ package is the upgrade path.
|
|
@@ -54,6 +55,7 @@ py-modules = [
|
|
|
54
55
|
"development_paths",
|
|
55
56
|
"embedding_warmup",
|
|
56
57
|
"entity_timeline",
|
|
58
|
+
"forget",
|
|
57
59
|
"graph_mind_mcp_server",
|
|
58
60
|
"install",
|
|
59
61
|
"local_brain",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|