graph-mind-memory 0.2.0__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.2.0/graph_mind_memory.egg-info → graph_mind_memory-0.2.1}/PKG-INFO +14 -1
  2. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/README.md +13 -0
  3. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/brain_log.py +7 -1
  4. graph_mind_memory-0.2.1/forget.py +166 -0
  5. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1/graph_mind_memory.egg-info}/PKG-INFO +14 -1
  6. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/SOURCES.txt +1 -0
  7. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/entry_points.txt +1 -0
  8. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/top_level.txt +1 -0
  9. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/pyproject.toml +3 -1
  10. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/LICENSE +0 -0
  11. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/associative_memory.py +0 -0
  12. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/automatic_capture.py +0 -0
  13. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/automatic_capture_cli.py +0 -0
  14. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/clock.py +0 -0
  15. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/coding_memory.py +0 -0
  16. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/conversation_memory.py +0 -0
  17. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/development_memory.py +0 -0
  18. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/development_paths.py +0 -0
  19. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/embedding_warmup.py +0 -0
  20. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/entity_timeline.py +0 -0
  21. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/graph_mind_mcp_server.py +0 -0
  22. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/dependency_links.txt +0 -0
  23. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/graph_mind_memory.egg-info/requires.txt +0 -0
  24. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/install.py +0 -0
  25. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/local_brain.py +0 -0
  26. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/local_embedder.py +0 -0
  27. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/semantic_llm_extractor.py +0 -0
  28. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/semantic_memory.py +0 -0
  29. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/semantic_recall.py +0 -0
  30. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/setup.cfg +0 -0
  31. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/temporal_resolver.py +0 -0
  32. {graph_mind_memory-0.2.0 → graph_mind_memory-0.2.1}/universal_personal_memory.py +0 -0
  33. {graph_mind_memory-0.2.0 → 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.2.0
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
@@ -166,6 +166,19 @@ 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
+ 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
+
169
182
  ---
170
183
 
171
184
  ## MCP tools
@@ -140,6 +140,19 @@ 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
+ 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
+
143
156
  ---
144
157
 
145
158
  ## MCP tools
@@ -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(?)",
@@ -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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graph-mind-memory
3
- Version: 0.2.0
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
@@ -166,6 +166,19 @@ 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
+ 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
+
169
182
  ---
170
183
 
171
184
  ## MCP tools
@@ -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.2.0"
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",