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.
Files changed (33) hide show
  1. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/PKG-INFO +28 -14
  2. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/README.md +27 -13
  3. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/brain_log.py +11 -2
  4. graph_mind_memory-0.2.1/forget.py +166 -0
  5. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_mcp_server.py +47 -243
  6. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/PKG-INFO +28 -14
  7. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/SOURCES.txt +1 -0
  8. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/entry_points.txt +1 -0
  9. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/top_level.txt +1 -0
  10. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/pyproject.toml +3 -1
  11. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/LICENSE +0 -0
  12. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/associative_memory.py +0 -0
  13. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/automatic_capture.py +0 -0
  14. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/automatic_capture_cli.py +0 -0
  15. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/clock.py +0 -0
  16. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/coding_memory.py +0 -0
  17. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/conversation_memory.py +0 -0
  18. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/development_memory.py +0 -0
  19. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/development_paths.py +0 -0
  20. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/embedding_warmup.py +0 -0
  21. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/entity_timeline.py +0 -0
  22. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/dependency_links.txt +0 -0
  23. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/requires.txt +0 -0
  24. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/install.py +0 -0
  25. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/local_brain.py +0 -0
  26. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/local_embedder.py +0 -0
  27. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/semantic_llm_extractor.py +0 -0
  28. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/semantic_memory.py +0 -0
  29. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/semantic_recall.py +0 -0
  30. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/setup.cfg +0 -0
  31. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/temporal_resolver.py +0 -0
  32. {graph_mind_memory-0.1.5 → graph_mind_memory-0.2.1}/universal_personal_memory.py +0 -0
  33. {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.5
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 memories and conversation turns that answer a request |
172
- | `brain_recall` | inspect memories and captured turns directly |
173
- | `brain_remember` | save a sourced memory (secrets masked) |
174
- | `brain_associate` | recall from a vague cue |
175
- | `brain_timeline` | everything about one thing, oldest first, with replaced entries marked |
176
- | `brain_folder` | show or choose where the memory lives; share it with other PCs |
177
- | `brain_index` | bring an imported backlog up to date |
178
- | `conversation_recall` | search the captured turns themselves |
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 memories and conversation turns that answer a request |
146
- | `brain_recall` | inspect memories and captured turns directly |
147
- | `brain_remember` | save a sourced memory (secrets masked) |
148
- | `brain_associate` | recall from a vague cue |
149
- | `brain_timeline` | everything about one thing, oldest first, with replaced entries marked |
150
- | `brain_folder` | show or choose where the memory lives; share it with other PCs |
151
- | `brain_index` | bring an imported backlog up to date |
152
- | `conversation_recall` | search the captured turns themselves |
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
- return Path(override) if override else Path.home() / ".graph-mind" / "config.json"
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, documents, projects or work. It skips unrelated requests and "
350
- "returns a bounded evidence packet instead of replaying full history. Use brain_recall "
351
- "for direct memory inspection. It searches curated memories and automatically captured "
352
- "conversation turns without requiring a project ID. Use brain_associate for vague cues "
353
- "or indirect reminders, then verify its candidate sources with the deterministic tools. "
354
- "For questions about code changed in a time range, project, file or symbol, translate "
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, scopes: list[str] | None = None,
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
- """Recall relevant local memories from natural wording without requiring a project ID.
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) -> dict[str, Any]:
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
- """Return indexed code changes and diff evidence without rereading a repository."""
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.5
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 memories and conversation turns that answer a request |
172
- | `brain_recall` | inspect memories and captured turns directly |
173
- | `brain_remember` | save a sourced memory (secrets masked) |
174
- | `brain_associate` | recall from a vague cue |
175
- | `brain_timeline` | everything about one thing, oldest first, with replaced entries marked |
176
- | `brain_folder` | show or choose where the memory lives; share it with other PCs |
177
- | `brain_index` | bring an imported backlog up to date |
178
- | `conversation_recall` | search the captured turns themselves |
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
@@ -11,6 +11,7 @@ development_memory.py
11
11
  development_paths.py
12
12
  embedding_warmup.py
13
13
  entity_timeline.py
14
+ forget.py
14
15
  graph_mind_mcp_server.py
15
16
  install.py
16
17
  local_brain.py
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
2
  graph-mind = graph_mind_mcp_server:main
3
3
  graph-mind-capture = automatic_capture_cli:main
4
+ graph-mind-forget = forget:main
4
5
  graph-mind-install = install:main
@@ -9,6 +9,7 @@ development_memory
9
9
  development_paths
10
10
  embedding_warmup
11
11
  entity_timeline
12
+ forget
12
13
  graph_mind_mcp_server
13
14
  install
14
15
  local_brain
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graph-mind-memory"
7
- version = "0.1.5"
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",