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.
Files changed (50) hide show
  1. tokenmizer/__init__.py +21 -0
  2. tokenmizer/agents/__init__.py +0 -0
  3. tokenmizer/analytics/__init__.py +0 -0
  4. tokenmizer/analytics/engine.py +188 -0
  5. tokenmizer/api/__init__.py +0 -0
  6. tokenmizer/api/app.py +958 -0
  7. tokenmizer/api/rate_limiter.py +110 -0
  8. tokenmizer/checkpoints/__init__.py +0 -0
  9. tokenmizer/checkpoints/manager.py +383 -0
  10. tokenmizer/cli.py +153 -0
  11. tokenmizer/compression/__init__.py +0 -0
  12. tokenmizer/compression/engine.py +669 -0
  13. tokenmizer/compression/output_trimmer.py +95 -0
  14. tokenmizer/compression/window.py +104 -0
  15. tokenmizer/config/__init__.py +0 -0
  16. tokenmizer/config/settings.py +170 -0
  17. tokenmizer/core/__init__.py +0 -0
  18. tokenmizer/core/dto.py +196 -0
  19. tokenmizer/core/errors.py +35 -0
  20. tokenmizer/core/tokenizer.py +96 -0
  21. tokenmizer/dashboard/__init__.py +0 -0
  22. tokenmizer/dashboard/page.py +267 -0
  23. tokenmizer/filters/__init__.py +0 -0
  24. tokenmizer/filters/file_intelligence.py +960 -0
  25. tokenmizer/graph_memory/__init__.py +0 -0
  26. tokenmizer/graph_memory/decision_tracker.py +225 -0
  27. tokenmizer/graph_memory/graph.py +1287 -0
  28. tokenmizer/graph_memory/helpers.py +121 -0
  29. tokenmizer/graph_memory/hybrid_extractor.py +703 -0
  30. tokenmizer/graph_memory/types.py +134 -0
  31. tokenmizer/graph_memory/validator.py +304 -0
  32. tokenmizer/graph_memory/visualization.py +228 -0
  33. tokenmizer/mcp/__init__.py +0 -0
  34. tokenmizer/mcp/server.py +368 -0
  35. tokenmizer/providers/__init__.py +0 -0
  36. tokenmizer/providers/providers.py +456 -0
  37. tokenmizer/security/__init__.py +0 -0
  38. tokenmizer/security/auth.py +95 -0
  39. tokenmizer/security/middleware.py +138 -0
  40. tokenmizer/security/redaction.py +126 -0
  41. tokenmizer/semantic_cache/__init__.py +0 -0
  42. tokenmizer/semantic_cache/cache.py +383 -0
  43. tokenmizer/state/__init__.py +0 -0
  44. tokenmizer/state/backend.py +137 -0
  45. tokenmizer/storage/__init__.py +56 -0
  46. tokenmizer-0.2.4.dist-info/METADATA +529 -0
  47. tokenmizer-0.2.4.dist-info/RECORD +50 -0
  48. tokenmizer-0.2.4.dist-info/WHEEL +4 -0
  49. tokenmizer-0.2.4.dist-info/entry_points.txt +2 -0
  50. 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)