graph-mind-memory 0.2.1__tar.gz → 0.3.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.2.1 → graph_mind_memory-0.3.1}/PKG-INFO +16 -3
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/README.md +15 -2
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/automatic_capture.py +5 -3
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/conversation_memory.py +100 -3
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/forget.py +42 -5
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/graph_mind_mcp_server.py +64 -1
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/graph_mind_memory.egg-info/PKG-INFO +16 -3
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/pyproject.toml +1 -1
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/LICENSE +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/associative_memory.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/automatic_capture_cli.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/brain_log.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/clock.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/coding_memory.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/development_memory.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/development_paths.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/embedding_warmup.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/entity_timeline.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/graph_mind_memory.egg-info/SOURCES.txt +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/graph_mind_memory.egg-info/dependency_links.txt +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/graph_mind_memory.egg-info/entry_points.txt +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/graph_mind_memory.egg-info/requires.txt +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/graph_mind_memory.egg-info/top_level.txt +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/install.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/local_brain.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/local_embedder.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/semantic_llm_extractor.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/semantic_memory.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/semantic_recall.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/setup.cfg +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/temporal_resolver.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/universal_personal_memory.py +0 -0
- {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.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.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -164,7 +164,15 @@ Then restart your AI apps. The server is also listed in the
|
|
|
164
164
|
| Claude desktop: chat | what the model saves with `brain_remember` |
|
|
165
165
|
|
|
166
166
|
Before anything is stored, these are masked: API keys, tokens from GitHub, AWS, Google and Slack,
|
|
167
|
-
private keys,
|
|
167
|
+
private keys, passwords inside URLs, values written after `password:` / `api_key=` / `token:`,
|
|
168
|
+
and any long machine-random string even from a service no rule knows (by its randomness: 91% of
|
|
169
|
+
random 20-64 character tokens caught; in 246,750 ordinary chat turns it fired 92 times, nearly
|
|
170
|
+
all on real ids and tokens). Commit ids, hashes and UUIDs are kept, since you recall those on
|
|
171
|
+
purpose.
|
|
172
|
+
|
|
173
|
+
There is no switch to keep keys: a recalled memory goes to your model's provider with the question,
|
|
174
|
+
so a stored key would leave your PC the first time it is useful. To have Graph-MIND remember a key,
|
|
175
|
+
tell it **where the key is**, not the key: *"my OpenAI key is in 1Password, under Dev"*.
|
|
168
176
|
|
|
169
177
|
Only what you actually send is captured: the service reads each app's transcript, which is written
|
|
170
178
|
after you press Enter, so a paste you delete before sending never reaches it. A plain password
|
|
@@ -174,6 +182,10 @@ like `hunter2` has no recognizable format and is **not** masked. To remove somet
|
|
|
174
182
|
graph-mind-forget # asks for the phrase without showing it, then confirms
|
|
175
183
|
```
|
|
176
184
|
|
|
185
|
+
Or just ask your AI: *"delete the password I typed earlier"*. It lists masked candidates
|
|
186
|
+
(`wifi password is ****`), deletes only the ones you pick, and the conversation about deleting
|
|
187
|
+
is not saved.
|
|
188
|
+
|
|
177
189
|
It deletes every captured turn and memory containing the phrase from this PC and its indexes,
|
|
178
190
|
removes their lines from the shared memory, and tells your other PCs to delete their copies on
|
|
179
191
|
their next sync. Only ids are shared for that, never the phrase. Your AI app's own history
|
|
@@ -189,9 +201,10 @@ their next sync. Only ids are shared for that, never the phrase. Your AI app's o
|
|
|
189
201
|
| `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
|
|
190
202
|
| `brain_remember` | save a sourced memory or decision (secrets masked) |
|
|
191
203
|
| `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
|
|
204
|
+
| `brain_forget` | "delete the password I typed": lists masked candidates, deletes only what you pick, everywhere; the exchange itself is not saved |
|
|
192
205
|
| `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
|
|
193
206
|
|
|
194
|
-
|
|
207
|
+
Six tools on purpose: every tool's description is read by the model on every turn, and similar
|
|
195
208
|
tools get confused with each other. Version 0.1 had thirteen.
|
|
196
209
|
|
|
197
210
|
---
|
|
@@ -138,7 +138,15 @@ Then restart your AI apps. The server is also listed in the
|
|
|
138
138
|
| Claude desktop: chat | what the model saves with `brain_remember` |
|
|
139
139
|
|
|
140
140
|
Before anything is stored, these are masked: API keys, tokens from GitHub, AWS, Google and Slack,
|
|
141
|
-
private keys,
|
|
141
|
+
private keys, passwords inside URLs, values written after `password:` / `api_key=` / `token:`,
|
|
142
|
+
and any long machine-random string even from a service no rule knows (by its randomness: 91% of
|
|
143
|
+
random 20-64 character tokens caught; in 246,750 ordinary chat turns it fired 92 times, nearly
|
|
144
|
+
all on real ids and tokens). Commit ids, hashes and UUIDs are kept, since you recall those on
|
|
145
|
+
purpose.
|
|
146
|
+
|
|
147
|
+
There is no switch to keep keys: a recalled memory goes to your model's provider with the question,
|
|
148
|
+
so a stored key would leave your PC the first time it is useful. To have Graph-MIND remember a key,
|
|
149
|
+
tell it **where the key is**, not the key: *"my OpenAI key is in 1Password, under Dev"*.
|
|
142
150
|
|
|
143
151
|
Only what you actually send is captured: the service reads each app's transcript, which is written
|
|
144
152
|
after you press Enter, so a paste you delete before sending never reaches it. A plain password
|
|
@@ -148,6 +156,10 @@ like `hunter2` has no recognizable format and is **not** masked. To remove somet
|
|
|
148
156
|
graph-mind-forget # asks for the phrase without showing it, then confirms
|
|
149
157
|
```
|
|
150
158
|
|
|
159
|
+
Or just ask your AI: *"delete the password I typed earlier"*. It lists masked candidates
|
|
160
|
+
(`wifi password is ****`), deletes only the ones you pick, and the conversation about deleting
|
|
161
|
+
is not saved.
|
|
162
|
+
|
|
151
163
|
It deletes every captured turn and memory containing the phrase from this PC and its indexes,
|
|
152
164
|
removes their lines from the shared memory, and tells your other PCs to delete their copies on
|
|
153
165
|
their next sync. Only ids are shared for that, never the phrase. Your AI app's own history
|
|
@@ -163,9 +175,10 @@ their next sync. Only ids are shared for that, never the phrase. Your AI app's o
|
|
|
163
175
|
| `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
|
|
164
176
|
| `brain_remember` | save a sourced memory or decision (secrets masked) |
|
|
165
177
|
| `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
|
|
178
|
+
| `brain_forget` | "delete the password I typed": lists masked candidates, deletes only what you pick, everywhere; the exchange itself is not saved |
|
|
166
179
|
| `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
|
|
167
180
|
|
|
168
|
-
|
|
181
|
+
Six tools on purpose: every tool's description is read by the model on every turn, and similar
|
|
169
182
|
tools get confused with each other. Version 0.1 had thirteen.
|
|
170
183
|
|
|
171
184
|
---
|
|
@@ -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):
|
|
@@ -5,8 +5,10 @@ from development_paths import use_wal
|
|
|
5
5
|
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
import collections
|
|
8
9
|
import hashlib
|
|
9
10
|
import json
|
|
11
|
+
import math
|
|
10
12
|
import os
|
|
11
13
|
import re
|
|
12
14
|
import sqlite3
|
|
@@ -30,7 +32,34 @@ SECRET_PATTERNS = (
|
|
|
30
32
|
re.DOTALL), "[REDACTED_PRIVATE_KEY]"),
|
|
31
33
|
# a database URL carries its password: postgres://user:PASSWORD@host
|
|
32
34
|
(re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s:/@]+:)[^\s@/]+(@)"), r"\1[REDACTED]\2"),
|
|
35
|
+
# a password written after its name: "password: x", "pwd=x", "비밀번호: x", "비번=x"
|
|
36
|
+
(re.compile(r"(?i)((?<![a-z])(?:password|passwd|pwd)\s*[:=]\s*)\S+"), r"\1[REDACTED]"),
|
|
37
|
+
(re.compile(r"((?:비밀번호|비번|암호)\s*[:=]\s*)\S+"), r"\1[REDACTED]"),
|
|
33
38
|
)
|
|
39
|
+
# A token no pattern above knows: long, machine-random (mixed case and digits, no word-shaped
|
|
40
|
+
# pieces, high character entropy). Measured: catches 91% of random 20-64 character tokens; in
|
|
41
|
+
# LongMemEval's 246,750 chat turns it fires 92 times, nearly all on real ids and tokens (an
|
|
42
|
+
# OAuth token, Drive file ids, ad click ids); in this repository's code and docs, never.
|
|
43
|
+
# Commit ids, hashes and UUIDs are left alone: those get recalled on purpose.
|
|
44
|
+
RANDOM_CANDIDATE = re.compile(r"(?<![A-Za-z0-9_\-])[A-Za-z0-9_\-]{20,}(?![A-Za-z0-9_\-])")
|
|
45
|
+
UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
|
46
|
+
WORD_PIECE = re.compile(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _looks_random(token):
|
|
50
|
+
if UUID.fullmatch(token):
|
|
51
|
+
return False
|
|
52
|
+
core = re.sub(r"[_\-]", "", token)
|
|
53
|
+
if len(core) < 18 or re.fullmatch(r"[0-9a-fA-F]+", core):
|
|
54
|
+
return False
|
|
55
|
+
if not (any(c.isupper() for c in core) and any(c.islower() for c in core)
|
|
56
|
+
and sum(c.isdigit() for c in core) >= 2):
|
|
57
|
+
return False
|
|
58
|
+
pieces = WORD_PIECE.findall(core) # code names are words strung together
|
|
59
|
+
if pieces and sum(map(len, pieces)) / len(pieces) >= 3.0:
|
|
60
|
+
return False
|
|
61
|
+
counts = collections.Counter(core)
|
|
62
|
+
return -sum(n / len(core) * math.log2(n / len(core)) for n in counts.values()) >= 3.7
|
|
34
63
|
AUTOMATIC_BLOCKS = re.compile(
|
|
35
64
|
r"<(?:environment_context|recommended_plugins|in-app-browser-context)\b[^>]*>.*?"
|
|
36
65
|
r"</(?:environment_context|recommended_plugins|in-app-browser-context)>",
|
|
@@ -56,7 +85,15 @@ def _redact(value):
|
|
|
56
85
|
for pattern, replacement in SECRET_PATTERNS:
|
|
57
86
|
cleaned, replacements = pattern.subn(replacement, cleaned)
|
|
58
87
|
count += replacements
|
|
59
|
-
|
|
88
|
+
found = [0]
|
|
89
|
+
|
|
90
|
+
def unknown_token(match):
|
|
91
|
+
if not _looks_random(match.group(0)):
|
|
92
|
+
return match.group(0)
|
|
93
|
+
found[0] += 1
|
|
94
|
+
return "[REDACTED_SECRET]"
|
|
95
|
+
cleaned = RANDOM_CANDIDATE.sub(unknown_token, cleaned)
|
|
96
|
+
return cleaned.strip(), count + found[0]
|
|
60
97
|
|
|
61
98
|
|
|
62
99
|
def _message_text(payload):
|
|
@@ -238,7 +275,7 @@ class CodexConversationCapture:
|
|
|
238
275
|
CLIENT = "codex"
|
|
239
276
|
|
|
240
277
|
def __init__(self, sessions_root, store, *, state_path, workspace_scopes=None,
|
|
241
|
-
now=None, publish=None):
|
|
278
|
+
now=None, publish=None, forget=None):
|
|
242
279
|
self.sessions_root = Path(sessions_root).resolve()
|
|
243
280
|
if not self.sessions_root.is_dir():
|
|
244
281
|
raise ValueError("CODEX_SESSIONS_ROOT_NOT_FOUND")
|
|
@@ -251,6 +288,46 @@ class CodexConversationCapture:
|
|
|
251
288
|
for key, value in (workspace_scopes or {}).items()}
|
|
252
289
|
self.now = now or (lambda: datetime.now().isoformat(timespec="microseconds"))
|
|
253
290
|
self.publish = publish # called with each turn that is new to this store
|
|
291
|
+
# erases captured turns everywhere (forget.forget_ids with the shared log); by default
|
|
292
|
+
# this store only
|
|
293
|
+
self.forget = forget or (lambda turn_ids: __import__("forget").erase(
|
|
294
|
+
self.store.path, {"turn_ids": turn_ids}))
|
|
295
|
+
|
|
296
|
+
FORGET_TOOL = "brain_forget"
|
|
297
|
+
FORGET_WINDOW_MINUTES = 30
|
|
298
|
+
|
|
299
|
+
def _forget_call(self, item):
|
|
300
|
+
"""Whether this record is the assistant calling brain_forget. Codex: a function call
|
|
301
|
+
named for it, or (code mode) a completed MCP tool call of it."""
|
|
302
|
+
payload = item.get("payload") or {}
|
|
303
|
+
if payload.get("type") in {"function_call", "custom_tool_call"}:
|
|
304
|
+
return str(payload.get("name", "")).endswith(self.FORGET_TOOL)
|
|
305
|
+
call = payload.get("item") if isinstance(payload.get("item"), dict) else {}
|
|
306
|
+
return call.get("type") == "McpToolCall" and call.get("tool") == self.FORGET_TOOL
|
|
307
|
+
|
|
308
|
+
def _forget_exchange(self, session_id, at, sessions):
|
|
309
|
+
"""The assistant called brain_forget: what led up to it is a conversation about deleting,
|
|
310
|
+
so it goes too. From the user turn that asked (or from the previous forget call, when this
|
|
311
|
+
is the second, deleting call of the same exchange) up to now; the assistant's reply that
|
|
312
|
+
follows is skipped until the user speaks again."""
|
|
313
|
+
at = self.store._time(at)
|
|
314
|
+
previous = sessions.get(session_id, {}).get("signal_at")
|
|
315
|
+
db = self.store.db
|
|
316
|
+
if previous and (datetime.fromisoformat(at) - datetime.fromisoformat(previous)
|
|
317
|
+
).total_seconds() <= self.FORGET_WINDOW_MINUTES * 60:
|
|
318
|
+
since = previous
|
|
319
|
+
else:
|
|
320
|
+
since = db.execute(
|
|
321
|
+
"SELECT max(happened_at) FROM conversation_turns WHERE client=? AND "
|
|
322
|
+
"client_session_id=? AND role='user' AND happened_at<=?",
|
|
323
|
+
(self.CLIENT, session_id, at)).fetchone()[0]
|
|
324
|
+
if since:
|
|
325
|
+
ids = [row[0] for row in db.execute(
|
|
326
|
+
"SELECT turn_id FROM conversation_turns WHERE client=? AND client_session_id=? "
|
|
327
|
+
"AND happened_at>=?", (self.CLIENT, session_id, since))]
|
|
328
|
+
if ids:
|
|
329
|
+
self.forget(ids)
|
|
330
|
+
sessions[session_id] = {"signal_at": at, "quiet": True}
|
|
254
331
|
|
|
255
332
|
def _files(self):
|
|
256
333
|
return sorted(self.sessions_root.rglob("*.jsonl"))
|
|
@@ -293,11 +370,13 @@ class CodexConversationCapture:
|
|
|
293
370
|
value = json.loads(self.state_path.read_text(encoding="utf-8"))
|
|
294
371
|
if value.get("capture_version") != CAPTURE_VERSION:
|
|
295
372
|
raise ValueError("CONVERSATION_CAPTURE_VERSION_MISMATCH")
|
|
373
|
+
self._forget_sessions = value.get("forget_sessions", {})
|
|
296
374
|
return value.get("offsets", {})
|
|
297
375
|
|
|
298
376
|
def _write_state(self, offsets):
|
|
299
377
|
value = {"capture_version": CAPTURE_VERSION,
|
|
300
|
-
"sessions_root": str(self.sessions_root), "offsets": offsets
|
|
378
|
+
"sessions_root": str(self.sessions_root), "offsets": offsets,
|
|
379
|
+
"forget_sessions": getattr(self, "_forget_sessions", {})}
|
|
301
380
|
temporary = self.state_path.with_suffix(".tmp")
|
|
302
381
|
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n",
|
|
303
382
|
encoding="utf-8")
|
|
@@ -332,12 +411,21 @@ class CodexConversationCapture:
|
|
|
332
411
|
item = json.loads(line.decode("utf-8", "strict"))
|
|
333
412
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
334
413
|
continue
|
|
414
|
+
if self._forget_call(item):
|
|
415
|
+
self._forget_exchange(session_id, item.get("timestamp") or known_at,
|
|
416
|
+
self._forget_sessions)
|
|
417
|
+
continue
|
|
335
418
|
message = self._message(item)
|
|
336
419
|
if message is None:
|
|
337
420
|
continue
|
|
338
421
|
role, raw = message
|
|
339
422
|
if not raw.strip():
|
|
340
423
|
continue
|
|
424
|
+
quiet = self._forget_sessions.get(session_id)
|
|
425
|
+
if quiet and quiet.get("quiet"):
|
|
426
|
+
if role == "assistant":
|
|
427
|
+
continue # the reply to a forget call: not remembered
|
|
428
|
+
quiet["quiet"] = False # the user spoke again: the exchange is over
|
|
341
429
|
cleaned, count = _redact(raw)
|
|
342
430
|
if not cleaned:
|
|
343
431
|
continue
|
|
@@ -413,6 +501,15 @@ class ClaudeCodeConversationCapture(CodexConversationCapture):
|
|
|
413
501
|
resolved_cwd = str(Path(cwd).resolve()) if isinstance(cwd, str) and cwd else None
|
|
414
502
|
return session_id, cwd, self.workspace_scopes.get(resolved_cwd, "global")
|
|
415
503
|
|
|
504
|
+
def _forget_call(self, item):
|
|
505
|
+
"""An assistant record with a tool_use block calling brain_forget (any MCP prefix)."""
|
|
506
|
+
if item.get("type") != "assistant" or item.get("isSidechain"):
|
|
507
|
+
return False
|
|
508
|
+
content = (item.get("message") or {}).get("content")
|
|
509
|
+
return isinstance(content, list) and any(
|
|
510
|
+
isinstance(block, dict) and block.get("type") == "tool_use"
|
|
511
|
+
and str(block.get("name", "")).endswith(self.FORGET_TOOL) for block in content)
|
|
512
|
+
|
|
416
513
|
def _message(self, item):
|
|
417
514
|
if item.get("isSidechain") or item.get("isMeta"):
|
|
418
515
|
return None
|
|
@@ -119,19 +119,56 @@ def scrub_shared(shared, ids: dict) -> int:
|
|
|
119
119
|
return removed
|
|
120
120
|
|
|
121
121
|
|
|
122
|
-
def
|
|
123
|
-
"""
|
|
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
124
|
from brain_log import append
|
|
125
|
-
ids
|
|
126
|
-
if not (ids["turn_ids"] or ids["memory_ids"]):
|
|
125
|
+
if not (ids.get("turn_ids") or ids.get("memory_ids")):
|
|
127
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", []))}
|
|
128
129
|
result = erase(index, ids)
|
|
129
130
|
if shared:
|
|
130
131
|
result["shared_lines_removed"] = scrub_shared(shared, ids)
|
|
131
|
-
append(shared, "forget", ids) # ids only: the
|
|
132
|
+
append(shared, "forget", ids) # ids only: the text never leaves here
|
|
132
133
|
return result
|
|
133
134
|
|
|
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
|
+
|
|
135
172
|
def main(argv=None):
|
|
136
173
|
from brain_log import folder
|
|
137
174
|
from development_paths import default_development_db
|
|
@@ -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.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
|
|
@@ -164,7 +164,15 @@ Then restart your AI apps. The server is also listed in the
|
|
|
164
164
|
| Claude desktop: chat | what the model saves with `brain_remember` |
|
|
165
165
|
|
|
166
166
|
Before anything is stored, these are masked: API keys, tokens from GitHub, AWS, Google and Slack,
|
|
167
|
-
private keys,
|
|
167
|
+
private keys, passwords inside URLs, values written after `password:` / `api_key=` / `token:`,
|
|
168
|
+
and any long machine-random string even from a service no rule knows (by its randomness: 91% of
|
|
169
|
+
random 20-64 character tokens caught; in 246,750 ordinary chat turns it fired 92 times, nearly
|
|
170
|
+
all on real ids and tokens). Commit ids, hashes and UUIDs are kept, since you recall those on
|
|
171
|
+
purpose.
|
|
172
|
+
|
|
173
|
+
There is no switch to keep keys: a recalled memory goes to your model's provider with the question,
|
|
174
|
+
so a stored key would leave your PC the first time it is useful. To have Graph-MIND remember a key,
|
|
175
|
+
tell it **where the key is**, not the key: *"my OpenAI key is in 1Password, under Dev"*.
|
|
168
176
|
|
|
169
177
|
Only what you actually send is captured: the service reads each app's transcript, which is written
|
|
170
178
|
after you press Enter, so a paste you delete before sending never reaches it. A plain password
|
|
@@ -174,6 +182,10 @@ like `hunter2` has no recognizable format and is **not** masked. To remove somet
|
|
|
174
182
|
graph-mind-forget # asks for the phrase without showing it, then confirms
|
|
175
183
|
```
|
|
176
184
|
|
|
185
|
+
Or just ask your AI: *"delete the password I typed earlier"*. It lists masked candidates
|
|
186
|
+
(`wifi password is ****`), deletes only the ones you pick, and the conversation about deleting
|
|
187
|
+
is not saved.
|
|
188
|
+
|
|
177
189
|
It deletes every captured turn and memory containing the phrase from this PC and its indexes,
|
|
178
190
|
removes their lines from the shared memory, and tells your other PCs to delete their copies on
|
|
179
191
|
their next sync. Only ids are shared for that, never the phrase. Your AI app's own history
|
|
@@ -189,9 +201,10 @@ their next sync. Only ids are shared for that, never the phrase. Your AI app's o
|
|
|
189
201
|
| `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
|
|
190
202
|
| `brain_remember` | save a sourced memory or decision (secrets masked) |
|
|
191
203
|
| `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
|
|
204
|
+
| `brain_forget` | "delete the password I typed": lists masked candidates, deletes only what you pick, everywhere; the exchange itself is not saved |
|
|
192
205
|
| `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
|
|
193
206
|
|
|
194
|
-
|
|
207
|
+
Six tools on purpose: every tool's description is read by the model on every turn, and similar
|
|
195
208
|
tools get confused with each other. Version 0.1 had thirteen.
|
|
196
209
|
|
|
197
210
|
---
|
|
@@ -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.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"
|
|
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.2.1 → graph_mind_memory-0.3.1}/graph_mind_memory.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/graph_mind_memory.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{graph_mind_memory-0.2.1 → graph_mind_memory-0.3.1}/graph_mind_memory.egg-info/top_level.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
|