tokenmizer 0.2.4__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.
- tokenmizer/__init__.py +21 -0
- tokenmizer/agents/__init__.py +0 -0
- tokenmizer/analytics/__init__.py +0 -0
- tokenmizer/analytics/engine.py +188 -0
- tokenmizer/api/__init__.py +0 -0
- tokenmizer/api/app.py +958 -0
- tokenmizer/api/rate_limiter.py +110 -0
- tokenmizer/checkpoints/__init__.py +0 -0
- tokenmizer/checkpoints/manager.py +383 -0
- tokenmizer/cli.py +153 -0
- tokenmizer/compression/__init__.py +0 -0
- tokenmizer/compression/engine.py +669 -0
- tokenmizer/compression/output_trimmer.py +95 -0
- tokenmizer/compression/window.py +104 -0
- tokenmizer/config/__init__.py +0 -0
- tokenmizer/config/settings.py +170 -0
- tokenmizer/core/__init__.py +0 -0
- tokenmizer/core/dto.py +196 -0
- tokenmizer/core/errors.py +35 -0
- tokenmizer/core/tokenizer.py +96 -0
- tokenmizer/dashboard/__init__.py +0 -0
- tokenmizer/dashboard/page.py +267 -0
- tokenmizer/filters/__init__.py +0 -0
- tokenmizer/filters/file_intelligence.py +960 -0
- tokenmizer/graph_memory/__init__.py +0 -0
- tokenmizer/graph_memory/decision_tracker.py +225 -0
- tokenmizer/graph_memory/graph.py +1287 -0
- tokenmizer/graph_memory/helpers.py +121 -0
- tokenmizer/graph_memory/hybrid_extractor.py +703 -0
- tokenmizer/graph_memory/types.py +134 -0
- tokenmizer/graph_memory/validator.py +304 -0
- tokenmizer/graph_memory/visualization.py +228 -0
- tokenmizer/mcp/__init__.py +0 -0
- tokenmizer/mcp/server.py +368 -0
- tokenmizer/providers/__init__.py +0 -0
- tokenmizer/providers/providers.py +456 -0
- tokenmizer/security/__init__.py +0 -0
- tokenmizer/security/auth.py +95 -0
- tokenmizer/security/middleware.py +138 -0
- tokenmizer/security/redaction.py +126 -0
- tokenmizer/semantic_cache/__init__.py +0 -0
- tokenmizer/semantic_cache/cache.py +383 -0
- tokenmizer/state/__init__.py +0 -0
- tokenmizer/state/backend.py +137 -0
- tokenmizer/storage/__init__.py +56 -0
- tokenmizer-0.2.4.dist-info/METADATA +529 -0
- tokenmizer-0.2.4.dist-info/RECORD +50 -0
- tokenmizer-0.2.4.dist-info/WHEEL +4 -0
- tokenmizer-0.2.4.dist-info/entry_points.txt +2 -0
- tokenmizer-0.2.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1287 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Graph Memory — the core of TokenMizer's context continuity.
|
|
3
|
+
|
|
4
|
+
Key fixes over V3:
|
|
5
|
+
- Node deduplication by normalized label+type
|
|
6
|
+
- LLM-powered extraction (haiku/gpt-4o-mini) with heuristic fallback
|
|
7
|
+
- Full message history extraction (not just last 10)
|
|
8
|
+
- Incremental extraction (skip already-processed messages)
|
|
9
|
+
- New node types: ENVIRONMENT, GOAL, TEST, ENDPOINT, SCHEMA
|
|
10
|
+
- Graph pruning / aging
|
|
11
|
+
- Secret redaction on every write
|
|
12
|
+
- SQLite persistence (survives restarts)
|
|
13
|
+
|
|
14
|
+
Module layout (split for maintainability — see types.py / helpers.py):
|
|
15
|
+
- types.py: NodeType, NodeStatus, EdgeType, MemoryNode, MemoryEdge, DecisionTransition
|
|
16
|
+
- helpers.py: _content_to_text, _infer_trigger, _extract_evidence_from_text
|
|
17
|
+
- graph.py: GraphMemory (this file) — all extraction/query/persistence logic
|
|
18
|
+
|
|
19
|
+
All names below are re-exported here for backward compatibility:
|
|
20
|
+
existing code doing `from tokenmizer.graph_memory.graph import NodeType` etc.
|
|
21
|
+
continues to work unchanged.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import sqlite3
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import asdict
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from tokenmizer.graph_memory.helpers import (
|
|
34
|
+
_content_to_text,
|
|
35
|
+
_extract_evidence_from_text,
|
|
36
|
+
_infer_trigger,
|
|
37
|
+
)
|
|
38
|
+
from tokenmizer.graph_memory.types import (
|
|
39
|
+
DecisionTransition,
|
|
40
|
+
EdgeType,
|
|
41
|
+
MemoryEdge,
|
|
42
|
+
MemoryNode,
|
|
43
|
+
NodeStatus,
|
|
44
|
+
NodeType,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"GraphMemory",
|
|
49
|
+
"NodeType", "NodeStatus", "EdgeType",
|
|
50
|
+
"MemoryNode", "MemoryEdge", "DecisionTransition",
|
|
51
|
+
"_content_to_text", "_infer_trigger", "_extract_evidence_from_text",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Lazy import to avoid circular dependency
|
|
58
|
+
def _get_validator():
|
|
59
|
+
from tokenmizer.graph_memory.validator import get_validator
|
|
60
|
+
return get_validator()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── Graph ────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
class GraphMemory:
|
|
66
|
+
"""
|
|
67
|
+
In-process graph with SQLite persistence.
|
|
68
|
+
Survives process restarts. One DB file per storage_dir.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, session_id: str, storage_dir: str = "./checkpoints"):
|
|
72
|
+
self.session_id = session_id
|
|
73
|
+
self._nodes: dict[str, MemoryNode] = {}
|
|
74
|
+
self._edges: list[MemoryEdge] = []
|
|
75
|
+
self._transitions: list[DecisionTransition] = [] # full causal history
|
|
76
|
+
self._processed_hashes: set[str] = set()
|
|
77
|
+
self._schema_version = 1 # increment when storage format changes
|
|
78
|
+
# Counts non-fatal decision-contradiction-check failures (see add_node).
|
|
79
|
+
# Persistently non-zero means the supersede-tracking feature is
|
|
80
|
+
# broken, even though node creation itself keeps working.
|
|
81
|
+
self._decision_tracking_failures = 0
|
|
82
|
+
# True if the SQLite DB could not be reinitialized after corruption —
|
|
83
|
+
# the graph is running in-memory-only with no durable persistence.
|
|
84
|
+
self._persistence_broken = False
|
|
85
|
+
# Dirty-tracking for _persist() — see that method's docstring.
|
|
86
|
+
# Starts True so the first persist() call after construction always
|
|
87
|
+
# writes; cleared only after a confirmed successful write.
|
|
88
|
+
self._dirty = True
|
|
89
|
+
self._db_path = Path(storage_dir) / "graph_memory.db"
|
|
90
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
self._safe_init_db()
|
|
92
|
+
self._load()
|
|
93
|
+
self._load_transitions()
|
|
94
|
+
|
|
95
|
+
def _safe_init_db(self) -> None:
|
|
96
|
+
"""Initialize DB, deleting corrupt file if necessary."""
|
|
97
|
+
try:
|
|
98
|
+
self._init_db()
|
|
99
|
+
except Exception:
|
|
100
|
+
logger.warning(f"DB corrupt or unreadable — recreating: {self._db_path}")
|
|
101
|
+
try:
|
|
102
|
+
self._db_path.unlink(missing_ok=True)
|
|
103
|
+
except Exception as del_err:
|
|
104
|
+
logger.error(f"Could not delete corrupt graph DB: {del_err}")
|
|
105
|
+
try:
|
|
106
|
+
self._init_db()
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(
|
|
109
|
+
f"Cannot initialize DB after cleanup for {self.session_id}: {e} "
|
|
110
|
+
"— running with in-memory graph only (data won't persist)"
|
|
111
|
+
)
|
|
112
|
+
# FIXED: previously this was a dead end — logged once at
|
|
113
|
+
# startup and then silently true for the rest of the
|
|
114
|
+
# process's life with no way to query it. See _load()'s
|
|
115
|
+
# matching reinit-failure path for the same fix.
|
|
116
|
+
self._persistence_broken = True
|
|
117
|
+
|
|
118
|
+
# ── DB ──────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def _db_connect(self) -> "sqlite3.Connection":
|
|
121
|
+
"""
|
|
122
|
+
Open a SQLite connection with safe concurrent settings:
|
|
123
|
+
- WAL journal mode: readers don't block writers, writers don't block readers
|
|
124
|
+
- 5s timeout: prevents instant failure when another process holds a write lock
|
|
125
|
+
- check_same_thread=False: safe because we serialize via asyncio session locks
|
|
126
|
+
"""
|
|
127
|
+
conn = sqlite3.connect(str(self._db_path), timeout=5.0, check_same_thread=False)
|
|
128
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
129
|
+
conn.execute("PRAGMA synchronous=NORMAL") # WAL + NORMAL = safe + fast
|
|
130
|
+
return conn
|
|
131
|
+
|
|
132
|
+
def _init_db(self) -> None:
|
|
133
|
+
conn = self._db_connect()
|
|
134
|
+
try:
|
|
135
|
+
conn.execute("""
|
|
136
|
+
CREATE TABLE IF NOT EXISTS graphs (
|
|
137
|
+
session_id TEXT PRIMARY KEY,
|
|
138
|
+
nodes_json TEXT NOT NULL,
|
|
139
|
+
edges_json TEXT NOT NULL,
|
|
140
|
+
processed_hashes TEXT NOT NULL DEFAULT '[]',
|
|
141
|
+
updated_at REAL NOT NULL
|
|
142
|
+
)
|
|
143
|
+
""")
|
|
144
|
+
# Separate table for decision transitions — full causal story
|
|
145
|
+
# Kept separate so it survives graph pruning and is queryable independently
|
|
146
|
+
conn.execute("""
|
|
147
|
+
CREATE TABLE IF NOT EXISTS decision_transitions (
|
|
148
|
+
id TEXT PRIMARY KEY,
|
|
149
|
+
session_id TEXT NOT NULL,
|
|
150
|
+
from_decision_id TEXT NOT NULL,
|
|
151
|
+
to_decision_id TEXT NOT NULL,
|
|
152
|
+
from_label TEXT NOT NULL,
|
|
153
|
+
to_label TEXT NOT NULL,
|
|
154
|
+
trigger TEXT NOT NULL DEFAULT '',
|
|
155
|
+
reason TEXT NOT NULL DEFAULT '',
|
|
156
|
+
evidence TEXT NOT NULL DEFAULT '',
|
|
157
|
+
confidence_delta REAL NOT NULL DEFAULT 0.0,
|
|
158
|
+
timestamp REAL NOT NULL
|
|
159
|
+
)
|
|
160
|
+
""")
|
|
161
|
+
conn.commit()
|
|
162
|
+
finally:
|
|
163
|
+
conn.close()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _persist_transition(self, t: DecisionTransition) -> None:
|
|
167
|
+
"""Persist a single DecisionTransition to its own SQLite table."""
|
|
168
|
+
try:
|
|
169
|
+
conn = self._db_connect()
|
|
170
|
+
try:
|
|
171
|
+
conn.execute(
|
|
172
|
+
"""INSERT OR REPLACE INTO decision_transitions
|
|
173
|
+
(id, session_id, from_decision_id, to_decision_id,
|
|
174
|
+
from_label, to_label, trigger, reason, evidence,
|
|
175
|
+
confidence_delta, timestamp)
|
|
176
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
|
177
|
+
(t.id, t.session_id, t.from_decision_id, t.to_decision_id,
|
|
178
|
+
t.from_label, t.to_label, t.trigger, t.reason,
|
|
179
|
+
t.evidence, t.confidence_delta, t.timestamp),
|
|
180
|
+
)
|
|
181
|
+
conn.commit()
|
|
182
|
+
finally:
|
|
183
|
+
conn.close()
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.error(f"Transition persist failed: {e}")
|
|
186
|
+
|
|
187
|
+
def get_transitions(self) -> list[DecisionTransition]:
|
|
188
|
+
"""Return all decision transitions for this session, newest first."""
|
|
189
|
+
return sorted(self._transitions, key=lambda t: t.timestamp, reverse=True)
|
|
190
|
+
|
|
191
|
+
def _load_transitions(self) -> None:
|
|
192
|
+
"""Load transitions from SQLite into memory."""
|
|
193
|
+
try:
|
|
194
|
+
conn = self._db_connect()
|
|
195
|
+
try:
|
|
196
|
+
rows = conn.execute(
|
|
197
|
+
"SELECT id,session_id,from_decision_id,to_decision_id,"
|
|
198
|
+
"from_label,to_label,trigger,reason,evidence,"
|
|
199
|
+
"confidence_delta,timestamp "
|
|
200
|
+
"FROM decision_transitions WHERE session_id=?",
|
|
201
|
+
(self.session_id,),
|
|
202
|
+
).fetchall()
|
|
203
|
+
self._transitions = [
|
|
204
|
+
DecisionTransition(
|
|
205
|
+
id=r[0], session_id=r[1],
|
|
206
|
+
from_decision_id=r[2], to_decision_id=r[3],
|
|
207
|
+
from_label=r[4], to_label=r[5],
|
|
208
|
+
trigger=r[6], reason=r[7], evidence=r[8],
|
|
209
|
+
confidence_delta=r[9], timestamp=r[10],
|
|
210
|
+
)
|
|
211
|
+
for r in rows
|
|
212
|
+
]
|
|
213
|
+
finally:
|
|
214
|
+
conn.close()
|
|
215
|
+
except Exception as e:
|
|
216
|
+
logger.debug(f"Transition load skipped (table may not exist yet): {e}")
|
|
217
|
+
self._transitions = []
|
|
218
|
+
|
|
219
|
+
def _persist(self, force: bool = False) -> None:
|
|
220
|
+
"""
|
|
221
|
+
Persist the full graph (all nodes + edges) as JSON to SQLite.
|
|
222
|
+
|
|
223
|
+
KNOWN SCALING LIMITATION (documented, not silently shipped as if
|
|
224
|
+
it were fine): this rewrites EVERY node and edge as JSON on every
|
|
225
|
+
call, even when only 1-2 nodes actually changed. Cost is O(total
|
|
226
|
+
node count) per persist, and persist is called once per chat turn
|
|
227
|
+
in extract_from_messages(). The existing 200-node auto-prune cap
|
|
228
|
+
(see prune()) is itself evidence this was already a known
|
|
229
|
+
bottleneck — it caps the damage rather than fixing the cause.
|
|
230
|
+
|
|
231
|
+
Why this isn't rewritten to a proper per-node table in this pass:
|
|
232
|
+
that's a real schema migration (one row per node/edge instead of
|
|
233
|
+
one JSON blob per session), and shipping a migration without being
|
|
234
|
+
able to run it against real persisted data in this environment
|
|
235
|
+
(no app runtime available here — see repo's TESTING.md) is exactly
|
|
236
|
+
the kind of "looks fixed, silently corrupts production data" risk
|
|
237
|
+
this audit is supposed to eliminate, not introduce. Tracked as a
|
|
238
|
+
documented follow-up: migrate `graphs.nodes_json` blob storage to
|
|
239
|
+
a `graph_nodes(session_id, node_id, data_json, updated_at)` table
|
|
240
|
+
with per-node INSERT OR REPLACE, validated against a copy of real
|
|
241
|
+
checkpoint data before rollout.
|
|
242
|
+
|
|
243
|
+
What IS fixed here: a dirty flag so we skip the rewrite entirely
|
|
244
|
+
when nothing changed since the last successful persist (e.g. a
|
|
245
|
+
message produced zero new/updated nodes — common for short
|
|
246
|
+
acknowledgement turns). This doesn't fix the O(n) cost when a
|
|
247
|
+
write IS needed, but it does eliminate redundant full-rewrites,
|
|
248
|
+
which in practice is a meaningful fraction of calls.
|
|
249
|
+
|
|
250
|
+
force=True bypasses the dirty check. Required for callers that
|
|
251
|
+
mutate node/edge state directly without going through add_node()
|
|
252
|
+
(which sets the dirty flag) — e.g. the /api/decision/invalidate
|
|
253
|
+
endpoint flips `node.status` directly, then must force a write or
|
|
254
|
+
the change would silently never be saved.
|
|
255
|
+
"""
|
|
256
|
+
if not self._dirty and not force:
|
|
257
|
+
return
|
|
258
|
+
try:
|
|
259
|
+
conn = self._db_connect()
|
|
260
|
+
try:
|
|
261
|
+
conn.execute(
|
|
262
|
+
"""INSERT OR REPLACE INTO graphs
|
|
263
|
+
(session_id, nodes_json, edges_json, processed_hashes, updated_at)
|
|
264
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
265
|
+
(
|
|
266
|
+
self.session_id,
|
|
267
|
+
json.dumps([asdict(n) for n in self._nodes.values()]),
|
|
268
|
+
json.dumps([asdict(e) for e in self._edges]),
|
|
269
|
+
json.dumps(list(self._processed_hashes)),
|
|
270
|
+
time.time(),
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
conn.commit()
|
|
274
|
+
finally:
|
|
275
|
+
conn.close()
|
|
276
|
+
self._dirty = False # only clear on confirmed success
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logger.error(f"Graph persist failed for {self.session_id}: {e}")
|
|
279
|
+
# _dirty stays True — next call (even non-forced) will retry
|
|
280
|
+
# the full write rather than silently giving up on it forever.
|
|
281
|
+
|
|
282
|
+
def _load(self) -> None:
|
|
283
|
+
try:
|
|
284
|
+
conn = self._db_connect()
|
|
285
|
+
try:
|
|
286
|
+
row = conn.execute(
|
|
287
|
+
"SELECT nodes_json, edges_json, processed_hashes FROM graphs WHERE session_id=?",
|
|
288
|
+
(self.session_id,),
|
|
289
|
+
).fetchone()
|
|
290
|
+
finally:
|
|
291
|
+
conn.close()
|
|
292
|
+
if not row:
|
|
293
|
+
return
|
|
294
|
+
nodes_data = json.loads(row[0])
|
|
295
|
+
edges_data = json.loads(row[1])
|
|
296
|
+
|
|
297
|
+
# FIXED — real bug, found while writing a proper (non-vacuous) test
|
|
298
|
+
# for tests/chaos/test_recovery.py::test_partial_write_recovery.
|
|
299
|
+
# processed_hashes used to be parsed inline with nodes/edges, all
|
|
300
|
+
# inside the same try block. If processed_hashes was corrupted
|
|
301
|
+
# (e.g. a partial/interrupted write), json.loads() on it raised
|
|
302
|
+
# BEFORE the node-population loop below ever ran — so a session
|
|
303
|
+
# with perfectly valid nodes_json still lost every node on reload,
|
|
304
|
+
# just because the unrelated hashes field was bad. That directly
|
|
305
|
+
# contradicts this method's whole purpose (recover what's good).
|
|
306
|
+
# Isolating this parse means a corrupt hash set only costs you
|
|
307
|
+
# incremental-extraction dedup (some messages get re-processed —
|
|
308
|
+
# harmless, add_node() already dedupes), not your entire graph.
|
|
309
|
+
try:
|
|
310
|
+
self._processed_hashes = set(json.loads(row[2]))
|
|
311
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
312
|
+
logger.warning(
|
|
313
|
+
f"processed_hashes corrupted for {self.session_id}, "
|
|
314
|
+
f"resetting (nodes/edges are unaffected): {e}"
|
|
315
|
+
)
|
|
316
|
+
self._processed_hashes = set()
|
|
317
|
+
|
|
318
|
+
for nd in nodes_data:
|
|
319
|
+
nd.pop("_evicted", None)
|
|
320
|
+
n = MemoryNode(**{k: v for k, v in nd.items() if k != "_evicted"})
|
|
321
|
+
self._nodes[n.id] = n
|
|
322
|
+
for ed in edges_data:
|
|
323
|
+
self._edges.append(MemoryEdge(**ed))
|
|
324
|
+
except (sqlite3.DatabaseError, sqlite3.OperationalError) as e:
|
|
325
|
+
logger.warning(f"Corrupted DB for {self.session_id} — starting fresh: {e}")
|
|
326
|
+
self._nodes = {}
|
|
327
|
+
self._edges = []
|
|
328
|
+
self._processed_hashes = set()
|
|
329
|
+
# Re-initialize the DB file
|
|
330
|
+
try:
|
|
331
|
+
self._db_path.unlink(missing_ok=True)
|
|
332
|
+
self._init_db()
|
|
333
|
+
except Exception as reinit_err:
|
|
334
|
+
logger.error(
|
|
335
|
+
f"Graph DB reinit failed for {self.session_id}: {reinit_err} "
|
|
336
|
+
"— running with in-memory graph only (data won't persist)"
|
|
337
|
+
)
|
|
338
|
+
# FIXED: this is the worst-case path — persistence is
|
|
339
|
+
# completely broken for this session going forward, but
|
|
340
|
+
# previously the only trace of that fact was a log line.
|
|
341
|
+
# Surfacing it via stats() means a health-check script (or
|
|
342
|
+
# a human looking at /api/graph/{session_id}) can detect
|
|
343
|
+
# "this session has no durable memory" instead of finding
|
|
344
|
+
# out only after a restart wipes everything.
|
|
345
|
+
self._persistence_broken = True
|
|
346
|
+
except Exception as e:
|
|
347
|
+
logger.warning(f"Graph load failed for {self.session_id}: {e}")
|
|
348
|
+
|
|
349
|
+
# ── Nodes ────────────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
def _node_id(self, node_type: str, label: str) -> str:
|
|
352
|
+
normalized = f"{node_type}:{label.lower().strip()}"
|
|
353
|
+
return hashlib.sha1(normalized.encode()).hexdigest()[:12]
|
|
354
|
+
|
|
355
|
+
def _normalize_label(self, label: str) -> str:
|
|
356
|
+
return label.lower().strip().rstrip(".,!?")
|
|
357
|
+
|
|
358
|
+
def add_node(
|
|
359
|
+
self,
|
|
360
|
+
node_type: NodeType,
|
|
361
|
+
label: str,
|
|
362
|
+
status: NodeStatus = NodeStatus.PENDING,
|
|
363
|
+
summary: str = "",
|
|
364
|
+
importance: float = 0.5,
|
|
365
|
+
confidence: float = 0.7,
|
|
366
|
+
) -> str:
|
|
367
|
+
from tokenmizer.security.redaction import redact_node
|
|
368
|
+
label, summary = redact_node(label, summary)
|
|
369
|
+
|
|
370
|
+
norm = self._normalize_label(label)
|
|
371
|
+
node_id = self._node_id(node_type.value, norm)
|
|
372
|
+
|
|
373
|
+
if node_id in self._nodes:
|
|
374
|
+
# Dedup: update existing node instead of creating duplicate
|
|
375
|
+
existing = self._nodes[node_id]
|
|
376
|
+
existing.touch()
|
|
377
|
+
self._dirty = True # touch() always changes updated_at, must persist
|
|
378
|
+
# Only upgrade status (completed > in_progress > pending)
|
|
379
|
+
status_rank = {
|
|
380
|
+
NodeStatus.PENDING: 0,
|
|
381
|
+
NodeStatus.IN_PROGRESS: 1,
|
|
382
|
+
NodeStatus.COMPLETED: 2,
|
|
383
|
+
NodeStatus.FAILED: 3,
|
|
384
|
+
NodeStatus.ARCHIVED: 4,
|
|
385
|
+
NodeStatus.SUPERSEDED: 5,
|
|
386
|
+
NodeStatus.MODIFIED: 5, # alias for SUPERSEDED
|
|
387
|
+
NodeStatus.INVALIDATED: 6,
|
|
388
|
+
}
|
|
389
|
+
if status_rank.get(status, 0) > status_rank.get(existing.status, 0):
|
|
390
|
+
existing.status = status
|
|
391
|
+
if summary and not existing.summary:
|
|
392
|
+
existing.summary = summary
|
|
393
|
+
return node_id
|
|
394
|
+
|
|
395
|
+
# Validate before inserting — reject noise and low-confidence nodes
|
|
396
|
+
validator = _get_validator()
|
|
397
|
+
result = validator.validate(
|
|
398
|
+
label=label,
|
|
399
|
+
node_type=node_type.value,
|
|
400
|
+
summary=summary,
|
|
401
|
+
)
|
|
402
|
+
if not result.accepted:
|
|
403
|
+
logger.debug(f"Node rejected: {label!r} ({result.rejection_reason})")
|
|
404
|
+
return "" # empty string = rejected, callers must check
|
|
405
|
+
|
|
406
|
+
# Apply type correction if validator detected mismatch
|
|
407
|
+
if result.corrected_type:
|
|
408
|
+
try:
|
|
409
|
+
node_type = NodeType(result.corrected_type)
|
|
410
|
+
node_id = self._node_id(node_type.value, norm)
|
|
411
|
+
except ValueError:
|
|
412
|
+
pass # keep original type if correction is unknown
|
|
413
|
+
|
|
414
|
+
node = MemoryNode(
|
|
415
|
+
id=node_id,
|
|
416
|
+
type=node_type,
|
|
417
|
+
label=label[:120],
|
|
418
|
+
status=status,
|
|
419
|
+
summary=summary[:300],
|
|
420
|
+
importance=importance,
|
|
421
|
+
confidence=confidence if confidence != 0.7 else result.confidence,
|
|
422
|
+
)
|
|
423
|
+
self._nodes[node_id] = node
|
|
424
|
+
self._dirty = True
|
|
425
|
+
|
|
426
|
+
# Decision contradiction detection — capture full transition story
|
|
427
|
+
if node_type == NodeType.DECISION and status == NodeStatus.COMPLETED:
|
|
428
|
+
try:
|
|
429
|
+
from tokenmizer.graph_memory.decision_tracker import (
|
|
430
|
+
find_contradicting_decisions,
|
|
431
|
+
)
|
|
432
|
+
to_supersede = find_contradicting_decisions(
|
|
433
|
+
label, summary, self._nodes
|
|
434
|
+
)
|
|
435
|
+
for old_id in to_supersede:
|
|
436
|
+
if old_id != node_id and old_id in self._nodes:
|
|
437
|
+
old_node = self._nodes[old_id]
|
|
438
|
+
old_confidence = old_node.confidence
|
|
439
|
+
|
|
440
|
+
# Mark old decision superseded
|
|
441
|
+
old_node.status = NodeStatus.SUPERSEDED
|
|
442
|
+
old_node.valid_until = time.time()
|
|
443
|
+
|
|
444
|
+
# Build full transition object
|
|
445
|
+
# Evidence: prefer explicit "|" separator, else extract from summary
|
|
446
|
+
parts = (summary or "").split("|", 1)
|
|
447
|
+
reason_text = parts[0].strip()
|
|
448
|
+
evidence_text = parts[1].strip() if len(parts) > 1 else ""
|
|
449
|
+
|
|
450
|
+
# Auto-extract evidence from summary if not explicit
|
|
451
|
+
if not evidence_text and summary:
|
|
452
|
+
evidence_text = _extract_evidence_from_text(summary)
|
|
453
|
+
|
|
454
|
+
trigger = _infer_trigger(old_node.label, label, summary)
|
|
455
|
+
|
|
456
|
+
transition = DecisionTransition(
|
|
457
|
+
id=f"tr_{old_id[:8]}_{node_id[:8]}",
|
|
458
|
+
session_id=self.session_id,
|
|
459
|
+
from_decision_id=old_id,
|
|
460
|
+
to_decision_id=node_id,
|
|
461
|
+
from_label=old_node.label,
|
|
462
|
+
to_label=label,
|
|
463
|
+
trigger=trigger,
|
|
464
|
+
reason=reason_text,
|
|
465
|
+
evidence=evidence_text,
|
|
466
|
+
confidence_delta=round(confidence - old_confidence, 3),
|
|
467
|
+
)
|
|
468
|
+
self._transitions.append(transition)
|
|
469
|
+
self._persist_transition(transition)
|
|
470
|
+
|
|
471
|
+
old_node.summary = (
|
|
472
|
+
f"Superseded by: {label[:60]}"
|
|
473
|
+
+ (f" — {reason_text[:40]}" if reason_text else "")
|
|
474
|
+
)
|
|
475
|
+
self.add_edge(node_id, old_id, EdgeType.SUPERSEDES, weight=1.0)
|
|
476
|
+
logger.info(
|
|
477
|
+
f"Decision transition: {old_node.label!r} → {label!r}"
|
|
478
|
+
f" | trigger: {trigger[:40]}"
|
|
479
|
+
)
|
|
480
|
+
except Exception as e:
|
|
481
|
+
# Intentionally non-fatal: a bug in contradiction detection
|
|
482
|
+
# must not block creating the new decision node itself —
|
|
483
|
+
# the node is more important than the supersede-tracking
|
|
484
|
+
# metadata around it.
|
|
485
|
+
#
|
|
486
|
+
# FIXED: previously logged at `debug` (off by default in
|
|
487
|
+
# production), meaning this core advertised feature —
|
|
488
|
+
# "decision transition tracking" / "Changed X → Y" in
|
|
489
|
+
# resume context — could silently stop working entirely
|
|
490
|
+
# and nobody would notice until they wondered why resume
|
|
491
|
+
# context never showed any decision changes. Bumped to
|
|
492
|
+
# `warning` and counted on the instance (surfaced via
|
|
493
|
+
# stats(), see below) so persistent failures are visible
|
|
494
|
+
# to anyone inspecting graph health, not just to someone
|
|
495
|
+
# who happens to be tailing logs at warning level.
|
|
496
|
+
logger.warning(
|
|
497
|
+
f"Decision contradiction check failed for node {node_id} "
|
|
498
|
+
f"(non-fatal — node was still created): {e}"
|
|
499
|
+
)
|
|
500
|
+
self._decision_tracking_failures += 1
|
|
501
|
+
|
|
502
|
+
return node_id
|
|
503
|
+
|
|
504
|
+
def add_edge(
|
|
505
|
+
self, source_id: str, target_id: str, edge_type: EdgeType, weight: float = 1.0
|
|
506
|
+
) -> None:
|
|
507
|
+
# No duplicate edges
|
|
508
|
+
for e in self._edges:
|
|
509
|
+
if e.source_id == source_id and e.target_id == target_id and e.type == edge_type:
|
|
510
|
+
return
|
|
511
|
+
self._edges.append(MemoryEdge(source_id=source_id, target_id=target_id,
|
|
512
|
+
type=edge_type, weight=weight))
|
|
513
|
+
self._dirty = True
|
|
514
|
+
|
|
515
|
+
# ── Extraction ───────────────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
def _msg_hash(self, msg: dict) -> str:
|
|
518
|
+
"""
|
|
519
|
+
Hash a message for dedup tracking.
|
|
520
|
+
Handles non-string content: None (empty), list (multimodal — extract text
|
|
521
|
+
parts), dict, or any other type (str() fallback).
|
|
522
|
+
"""
|
|
523
|
+
content = msg.get("content", "")
|
|
524
|
+
text = _content_to_text(content)
|
|
525
|
+
return hashlib.sha1(text[:500].encode()).hexdigest()[:16]
|
|
526
|
+
|
|
527
|
+
def extract_from_messages(
|
|
528
|
+
self,
|
|
529
|
+
messages: list[dict],
|
|
530
|
+
incremental: bool = True,
|
|
531
|
+
extracted_data: dict | None = None,
|
|
532
|
+
) -> None:
|
|
533
|
+
"""
|
|
534
|
+
Update graph from messages.
|
|
535
|
+
|
|
536
|
+
Pipeline:
|
|
537
|
+
1. If extracted_data is provided (from LLM/HybridExtractor) — use it directly.
|
|
538
|
+
2. Otherwise run _heuristic_extract() as fallback.
|
|
539
|
+
"""
|
|
540
|
+
if incremental:
|
|
541
|
+
new_messages = [m for m in messages
|
|
542
|
+
if self._msg_hash(m) not in self._processed_hashes]
|
|
543
|
+
if not new_messages:
|
|
544
|
+
return
|
|
545
|
+
else:
|
|
546
|
+
new_messages = messages
|
|
547
|
+
|
|
548
|
+
# Auto-select sliding window for long sessions
|
|
549
|
+
# For sessions > 30 messages: only extract WIP/errors from last 20
|
|
550
|
+
window_size = 20 if len(messages) > 30 else 0
|
|
551
|
+
|
|
552
|
+
# Use provided data (from LLM pipeline) or run HybridExtractor heuristic pass
|
|
553
|
+
if extracted_data is not None:
|
|
554
|
+
data = extracted_data
|
|
555
|
+
else:
|
|
556
|
+
from tokenmizer.graph_memory.hybrid_extractor import get_hybrid_extractor
|
|
557
|
+
_he = get_hybrid_extractor()
|
|
558
|
+
_extracted = _he.heuristic_extract(new_messages, window_size=window_size)
|
|
559
|
+
data = {
|
|
560
|
+
"goals": _extracted.goals,
|
|
561
|
+
"tasks": (
|
|
562
|
+
[{"label": t, "status": "completed"} for t in _extracted.tasks_done] +
|
|
563
|
+
[{"label": t, "status": "in_progress"} for t in _extracted.tasks_wip] +
|
|
564
|
+
[{"label": t, "status": "pending"} for t in _extracted.tasks_todo]
|
|
565
|
+
),
|
|
566
|
+
"decisions": _extracted.decisions,
|
|
567
|
+
"files": _extracted.files,
|
|
568
|
+
"errors": _extracted.errors,
|
|
569
|
+
"dependencies": _extracted.dependencies,
|
|
570
|
+
"environments": _extracted.environments,
|
|
571
|
+
"endpoints": _extracted.endpoints,
|
|
572
|
+
"schemas": _extracted.schemas,
|
|
573
|
+
"superseded": _extracted.superseded,
|
|
574
|
+
}
|
|
575
|
+
self._apply_extracted(data, new_messages)
|
|
576
|
+
|
|
577
|
+
for m in new_messages:
|
|
578
|
+
self._processed_hashes.add(self._msg_hash(m))
|
|
579
|
+
if new_messages:
|
|
580
|
+
self._dirty = True # processed_hashes changed even if no nodes did
|
|
581
|
+
|
|
582
|
+
# Cap processed_hashes — for very long sessions (1000+ turns), this set
|
|
583
|
+
# would otherwise grow unbounded (each hash ~16 bytes, but still).
|
|
584
|
+
# When over cap, rebuild from the most recent messages only.
|
|
585
|
+
# Effect: very old messages may be re-scanned on restart, but since
|
|
586
|
+
# their content is already in the graph, add_node() dedup makes
|
|
587
|
+
# re-extraction a safe no-op.
|
|
588
|
+
_MAX_PROCESSED_HASHES = 500
|
|
589
|
+
if len(self._processed_hashes) > _MAX_PROCESSED_HASHES:
|
|
590
|
+
self._processed_hashes = {
|
|
591
|
+
self._msg_hash(m) for m in messages[-_MAX_PROCESSED_HASHES:]
|
|
592
|
+
}
|
|
593
|
+
self._dirty = True # processed_hashes is part of the persisted row
|
|
594
|
+
|
|
595
|
+
# Apply importance decay — completed tasks fade, superseded decisions fade
|
|
596
|
+
# Active decisions and goals never decay
|
|
597
|
+
decayed = self.apply_importance_decay()
|
|
598
|
+
if decayed:
|
|
599
|
+
logger.debug(f"Importance decay applied to {len(decayed)} nodes")
|
|
600
|
+
|
|
601
|
+
# Auto-prune: if graph has grown large, remove low-importance old nodes.
|
|
602
|
+
# Runs only when over threshold — cheap no-op for typical sessions.
|
|
603
|
+
if len(self._nodes) > 200:
|
|
604
|
+
pruned = self.prune(max_nodes=200)
|
|
605
|
+
if pruned:
|
|
606
|
+
logger.debug(f"Auto-pruned {pruned} nodes (graph exceeded 200 nodes)")
|
|
607
|
+
|
|
608
|
+
self._persist()
|
|
609
|
+
|
|
610
|
+
def _apply_extracted(self, data: dict, messages: list[dict]) -> None:
|
|
611
|
+
"""
|
|
612
|
+
Apply extracted structured data to the graph.
|
|
613
|
+
|
|
614
|
+
Edge rule: edges are created only between semantically related nodes,
|
|
615
|
+
NOT by accident-of-order (previous version used task_ids[-3:] which
|
|
616
|
+
linked any task to any file extracted in the same message — wrong).
|
|
617
|
+
|
|
618
|
+
Relationship logic:
|
|
619
|
+
- decision → task: only if decision label shares ≥1 meaningful word with task
|
|
620
|
+
- task → file: only if file name appears in task label or vice versa
|
|
621
|
+
- file → endpoint: only if endpoint label shares a path segment with file name
|
|
622
|
+
"""
|
|
623
|
+
# Collect accepted node IDs by type for relationship inference
|
|
624
|
+
goal_ids: list[str] = []
|
|
625
|
+
task_ids: list[str] = []
|
|
626
|
+
file_ids: list[str] = []
|
|
627
|
+
decision_ids: list[str] = []
|
|
628
|
+
|
|
629
|
+
# Goals
|
|
630
|
+
for goal in data.get("goals", []):
|
|
631
|
+
if goal:
|
|
632
|
+
nid = self.add_node(NodeType.GOAL, goal, NodeStatus.IN_PROGRESS, importance=1.0)
|
|
633
|
+
if nid:
|
|
634
|
+
goal_ids.append(nid)
|
|
635
|
+
|
|
636
|
+
# Tasks
|
|
637
|
+
status_map = {
|
|
638
|
+
"completed": NodeStatus.COMPLETED,
|
|
639
|
+
"in_progress": NodeStatus.IN_PROGRESS,
|
|
640
|
+
"failed": NodeStatus.FAILED,
|
|
641
|
+
}
|
|
642
|
+
for t in data.get("tasks", []):
|
|
643
|
+
label = t.get("label", "")
|
|
644
|
+
if not label or len(label) < 5:
|
|
645
|
+
continue
|
|
646
|
+
status = status_map.get(t.get("status", "pending"), NodeStatus.PENDING)
|
|
647
|
+
importance = 0.8 if status == NodeStatus.COMPLETED else 0.6
|
|
648
|
+
nid = self.add_node(NodeType.TASK, label, status, importance=importance)
|
|
649
|
+
if nid:
|
|
650
|
+
task_ids.append(nid)
|
|
651
|
+
# Tasks are part of the session goal
|
|
652
|
+
for gid in goal_ids:
|
|
653
|
+
self.add_edge(nid, gid, EdgeType.PART_OF)
|
|
654
|
+
|
|
655
|
+
# Decisions — linked to tasks that share vocabulary
|
|
656
|
+
for d in data.get("decisions", []):
|
|
657
|
+
label = d.get("label", "")
|
|
658
|
+
if not label or len(label) < 5:
|
|
659
|
+
continue
|
|
660
|
+
summary = d.get("rationale", d.get("reason", ""))
|
|
661
|
+
# Use per-item confidence from merge() if provided (corroboration signal).
|
|
662
|
+
# Fallback: 0.9 for explicit decisions (high-value nodes).
|
|
663
|
+
node_confidence = float(d.get("confidence", 0.9))
|
|
664
|
+
nid = self.add_node(NodeType.DECISION, label, NodeStatus.COMPLETED,
|
|
665
|
+
summary=summary, importance=0.9,
|
|
666
|
+
confidence=node_confidence)
|
|
667
|
+
if nid:
|
|
668
|
+
decision_ids.append(nid)
|
|
669
|
+
# Link to tasks if they share meaningful vocabulary (with alias expansion)
|
|
670
|
+
decision_words = self._expand_with_aliases(
|
|
671
|
+
self._meaningful_words(label)
|
|
672
|
+
)
|
|
673
|
+
for tid in task_ids:
|
|
674
|
+
task_node = self._nodes.get(tid)
|
|
675
|
+
if task_node:
|
|
676
|
+
task_words = self._expand_with_aliases(
|
|
677
|
+
self._meaningful_words(task_node.label)
|
|
678
|
+
)
|
|
679
|
+
if decision_words & task_words:
|
|
680
|
+
self.add_edge(nid, tid, EdgeType.RELATED_TO)
|
|
681
|
+
|
|
682
|
+
# SUPERSEDES edge: link new decision to any SUPERSEDED decision
|
|
683
|
+
# that shares topic words — enables "changed from X to Y" in resume
|
|
684
|
+
for existing_id, existing_node in list(self._nodes.items()):
|
|
685
|
+
if (existing_id != nid
|
|
686
|
+
and existing_node.type == NodeType.DECISION
|
|
687
|
+
and existing_node.status == NodeStatus.SUPERSEDED):
|
|
688
|
+
existing_words = self._expand_with_aliases(
|
|
689
|
+
self._meaningful_words(existing_node.label)
|
|
690
|
+
)
|
|
691
|
+
if decision_words & existing_words:
|
|
692
|
+
self.add_edge(nid, existing_id, EdgeType.SUPERSEDES)
|
|
693
|
+
|
|
694
|
+
# Files — linked to tasks only if file name appears in task description
|
|
695
|
+
for f in data.get("files", []):
|
|
696
|
+
if not f or len(f) < 3:
|
|
697
|
+
continue
|
|
698
|
+
nid = self.add_node(NodeType.FILE, f, NodeStatus.IN_PROGRESS, importance=0.7)
|
|
699
|
+
if nid:
|
|
700
|
+
file_ids.append(nid)
|
|
701
|
+
file_stem = f.split("/")[-1].split(".")[0].lower()
|
|
702
|
+
for tid in task_ids:
|
|
703
|
+
task_node = self._nodes.get(tid)
|
|
704
|
+
if task_node and file_stem and file_stem in task_node.label.lower():
|
|
705
|
+
self.add_edge(tid, nid, EdgeType.IMPLEMENTS)
|
|
706
|
+
|
|
707
|
+
# Errors — handle both str and dict formats
|
|
708
|
+
for e in data.get("errors", []):
|
|
709
|
+
if isinstance(e, str):
|
|
710
|
+
label, resolved = e, False
|
|
711
|
+
else:
|
|
712
|
+
label, resolved = e.get("label", ""), e.get("resolved", False)
|
|
713
|
+
if not label:
|
|
714
|
+
continue
|
|
715
|
+
status = NodeStatus.COMPLETED if resolved else NodeStatus.FAILED
|
|
716
|
+
importance = 0.5 if resolved else 0.9
|
|
717
|
+
err_nid = self.add_node(NodeType.ERROR, label, status, importance=importance)
|
|
718
|
+
if err_nid:
|
|
719
|
+
for fid in file_ids:
|
|
720
|
+
file_node = self._nodes.get(fid)
|
|
721
|
+
if file_node and file_node.label.split("/")[-1] in label:
|
|
722
|
+
self.add_edge(err_nid, fid, EdgeType.RELATED_TO)
|
|
723
|
+
|
|
724
|
+
# Dependencies (no edges — standalone nodes)
|
|
725
|
+
for dep in data.get("dependencies", []):
|
|
726
|
+
if dep and len(dep) > 1:
|
|
727
|
+
self.add_node(NodeType.DEPENDENCY, dep, NodeStatus.COMPLETED, importance=0.6)
|
|
728
|
+
|
|
729
|
+
# Environment (no edges — standalone nodes)
|
|
730
|
+
for env in data.get("environments", data.get("environment", [])):
|
|
731
|
+
if env:
|
|
732
|
+
self.add_node(NodeType.ENVIRONMENT, env, NodeStatus.COMPLETED, importance=0.8)
|
|
733
|
+
|
|
734
|
+
# Endpoints — linked to files only when they share a path segment
|
|
735
|
+
for ep in data.get("endpoints", []):
|
|
736
|
+
if not ep:
|
|
737
|
+
continue
|
|
738
|
+
ep_nid = self.add_node(NodeType.ENDPOINT, ep, NodeStatus.COMPLETED, importance=0.7)
|
|
739
|
+
if ep_nid:
|
|
740
|
+
ep_parts = set(ep.lower().replace("/", " ").split())
|
|
741
|
+
for fid in file_ids:
|
|
742
|
+
file_node = self._nodes.get(fid)
|
|
743
|
+
if file_node:
|
|
744
|
+
file_parts = self._meaningful_words(file_node.label)
|
|
745
|
+
if ep_parts & file_parts:
|
|
746
|
+
self.add_edge(fid, ep_nid, EdgeType.IMPLEMENTS)
|
|
747
|
+
|
|
748
|
+
# Schemas
|
|
749
|
+
for schema in data.get("schemas", []):
|
|
750
|
+
if schema:
|
|
751
|
+
self.add_node(NodeType.SCHEMA, schema, NodeStatus.COMPLETED, importance=0.7)
|
|
752
|
+
|
|
753
|
+
_STOP_WORDS = frozenset({
|
|
754
|
+
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to",
|
|
755
|
+
"for", "of", "with", "is", "are", "was", "were", "be", "been",
|
|
756
|
+
"have", "has", "do", "does", "will", "would", "could", "should",
|
|
757
|
+
"this", "that", "it", "we", "i", "you", "they",
|
|
758
|
+
# NOTE: "use" and "using" intentionally NOT in stop words —
|
|
759
|
+
# they appear in decision labels like "Use PostgreSQL for sessions"
|
|
760
|
+
# and removing them kills edge matching between decisions and tasks.
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
# Tech aliases: maps common abbreviations/variants to canonical tokens
|
|
764
|
+
# Allows "Use PG" to match task "Set up PostgreSQL database"
|
|
765
|
+
_TECH_ALIASES: dict[str, frozenset] = {
|
|
766
|
+
"postgres": frozenset({"postgres", "postgresql", "pg", "psql"}),
|
|
767
|
+
"postgresql": frozenset({"postgres", "postgresql", "pg", "psql"}),
|
|
768
|
+
"pg": frozenset({"postgres", "postgresql", "pg", "psql"}),
|
|
769
|
+
"mongo": frozenset({"mongo", "mongodb"}),
|
|
770
|
+
"mongodb": frozenset({"mongo", "mongodb"}),
|
|
771
|
+
"redis": frozenset({"redis", "cache", "caching"}),
|
|
772
|
+
"jwt": frozenset({"jwt", "token", "auth", "authentication"}),
|
|
773
|
+
"auth": frozenset({"auth", "authentication", "authorize", "jwt"}),
|
|
774
|
+
"authentication": frozenset({"auth", "authentication", "authorize", "jwt"}),
|
|
775
|
+
"db": frozenset({"db", "database", "storage"}),
|
|
776
|
+
"database": frozenset({"db", "database", "storage"}),
|
|
777
|
+
"api": frozenset({"api", "endpoint", "route", "rest"}),
|
|
778
|
+
"endpoint": frozenset({"api", "endpoint", "route", "rest"}),
|
|
779
|
+
"fastapi": frozenset({"fastapi", "api", "endpoint", "route"}),
|
|
780
|
+
"docker": frozenset({"docker", "container", "containerize"}),
|
|
781
|
+
"k8s": frozenset({"k8s", "kubernetes", "cluster"}),
|
|
782
|
+
"kubernetes": frozenset({"k8s", "kubernetes", "cluster"}),
|
|
783
|
+
"ts": frozenset({"ts", "typescript"}),
|
|
784
|
+
"typescript": frozenset({"ts", "typescript"}),
|
|
785
|
+
"js": frozenset({"js", "javascript", "node", "nodejs"}),
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
def _expand_with_aliases(self, words: frozenset) -> frozenset:
|
|
789
|
+
"""Expand a word set with known tech aliases for fuzzy matching."""
|
|
790
|
+
expanded = set(words)
|
|
791
|
+
for w in words:
|
|
792
|
+
if w in self._TECH_ALIASES:
|
|
793
|
+
expanded |= self._TECH_ALIASES[w]
|
|
794
|
+
return frozenset(expanded)
|
|
795
|
+
|
|
796
|
+
def _meaningful_words(self, text: str) -> frozenset:
|
|
797
|
+
"""Extract meaningful words from text for semantic edge linking."""
|
|
798
|
+
words = set(text.lower().split())
|
|
799
|
+
# Remove stop words, punctuation, and very short words
|
|
800
|
+
return frozenset(
|
|
801
|
+
w.strip(".,!?:;()[]") for w in words
|
|
802
|
+
if len(w) > 3 and w not in self._STOP_WORDS
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
# ── Query ────────────────────────────────────────────────────────────────
|
|
806
|
+
|
|
807
|
+
def query(self, task: str, top_k: int = 12) -> list[MemoryNode]:
|
|
808
|
+
"""
|
|
809
|
+
Keyword + importance + type-boosted ranked retrieval.
|
|
810
|
+
Uses alias expansion so 'auth' matches 'authentication', 'PG' matches 'PostgreSQL'.
|
|
811
|
+
Type boost: DECISION/GOAL nodes score 20% higher when relevant.
|
|
812
|
+
"""
|
|
813
|
+
query_words = self._expand_with_aliases(
|
|
814
|
+
frozenset(w.strip(".,!?:;()[]").lower() for w in task.split() if len(w) > 2)
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
# Type boost factors — decisions and goals are most valuable to surface
|
|
818
|
+
_TYPE_BOOST = {
|
|
819
|
+
NodeType.GOAL: 1.25,
|
|
820
|
+
NodeType.DECISION: 1.20,
|
|
821
|
+
NodeType.TASK: 1.00,
|
|
822
|
+
NodeType.ERROR: 0.95,
|
|
823
|
+
NodeType.FILE: 0.90,
|
|
824
|
+
NodeType.ENDPOINT: 0.90,
|
|
825
|
+
NodeType.SCHEMA: 0.85,
|
|
826
|
+
NodeType.DEPENDENCY: 0.70,
|
|
827
|
+
NodeType.ENVIRONMENT: 0.70,
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
scored: list[tuple[float, MemoryNode]] = []
|
|
831
|
+
|
|
832
|
+
for node in self._nodes.values():
|
|
833
|
+
if node._evicted:
|
|
834
|
+
continue
|
|
835
|
+
# Skip archived/superseded/invalidated — historical noise
|
|
836
|
+
if node.status in (
|
|
837
|
+
NodeStatus.ARCHIVED, NodeStatus.SUPERSEDED,
|
|
838
|
+
NodeStatus.MODIFIED, NodeStatus.INVALIDATED,
|
|
839
|
+
):
|
|
840
|
+
continue
|
|
841
|
+
|
|
842
|
+
node_words = self._expand_with_aliases(
|
|
843
|
+
frozenset(w.strip(".,!?:;()[]").lower() for w in node.label.split() if len(w) > 2)
|
|
844
|
+
)
|
|
845
|
+
if not node_words:
|
|
846
|
+
continue
|
|
847
|
+
|
|
848
|
+
overlap = len(query_words & node_words) / max(1, len(query_words))
|
|
849
|
+
recency = 1.0 / (1.0 + node.age_days() * 0.1)
|
|
850
|
+
type_boost = _TYPE_BOOST.get(node.type, 1.0)
|
|
851
|
+
|
|
852
|
+
# Score: overlap is primary signal; importance and recency are tiebreakers
|
|
853
|
+
score = (overlap * 0.6 + node.importance * 0.3 + recency * 0.1) * type_boost
|
|
854
|
+
|
|
855
|
+
if score > 0.05: # minimum threshold — don't return completely unrelated nodes
|
|
856
|
+
scored.append((score, node))
|
|
857
|
+
|
|
858
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
859
|
+
return [n for _, n in scored[:top_k]]
|
|
860
|
+
|
|
861
|
+
def query_at_time(self, task: str, at_time: float, top_k: int = 12) -> list[MemoryNode]:
|
|
862
|
+
"""
|
|
863
|
+
Return nodes that were ACTIVE at a specific point in time.
|
|
864
|
+
|
|
865
|
+
Enables: "What did we decide last Tuesday?"
|
|
866
|
+
|
|
867
|
+
Bug fixed: was calling query() which excludes SUPERSEDED nodes.
|
|
868
|
+
A superseded decision WAS active before it was superseded.
|
|
869
|
+
We must scan ALL nodes and filter by valid_from/valid_until.
|
|
870
|
+
|
|
871
|
+
valid_from: when the node was created (always set)
|
|
872
|
+
valid_until: when it was superseded/invalidated (0.0 = still active)
|
|
873
|
+
|
|
874
|
+
A node was active at at_time if:
|
|
875
|
+
valid_from <= at_time AND (valid_until == 0 OR valid_until > at_time)
|
|
876
|
+
"""
|
|
877
|
+
query_words = self._expand_with_aliases(
|
|
878
|
+
frozenset(
|
|
879
|
+
w.strip(".,!?:;()[]").lower()
|
|
880
|
+
for w in (task or "").split()
|
|
881
|
+
if len(w) > 2
|
|
882
|
+
)
|
|
883
|
+
) if task else frozenset()
|
|
884
|
+
|
|
885
|
+
_TYPE_BOOST = {
|
|
886
|
+
NodeType.GOAL: 1.25,
|
|
887
|
+
NodeType.DECISION: 1.20,
|
|
888
|
+
NodeType.TASK: 1.00,
|
|
889
|
+
NodeType.ERROR: 0.90,
|
|
890
|
+
NodeType.FILE: 0.85,
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
scored: list[tuple[float, MemoryNode]] = []
|
|
894
|
+
for node in self._nodes.values():
|
|
895
|
+
if node._evicted:
|
|
896
|
+
continue
|
|
897
|
+
|
|
898
|
+
# Was this node active at at_time?
|
|
899
|
+
was_created = node.valid_from <= at_time
|
|
900
|
+
not_yet_closed = (node.valid_until == 0.0 or node.valid_until > at_time)
|
|
901
|
+
if not (was_created and not_yet_closed):
|
|
902
|
+
continue
|
|
903
|
+
|
|
904
|
+
if not query_words:
|
|
905
|
+
# No query — return all active nodes at that time
|
|
906
|
+
scored.append((node.importance, node))
|
|
907
|
+
continue
|
|
908
|
+
|
|
909
|
+
node_words = self._expand_with_aliases(
|
|
910
|
+
frozenset(
|
|
911
|
+
w.strip(".,!?:;()[]").lower()
|
|
912
|
+
for w in node.label.split()
|
|
913
|
+
if len(w) > 2
|
|
914
|
+
)
|
|
915
|
+
)
|
|
916
|
+
if not node_words:
|
|
917
|
+
continue
|
|
918
|
+
|
|
919
|
+
overlap = len(query_words & node_words) / max(1, len(query_words))
|
|
920
|
+
type_boost = _TYPE_BOOST.get(node.type, 1.0)
|
|
921
|
+
score = (overlap * 0.7 + node.importance * 0.3) * type_boost
|
|
922
|
+
|
|
923
|
+
if score > 0.05:
|
|
924
|
+
scored.append((score, node))
|
|
925
|
+
|
|
926
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
927
|
+
return [n for _, n in scored[:top_k]]
|
|
928
|
+
|
|
929
|
+
# ── Prune ────────────────────────────────────────────────────────────────
|
|
930
|
+
|
|
931
|
+
def apply_importance_decay(self) -> dict[str, float]:
|
|
932
|
+
"""
|
|
933
|
+
Time-based importance decay — runs automatically during extract_from_messages.
|
|
934
|
+
|
|
935
|
+
Decay rules (all intentional):
|
|
936
|
+
- COMPLETED tasks: decay 15% per day after 3 days (they're done — less relevant)
|
|
937
|
+
- SUPERSEDED decisions: decay 30% per day (old dead branches)
|
|
938
|
+
- ERROR nodes (resolved): decay 20% per day
|
|
939
|
+
- ACTIVE decisions: NO decay (current choices always matter)
|
|
940
|
+
- GOALS: NO decay (always relevant for resume context)
|
|
941
|
+
- IN_PROGRESS tasks: slight decay 5% per day after 7 days (stale WIP)
|
|
942
|
+
|
|
943
|
+
Min importance floor = 0.1 (never fully disappear from graph)
|
|
944
|
+
Max decay per call = 50% of current value (prevents single-call wipeout)
|
|
945
|
+
|
|
946
|
+
Returns: dict of {node_id: new_importance} for changed nodes
|
|
947
|
+
"""
|
|
948
|
+
changed: dict[str, float] = {}
|
|
949
|
+
|
|
950
|
+
# Decay rates per day
|
|
951
|
+
_DECAY_RATE = {
|
|
952
|
+
# (status, type): daily_decay_fraction
|
|
953
|
+
(NodeStatus.COMPLETED, NodeType.TASK): 0.15,
|
|
954
|
+
(NodeStatus.COMPLETED, NodeType.ERROR): 0.20,
|
|
955
|
+
(NodeStatus.SUPERSEDED, NodeType.DECISION): 0.30,
|
|
956
|
+
(NodeStatus.ARCHIVED, NodeType.DECISION): 0.25,
|
|
957
|
+
(NodeStatus.FAILED, NodeType.TASK): 0.10,
|
|
958
|
+
(NodeStatus.IN_PROGRESS, NodeType.TASK): 0.05,
|
|
959
|
+
}
|
|
960
|
+
_NO_DECAY_TYPES = {NodeType.GOAL, NodeType.ENVIRONMENT, NodeType.SCHEMA}
|
|
961
|
+
_NO_DECAY_STATUSES = {NodeStatus.IN_PROGRESS, NodeStatus.PENDING}
|
|
962
|
+
|
|
963
|
+
for nid, node in self._nodes.items():
|
|
964
|
+
if node._evicted:
|
|
965
|
+
continue
|
|
966
|
+
# Never decay goals, environments, schemas
|
|
967
|
+
if node.type in _NO_DECAY_TYPES:
|
|
968
|
+
continue
|
|
969
|
+
# Never decay active decisions
|
|
970
|
+
if node.type == NodeType.DECISION and node.status == NodeStatus.COMPLETED:
|
|
971
|
+
continue
|
|
972
|
+
|
|
973
|
+
rate = _DECAY_RATE.get((node.status, node.type), 0.0)
|
|
974
|
+
if rate == 0.0:
|
|
975
|
+
continue
|
|
976
|
+
|
|
977
|
+
age_days = node.age_days()
|
|
978
|
+
|
|
979
|
+
# Grace period: no decay in first N days
|
|
980
|
+
grace = {
|
|
981
|
+
NodeType.TASK: 3.0,
|
|
982
|
+
NodeType.ERROR: 1.0,
|
|
983
|
+
}.get(node.type, 0.0)
|
|
984
|
+
|
|
985
|
+
if age_days <= grace:
|
|
986
|
+
continue
|
|
987
|
+
|
|
988
|
+
# Apply decay: importance *= (1 - rate) ^ days_since_grace
|
|
989
|
+
effective_days = age_days - grace
|
|
990
|
+
decay_factor = max(0.5, (1.0 - rate) ** effective_days)
|
|
991
|
+
new_importance = max(0.10, round(node.importance * decay_factor, 3))
|
|
992
|
+
|
|
993
|
+
if abs(new_importance - node.importance) > 0.005:
|
|
994
|
+
node.importance = new_importance
|
|
995
|
+
changed[nid] = new_importance
|
|
996
|
+
self._dirty = True
|
|
997
|
+
|
|
998
|
+
return changed
|
|
999
|
+
|
|
1000
|
+
def prune(
|
|
1001
|
+
self,
|
|
1002
|
+
max_nodes: int = 200,
|
|
1003
|
+
max_age_days: float = 60.0,
|
|
1004
|
+
) -> int:
|
|
1005
|
+
"""Remove low-importance, old, completed nodes. Preserve decisions, envs, goals."""
|
|
1006
|
+
preserve_types = {NodeType.GOAL, NodeType.SCHEMA}
|
|
1007
|
+
# Decisions are kept even when old — history matters
|
|
1008
|
+
# But ARCHIVED/SUPERSEDED decisions can be pruned after max_age_days
|
|
1009
|
+
cutoff = time.time() - max_age_days * 86400
|
|
1010
|
+
# Superseded decisions expire faster (30 days default)
|
|
1011
|
+
superseded_cutoff = time.time() - min(max_age_days, 30) * 86400
|
|
1012
|
+
candidates: list[tuple[float, str]] = []
|
|
1013
|
+
|
|
1014
|
+
for nid, node in self._nodes.items():
|
|
1015
|
+
if node.type in preserve_types:
|
|
1016
|
+
continue
|
|
1017
|
+
# ACTIVE decisions and environments: keep unless very old
|
|
1018
|
+
if node.type in (NodeType.DECISION, NodeType.ENVIRONMENT):
|
|
1019
|
+
if node.status == NodeStatus.COMPLETED and node.updated_at < cutoff:
|
|
1020
|
+
score = node.importance * 0.1 # low score = prune first
|
|
1021
|
+
candidates.append((score, nid))
|
|
1022
|
+
elif node.status in (NodeStatus.SUPERSEDED, NodeStatus.MODIFIED,
|
|
1023
|
+
NodeStatus.ARCHIVED) and node.updated_at < superseded_cutoff:
|
|
1024
|
+
candidates.append((0.0, nid)) # prune superseded decisions after 30d
|
|
1025
|
+
continue
|
|
1026
|
+
# All other nodes: prune if old and completed
|
|
1027
|
+
if node.status in (NodeStatus.COMPLETED, NodeStatus.FAILED,
|
|
1028
|
+
NodeStatus.ARCHIVED) and node.updated_at < cutoff:
|
|
1029
|
+
score = node.importance * (node.updated_at / (time.time() + 1))
|
|
1030
|
+
candidates.append((score, nid))
|
|
1031
|
+
|
|
1032
|
+
if len(self._nodes) <= max_nodes:
|
|
1033
|
+
return 0
|
|
1034
|
+
|
|
1035
|
+
candidates.sort()
|
|
1036
|
+
to_prune = len(self._nodes) - max_nodes
|
|
1037
|
+
|
|
1038
|
+
# If age-based pruning didn't find enough candidates (graph is fresh —
|
|
1039
|
+
# all nodes created recently), fall back to importance-only pruning.
|
|
1040
|
+
# This ensures the hard cap is always enforced even in long single-day sessions.
|
|
1041
|
+
if len(candidates) < to_prune:
|
|
1042
|
+
importance_candidates = [
|
|
1043
|
+
(node.importance, nid)
|
|
1044
|
+
for nid, node in self._nodes.items()
|
|
1045
|
+
if node.type not in preserve_types
|
|
1046
|
+
and node.type != NodeType.DECISION
|
|
1047
|
+
and nid not in {nid for _, nid in candidates}
|
|
1048
|
+
]
|
|
1049
|
+
importance_candidates.sort() # lowest importance first
|
|
1050
|
+
candidates.extend(importance_candidates)
|
|
1051
|
+
|
|
1052
|
+
pruned = 0
|
|
1053
|
+
|
|
1054
|
+
for _, nid in candidates[:to_prune]:
|
|
1055
|
+
del self._nodes[nid]
|
|
1056
|
+
self._edges = [e for e in self._edges
|
|
1057
|
+
if e.source_id != nid and e.target_id != nid]
|
|
1058
|
+
pruned += 1
|
|
1059
|
+
|
|
1060
|
+
if pruned:
|
|
1061
|
+
self._persist(force=True)
|
|
1062
|
+
logger.info(f"Graph pruned {pruned} nodes for session {self.session_id}")
|
|
1063
|
+
|
|
1064
|
+
return pruned
|
|
1065
|
+
|
|
1066
|
+
# ── Context block ────────────────────────────────────────────────────────
|
|
1067
|
+
|
|
1068
|
+
def to_context_block(self, token_budget: int = 400) -> str:
|
|
1069
|
+
"""
|
|
1070
|
+
Build tiered resume context block for LLM injection.
|
|
1071
|
+
|
|
1072
|
+
Priority order (truncates from bottom if over budget):
|
|
1073
|
+
1. Goal — always shown (anchor)
|
|
1074
|
+
2. In-progress tasks — sorted by importance (current focus)
|
|
1075
|
+
3. Recent completed tasks — top 5 by recency, not all 50
|
|
1076
|
+
4. Active decisions — top 6 by importance, with rationale
|
|
1077
|
+
5. Recent decision changes — transition summary (not strikethrough waste)
|
|
1078
|
+
6. Pending tasks — what's next
|
|
1079
|
+
7. Files touched — context for file-specific questions
|
|
1080
|
+
8. Environment — versions, if present
|
|
1081
|
+
9. Open errors — unresolved failures
|
|
1082
|
+
|
|
1083
|
+
Quality rules applied:
|
|
1084
|
+
- SUPERSEDED decisions: shown only as "Changed X → Y" one-liner
|
|
1085
|
+
(not full label — wastes tokens showing wrong answer)
|
|
1086
|
+
- Completed tasks: importance-weighted, capped at 5 most recent
|
|
1087
|
+
(full history is in SQLite, not needed in resume)
|
|
1088
|
+
- Similar nodes: deduplicated by normalized label prefix
|
|
1089
|
+
- Transitions: shown as compact lines, not repeated decision labels
|
|
1090
|
+
"""
|
|
1091
|
+
sections: list[str] = []
|
|
1092
|
+
|
|
1093
|
+
# ── 1. Goal ──────────────────────────────────────────────────────────
|
|
1094
|
+
goals = sorted(
|
|
1095
|
+
[n for n in self._nodes.values()
|
|
1096
|
+
if n.type == NodeType.GOAL and not n._evicted],
|
|
1097
|
+
key=lambda x: x.importance, reverse=True
|
|
1098
|
+
)
|
|
1099
|
+
if goals:
|
|
1100
|
+
sections.append("Goal: " + " | ".join(g.label for g in goals[:2]))
|
|
1101
|
+
|
|
1102
|
+
# ── 2. In-progress tasks ──────────────────────────────────────────────
|
|
1103
|
+
open_tasks = sorted(
|
|
1104
|
+
[n for n in self._nodes.values()
|
|
1105
|
+
if n.type == NodeType.TASK
|
|
1106
|
+
and n.status == NodeStatus.IN_PROGRESS
|
|
1107
|
+
and not n._evicted],
|
|
1108
|
+
key=lambda x: x.importance, reverse=True
|
|
1109
|
+
)
|
|
1110
|
+
# ── 3. Pending tasks (next steps) ─────────────────────────────────────
|
|
1111
|
+
pending_tasks = sorted(
|
|
1112
|
+
[n for n in self._nodes.values()
|
|
1113
|
+
if n.type == NodeType.TASK
|
|
1114
|
+
and n.status == NodeStatus.PENDING
|
|
1115
|
+
and not n._evicted],
|
|
1116
|
+
key=lambda x: x.importance, reverse=True
|
|
1117
|
+
)
|
|
1118
|
+
current_work = open_tasks[:4] + pending_tasks[:2]
|
|
1119
|
+
if current_work:
|
|
1120
|
+
sections.append("Working on: " + " | ".join(t.label for t in current_work))
|
|
1121
|
+
|
|
1122
|
+
# ── 4. Recent completed tasks — top 5 by recency+importance ───────────
|
|
1123
|
+
done = sorted(
|
|
1124
|
+
[n for n in self._nodes.values()
|
|
1125
|
+
if n.type == NodeType.TASK
|
|
1126
|
+
and n.status == NodeStatus.COMPLETED
|
|
1127
|
+
and not n._evicted],
|
|
1128
|
+
key=lambda x: (x.updated_at * 0.6 + x.importance * 0.4),
|
|
1129
|
+
reverse=True
|
|
1130
|
+
)
|
|
1131
|
+
# Deduplicate: skip if label is very similar to already-included task
|
|
1132
|
+
done_deduped = []
|
|
1133
|
+
seen_prefixes: set[str] = set()
|
|
1134
|
+
for t in done:
|
|
1135
|
+
prefix = self._normalize_label(t.label)[:20]
|
|
1136
|
+
if prefix not in seen_prefixes:
|
|
1137
|
+
done_deduped.append(t)
|
|
1138
|
+
seen_prefixes.add(prefix)
|
|
1139
|
+
if len(done_deduped) >= 6:
|
|
1140
|
+
break
|
|
1141
|
+
if done_deduped:
|
|
1142
|
+
sections.append("Done: " + " | ".join(t.label for t in done_deduped))
|
|
1143
|
+
|
|
1144
|
+
# ── 5. Active decisions — top 6 by importance ─────────────────────────
|
|
1145
|
+
decisions = sorted(
|
|
1146
|
+
[n for n in self._nodes.values()
|
|
1147
|
+
if n.type == NodeType.DECISION
|
|
1148
|
+
and n.status == NodeStatus.COMPLETED
|
|
1149
|
+
and not n._evicted],
|
|
1150
|
+
key=lambda x: x.importance, reverse=True
|
|
1151
|
+
)
|
|
1152
|
+
if decisions:
|
|
1153
|
+
parts = []
|
|
1154
|
+
for d in decisions[:6]:
|
|
1155
|
+
entry = d.label
|
|
1156
|
+
# Include brief rationale if not redundant with label
|
|
1157
|
+
if d.summary and "Superseded by" not in d.summary:
|
|
1158
|
+
entry += f" ({d.summary[:50]})"
|
|
1159
|
+
parts.append(entry)
|
|
1160
|
+
sections.append("Decided: " + " | ".join(parts))
|
|
1161
|
+
|
|
1162
|
+
# ── 6. Decision transitions — compact, no wasted tokens on wrong answer ─
|
|
1163
|
+
# Show as "Changed X → Y" not full old label — the old label is wrong,
|
|
1164
|
+
# showing it in full wastes tokens and risks LLM being confused about
|
|
1165
|
+
# which is current.
|
|
1166
|
+
recent_transitions = sorted(
|
|
1167
|
+
self._transitions,
|
|
1168
|
+
key=lambda t: t.timestamp, reverse=True
|
|
1169
|
+
)[:3]
|
|
1170
|
+
if recent_transitions:
|
|
1171
|
+
lines = [t.to_context_line() for t in recent_transitions]
|
|
1172
|
+
sections.append("Changes: " + " | ".join(lines))
|
|
1173
|
+
elif any(
|
|
1174
|
+
n.type == NodeType.DECISION
|
|
1175
|
+
and n.status == NodeStatus.SUPERSEDED
|
|
1176
|
+
and n.age_days() < 3
|
|
1177
|
+
and not n._evicted
|
|
1178
|
+
for n in self._nodes.values()
|
|
1179
|
+
):
|
|
1180
|
+
# No transition object but recent supersede — note count only, no label
|
|
1181
|
+
# (showing the old wrong label wastes tokens and risks LLM confusion)
|
|
1182
|
+
changed_count = sum(
|
|
1183
|
+
1 for n in self._nodes.values()
|
|
1184
|
+
if n.type == NodeType.DECISION
|
|
1185
|
+
and n.status == NodeStatus.SUPERSEDED
|
|
1186
|
+
and n.age_days() < 3
|
|
1187
|
+
and not n._evicted
|
|
1188
|
+
)
|
|
1189
|
+
sections.append(f"Note: {changed_count} decision(s) changed recently — see graph history")
|
|
1190
|
+
|
|
1191
|
+
# ── 7. Invalidated decisions — always warn ─────────────────────────────
|
|
1192
|
+
invalidated = [
|
|
1193
|
+
n for n in self._nodes.values()
|
|
1194
|
+
if n.type == NodeType.DECISION
|
|
1195
|
+
and n.status == NodeStatus.INVALIDATED
|
|
1196
|
+
and not n._evicted
|
|
1197
|
+
]
|
|
1198
|
+
if invalidated:
|
|
1199
|
+
sections.append(
|
|
1200
|
+
"Avoid: " + " | ".join(f"[DO NOT USE] {n.label[:40]}" for n in invalidated[:2])
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
# ── 8. Files ──────────────────────────────────────────────────────────
|
|
1204
|
+
files = sorted(
|
|
1205
|
+
[n for n in self._nodes.values()
|
|
1206
|
+
if n.type == NodeType.FILE and not n._evicted],
|
|
1207
|
+
key=lambda x: x.importance, reverse=True
|
|
1208
|
+
)
|
|
1209
|
+
if files:
|
|
1210
|
+
sections.append("Files: " + ", ".join(f.label for f in files[:10]))
|
|
1211
|
+
|
|
1212
|
+
# ── 9. Environment ────────────────────────────────────────────────────
|
|
1213
|
+
env_nodes = [
|
|
1214
|
+
n for n in self._nodes.values()
|
|
1215
|
+
if n.type == NodeType.ENVIRONMENT and not n._evicted
|
|
1216
|
+
]
|
|
1217
|
+
if env_nodes:
|
|
1218
|
+
sections.append("Env: " + ", ".join(e.label for e in env_nodes[:4]))
|
|
1219
|
+
|
|
1220
|
+
# ── 10. Open errors ───────────────────────────────────────────────────
|
|
1221
|
+
errors = sorted(
|
|
1222
|
+
[n for n in self._nodes.values()
|
|
1223
|
+
if n.type == NodeType.ERROR
|
|
1224
|
+
and n.status == NodeStatus.FAILED
|
|
1225
|
+
and not n._evicted],
|
|
1226
|
+
key=lambda x: x.importance, reverse=True
|
|
1227
|
+
)
|
|
1228
|
+
if errors:
|
|
1229
|
+
sections.append("Open issues: " + " | ".join(e.label for e in errors[:3]))
|
|
1230
|
+
|
|
1231
|
+
block = "\n".join(sections)
|
|
1232
|
+
|
|
1233
|
+
# Trim to budget — count once, char-estimate for loop, exact verify at end
|
|
1234
|
+
from tokenmizer.core.tokenizer import count_tokens
|
|
1235
|
+
total_tokens = count_tokens(block)
|
|
1236
|
+
if total_tokens > token_budget and sections:
|
|
1237
|
+
|
|
1238
|
+
chars_per_token = len(block) / max(total_tokens, 1)
|
|
1239
|
+
target_chars = int(token_budget * chars_per_token * 0.92) # 8% safety buffer
|
|
1240
|
+
while len("\n".join(sections)) > target_chars and sections:
|
|
1241
|
+
sections.pop()
|
|
1242
|
+
block = "\n".join(sections)
|
|
1243
|
+
# One final accurate tiktoken verify — trim one more section if still over
|
|
1244
|
+
if sections and count_tokens(block) > token_budget:
|
|
1245
|
+
sections.pop()
|
|
1246
|
+
block = "\n".join(sections)
|
|
1247
|
+
|
|
1248
|
+
return block
|
|
1249
|
+
|
|
1250
|
+
# ── Stats ────────────────────────────────────────────────────────────────
|
|
1251
|
+
|
|
1252
|
+
def stats(self) -> dict:
|
|
1253
|
+
from tokenmizer.core.dto import GraphStatsDTO
|
|
1254
|
+
by_type: dict[str, int] = {}
|
|
1255
|
+
by_status: dict[str, int] = {}
|
|
1256
|
+
confidences: list[float] = []
|
|
1257
|
+
for n in self._nodes.values():
|
|
1258
|
+
by_type[n.type.value] = by_type.get(n.type.value, 0) + 1
|
|
1259
|
+
by_status[n.status.value] = by_status.get(n.status.value, 0) + 1
|
|
1260
|
+
confidences.append(n.confidence)
|
|
1261
|
+
avg_confidence = round(sum(confidences) / max(1, len(confidences)), 3)
|
|
1262
|
+
dto = GraphStatsDTO(
|
|
1263
|
+
session_id=self.session_id,
|
|
1264
|
+
node_count=len(self._nodes),
|
|
1265
|
+
edge_count=len(self._edges),
|
|
1266
|
+
by_type=by_type,
|
|
1267
|
+
by_status=by_status,
|
|
1268
|
+
processed_messages=len(self._processed_hashes),
|
|
1269
|
+
avg_confidence=avg_confidence,
|
|
1270
|
+
decision_tracking_failures=self._decision_tracking_failures,
|
|
1271
|
+
persistence_broken=self._persistence_broken,
|
|
1272
|
+
)
|
|
1273
|
+
# Return as dict for JSON serialization — DTO used for type safety at boundary
|
|
1274
|
+
from dataclasses import asdict
|
|
1275
|
+
return asdict(dto)
|
|
1276
|
+
|
|
1277
|
+
# ── Visualization exports (see visualization.py) ──────────────────────────
|
|
1278
|
+
|
|
1279
|
+
def to_vis_json(self) -> dict:
|
|
1280
|
+
"""D3-compatible JSON export. Full implementation in visualization.py."""
|
|
1281
|
+
from tokenmizer.graph_memory.visualization import to_vis_json
|
|
1282
|
+
return to_vis_json(self)
|
|
1283
|
+
|
|
1284
|
+
def to_obsidian_canvas(self) -> dict:
|
|
1285
|
+
"""Obsidian Canvas export. Full implementation in visualization.py."""
|
|
1286
|
+
from tokenmizer.graph_memory.visualization import to_obsidian_canvas
|
|
1287
|
+
return to_obsidian_canvas(self)
|