graph-mind-memory 0.2.0__tar.gz → 0.3.0__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.2.0 → graph_mind_memory-0.3.0}/PKG-INFO +20 -2
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/README.md +19 -1
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/automatic_capture.py +5 -3
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/brain_log.py +7 -1
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/conversation_memory.py +62 -2
- graph_mind_memory-0.3.0/forget.py +203 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/graph_mind_mcp_server.py +64 -1
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/graph_mind_memory.egg-info/PKG-INFO +20 -2
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/graph_mind_memory.egg-info/SOURCES.txt +1 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/graph_mind_memory.egg-info/entry_points.txt +1 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/graph_mind_memory.egg-info/top_level.txt +1 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/pyproject.toml +3 -1
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/LICENSE +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/associative_memory.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/automatic_capture_cli.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/clock.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/coding_memory.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/development_memory.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/development_paths.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/embedding_warmup.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/entity_timeline.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/graph_mind_memory.egg-info/dependency_links.txt +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/graph_mind_memory.egg-info/requires.txt +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/install.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/local_brain.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/local_embedder.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/semantic_llm_extractor.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/semantic_memory.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/semantic_recall.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/setup.cfg +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/temporal_resolver.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/universal_personal_memory.py +0 -0
- {graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/vector_cache.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graph-mind-memory
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -166,6 +166,23 @@ Then restart your AI apps. The server is also listed in the
|
|
|
166
166
|
Before anything is stored, these are masked: API keys, tokens from GitHub, AWS, Google and Slack,
|
|
167
167
|
private keys, and passwords inside URLs.
|
|
168
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
|
+
Or just ask your AI: *"delete the password I typed earlier"*. It lists masked candidates
|
|
178
|
+
(`wifi password is ****`), deletes only the ones you pick, and the conversation about deleting
|
|
179
|
+
is not saved.
|
|
180
|
+
|
|
181
|
+
It deletes every captured turn and memory containing the phrase from this PC and its indexes,
|
|
182
|
+
removes their lines from the shared memory, and tells your other PCs to delete their copies on
|
|
183
|
+
their next sync. Only ids are shared for that, never the phrase. Your AI app's own history
|
|
184
|
+
(for Claude Code, `~/.claude/projects`) is separate and is not touched.
|
|
185
|
+
|
|
169
186
|
---
|
|
170
187
|
|
|
171
188
|
## MCP tools
|
|
@@ -176,9 +193,10 @@ private keys, and passwords inside URLs.
|
|
|
176
193
|
| `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
|
|
177
194
|
| `brain_remember` | save a sourced memory or decision (secrets masked) |
|
|
178
195
|
| `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
|
|
196
|
+
| `brain_forget` | "delete the password I typed": lists masked candidates, deletes only what you pick, everywhere; the exchange itself is not saved |
|
|
179
197
|
| `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
|
|
180
198
|
|
|
181
|
-
|
|
199
|
+
Six tools on purpose: every tool's description is read by the model on every turn, and similar
|
|
182
200
|
tools get confused with each other. Version 0.1 had thirteen.
|
|
183
201
|
|
|
184
202
|
---
|
|
@@ -140,6 +140,23 @@ Then restart your AI apps. The server is also listed in the
|
|
|
140
140
|
Before anything is stored, these are masked: API keys, tokens from GitHub, AWS, Google and Slack,
|
|
141
141
|
private keys, and passwords inside URLs.
|
|
142
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
|
+
Or just ask your AI: *"delete the password I typed earlier"*. It lists masked candidates
|
|
152
|
+
(`wifi password is ****`), deletes only the ones you pick, and the conversation about deleting
|
|
153
|
+
is not saved.
|
|
154
|
+
|
|
155
|
+
It deletes every captured turn and memory containing the phrase from this PC and its indexes,
|
|
156
|
+
removes their lines from the shared memory, and tells your other PCs to delete their copies on
|
|
157
|
+
their next sync. Only ids are shared for that, never the phrase. Your AI app's own history
|
|
158
|
+
(for Claude Code, `~/.claude/projects`) is separate and is not touched.
|
|
159
|
+
|
|
143
160
|
---
|
|
144
161
|
|
|
145
162
|
## MCP tools
|
|
@@ -150,9 +167,10 @@ private keys, and passwords inside URLs.
|
|
|
150
167
|
| `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
|
|
151
168
|
| `brain_remember` | save a sourced memory or decision (secrets masked) |
|
|
152
169
|
| `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
|
|
170
|
+
| `brain_forget` | "delete the password I typed": lists masked candidates, deletes only what you pick, everywhere; the exchange itself is not saved |
|
|
153
171
|
| `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
|
|
154
172
|
|
|
155
|
-
|
|
173
|
+
Six tools on purpose: every tool's description is read by the model on every turn, and similar
|
|
156
174
|
tools get confused with each other. Version 0.1 had thirteen.
|
|
157
175
|
|
|
158
176
|
---
|
|
@@ -118,24 +118,26 @@ class AutomaticCaptureService:
|
|
|
118
118
|
if shared:
|
|
119
119
|
from brain_log import append
|
|
120
120
|
publish = lambda turn: append(shared, "turn", turn) # a folder or a Postgres URL
|
|
121
|
+
from forget import forget_ids
|
|
122
|
+
forget = lambda turn_ids: forget_ids({"turn_ids": turn_ids}, self.db_path, shared)
|
|
121
123
|
sources = []
|
|
122
124
|
# A source whose folder does not exist yet (Codex never run on this PC) is skipped, not
|
|
123
125
|
# fatal: it used to stop every source, so a Claude-Code-only user captured nothing.
|
|
124
126
|
if Path(self.config["codex_sessions_root"]).is_dir():
|
|
125
127
|
sources.append(("codex", CodexConversationCapture(
|
|
126
128
|
self.config["codex_sessions_root"], store, state_path=self.conversation_cursor,
|
|
127
|
-
workspace_scopes=scopes, now=self.now, publish=publish), self.conversation_cursor))
|
|
129
|
+
workspace_scopes=scopes, now=self.now, publish=publish, forget=forget), self.conversation_cursor))
|
|
128
130
|
claude_root = Path(self.config.get("claude_projects_root")
|
|
129
131
|
or Path.home() / ".claude" / "projects")
|
|
130
132
|
if claude_root.is_dir():
|
|
131
133
|
sources.append(("claude-code", ClaudeCodeConversationCapture(
|
|
132
134
|
claude_root, store, state_path=self.claude_cursor,
|
|
133
|
-
workspace_scopes=scopes, now=self.now, publish=publish), self.claude_cursor))
|
|
135
|
+
workspace_scopes=scopes, now=self.now, publish=publish, forget=forget), self.claude_cursor))
|
|
134
136
|
cowork = self.config.get("claude_cowork_root") or cowork_root()
|
|
135
137
|
if cowork and Path(cowork).is_dir():
|
|
136
138
|
sources.append(("claude-cowork", ClaudeCoworkConversationCapture(
|
|
137
139
|
cowork, store, state_path=self.cowork_cursor,
|
|
138
|
-
workspace_scopes=scopes, now=self.now, publish=publish), self.cowork_cursor))
|
|
140
|
+
workspace_scopes=scopes, now=self.now, publish=publish, forget=forget), self.cowork_cursor))
|
|
139
141
|
return sources
|
|
140
142
|
|
|
141
143
|
def baseline(self):
|
|
@@ -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:
|
|
@@ -426,6 +426,8 @@ def sync(shared: Path, index: Path) -> dict:
|
|
|
426
426
|
except ValueError:
|
|
427
427
|
continue # a corrupt line is skipped, not fatal
|
|
428
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"]
|
|
429
431
|
imported, conflicts, waiting = 0, 0, []
|
|
430
432
|
with LocalBrainStore(index) as brain, ConversationMemoryStore(index) as turns:
|
|
431
433
|
# A supersession can arrive before the memory it replaces when two devices' logs are
|
|
@@ -450,6 +452,10 @@ def sync(shared: Path, index: Path) -> dict:
|
|
|
450
452
|
if not progress:
|
|
451
453
|
waiting = [line for _, line in retry]
|
|
452
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)
|
|
453
459
|
with db:
|
|
454
460
|
db.execute("DELETE FROM brain_log_pending")
|
|
455
461
|
db.executemany("INSERT OR IGNORE INTO brain_log_pending VALUES(?)",
|
|
@@ -238,7 +238,7 @@ class CodexConversationCapture:
|
|
|
238
238
|
CLIENT = "codex"
|
|
239
239
|
|
|
240
240
|
def __init__(self, sessions_root, store, *, state_path, workspace_scopes=None,
|
|
241
|
-
now=None, publish=None):
|
|
241
|
+
now=None, publish=None, forget=None):
|
|
242
242
|
self.sessions_root = Path(sessions_root).resolve()
|
|
243
243
|
if not self.sessions_root.is_dir():
|
|
244
244
|
raise ValueError("CODEX_SESSIONS_ROOT_NOT_FOUND")
|
|
@@ -251,6 +251,46 @@ class CodexConversationCapture:
|
|
|
251
251
|
for key, value in (workspace_scopes or {}).items()}
|
|
252
252
|
self.now = now or (lambda: datetime.now().isoformat(timespec="microseconds"))
|
|
253
253
|
self.publish = publish # called with each turn that is new to this store
|
|
254
|
+
# erases captured turns everywhere (forget.forget_ids with the shared log); by default
|
|
255
|
+
# this store only
|
|
256
|
+
self.forget = forget or (lambda turn_ids: __import__("forget").erase(
|
|
257
|
+
self.store.path, {"turn_ids": turn_ids}))
|
|
258
|
+
|
|
259
|
+
FORGET_TOOL = "brain_forget"
|
|
260
|
+
FORGET_WINDOW_MINUTES = 30
|
|
261
|
+
|
|
262
|
+
def _forget_call(self, item):
|
|
263
|
+
"""Whether this record is the assistant calling brain_forget. Codex: a function call
|
|
264
|
+
named for it, or (code mode) a completed MCP tool call of it."""
|
|
265
|
+
payload = item.get("payload") or {}
|
|
266
|
+
if payload.get("type") in {"function_call", "custom_tool_call"}:
|
|
267
|
+
return str(payload.get("name", "")).endswith(self.FORGET_TOOL)
|
|
268
|
+
call = payload.get("item") if isinstance(payload.get("item"), dict) else {}
|
|
269
|
+
return call.get("type") == "McpToolCall" and call.get("tool") == self.FORGET_TOOL
|
|
270
|
+
|
|
271
|
+
def _forget_exchange(self, session_id, at, sessions):
|
|
272
|
+
"""The assistant called brain_forget: what led up to it is a conversation about deleting,
|
|
273
|
+
so it goes too. From the user turn that asked (or from the previous forget call, when this
|
|
274
|
+
is the second, deleting call of the same exchange) up to now; the assistant's reply that
|
|
275
|
+
follows is skipped until the user speaks again."""
|
|
276
|
+
at = self.store._time(at)
|
|
277
|
+
previous = sessions.get(session_id, {}).get("signal_at")
|
|
278
|
+
db = self.store.db
|
|
279
|
+
if previous and (datetime.fromisoformat(at) - datetime.fromisoformat(previous)
|
|
280
|
+
).total_seconds() <= self.FORGET_WINDOW_MINUTES * 60:
|
|
281
|
+
since = previous
|
|
282
|
+
else:
|
|
283
|
+
since = db.execute(
|
|
284
|
+
"SELECT max(happened_at) FROM conversation_turns WHERE client=? AND "
|
|
285
|
+
"client_session_id=? AND role='user' AND happened_at<=?",
|
|
286
|
+
(self.CLIENT, session_id, at)).fetchone()[0]
|
|
287
|
+
if since:
|
|
288
|
+
ids = [row[0] for row in db.execute(
|
|
289
|
+
"SELECT turn_id FROM conversation_turns WHERE client=? AND client_session_id=? "
|
|
290
|
+
"AND happened_at>=?", (self.CLIENT, session_id, since))]
|
|
291
|
+
if ids:
|
|
292
|
+
self.forget(ids)
|
|
293
|
+
sessions[session_id] = {"signal_at": at, "quiet": True}
|
|
254
294
|
|
|
255
295
|
def _files(self):
|
|
256
296
|
return sorted(self.sessions_root.rglob("*.jsonl"))
|
|
@@ -293,11 +333,13 @@ class CodexConversationCapture:
|
|
|
293
333
|
value = json.loads(self.state_path.read_text(encoding="utf-8"))
|
|
294
334
|
if value.get("capture_version") != CAPTURE_VERSION:
|
|
295
335
|
raise ValueError("CONVERSATION_CAPTURE_VERSION_MISMATCH")
|
|
336
|
+
self._forget_sessions = value.get("forget_sessions", {})
|
|
296
337
|
return value.get("offsets", {})
|
|
297
338
|
|
|
298
339
|
def _write_state(self, offsets):
|
|
299
340
|
value = {"capture_version": CAPTURE_VERSION,
|
|
300
|
-
"sessions_root": str(self.sessions_root), "offsets": offsets
|
|
341
|
+
"sessions_root": str(self.sessions_root), "offsets": offsets,
|
|
342
|
+
"forget_sessions": getattr(self, "_forget_sessions", {})}
|
|
301
343
|
temporary = self.state_path.with_suffix(".tmp")
|
|
302
344
|
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n",
|
|
303
345
|
encoding="utf-8")
|
|
@@ -332,12 +374,21 @@ class CodexConversationCapture:
|
|
|
332
374
|
item = json.loads(line.decode("utf-8", "strict"))
|
|
333
375
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
334
376
|
continue
|
|
377
|
+
if self._forget_call(item):
|
|
378
|
+
self._forget_exchange(session_id, item.get("timestamp") or known_at,
|
|
379
|
+
self._forget_sessions)
|
|
380
|
+
continue
|
|
335
381
|
message = self._message(item)
|
|
336
382
|
if message is None:
|
|
337
383
|
continue
|
|
338
384
|
role, raw = message
|
|
339
385
|
if not raw.strip():
|
|
340
386
|
continue
|
|
387
|
+
quiet = self._forget_sessions.get(session_id)
|
|
388
|
+
if quiet and quiet.get("quiet"):
|
|
389
|
+
if role == "assistant":
|
|
390
|
+
continue # the reply to a forget call: not remembered
|
|
391
|
+
quiet["quiet"] = False # the user spoke again: the exchange is over
|
|
341
392
|
cleaned, count = _redact(raw)
|
|
342
393
|
if not cleaned:
|
|
343
394
|
continue
|
|
@@ -413,6 +464,15 @@ class ClaudeCodeConversationCapture(CodexConversationCapture):
|
|
|
413
464
|
resolved_cwd = str(Path(cwd).resolve()) if isinstance(cwd, str) and cwd else None
|
|
414
465
|
return session_id, cwd, self.workspace_scopes.get(resolved_cwd, "global")
|
|
415
466
|
|
|
467
|
+
def _forget_call(self, item):
|
|
468
|
+
"""An assistant record with a tool_use block calling brain_forget (any MCP prefix)."""
|
|
469
|
+
if item.get("type") != "assistant" or item.get("isSidechain"):
|
|
470
|
+
return False
|
|
471
|
+
content = (item.get("message") or {}).get("content")
|
|
472
|
+
return isinstance(content, list) and any(
|
|
473
|
+
isinstance(block, dict) and block.get("type") == "tool_use"
|
|
474
|
+
and str(block.get("name", "")).endswith(self.FORGET_TOOL) for block in content)
|
|
475
|
+
|
|
416
476
|
def _message(self, item):
|
|
417
477
|
if item.get("isSidechain") or item.get("isMeta"):
|
|
418
478
|
return None
|
|
@@ -0,0 +1,203 @@
|
|
|
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_ids(ids: dict, index: Path, shared=None) -> dict:
|
|
123
|
+
"""Erase these turns and memories here, scrub the shared log, and tell the other PCs."""
|
|
124
|
+
from brain_log import append
|
|
125
|
+
if not (ids.get("turn_ids") or ids.get("memory_ids")):
|
|
126
|
+
return {"turns": 0, "memories": 0, "shared_lines_removed": 0}
|
|
127
|
+
ids = {"turn_ids": list(ids.get("turn_ids", [])),
|
|
128
|
+
"memory_ids": list(ids.get("memory_ids", []))}
|
|
129
|
+
result = erase(index, ids)
|
|
130
|
+
if shared:
|
|
131
|
+
result["shared_lines_removed"] = scrub_shared(shared, ids)
|
|
132
|
+
append(shared, "forget", ids) # ids only: the text never leaves here
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def forget(phrase: str, index: Path, shared=None) -> dict:
|
|
137
|
+
"""Find everything containing the phrase, then forget it as forget_ids does."""
|
|
138
|
+
return forget_ids(find(index, phrase), index, shared)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
SECRET_HINTS = ("password", "passwd", "pw", "pin", "비밀번호", "비번", "암호", "key", "token",
|
|
142
|
+
"secret", "api")
|
|
143
|
+
LINKS = {"is", "was", "=", ":", "->", "는", "은", "이", "가"}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def masked_preview(text: str, width: int = 90) -> str:
|
|
147
|
+
"""Enough of a turn to recognise it, with what could be a secret replaced by ****: the word
|
|
148
|
+
after "password"/"비번"/"key"..., anything after "password:" or "key=", and any word that
|
|
149
|
+
mixes letters with digits or symbols. Over-masks on purpose; the original is never shown, so
|
|
150
|
+
listing candidates does not put a secret back into the chat it is being removed from."""
|
|
151
|
+
out, hide_next = [], False
|
|
152
|
+
for token in str(text).split():
|
|
153
|
+
lowered = token.lower()
|
|
154
|
+
bare = token.strip(".,!?\"'()[]{}")
|
|
155
|
+
hint = any(word in lowered for word in SECRET_HINTS)
|
|
156
|
+
if hint and (":" in token or "=" in token):
|
|
157
|
+
separator = ":" if ":" in token else "="
|
|
158
|
+
out.append(token.split(separator, 1)[0] + separator + "****")
|
|
159
|
+
hide_next = not token.split(separator, 1)[1]
|
|
160
|
+
continue
|
|
161
|
+
if hide_next and lowered in LINKS: # "password is ...", "비번은 = ..."
|
|
162
|
+
out.append(token)
|
|
163
|
+
continue
|
|
164
|
+
secretish = hide_next or (any(c.isdigit() for c in bare) and any(c.isalpha() for c in bare)) \
|
|
165
|
+
or any(c in "!@#$%^&*_=+<>|~`\\" for c in bare)
|
|
166
|
+
out.append("****" if secretish else token)
|
|
167
|
+
hide_next = hint
|
|
168
|
+
preview = " ".join(out)
|
|
169
|
+
return preview if len(preview) <= width else preview[:width] + "…"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main(argv=None):
|
|
173
|
+
from brain_log import folder
|
|
174
|
+
from development_paths import default_development_db
|
|
175
|
+
parser = argparse.ArgumentParser(description="Forget every captured turn and memory "
|
|
176
|
+
"that contains a phrase, on every PC.")
|
|
177
|
+
parser.add_argument("phrase", nargs="?", help="omit it to type it without echo")
|
|
178
|
+
parser.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
179
|
+
args = parser.parse_args(argv)
|
|
180
|
+
phrase = args.phrase or getpass.getpass("Phrase to forget (not shown): ")
|
|
181
|
+
if len(phrase.strip()) < 4:
|
|
182
|
+
sys.exit("Use at least 4 characters, so a short word does not wipe half the memory.")
|
|
183
|
+
index = default_development_db()
|
|
184
|
+
ids = find(index, phrase)
|
|
185
|
+
count = len(ids["turn_ids"]) + len(ids["memory_ids"])
|
|
186
|
+
if not count:
|
|
187
|
+
print("Nothing in this PC's memory contains that phrase.")
|
|
188
|
+
return
|
|
189
|
+
print(f"{len(ids['turn_ids'])} conversation turns and {len(ids['memory_ids'])} memories "
|
|
190
|
+
"contain it.")
|
|
191
|
+
if not args.yes and input("Forget them here and on your other PCs? [y/N] ").lower() != "y":
|
|
192
|
+
print("Nothing changed.")
|
|
193
|
+
return
|
|
194
|
+
shared = folder()
|
|
195
|
+
result = forget(phrase, index, shared)
|
|
196
|
+
print(f"Forgotten: {result['turns']} turns, {result['memories']} memories"
|
|
197
|
+
+ (f"; {result['shared_lines_removed']} lines removed from the shared memory, "
|
|
198
|
+
"other PCs follow on their next sync" if shared else "") + ".")
|
|
199
|
+
print("Your AI app may still have it in its own history (Claude Code: ~/.claude/projects).")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
main()
|
|
@@ -342,7 +342,9 @@ def build_server(db_path=None):
|
|
|
342
342
|
"replaying full history. Use brain_recall to look memories up directly, or with "
|
|
343
343
|
"`entity` for the history of one thing in order. For code changed in a time range, "
|
|
344
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.
|
|
345
|
+
"memory lives and shares it with the user's other PCs. When the user asks to "
|
|
346
|
+
"delete something from memory, use brain_forget: list first, delete only what the "
|
|
347
|
+
"user picks. Preserve provenance."
|
|
346
348
|
),
|
|
347
349
|
version=SERVER_VERSION,
|
|
348
350
|
)
|
|
@@ -605,6 +607,67 @@ def build_server(db_path=None):
|
|
|
605
607
|
"existing_memories_shared": exported or "not shared; ask the user before "
|
|
606
608
|
"setting share_existing=true"}
|
|
607
609
|
|
|
610
|
+
@server.tool(
|
|
611
|
+
name="brain_forget",
|
|
612
|
+
structured_output=True,
|
|
613
|
+
annotations=types.ToolAnnotations(
|
|
614
|
+
readOnlyHint=False,
|
|
615
|
+
destructiveHint=True,
|
|
616
|
+
idempotentHint=True,
|
|
617
|
+
openWorldHint=False,
|
|
618
|
+
),
|
|
619
|
+
)
|
|
620
|
+
def brain_forget(query: str | None = None, ids: list[str] | None = None) -> dict[str, Any]:
|
|
621
|
+
"""Delete what the user wants gone from memory: "delete the password I typed earlier",
|
|
622
|
+
"그 토큰 지워줘", "forget what I said about my ex".
|
|
623
|
+
|
|
624
|
+
Always two calls. First `query`: what to look for, in general words ("password", "wifi",
|
|
625
|
+
"DB login"); never repeat a secret the user typed. It returns numbered candidates with
|
|
626
|
+
masked previews. Show the user exactly those previews and ask which to delete; never
|
|
627
|
+
quote the original text. Then call again with the `ids` the user picked, and only those.
|
|
628
|
+
|
|
629
|
+
Deleting removes them from this PC, its search indexes, the shared memory and the user's
|
|
630
|
+
other PCs. This exchange about deleting is not saved either. The AI app's own chat
|
|
631
|
+
history is separate and untouched (Claude Code keeps ~/.claude/projects).
|
|
632
|
+
"""
|
|
633
|
+
import sqlite3
|
|
634
|
+
|
|
635
|
+
from forget import forget_ids, masked_preview
|
|
636
|
+
if ids:
|
|
637
|
+
db = sqlite3.connect(database, timeout=30)
|
|
638
|
+
try:
|
|
639
|
+
turns = [row[0] for row in db.execute(
|
|
640
|
+
"SELECT turn_id FROM conversation_turns WHERE turn_id IN ("
|
|
641
|
+
+ ",".join("?" * len(ids)) + ")", ids)]
|
|
642
|
+
memories = [row[0] for row in db.execute(
|
|
643
|
+
"SELECT memory_id FROM local_brain_memories WHERE memory_id IN ("
|
|
644
|
+
+ ",".join("?" * len(ids)) + ")", ids)]
|
|
645
|
+
except sqlite3.OperationalError:
|
|
646
|
+
turns, memories = [], []
|
|
647
|
+
finally:
|
|
648
|
+
db.close()
|
|
649
|
+
from brain_log import folder
|
|
650
|
+
try:
|
|
651
|
+
shared = folder()
|
|
652
|
+
except Exception:
|
|
653
|
+
shared = None
|
|
654
|
+
result = forget_ids({"turn_ids": turns, "memory_ids": memories}, database, shared)
|
|
655
|
+
return {"status": "FORGOTTEN", **result,
|
|
656
|
+
"not_found": [i for i in ids if i not in turns and i not in memories]}
|
|
657
|
+
if not query:
|
|
658
|
+
return {"status": "QUERY_OR_IDS_REQUIRED", "candidates": []}
|
|
659
|
+
recalled = gather(database, query, as_of=_now(), limit=10)
|
|
660
|
+
candidates = [{"id": m["memory_id"], "kind": "memory", "when": m.get("effective_at"),
|
|
661
|
+
"preview": masked_preview(m.get("content", ""))}
|
|
662
|
+
for m in recalled.get("matches", [])]
|
|
663
|
+
candidates += [{"id": t["turn_id"], "kind": f"{t.get('role')} turn in {t.get('client')}",
|
|
664
|
+
"when": t.get("happened_at"),
|
|
665
|
+
"preview": masked_preview(t.get("content", ""))}
|
|
666
|
+
for t in recalled.get("conversation_turns", [])]
|
|
667
|
+
return {"status": "CANDIDATES", "candidates": candidates,
|
|
668
|
+
"next": "Show these numbered with their masked previews exactly as given, ask "
|
|
669
|
+
"which to delete, then call brain_forget with those ids."}
|
|
670
|
+
|
|
608
671
|
@server.tool(
|
|
609
672
|
name="code_activity",
|
|
610
673
|
structured_output=True,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graph-mind-memory
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -166,6 +166,23 @@ Then restart your AI apps. The server is also listed in the
|
|
|
166
166
|
Before anything is stored, these are masked: API keys, tokens from GitHub, AWS, Google and Slack,
|
|
167
167
|
private keys, and passwords inside URLs.
|
|
168
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
|
+
Or just ask your AI: *"delete the password I typed earlier"*. It lists masked candidates
|
|
178
|
+
(`wifi password is ****`), deletes only the ones you pick, and the conversation about deleting
|
|
179
|
+
is not saved.
|
|
180
|
+
|
|
181
|
+
It deletes every captured turn and memory containing the phrase from this PC and its indexes,
|
|
182
|
+
removes their lines from the shared memory, and tells your other PCs to delete their copies on
|
|
183
|
+
their next sync. Only ids are shared for that, never the phrase. Your AI app's own history
|
|
184
|
+
(for Claude Code, `~/.claude/projects`) is separate and is not touched.
|
|
185
|
+
|
|
169
186
|
---
|
|
170
187
|
|
|
171
188
|
## MCP tools
|
|
@@ -176,9 +193,10 @@ private keys, and passwords inside URLs.
|
|
|
176
193
|
| `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
|
|
177
194
|
| `brain_remember` | save a sourced memory or decision (secrets masked) |
|
|
178
195
|
| `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
|
|
196
|
+
| `brain_forget` | "delete the password I typed": lists masked candidates, deletes only what you pick, everywhere; the exchange itself is not saved |
|
|
179
197
|
| `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
|
|
180
198
|
|
|
181
|
-
|
|
199
|
+
Six tools on purpose: every tool's description is read by the model on every turn, and similar
|
|
182
200
|
tools get confused with each other. Version 0.1 had thirteen.
|
|
183
201
|
|
|
184
202
|
---
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graph-mind-memory"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
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
|
{graph_mind_memory-0.2.0 → graph_mind_memory-0.3.0}/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
|