graph-engine-memory 0.6.1__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 (53) hide show
  1. graph_engine/__init__.py +1 -0
  2. graph_engine/__main__.py +1275 -0
  3. graph_engine/access.py +202 -0
  4. graph_engine/agent_memory.py +103 -0
  5. graph_engine/brain.py +731 -0
  6. graph_engine/brain_engine.py +559 -0
  7. graph_engine/communities.py +503 -0
  8. graph_engine/consolidation.py +191 -0
  9. graph_engine/context.py +203 -0
  10. graph_engine/decay.py +65 -0
  11. graph_engine/demo.py +165 -0
  12. graph_engine/dream.py +408 -0
  13. graph_engine/embedder.py +84 -0
  14. graph_engine/engine.py +58 -0
  15. graph_engine/entities.py +376 -0
  16. graph_engine/evals.py +1206 -0
  17. graph_engine/feedback.py +155 -0
  18. graph_engine/gaps.py +209 -0
  19. graph_engine/graph.py +133 -0
  20. graph_engine/hygiene.py +213 -0
  21. graph_engine/inference.py +251 -0
  22. graph_engine/intent.py +207 -0
  23. graph_engine/mcp/__init__.py +1 -0
  24. graph_engine/mcp/agent/instructions.md +34 -0
  25. graph_engine/mcp/format.py +66 -0
  26. graph_engine/mcp/server.py +407 -0
  27. graph_engine/merge.py +220 -0
  28. graph_engine/model.py +56 -0
  29. graph_engine/namespaces.py +74 -0
  30. graph_engine/recall.py +161 -0
  31. graph_engine/report.py +401 -0
  32. graph_engine/reranker.py +86 -0
  33. graph_engine/retrieval.py +296 -0
  34. graph_engine/review.py +95 -0
  35. graph_engine/runtime.py +83 -0
  36. graph_engine/server.py +239 -0
  37. graph_engine/similarity.py +42 -0
  38. graph_engine/store.py +63 -0
  39. graph_engine/suggester.py +63 -0
  40. graph_engine/temporal.py +216 -0
  41. graph_engine/tiers.py +135 -0
  42. graph_engine/web/app.js +578 -0
  43. graph_engine/web/index.html +169 -0
  44. graph_engine/web/report.html +39 -0
  45. graph_engine/web/report.js +50 -0
  46. graph_engine/web/review.html +104 -0
  47. graph_engine/web/review.js +211 -0
  48. graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
  49. graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
  50. graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
  51. graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
  52. graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
  53. graph_engine_memory-0.6.1.dist-info/top_level.txt +1 -0
