graph-mind-memory 0.1.0__py3-none-any.whl
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.
- associative_memory.py +507 -0
- automatic_capture.py +288 -0
- automatic_capture_cli.py +72 -0
- brain_log.py +501 -0
- coding_memory.py +438 -0
- concept_plan_benchmark_v071.py +248 -0
- conversation_memory.py +476 -0
- development_memory.py +450 -0
- development_paths.py +40 -0
- embedding_warmup.py +171 -0
- entity_timeline.py +130 -0
- executable_memory.py +312 -0
- executable_plan_v2_diagnostic.py +272 -0
- graph_mind_mcp_server.py +864 -0
- graph_mind_memory-0.1.0.dist-info/METADATA +252 -0
- graph_mind_memory-0.1.0.dist-info/RECORD +43 -0
- graph_mind_memory-0.1.0.dist-info/WHEEL +5 -0
- graph_mind_memory-0.1.0.dist-info/entry_points.txt +4 -0
- graph_mind_memory-0.1.0.dist-info/licenses/LICENSE +661 -0
- graph_mind_memory-0.1.0.dist-info/top_level.txt +37 -0
- grounded_concept_bridge_v072.py +511 -0
- ingestion_gate.py +113 -0
- ingestion_synonyms.py +165 -0
- install.py +198 -0
- llm_concept_planner.py +170 -0
- local_brain.py +354 -0
- longmemeval_adapter.py +120 -0
- longmemeval_ingestion_calibration.py +366 -0
- longmemeval_ingestion_preflight.py +188 -0
- longmemeval_ingestion_v7.py +356 -0
- memory_algebra.py +155 -0
- memory_contract.py +154 -0
- memory_normalization.py +13 -0
- memory_vocabulary.py +34 -0
- semantic_grounding_v073.py +501 -0
- semantic_llm_extractor.py +215 -0
- semantic_memory.py +155 -0
- semantic_recall.py +100 -0
- state_relation_contract.py +158 -0
- temporal_resolver.py +272 -0
- temporal_state.py +210 -0
- universal_personal_memory.py +234 -0
- vector_cache.py +194 -0
brain_log.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""One brain across devices: the shared folder holds the memory, each machine holds an index.
|
|
2
|
+
|
|
3
|
+
A database file in a synced folder breaks: OneDrive and Dropbox copy files, they do not order two
|
|
4
|
+
machines' writes. So the memory itself is kept as plain append-only text in the shared folder, and
|
|
5
|
+
every machine builds its own SQLite index from it:
|
|
6
|
+
|
|
7
|
+
<GRAPH_MIND_FOLDER>/log/
|
|
8
|
+
desktop.jsonl <- only the desktop ever appends here
|
|
9
|
+
laptop.jsonl <- only the laptop ever appends here
|
|
10
|
+
|
|
11
|
+
Each line is one record (a memory, or one conversation turn), never edited, never removed. A file
|
|
12
|
+
has exactly one writer, so syncing cannot interleave two machines' writes, and a line half-written
|
|
13
|
+
or half-synced is simply not read until its newline arrives. Reading imports every device's new
|
|
14
|
+
lines into the local index, in the order they were written, so a memory on the laptop can
|
|
15
|
+
supersede one written on the desktop: by then both are in the same database.
|
|
16
|
+
|
|
17
|
+
The index lives outside the folder and can be deleted at any time; `sync` rebuilds it.
|
|
18
|
+
|
|
19
|
+
The same log can live in Postgres instead of a folder ("memory_folder": "local" or a postgres://
|
|
20
|
+
URL). A synced folder reaches other PCs when the sync client gets round to it, often a minute
|
|
21
|
+
later; a Postgres table is there the moment the row commits, so a PC asking right after another
|
|
22
|
+
answered already sees it. "local" runs Postgres on this PC (pgserver, no setup); other PCs reach
|
|
23
|
+
it over Tailscale with its postgres:// URL. Rows are only ever inserted, one writer at a time
|
|
24
|
+
(an advisory lock), so reading by sequence number never skips a row committed late.
|
|
25
|
+
|
|
26
|
+
python brain_log.py # self-check with two simulated devices
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
import base64
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import platform
|
|
36
|
+
import re
|
|
37
|
+
import sqlite3
|
|
38
|
+
|
|
39
|
+
KINDS = ("memory", "turn")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def device_name() -> str:
|
|
43
|
+
"""This machine's log name. GRAPH_MIND_DEVICE overrides it (tests, two accounts on one PC)."""
|
|
44
|
+
raw = os.environ.get("GRAPH_MIND_DEVICE") or platform.node() or "device"
|
|
45
|
+
return re.sub(r"[^a-z0-9]+", "-", raw.casefold()).strip("-") or "device"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def config_path() -> Path:
|
|
49
|
+
"""Where the chosen brain folder is remembered for every client on this machine."""
|
|
50
|
+
override = os.environ.get("GRAPH_MIND_CONFIG")
|
|
51
|
+
return Path(override) if override else Path.home() / ".graph-mind" / "config.json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _config() -> dict:
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(config_path().read_text(encoding="utf-8"))
|
|
57
|
+
except (OSError, ValueError):
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_database(shared) -> bool:
|
|
62
|
+
return isinstance(shared, str) and shared.startswith(("postgres://", "postgresql://"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_LOCAL_URI = {}
|
|
66
|
+
_JOINED = {}
|
|
67
|
+
PORT = int(os.environ.get("GRAPH_MIND_PG_PORT", "54329")) # fixed, so a connection code stays valid
|
|
68
|
+
CODE_PREFIX = "gm1."
|
|
69
|
+
TAILSCALE = "100.64.0.0/10"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _pg_data() -> Path:
|
|
73
|
+
from development_paths import graph_mind_home
|
|
74
|
+
return graph_mind_home() / "postgres"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def local_postgres() -> str:
|
|
78
|
+
"""This PC's own Postgres, started on first use and left running for every other client
|
|
79
|
+
(Claude, Codex and the capture service find the running server and share it).
|
|
80
|
+
|
|
81
|
+
pgserver supplies the binaries; the server is started here rather than by pgserver because
|
|
82
|
+
pgserver picks a new port on every start and trusts every connection, and a PC that shares
|
|
83
|
+
its brain needs a port that stays put and a password for anyone not on this PC. The memory
|
|
84
|
+
lives in database "graphmind" under role "graphmind", which is not a superuser: that is the
|
|
85
|
+
role other PCs log in as."""
|
|
86
|
+
if "uri" not in _LOCAL_URI:
|
|
87
|
+
import fasteners
|
|
88
|
+
from pgserver._commands import initdb, pg_ctl
|
|
89
|
+
from pgserver.utils import PostmasterInfo
|
|
90
|
+
data = _pg_data()
|
|
91
|
+
data.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
with fasteners.InterProcessLock(str(data.parent / "postgres.lock")):
|
|
93
|
+
fresh = not (data / "PG_VERSION").exists()
|
|
94
|
+
if fresh:
|
|
95
|
+
initdb(["--auth=trust", "--encoding=utf8", "-U", "postgres"], pgdata=data)
|
|
96
|
+
running = PostmasterInfo.read_from_pgdata(data)
|
|
97
|
+
if running is None or not running.is_running():
|
|
98
|
+
pg_ctl(["-w", "-o", f"-p {PORT}", "-l", str(data / "log"), "start"],
|
|
99
|
+
pgdata=data, timeout=60)
|
|
100
|
+
if fresh:
|
|
101
|
+
import psycopg
|
|
102
|
+
with psycopg.connect(_admin_uri(), autocommit=True) as admin:
|
|
103
|
+
admin.execute("CREATE ROLE graphmind LOGIN")
|
|
104
|
+
admin.execute("CREATE DATABASE graphmind OWNER graphmind")
|
|
105
|
+
_LOCAL_URI["uri"] = f"postgresql://graphmind@127.0.0.1:{PORT}/graphmind"
|
|
106
|
+
return _LOCAL_URI["uri"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _admin_uri() -> str:
|
|
110
|
+
return f"postgresql://postgres@127.0.0.1:{PORT}/postgres"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def share(open_firewall: bool = True) -> dict:
|
|
114
|
+
"""Let the user's other PCs in: the ones on the same network, and the ones on Tailscale.
|
|
115
|
+
|
|
116
|
+
Postgres starts listening beyond this PC, role "graphmind" gets a password, and only that
|
|
117
|
+
role, only for its own database, may log in from elsewhere (and only with the password);
|
|
118
|
+
this PC itself still connects without one. The result is a connection code that carries the
|
|
119
|
+
addresses, the port and the password, so the other PC's user pastes one line and types none
|
|
120
|
+
of it. The same code comes back every time; it is as good as the password, so it is shown
|
|
121
|
+
to the user and never posted anywhere."""
|
|
122
|
+
import psycopg
|
|
123
|
+
import secrets
|
|
124
|
+
from pgserver._commands import pg_ctl
|
|
125
|
+
local_postgres()
|
|
126
|
+
password = _config().get("share_password") or secrets.token_urlsafe(18)
|
|
127
|
+
data = _pg_data()
|
|
128
|
+
with psycopg.connect(_admin_uri(), autocommit=True) as admin:
|
|
129
|
+
# token_urlsafe is [A-Za-z0-9_-]: nothing in it can close the quoted literal
|
|
130
|
+
admin.execute(f"ALTER ROLE graphmind PASSWORD '{password}'")
|
|
131
|
+
restart = admin.execute("SHOW listen_addresses").fetchone()[0] != "*"
|
|
132
|
+
if restart:
|
|
133
|
+
admin.execute("ALTER SYSTEM SET listen_addresses = '*'")
|
|
134
|
+
hba = data / "pg_hba.conf"
|
|
135
|
+
rule = "host graphmind graphmind all scram-sha-256"
|
|
136
|
+
if rule not in hba.read_text(encoding="utf-8"):
|
|
137
|
+
with hba.open("a", encoding="utf-8") as stream:
|
|
138
|
+
stream.write("\n# Graph-MIND: other PCs, with the password\n" + rule + "\n")
|
|
139
|
+
pg_ctl(["-w", "-m", "fast", "restart"] if restart else ["reload"], pgdata=data, timeout=60)
|
|
140
|
+
set_folder("local")
|
|
141
|
+
_save({"share_password": password})
|
|
142
|
+
hosts = _addresses()
|
|
143
|
+
code = CODE_PREFIX + base64.urlsafe_b64encode(json.dumps(
|
|
144
|
+
{"h": hosts, "p": PORT, "k": password}).encode()).decode().rstrip("=")
|
|
145
|
+
return {"connection_code": code, "addresses": hosts, "port": PORT,
|
|
146
|
+
"firewall": _firewall() if open_firewall else "not changed"}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _addresses() -> list[str]:
|
|
150
|
+
"""This PC's IPv4 addresses other PCs can use: the local network first, then Tailscale."""
|
|
151
|
+
import ipaddress
|
|
152
|
+
import psutil
|
|
153
|
+
import socket
|
|
154
|
+
up = {name for name, stats in psutil.net_if_stats().items() if stats.isup}
|
|
155
|
+
found = {entry.address for name, entries in psutil.net_if_addrs().items() if name in up
|
|
156
|
+
for entry in entries if entry.family == socket.AF_INET}
|
|
157
|
+
usable = [a for a in found if not (ipaddress.ip_address(a).is_loopback
|
|
158
|
+
or ipaddress.ip_address(a).is_link_local)]
|
|
159
|
+
tailscale = ipaddress.ip_network(TAILSCALE)
|
|
160
|
+
return sorted(usable, key=lambda a: (ipaddress.ip_address(a) in tailscale,
|
|
161
|
+
ipaddress.ip_address(a)))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _firewall() -> str:
|
|
165
|
+
"""Windows blocks the port until a rule allows it. The rule admits only the local network
|
|
166
|
+
and Tailscale, and adding it asks the user's permission once (an administrator prompt)."""
|
|
167
|
+
if platform.system() != "Windows":
|
|
168
|
+
return "not needed"
|
|
169
|
+
import subprocess
|
|
170
|
+
name = "Graph-MIND-Postgres"
|
|
171
|
+
|
|
172
|
+
def exists():
|
|
173
|
+
return subprocess.run(["netsh", "advfirewall", "firewall", "show", "rule", f"name={name}"],
|
|
174
|
+
capture_output=True).returncode == 0
|
|
175
|
+
if exists():
|
|
176
|
+
return "open"
|
|
177
|
+
arguments = (f"advfirewall firewall add rule name={name} dir=in action=allow "
|
|
178
|
+
f"protocol=TCP localport={PORT} remoteip=LocalSubnet,{TAILSCALE}")
|
|
179
|
+
subprocess.run(["powershell", "-NoProfile", "-Command",
|
|
180
|
+
f"Start-Process netsh -Verb RunAs -Wait -WindowStyle Hidden "
|
|
181
|
+
f"-ArgumentList '{arguments}'"], capture_output=True)
|
|
182
|
+
return "open" if exists() else "blocked: the administrator prompt was declined"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _decode(code: str) -> dict:
|
|
186
|
+
body = code.strip()[len(CODE_PREFIX):]
|
|
187
|
+
try:
|
|
188
|
+
value = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
|
|
189
|
+
return {"h": [str(h) for h in value["h"]], "p": int(value["p"]), "k": str(value["k"])}
|
|
190
|
+
except (ValueError, KeyError, TypeError) as error:
|
|
191
|
+
raise ValueError("INVALID_CONNECTION_CODE") from error
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _joined(code: str) -> str:
|
|
195
|
+
"""The URL for a connection code: the first of its addresses that answers right now, so a
|
|
196
|
+
laptop reaches the same brain over the office network and over Tailscale from home."""
|
|
197
|
+
import socket
|
|
198
|
+
import time
|
|
199
|
+
import urllib.parse
|
|
200
|
+
cached = _JOINED.get(code)
|
|
201
|
+
if cached and time.monotonic() - cached[0] < 300:
|
|
202
|
+
return cached[1]
|
|
203
|
+
value = _decode(code)
|
|
204
|
+
host = value["h"][0] if value["h"] else "127.0.0.1"
|
|
205
|
+
for candidate in value["h"]:
|
|
206
|
+
try:
|
|
207
|
+
socket.create_connection((candidate, value["p"]), timeout=1.5).close()
|
|
208
|
+
host = candidate
|
|
209
|
+
break
|
|
210
|
+
except OSError:
|
|
211
|
+
continue
|
|
212
|
+
url = (f"postgresql://graphmind:{urllib.parse.quote(value['k'], safe='')}"
|
|
213
|
+
f"@{host}:{value['p']}/graphmind")
|
|
214
|
+
_JOINED[code] = (time.monotonic(), url)
|
|
215
|
+
return url
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def folder():
|
|
219
|
+
"""Where the shared brain lives: a folder (Path), or a Postgres URL (str). GRAPH_MIND_FOLDER
|
|
220
|
+
if set, otherwise the one chosen in conversation; "local" means this PC's own Postgres and a
|
|
221
|
+
connection code means another PC's."""
|
|
222
|
+
value = os.environ.get("GRAPH_MIND_FOLDER") or _config().get("memory_folder")
|
|
223
|
+
if not value:
|
|
224
|
+
return None
|
|
225
|
+
if value == "local":
|
|
226
|
+
return local_postgres()
|
|
227
|
+
if value.startswith(CODE_PREFIX):
|
|
228
|
+
return _joined(value)
|
|
229
|
+
return value if is_database(value) else Path(value).expanduser()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _save(changes: dict) -> None:
|
|
233
|
+
settings = {**_config(), **changes}
|
|
234
|
+
target = config_path()
|
|
235
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
temporary = target.with_suffix(".tmp")
|
|
237
|
+
temporary.write_text(json.dumps(settings, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
238
|
+
os.replace(temporary, target)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def set_folder(path) -> Path:
|
|
242
|
+
"""Choose the brain folder for this machine. A synced folder (OneDrive, Google Drive, Dropbox)
|
|
243
|
+
makes the same brain available on every PC that chooses it too. "local" is this PC's own
|
|
244
|
+
Postgres; a connection code (from `share` on another PC) joins that PC's."""
|
|
245
|
+
path = str(path).strip()
|
|
246
|
+
if path == "local" or is_database(path):
|
|
247
|
+
chosen = path
|
|
248
|
+
elif path.startswith(CODE_PREFIX):
|
|
249
|
+
chosen = path
|
|
250
|
+
_decode(chosen) # refuse a mangled code before saving it
|
|
251
|
+
else:
|
|
252
|
+
chosen = Path(path).expanduser().resolve()
|
|
253
|
+
(chosen / "log").mkdir(parents=True, exist_ok=True)
|
|
254
|
+
_save({"memory_folder": str(chosen)})
|
|
255
|
+
return chosen
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def append(shared: Path, kind: str, record: dict) -> None:
|
|
259
|
+
"""Add one record to this device's log. Only ever appends, and flushes it to disk."""
|
|
260
|
+
append_many(shared, [(kind, record)])
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def append_many(shared: Path, items) -> int:
|
|
264
|
+
"""Append several records in one write and one flush (an export can be thousands)."""
|
|
265
|
+
lines = []
|
|
266
|
+
for kind, record in items:
|
|
267
|
+
if kind not in KINDS:
|
|
268
|
+
raise ValueError("UNKNOWN_LOG_KIND")
|
|
269
|
+
lines.append(json.dumps({"kind": kind, "device": device_name(),
|
|
270
|
+
"written_at": datetime.now().isoformat(timespec="microseconds"),
|
|
271
|
+
"record": record}, ensure_ascii=False, sort_keys=True))
|
|
272
|
+
if not lines:
|
|
273
|
+
return 0
|
|
274
|
+
if is_database(shared):
|
|
275
|
+
return _pg_append(shared, lines)
|
|
276
|
+
log = Path(shared).expanduser() / "log" / f"{device_name()}.jsonl"
|
|
277
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
with log.open("a", encoding="utf-8") as stream:
|
|
279
|
+
stream.write("\n".join(lines) + "\n")
|
|
280
|
+
stream.flush()
|
|
281
|
+
os.fsync(stream.fileno())
|
|
282
|
+
return len(lines)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def export_history(shared: Path, index: Path) -> dict:
|
|
286
|
+
"""Put what this machine remembered before the folder existed into its log, once.
|
|
287
|
+
|
|
288
|
+
This uploads existing memories to wherever the folder syncs, so it runs only when the user
|
|
289
|
+
asks for it. Each record is marked as exported and never written twice; memories go out in
|
|
290
|
+
the order they were stored so a replacement always follows what it replaces.
|
|
291
|
+
"""
|
|
292
|
+
db = _cursor_db(index)
|
|
293
|
+
db.row_factory = sqlite3.Row
|
|
294
|
+
try:
|
|
295
|
+
db.execute("CREATE TABLE IF NOT EXISTS brain_log_exported("
|
|
296
|
+
"kind TEXT NOT NULL, id TEXT NOT NULL, PRIMARY KEY(kind, id))")
|
|
297
|
+
tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master")}
|
|
298
|
+
done = {(row[0], row[1]) for row in db.execute("SELECT kind, id FROM brain_log_exported")}
|
|
299
|
+
items = []
|
|
300
|
+
if "local_brain_memories" in tables:
|
|
301
|
+
for row in db.execute("SELECT * FROM local_brain_memories ORDER BY revision"):
|
|
302
|
+
if ("memory", row["memory_id"]) in done:
|
|
303
|
+
continue
|
|
304
|
+
items.append(("memory", {
|
|
305
|
+
"memory_id": row["memory_id"], "scope": row["scope"],
|
|
306
|
+
"memory_type": row["memory_type"], "title": row["title"],
|
|
307
|
+
"content": row["content"], "effective_at": row["effective_at"],
|
|
308
|
+
"known_at": row["known_at"], "actor": row["actor"],
|
|
309
|
+
"tags": json.loads(row["tags_json"]),
|
|
310
|
+
"entities": json.loads(row["entities_json"]),
|
|
311
|
+
"provenance": json.loads(row["provenance_json"]),
|
|
312
|
+
"supersedes_memory_id": row["supersedes_memory_id"]}))
|
|
313
|
+
if "conversation_turns" in tables:
|
|
314
|
+
for row in db.execute("SELECT * FROM conversation_turns ORDER BY revision"):
|
|
315
|
+
if ("turn", row["turn_id"]) in done:
|
|
316
|
+
continue
|
|
317
|
+
items.append(("turn", {key: row[key] for key in (
|
|
318
|
+
"turn_id", "client", "client_session_id", "scope", "cwd", "happened_at",
|
|
319
|
+
"known_at", "role", "content_redacted", "raw_sha256", "redaction_count",
|
|
320
|
+
"source_path", "source_ordinal")}))
|
|
321
|
+
written = append_many(shared, items)
|
|
322
|
+
with db:
|
|
323
|
+
db.executemany("INSERT OR IGNORE INTO brain_log_exported VALUES(?,?)",
|
|
324
|
+
[(kind, record["memory_id"] if kind == "memory" else record["turn_id"])
|
|
325
|
+
for kind, record in items])
|
|
326
|
+
return {"memories": sum(kind == "memory" for kind, _ in items),
|
|
327
|
+
"turns": sum(kind == "turn" for kind, _ in items), "lines_written": written}
|
|
328
|
+
finally:
|
|
329
|
+
db.close()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _cursor_db(index: Path) -> sqlite3.Connection:
|
|
333
|
+
db = sqlite3.connect(index)
|
|
334
|
+
db.executescript("""
|
|
335
|
+
CREATE TABLE IF NOT EXISTS brain_log_cursor(
|
|
336
|
+
source TEXT PRIMARY KEY, offset INTEGER NOT NULL);
|
|
337
|
+
CREATE TABLE IF NOT EXISTS brain_log_pending(
|
|
338
|
+
line TEXT PRIMARY KEY);""")
|
|
339
|
+
return db
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _pg(url):
|
|
343
|
+
import psycopg
|
|
344
|
+
connection = psycopg.connect(url, autocommit=False)
|
|
345
|
+
connection.execute("""CREATE TABLE IF NOT EXISTS brain_log(
|
|
346
|
+
seq BIGSERIAL PRIMARY KEY, device TEXT NOT NULL, line TEXT NOT NULL)""")
|
|
347
|
+
connection.commit()
|
|
348
|
+
return connection
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _pg_append(url, lines) -> int:
|
|
352
|
+
with _pg(url) as connection:
|
|
353
|
+
# one writer at a time: a row can then never commit after a later sequence number has
|
|
354
|
+
# been read, which would make a reader's cursor step over it for good
|
|
355
|
+
connection.execute("SELECT pg_advisory_xact_lock(4242)")
|
|
356
|
+
with connection.cursor() as cursor:
|
|
357
|
+
cursor.executemany("INSERT INTO brain_log(device, line) VALUES (%s, %s)",
|
|
358
|
+
[(device_name(), line) for line in lines])
|
|
359
|
+
connection.commit()
|
|
360
|
+
return len(lines)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def devices(shared) -> list[str]:
|
|
364
|
+
"""Every device that has written to this brain."""
|
|
365
|
+
if is_database(shared):
|
|
366
|
+
with _pg(shared) as connection:
|
|
367
|
+
return [row[0] for row in connection.execute(
|
|
368
|
+
"SELECT DISTINCT device FROM brain_log ORDER BY device")]
|
|
369
|
+
return sorted(p.stem for p in (Path(shared) / "log").glob("*.jsonl"))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _new_lines(db: sqlite3.Connection, shared) -> list[str]:
|
|
373
|
+
"""Complete lines added to any device's log since this machine last read it."""
|
|
374
|
+
if is_database(shared):
|
|
375
|
+
source = "postgres"
|
|
376
|
+
row = db.execute("SELECT offset FROM brain_log_cursor WHERE source=?",
|
|
377
|
+
(source,)).fetchone()
|
|
378
|
+
with _pg(shared) as connection:
|
|
379
|
+
rows = connection.execute("SELECT seq, line FROM brain_log WHERE seq > %s ORDER BY seq",
|
|
380
|
+
(row[0] if row else 0,)).fetchall()
|
|
381
|
+
if rows:
|
|
382
|
+
with db:
|
|
383
|
+
db.execute("INSERT OR REPLACE INTO brain_log_cursor VALUES(?,?)",
|
|
384
|
+
(source, rows[-1][0]))
|
|
385
|
+
return [line for _, line in rows]
|
|
386
|
+
lines = []
|
|
387
|
+
for log in sorted((Path(shared) / "log").glob("*.jsonl")):
|
|
388
|
+
row = db.execute("SELECT offset FROM brain_log_cursor WHERE source=?",
|
|
389
|
+
(log.name,)).fetchone()
|
|
390
|
+
start = row[0] if row else 0
|
|
391
|
+
if log.stat().st_size < start: # replaced by the sync client: re-read, imports are idempotent
|
|
392
|
+
start = 0
|
|
393
|
+
with log.open("rb") as handle:
|
|
394
|
+
handle.seek(start)
|
|
395
|
+
data = handle.read()
|
|
396
|
+
complete = data[:data.rfind(b"\n") + 1] # an unfinished last line waits for its newline
|
|
397
|
+
lines += [l for l in complete.decode("utf-8", "replace").splitlines() if l.strip()]
|
|
398
|
+
with db:
|
|
399
|
+
db.execute("INSERT OR REPLACE INTO brain_log_cursor VALUES(?,?)",
|
|
400
|
+
(log.name, start + len(complete)))
|
|
401
|
+
return lines
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def sync(shared: Path, index: Path) -> dict:
|
|
405
|
+
"""Bring this machine's index up to date with every device's log."""
|
|
406
|
+
from conversation_memory import ConversationMemoryStore
|
|
407
|
+
from local_brain import LocalBrainStore
|
|
408
|
+
|
|
409
|
+
db = _cursor_db(index)
|
|
410
|
+
try:
|
|
411
|
+
pending = [row[0] for row in db.execute("SELECT line FROM brain_log_pending")]
|
|
412
|
+
lines = pending + _new_lines(db, shared)
|
|
413
|
+
items = []
|
|
414
|
+
for line in lines:
|
|
415
|
+
try:
|
|
416
|
+
items.append((json.loads(line), line))
|
|
417
|
+
except ValueError:
|
|
418
|
+
continue # a corrupt line is skipped, not fatal
|
|
419
|
+
items.sort(key=lambda pair: pair[0].get("written_at", ""))
|
|
420
|
+
imported, conflicts, waiting = 0, 0, []
|
|
421
|
+
with LocalBrainStore(index) as brain, ConversationMemoryStore(index) as turns:
|
|
422
|
+
# A supersession can arrive before the memory it replaces when two devices' logs are
|
|
423
|
+
# read in one batch; keep retrying while anything is still landing.
|
|
424
|
+
while items:
|
|
425
|
+
progress, retry = False, []
|
|
426
|
+
for item, line in items:
|
|
427
|
+
try:
|
|
428
|
+
if item["kind"] == "memory":
|
|
429
|
+
outcome = brain.remember(item["record"])
|
|
430
|
+
else:
|
|
431
|
+
outcome = turns.record(item["record"])
|
|
432
|
+
except ValueError as error:
|
|
433
|
+
if str(error) == "SUPERSEDED_LOCAL_MEMORY_NOT_FOUND":
|
|
434
|
+
retry.append((item, line))
|
|
435
|
+
continue
|
|
436
|
+
conflicts += 1 # e.g. an id reused with other content
|
|
437
|
+
continue
|
|
438
|
+
progress = True
|
|
439
|
+
imported += outcome.get("disposition") != "ALREADY_RECORDED"
|
|
440
|
+
items = retry
|
|
441
|
+
if not progress:
|
|
442
|
+
waiting = [line for _, line in retry]
|
|
443
|
+
break
|
|
444
|
+
with db:
|
|
445
|
+
db.execute("DELETE FROM brain_log_pending")
|
|
446
|
+
db.executemany("INSERT OR IGNORE INTO brain_log_pending VALUES(?)",
|
|
447
|
+
[(line,) for line in waiting])
|
|
448
|
+
return {"imported": imported, "conflicts": conflicts, "waiting": len(waiting)}
|
|
449
|
+
finally:
|
|
450
|
+
db.close()
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _self_check():
|
|
454
|
+
import tempfile
|
|
455
|
+
|
|
456
|
+
root = Path(tempfile.mkdtemp())
|
|
457
|
+
shared, desk_index, lap_index = root / "OneDrive" / "Graph-MIND", root / "desk.sqlite", \
|
|
458
|
+
root / "lap.sqlite"
|
|
459
|
+
from conversation_memory import ConversationMemoryStore
|
|
460
|
+
from local_brain import LocalBrainStore
|
|
461
|
+
|
|
462
|
+
def memory(mid, text, when, supersedes=None):
|
|
463
|
+
return {"memory_id": mid, "scope": "global", "memory_type": "fact", "title": text,
|
|
464
|
+
"content": text, "effective_at": when, "known_at": when, "actor": "assistant",
|
|
465
|
+
"tags": [], "entities": ["사는 곳"],
|
|
466
|
+
"provenance": {"source_type": "test", "source_ref": "self-check"},
|
|
467
|
+
"supersedes_memory_id": supersedes}
|
|
468
|
+
|
|
469
|
+
os.environ["GRAPH_MIND_DEVICE"] = "desktop"
|
|
470
|
+
append(shared, "memory", memory("m1", "사는 곳: 서울", "2026-03-01T09:00:00"))
|
|
471
|
+
append(shared, "turn", {"turn_id": "t1", "client": "claude-code", "client_session_id": "s",
|
|
472
|
+
"scope": "global", "role": "user",
|
|
473
|
+
"content_redacted": "콩이를 제주도에 데려갔어", "raw_sha256": "x",
|
|
474
|
+
"source_path": "p", "happened_at": "2026-03-01T09:00:00",
|
|
475
|
+
"known_at": "2026-03-01T09:00:00", "source_ordinal": 1})
|
|
476
|
+
os.environ["GRAPH_MIND_DEVICE"] = "laptop"
|
|
477
|
+
# the laptop has not seen the desktop yet, so this lands in its log first in file order
|
|
478
|
+
append(shared, "memory", memory("m2", "사는 곳: 부산", "2026-10-01T09:00:00", supersedes="m1"))
|
|
479
|
+
|
|
480
|
+
first = sync(shared, desk_index)
|
|
481
|
+
assert first == {"imported": 3, "conflicts": 0, "waiting": 0}, first
|
|
482
|
+
assert sync(shared, desk_index)["imported"] == 0, "re-sync imports nothing twice"
|
|
483
|
+
|
|
484
|
+
with LocalBrainStore(desk_index) as brain:
|
|
485
|
+
found = brain.recall("사는 곳", as_of="2026-12-01T00:00:00")
|
|
486
|
+
assert [m["memory_id"] for m in found["matches"]] == ["m2"], found
|
|
487
|
+
with ConversationMemoryStore(desk_index) as turns:
|
|
488
|
+
assert turns.search("콩이")["status"] == "KNOWN", "the turn from the other device arrived"
|
|
489
|
+
|
|
490
|
+
log = shared / "log" / "laptop.jsonl"
|
|
491
|
+
with log.open("a", encoding="utf-8") as stream:
|
|
492
|
+
stream.write('{"kind": "memory", "half": ') # a line still syncing: no newline yet
|
|
493
|
+
assert sync(shared, lap_index)["imported"] == 3, "the torn line is not read"
|
|
494
|
+
assert sync(shared, lap_index)["conflicts"] == 0
|
|
495
|
+
|
|
496
|
+
os.environ.pop("GRAPH_MIND_DEVICE")
|
|
497
|
+
print("brain_log self-check ok")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
if __name__ == "__main__":
|
|
501
|
+
_self_check()
|