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.
- graph_engine/__init__.py +1 -0
- graph_engine/__main__.py +1275 -0
- graph_engine/access.py +202 -0
- graph_engine/agent_memory.py +103 -0
- graph_engine/brain.py +731 -0
- graph_engine/brain_engine.py +559 -0
- graph_engine/communities.py +503 -0
- graph_engine/consolidation.py +191 -0
- graph_engine/context.py +203 -0
- graph_engine/decay.py +65 -0
- graph_engine/demo.py +165 -0
- graph_engine/dream.py +408 -0
- graph_engine/embedder.py +84 -0
- graph_engine/engine.py +58 -0
- graph_engine/entities.py +376 -0
- graph_engine/evals.py +1206 -0
- graph_engine/feedback.py +155 -0
- graph_engine/gaps.py +209 -0
- graph_engine/graph.py +133 -0
- graph_engine/hygiene.py +213 -0
- graph_engine/inference.py +251 -0
- graph_engine/intent.py +207 -0
- graph_engine/mcp/__init__.py +1 -0
- graph_engine/mcp/agent/instructions.md +34 -0
- graph_engine/mcp/format.py +66 -0
- graph_engine/mcp/server.py +407 -0
- graph_engine/merge.py +220 -0
- graph_engine/model.py +56 -0
- graph_engine/namespaces.py +74 -0
- graph_engine/recall.py +161 -0
- graph_engine/report.py +401 -0
- graph_engine/reranker.py +86 -0
- graph_engine/retrieval.py +296 -0
- graph_engine/review.py +95 -0
- graph_engine/runtime.py +83 -0
- graph_engine/server.py +239 -0
- graph_engine/similarity.py +42 -0
- graph_engine/store.py +63 -0
- graph_engine/suggester.py +63 -0
- graph_engine/temporal.py +216 -0
- graph_engine/tiers.py +135 -0
- graph_engine/web/app.js +578 -0
- graph_engine/web/index.html +169 -0
- graph_engine/web/report.html +39 -0
- graph_engine/web/report.js +50 -0
- graph_engine/web/review.html +104 -0
- graph_engine/web/review.js +211 -0
- graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
- graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
- graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
- graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
- graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
- 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")
|