graph_engine/merge.py ADDED
@@ -0,0 +1,220 @@
1
+ """Manual near-duplicate consolidation (`ig merge`).
2
+
3
+ Consolidates two closely related nodes into one: all edges of the node
4
+ being removed are redirected to the survivor (deduplicated, no
5
+ self-loops), the text is appended to preserve information, and the
6
+ removed node is marked as a tombstone (never hard-deleted — the
7
+ graph remains an append-only history). INDEX.md is rebuilt and everything
8
+ is written in ONE commit.
9
+
10
+ Audit #3/#9/#41 fixes:
11
+ - The deletee is tombstoned instead of being set to probation, and its file
12
+ is no longer deleted before the edge rewrite (half-written state
13
+ on a crash mid-merge).
14
+ - Edges with intent semantics (`supersedes`, `contradicts`, `continues`,
15
+ `replaces`, …) are NOT blindly redirected: a deletee→X `supersedes`
16
+ edge says "the deletee supersedes X" — after the merge the survivor is the
17
+ continuation of the deletee's content, so the edge is redirected to
18
+ survivor→X ONLY if the direction stays semantically valid.
19
+ For supersedes/replaces/contradicts (deletee as source) the edge
20
+ is invalidated instead (valid_to set) rather than making a possibly
21
+ wrong statement about the survivor; extends/similar/linked edges
22
+ are direction-neutral enough for a redirect.
23
+ - Merge provenance: the merge commit and the survivor node document
24
+ the deletee ID and timestamp; the deletee's status remains traceable
25
+ as a tombstone.
26
+
27
+ This is the manual complement to automatic ingest dedup (cos >= 0.92):
28
+ near-duplicates in the ~0.78–0.92 range stay below the auto threshold and
29
+ need this consolidation. Destructive — use with care, ideally after
30
+ a dry run on a brain copy.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ from dataclasses import dataclass, field
36
+ from datetime import datetime, timezone
37
+ from pathlib import Path
38
+
39
+ from .brain import Brain
40
+
41
+ # Intent edges with directed semantics: a redirect deletee→X to
42
+ # survivor→X would invert the statement (Audit #9). They are invalidated
43
+ # instead of redirected. Direction-neutral kinds (similar/extends/same_as/…)
44
+ # are redirected normally.
45
+ DIRECTIONAL_KINDS = frozenset({
46
+ "supersedes", "ersetzt", "contradicts", "replaces", "obsoletes",
47
+ "korrigiert", "widerspricht",
48
+ })
49
+
50
+
51
+ @dataclass
52
+ class MergeResult:
53
+ survivor: str
54
+ deletee: str
55
+ edges_redirected: int
56
+ edges_removed: int # self-loops + duplicate pairs
57
+ edges_invalidated: int = 0 # directed intent edges (not redirectable)
58
+ redirected_ids: list[str] = field(default_factory=list)
59
+ invalidated_ids: list[str] = field(default_factory=list)
60
+
61
+
62
+ def _redirect_edges(edges: list, survivor: str, deletee: str,
63
+ now_iso: str) -> tuple[list, int, int, int, list[str], list[str]]:
64
+ """Redirect the deletee's edges to the survivor; removes self-loops + duplicates.
65
+
66
+ Directed intent edges (DIRECTIONAL_KINDS) with the deletee as source are
67
+ invalidated (valid_to set) instead of redirected — the survivor has not
68
+ necessarily inherited the statement "supersedes X" (Audit #9).
69
+ """
70
+ new_edges: list = []
71
+ seen: set[tuple] = set()
72
+ removed = 0
73
+ redirected = 0
74
+ invalidated = 0
75
+ redirected_ids: list[str] = []
76
+ invalidated_ids: list[str] = []
77
+ for e in edges:
78
+ if e.kind == "fact":
79
+ # Entity identities cannot be text-merged (guard below). Preserve
80
+ # all fact predicates, directions, validity windows and historical
81
+ # evidence references when unrelated semantic notes are merged.
82
+ new_edges.append(e)
83
+ continue
84
+ s, t = e.source, e.target
85
+ if s == deletee or t == deletee:
86
+ if e.kind in DIRECTIONAL_KINDS and s == deletee:
87
+ # The statement no longer holds about the survivor — historicize the edge.
88
+ if not e.is_invalidated:
89
+ e.invalidate(now_iso)
90
+ invalidated += 1
91
+ invalidated_ids.append(e.id)
92
+ else:
93
+ removed += 1
94
+ new_edges.append(e)
95
+ continue
96
+ if s == deletee:
97
+ s = survivor
98
+ redirected += 1
99
+ if t == deletee:
100
+ t = survivor
101
+ redirected += 1
102
+ redirected_ids.append(e.id)
103
+ if s == t: # self-loop after merge
104
+ removed += 1
105
+ continue
106
+ key = tuple(sorted((s, t)))
107
+ if key in seen: # duplicate pair (survivor already had that target)
108
+ removed += 1
109
+ continue
110
+ seen.add(key)
111
+ e.source, e.target = s, t
112
+ new_edges.append(e)
113
+ return new_edges, removed, redirected, invalidated, redirected_ids, invalidated_ids
114
+
115
+
116
+ def _drop_vector(brain: Brain, deletee: str) -> None:
117
+ vec_file: Path = brain.path / "vectors.jsonl"
118
+ if not vec_file.exists():
119
+ return
120
+ lines = [l for l in vec_file.read_text(encoding="utf-8").splitlines() if l.strip()]
121
+ keep = [l for l in lines if json.loads(l).get("id") != deletee]
122
+ brain.write_vectors({json.loads(l)["id"]: json.loads(l)["vec"] for l in keep})
123
+
124
+
125
+ def merge_nodes(
126
+ brain: Brain,
127
+ survivor_id: str,
128
+ deletee_id: str,
129
+ commit: bool = True,
130
+ embedder=None,
131
+ ) -> MergeResult:
132
+ """Consolidates the deletee into the survivor. Destructive; one commit.
133
+
134
+ embedder (optional): when given, the survivor vector is recomputed after
135
+ the text append — without it the old vector would remain and search
136
+ would dedupe/rank against the PRE-merge text (audit finding
137
+ "survivor vector stale"). The CLI always passes the engine embedder.
138
+ """
139
+ brain.authorize("admin")
140
+ if survivor_id == deletee_id:
141
+ raise ValueError("Survivor and deletee are identical.")
142
+ # Git mode: repo existence + fresh pull BEFORE the mutation — otherwise
143
+ # the merge can work on a stale state and the history diverges
144
+ # (audit finding "merge_nodes makes no pull()").
145
+ brain.ensure_ready()
146
+ brain.pull()
147
+ nodes = {n.id: n for n in brain.read_nodes()}
148
+ if survivor_id not in nodes:
149
+ raise ValueError(f"Node not found: {survivor_id}")
150
+ if deletee_id not in nodes:
151
+ raise ValueError(f"Node not found: {deletee_id}")
152
+ survivor, deletee = nodes[survivor_id], nodes[deletee_id]
153
+ if survivor.ntype == "entity" or deletee.ntype == "entity":
154
+ raise ValueError("Entity identities cannot be merged as near-duplicate text; use explicit aliases.")
155
+ now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
156
+
157
+ # Redirect + deduplicate edges — BEFORE deleting the deletee file
158
+ # (Audit #3: previously the node file was unlinked first; a crash
159
+ # between unlink and edge rewrite left a half-merged state with
160
+ # dangling edges pointing to a no-longer-existing node).
161
+ edges = brain.read_edges(include_rejected=False)
162
+ new_edges, removed, redirected, invalidated, redirected_ids, invalidated_ids = \
163
+ _redirect_edges(edges, survivor_id, deletee_id, now_iso)
164
+ # Keep unrelated dismissed suggestions available for undo. Decisions about
165
+ # the deleted node cannot be restored after consolidation.
166
+ rejected = [e for e in brain.read_edges(include_rejected=True)
167
+ if e.rejected and deletee_id not in (e.source, e.target)]
168
+ brain.write_edges(new_edges + rejected)
169
+
170
+ # Merge text (information preservation) — with merge provenance (Audit #41)
171
+ survivor.text = (
172
+ survivor.text.rstrip()
173
+ + f"\n\n[consolidated from {deletee_id} on {now_iso}: {deletee.text.strip()}]"
174
+ )
175
+ brain.write_node(survivor)
176
+
177
+ # Tombstone the deletee (never hard-delete — Audit #3: previously unlink
178
+ # + status loss, dangling edges could point to a "living" probation node)
179
+ brain.tombstone_node(deletee_id)
180
+
181
+ # Remove vector
182
+ _drop_vector(brain, deletee_id)
183
+
184
+ # Refresh the survivor vector: the text has grown, the old vector
185
+ # represents the pre-merge text (audit finding: "survivor vector stale").
186
+ if embedder is not None:
187
+ normalized = " ".join(survivor.text.lower().split())
188
+ vecs = brain.read_vectors()
189
+ vecs[survivor_id] = list(embedder.embed(normalized))
190
+ brain.write_vectors(vecs)
191
+
192
+ # Rebuild INDEX
193
+ brain.rebuild_index()
194
+
195
+ if commit:
196
+ try:
197
+ brain.commit_and_push(
198
+ f"merge: {deletee_id} consolidated into {survivor_id} "
199
+ f"({redirected} redirected, {invalidated} invalidated, {removed} removed)")
200
+ except Exception:
201
+ # Audit #31: the merge mutations are on disk but uncommitted —
202
+ # heal the derived index and raise an actionable error.
203
+ try:
204
+ brain.rebuild_index()
205
+ except Exception:
206
+ pass
207
+ raise RuntimeError(
208
+ "merge applied but commit/push failed — changes are on disk "
209
+ "UNCOMMITTED. Recovery: `git -C <brain> add -A && git commit` "
210
+ "or retry the merge (it is idempotent against the merged state).") from None
211
+
212
+ return MergeResult(
213
+ survivor=survivor_id,
214
+ deletee=deletee_id,
215
+ edges_redirected=redirected,
216
+ edges_removed=removed,
217
+ edges_invalidated=invalidated,
218
+ redirected_ids=redirected_ids,
219
+ invalidated_ids=invalidated_ids,
220
+ )
graph_engine/model.py ADDED
@@ -0,0 +1,56 @@
1
+ """Core data model: nodes (ideas) and typed edges.
2
+
3
+ Edges carry a type: "similar", "contradicts", "extends".
4
+ Pending edges are suggestions from the growth loop and wait for a
5
+ human-in-the-loop decision (accept/discard).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ import uuid
12
+ from dataclasses import dataclass, field, asdict
13
+
14
+ EDGE_TYPES = ("similar", "contradicts", "extends")
15
+
16
+
17
+ def _now() -> float:
18
+ return time.time()
19
+
20
+
21
+ @dataclass
22
+ class Node:
23
+ text: str
24
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
25
+ created: float = field(default_factory=_now)
26
+
27
+ def to_dict(self) -> dict:
28
+ return asdict(self)
29
+
30
+ @classmethod
31
+ def from_dict(cls, d: dict) -> "Node":
32
+ return cls(text=d["text"], id=d["id"], created=d.get("created", 0.0))
33
+
34
+
35
+ @dataclass
36
+ class Edge:
37
+ source: str # node ID (new idea)
38
+ target: str # node ID (existing idea)
39
+ kind: str # one of EDGE_TYPES
40
+ pending: bool = True # suggestion until accepted/discarded
41
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
42
+ created: float = field(default_factory=_now)
43
+
44
+ def to_dict(self) -> dict:
45
+ return asdict(self)
46
+
47
+ @classmethod
48
+ def from_dict(cls, d: dict) -> "Edge":
49
+ return cls(
50
+ source=d["source"],
51
+ target=d["target"],
52
+ kind=d["kind"],
53
+ pending=d.get("pending", False),
54
+ id=d["id"],
55
+ created=d.get("created", 0.0),
56
+ )
@@ -0,0 +1,74 @@
1
+ """Separate brain directories with an atomic, persisted CLI selection."""
2
+ from __future__ import annotations
3
+
4
+ from contextvars import ContextVar
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+
9
+ from .brain import Brain, _atomic_write
10
+ from . import access
11
+
12
+
13
+ request_graph: ContextVar[str | None] = ContextVar("ig_graph", default=None)
14
+
15
+
16
+ def home() -> Path | None:
17
+ value = os.environ.get("IG_GRAPH_HOME", "")
18
+ return Path(value).expanduser().resolve() if value else None
19
+
20
+
21
+ def validate_name(name: str) -> str:
22
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", name):
23
+ raise ValueError("Graph name must contain 1-64 letters, digits, underscores or hyphens")
24
+ return name
25
+
26
+
27
+ def selected() -> str:
28
+ if request_graph.get() is not None:
29
+ return request_graph.get()
30
+ explicit = os.environ.get("IG_GRAPH", "")
31
+ root = home()
32
+ if explicit:
33
+ if root is None:
34
+ raise ValueError("IG_GRAPH requires IG_GRAPH_HOME")
35
+ return validate_name(explicit)
36
+ if root and (root / "selected").exists():
37
+ return validate_name((root / "selected").read_text(encoding="utf-8").strip())
38
+ return "default"
39
+
40
+
41
+ def graph_path(name: str) -> Path:
42
+ root = home()
43
+ if root is None:
44
+ raise ValueError("Set IG_GRAPH_HOME to use named graphs")
45
+ path = root / "graphs" / validate_name(name)
46
+ if path.resolve() != path or (root / "graphs").is_symlink():
47
+ raise ValueError("Graph directories must not be symlinks")
48
+ return path
49
+
50
+
51
+ def list_graphs() -> list[str]:
52
+ root = home()
53
+ if root is None:
54
+ raise ValueError("Set IG_GRAPH_HOME to use named graphs")
55
+ names = sorted(p.name for p in (root / "graphs").glob("*") if p.is_dir() and not p.is_symlink())
56
+ return [n for n in names if not access.policy_path() or access.permitted(n, access.principal(), "read")]
57
+
58
+
59
+ def switch_graph(name: str) -> None:
60
+ path = graph_path(name)
61
+ if access.policy_path():
62
+ access.require(name, access.principal(), "read")
63
+ if not path.is_dir():
64
+ raise ValueError("Graph does not exist; use ig graph create first")
65
+ _atomic_write(home() / "selected", name + "\n")
66
+
67
+
68
+ def create_graph(name: str) -> None:
69
+ path = graph_path(name)
70
+ if access.policy_path():
71
+ # Provision roles in the trusted policy before creating a namespace.
72
+ access.require(name, access.principal(), "admin")
73
+ path.mkdir(parents=True, exist_ok=False)
74
+ Brain(str(path), mode=os.environ.get("IG_BRAIN_MODE", "git")).init()
graph_engine/recall.py ADDED
@@ -0,0 +1,161 @@
1
+ """Recall tracking: what the memory is actually asked for.
2
+
3
+ A memory system that does not know which of its entries are ever retrieved
4
+ cannot decide what to keep, promote, or let decay — OpenClaw's dreaming
5
+ promotes a candidate only when it passed `minRecallCount` / `minUniqueQueries`,
6
+ and GraphEngine had no such signal at all (every node was equally "fresh"
7
+ forever, all 2120 stuck in `probation`).
8
+
9
+ Two halves, deliberately split so the READ path stays cheap and git-clean:
10
+
11
+ * `record(brain, query, node_ids)` — append ONE line to the local ledger
12
+ `<brain>/recalls.jsonl` (gitignored). No read-modify-write of node files,
13
+ no commit per search: a search must not dirty the brain.
14
+ * `aggregate(brain)` — folds the ledger into the derived counters on the
15
+ nodes (`recall_count`, `recall_queries`, `last_recalled`) and writes them
16
+ back in ONE pass. Called by the dream pass / `ig recall --aggregate`.
17
+
18
+ The counters are DERIVED data, like INDEX.md: the ledger is the source of
19
+ truth, the node fields are the queryable projection.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import pathlib
28
+ from collections import Counter
29
+
30
+ from .brain import Brain, Node
31
+
32
+ LEDGER_NAME = "recalls.jsonl"
33
+ # Distinct query fingerprints kept per node: enough for "was this asked for in
34
+ # many different ways?" (OpenClaw's query-diversity gate) without unbounded
35
+ # frontmatter growth.
36
+ MAX_QUERY_FINGERPRINTS = 25
37
+
38
+
39
+ def ledger_path(brain: Brain) -> pathlib.Path:
40
+ return pathlib.Path(brain.path) / LEDGER_NAME
41
+
42
+
43
+ def fingerprint(query: str) -> str:
44
+ norm = " ".join(query.lower().split())
45
+ return hashlib.sha1(norm.encode("utf-8")).hexdigest()[:10]
46
+
47
+
48
+ def record(brain: Brain, query: str, node_ids: list[str], ts: str | None = None) -> int:
49
+ """Append one recall event. Returns the number of node ids recorded."""
50
+ brain.authorize("read")
51
+ ids = [nid for nid in node_ids if nid and brain.can_access("write", nid)]
52
+ if not ids:
53
+ return 0
54
+ entry = {"ts": ts or _now(), "q": fingerprint(query), "ids": ids}
55
+ with open(ledger_path(brain), "a", encoding="utf-8") as fh:
56
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
57
+ return len(ids)
58
+
59
+
60
+ def read_ledger(brain: Brain) -> list[dict]:
61
+ brain.authorize("read")
62
+ path = ledger_path(brain)
63
+ if not path.exists():
64
+ return []
65
+ out = []
66
+ for line in path.read_text(encoding="utf-8").splitlines():
67
+ if line.strip():
68
+ try:
69
+ entry = json.loads(line)
70
+ entry["ids"] = [nid for nid in entry.get("ids", []) if brain.can_access("read", nid)]
71
+ if entry["ids"]:
72
+ out.append(entry)
73
+ except json.JSONDecodeError:
74
+ continue
75
+ return out
76
+
77
+
78
+ def aggregate(brain: Brain, *, dry_run: bool = False, commit: bool = True) -> dict:
79
+ """Fold the recall ledger into the node counters (one pass, one commit).
80
+
81
+ Per node: `recall_count` += occurrences, `recall_queries` = distinct query
82
+ fingerprints (capped), `last_recalled` = newest ledger timestamp. The ledger
83
+ is then MOVED to `<ledger>.processed` (audit trail) and truncated, so a
84
+ second run is idempotent instead of double-counting.
85
+ """
86
+ from .tiers import rebalance
87
+ brain.authorize("admin")
88
+ entries = read_ledger(brain)
89
+ if not entries:
90
+ rebalance(brain, dry_run=dry_run, commit=commit)
91
+ return {"nodes": 0, "recalls": 0, "ledger_entries": 0, "dry_run": dry_run}
92
+
93
+ counts: Counter = Counter()
94
+ queries: dict[str, set[str]] = {}
95
+ last: dict[str, str] = {}
96
+ for entry in entries:
97
+ ts, fp = entry.get("ts"), entry.get("q")
98
+ for nid in entry.get("ids", []):
99
+ counts[nid] += 1
100
+ if fp:
101
+ queries.setdefault(nid, set()).add(fp)
102
+ if ts and ts > last.get(nid, ""):
103
+ last[nid] = ts
104
+
105
+ nodes = {n.id: n for n in brain.read_nodes()}
106
+ touched = 0
107
+ for nid, increment in counts.items():
108
+ node = nodes.get(nid)
109
+ if node is None:
110
+ continue # recalled node was deleted/merged away since the ledger write
111
+ node.recall_count = getattr(node, "recall_count", 0) + increment
112
+ seen = list(getattr(node, "recall_queries", []))
113
+ for fp in sorted(queries.get(nid, set())):
114
+ if fp not in seen:
115
+ seen.append(fp)
116
+ node.recall_queries = seen[:MAX_QUERY_FINGERPRINTS]
117
+ node.last_recalled = max(filter(None, [getattr(node, "last_recalled", None),
118
+ last.get(nid)]), default=None)
119
+ if not dry_run:
120
+ brain.write_node(node)
121
+ touched += 1
122
+
123
+ if not dry_run:
124
+ # Move the ledger, THEN optionally commit. The move is bookkeeping that
125
+ # must happen whenever the counters were written — tying it to `commit`
126
+ # was a real bug (found live 2026-09-18): `dream.refresh()` batches the
127
+ # commit with `commit=False`, so the source entries stayed in place and
128
+ # EVERY pass folded them again (recall_count 1 -> 2 on a single real
129
+ # ledger line), inflating exactly the signal the promotion gates read.
130
+ path = ledger_path(brain)
131
+ processed = pathlib.Path(brain.path) / (LEDGER_NAME + ".processed")
132
+ with open(processed, "a", encoding="utf-8") as fh:
133
+ fh.write(path.read_text(encoding="utf-8"))
134
+ path.write_text("", encoding="utf-8")
135
+ rebalance(brain, commit=False)
136
+ if commit:
137
+ brain.commit_and_push(
138
+ f"recall: aggregate {sum(counts.values())} recalls into {touched} nodes")
139
+ return {"nodes": touched, "recalls": sum(counts.values()),
140
+ "ledger_entries": len(entries), "dry_run": dry_run}
141
+
142
+
143
+ def top(brain: Brain, n: int = 10) -> list[tuple[str, int]]:
144
+ """Most-recalled nodes (derived counters, no aggregation)."""
145
+ counts = Counter()
146
+ for node in brain.read_nodes():
147
+ if getattr(node, "recall_count", 0):
148
+ counts[node.id] = node.recall_count
149
+ return counts.most_common(n)
150
+
151
+
152
+ def _now() -> str:
153
+ import datetime
154
+ return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
155
+
156
+
157
+ def tracking_enabled(env: dict[str, str] | None = None) -> bool:
158
+ """Recall tracking is on unless explicitly disabled (`IG_NO_RECALL_TRACKING=1`)."""
159
+ raw = (env or {}).get("IG_NO_RECALL_TRACKING",
160
+ os.environ.get("IG_NO_RECALL_TRACKING", ""))
161
+ return str(raw).lower() not in ("1", "true", "yes")