anchor-memory 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anchor/__init__.py +3 -0
- anchor/archive.py +72 -0
- anchor/bench.py +249 -0
- anchor/extract.py +82 -0
- anchor/index.py +126 -0
- anchor/ladder.py +342 -0
- anchor/mcp_server.py +326 -0
- anchor/ollama_client.py +30 -0
- anchor/replay.py +201 -0
- anchor/selftest.py +114 -0
- anchor/trigger.py +54 -0
- anchor_memory-0.7.0.dist-info/METADATA +116 -0
- anchor_memory-0.7.0.dist-info/RECORD +17 -0
- anchor_memory-0.7.0.dist-info/WHEEL +5 -0
- anchor_memory-0.7.0.dist-info/entry_points.txt +2 -0
- anchor_memory-0.7.0.dist-info/licenses/LICENSE +21 -0
- anchor_memory-0.7.0.dist-info/top_level.txt +1 -0
anchor/__init__.py
ADDED
anchor/archive.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Archive — the mm side. Append-only JSONL of raw session events, written
|
|
2
|
+
BEFORE compaction can destroy anything. Every record is citable by id."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from . import extract
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Archive:
|
|
11
|
+
def __init__(self, path):
|
|
12
|
+
self.path = path
|
|
13
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
14
|
+
self._n = 0
|
|
15
|
+
if os.path.exists(path): # resume ids after restart
|
|
16
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
17
|
+
for line in f:
|
|
18
|
+
if line.strip():
|
|
19
|
+
self._n += 1
|
|
20
|
+
self._fh = open(path, "a", encoding="utf-8")
|
|
21
|
+
|
|
22
|
+
def append(self, kind, text, task="", compaction=0, entities=None, ts=None, **extra):
|
|
23
|
+
"""Record one raw event. Entities auto-extracted when not given.
|
|
24
|
+
Extra keyword fields (session, source_id, source_ts) pass through."""
|
|
25
|
+
if entities is None:
|
|
26
|
+
entities = extract.extract_entities(text)
|
|
27
|
+
rec = {
|
|
28
|
+
"id": "a%06d" % (self._n + 1),
|
|
29
|
+
"ts": ts if ts is not None else time.time(),
|
|
30
|
+
"kind": kind, # message | tool_call | tool_result | decision
|
|
31
|
+
"task": task,
|
|
32
|
+
"compaction": compaction,
|
|
33
|
+
"entities": entities,
|
|
34
|
+
"text": text,
|
|
35
|
+
}
|
|
36
|
+
rec.update(extra)
|
|
37
|
+
self._fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
38
|
+
self._fh.flush()
|
|
39
|
+
self._n += 1
|
|
40
|
+
return rec["id"]
|
|
41
|
+
|
|
42
|
+
def get(self, rec_id):
|
|
43
|
+
for rec in self.iter():
|
|
44
|
+
if rec["id"] == rec_id:
|
|
45
|
+
return rec
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def iter(self):
|
|
49
|
+
if not os.path.exists(self.path):
|
|
50
|
+
return
|
|
51
|
+
with open(self.path, "r", encoding="utf-8") as f:
|
|
52
|
+
for line in f:
|
|
53
|
+
if line.strip():
|
|
54
|
+
yield json.loads(line)
|
|
55
|
+
|
|
56
|
+
def count(self):
|
|
57
|
+
return self._n
|
|
58
|
+
|
|
59
|
+
def reopen(self, path=None):
|
|
60
|
+
"""Close the append handle and recount (used after a file swap)."""
|
|
61
|
+
self._fh.close()
|
|
62
|
+
if path:
|
|
63
|
+
self.path = path
|
|
64
|
+
self._n = 0
|
|
65
|
+
with open(self.path, "r", encoding="utf-8") as f:
|
|
66
|
+
for line in f:
|
|
67
|
+
if line.strip():
|
|
68
|
+
self._n += 1
|
|
69
|
+
self._fh = open(self.path, "a", encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
def close(self):
|
|
72
|
+
self._fh.close()
|
anchor/bench.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""v0.3 — precision labeling + A/B benchmark on real compaction data.
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
|
|
5
|
+
python -m anchor.bench --sample N dump N random fires + their ground truth
|
|
6
|
+
for precision labeling
|
|
7
|
+
python -m anchor.bench [--sessions N] [--entities N] [--model NAME]
|
|
8
|
+
A/B: vanilla summary vs anchored summary
|
|
9
|
+
(auto-resolved citation cards injected),
|
|
10
|
+
same questions, real Ollama, token counts.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import random
|
|
15
|
+
import statistics
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from .archive import Archive
|
|
19
|
+
from .index import BM25, build_entity_table
|
|
20
|
+
from .ladder import Resolver
|
|
21
|
+
from .replay import DEFAULT_DB, load_opencode_db
|
|
22
|
+
from .trigger import find_dangling
|
|
23
|
+
from .ollama_client import _post
|
|
24
|
+
|
|
25
|
+
QUESTION = ("Before this summary, you were working in the same session and made "
|
|
26
|
+
"or used things that are only named above. What is '{e}' and what "
|
|
27
|
+
"did you do with it? One short paragraph, from memory.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# generic names that are named but never "forgotten knowledge" (label aid)
|
|
31
|
+
GENERIC = {"readme.md", "license", "package.json", "opencode.json", "agents.md",
|
|
32
|
+
"cmd.exe", "powershell.exe", "python.exe", "server.py", "start.bat",
|
|
33
|
+
"install.bat", "index.html", "todo.md", "notes.txt", "game.log",
|
|
34
|
+
"settings.json", "config.json", ".gitignore", "requirements.txt"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build(db, max_sessions=None):
|
|
38
|
+
out = db + ".anchor.jsonl"
|
|
39
|
+
if os.path.exists(out):
|
|
40
|
+
os.remove(out)
|
|
41
|
+
arch = Archive(out)
|
|
42
|
+
n_sess, n_comp, probes = load_opencode_db(db, arch, max_sessions)
|
|
43
|
+
records = list(arch.iter())
|
|
44
|
+
table = build_entity_table(records)
|
|
45
|
+
return arch, table, BM25(records), probes
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fire_set(summary):
|
|
49
|
+
return [d["entity"] for d in find_dangling(summary)]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make_qa(summary, table, k):
|
|
53
|
+
"""(entity, ground-truth line) for entities the summary names but doesn't explain."""
|
|
54
|
+
qa = []
|
|
55
|
+
for e in fire_set(summary):
|
|
56
|
+
if e.lower() in GENERIC or e not in table:
|
|
57
|
+
continue
|
|
58
|
+
gt = table[e]["line"] or ""
|
|
59
|
+
if len(gt) > 30:
|
|
60
|
+
qa.append((e, gt))
|
|
61
|
+
if len(qa) >= k:
|
|
62
|
+
break
|
|
63
|
+
return qa
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def chat_full(model, messages, timeout=180, num_ctx=8192, retries=3, temp=0.2):
|
|
67
|
+
last = None
|
|
68
|
+
for attempt in range(retries):
|
|
69
|
+
try:
|
|
70
|
+
resp = _post("/api/chat", {
|
|
71
|
+
"model": model, "messages": messages, "stream": False,
|
|
72
|
+
"options": {"num_ctx": num_ctx, "temperature": temp}}, timeout)
|
|
73
|
+
usage = {"prompt": resp.get("prompt_eval_count", 0),
|
|
74
|
+
"output": resp.get("eval_count", 0)}
|
|
75
|
+
return resp.get("message", {}).get("content", "").strip(), usage
|
|
76
|
+
except Exception as e: # Ollama 500s / timeouts: back off and retry
|
|
77
|
+
last = e
|
|
78
|
+
time.sleep(5 * (attempt + 1))
|
|
79
|
+
raise last
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fact_overlap(answer, ground_truth):
|
|
83
|
+
"""Fraction of ground-truth content words present in the answer
|
|
84
|
+
(plural-folded — 'villagers' matches 'villager' — to cut metric noise)."""
|
|
85
|
+
from .index import tokenize, norm_word
|
|
86
|
+
gt = {norm_word(w) for w in tokenize(ground_truth) if len(w) > 3}
|
|
87
|
+
if not gt:
|
|
88
|
+
return 0.0
|
|
89
|
+
a = {norm_word(w) for w in tokenize(answer)}
|
|
90
|
+
return sum(1 for w in gt if w in a) / len(gt)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def card(entity, r):
|
|
94
|
+
c = f"- {entity}: {(r['what'] or '')[:160]}"
|
|
95
|
+
if r.get("file"):
|
|
96
|
+
c += f" (file: {r['file']})"
|
|
97
|
+
return c
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
MEMORY_INTRO = "\n\nYour memory of the above (from your session archive):\n"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run_bench(sessions_n, entities_k, model, resolve_cap=12, repeats=1,
|
|
104
|
+
arms=("vanilla", "selective"), title_filter=None):
|
|
105
|
+
arch, table, bm25, probes = build(DEFAULT_DB, max_sessions=None)
|
|
106
|
+
resolver = Resolver(arch, table, bm25, llm=None)
|
|
107
|
+
random.seed(7)
|
|
108
|
+
probes = random.sample(probes, min(sessions_n, len(probes)))
|
|
109
|
+
|
|
110
|
+
rows = []
|
|
111
|
+
for p in probes:
|
|
112
|
+
if title_filter and title_filter.lower() not in (p["session"] or "").lower():
|
|
113
|
+
continue
|
|
114
|
+
summary = p["summary"]
|
|
115
|
+
qa = make_qa(summary, table, entities_k)
|
|
116
|
+
if not qa:
|
|
117
|
+
continue
|
|
118
|
+
resolver.workspace_root = p["workspace"] or resolver.workspace_root
|
|
119
|
+
if "bulk" in arms: # v0.3 behavior, kept for comparison runs
|
|
120
|
+
fired = fire_set(summary)[:resolve_cap]
|
|
121
|
+
bulk_cards = []
|
|
122
|
+
for e in fired:
|
|
123
|
+
r = resolver.resolve(e, hint="")
|
|
124
|
+
if r["found"]:
|
|
125
|
+
bulk_cards.append(card(e, r))
|
|
126
|
+
bulk_ctx = summary + MEMORY_INTRO + "\n".join(bulk_cards)
|
|
127
|
+
|
|
128
|
+
for entity, gt in qa:
|
|
129
|
+
r = resolver.resolve(entity, hint="")
|
|
130
|
+
# ground truth = union of true memory facets (index line + the
|
|
131
|
+
# resolver's evidence line/docstring) — an answer echoing any
|
|
132
|
+
# genuine recovered fact scores, whichever facet it echoes
|
|
133
|
+
gt_full = gt + " " + (r["what"] or "")
|
|
134
|
+
# v0.5 junk filter at the card layer: skip generic names and
|
|
135
|
+
# resolutions that add no real "what" line
|
|
136
|
+
use_card = (r["found"] and entity.lower() not in GENERIC
|
|
137
|
+
and len(r["what"] or "") > 30)
|
|
138
|
+
sel_ctx = summary + MEMORY_INTRO + (card(entity, r) if use_card else "")
|
|
139
|
+
for arm in arms:
|
|
140
|
+
ctx = summary if arm == "vanilla" else sel_ctx
|
|
141
|
+
for rep in range(repeats):
|
|
142
|
+
t0 = time.perf_counter()
|
|
143
|
+
try:
|
|
144
|
+
ans, usage = chat_full(model, [
|
|
145
|
+
{"role": "system", "content":
|
|
146
|
+
"You are a coding agent continuing after a context "
|
|
147
|
+
"compaction. Answer only from your memory of the session."},
|
|
148
|
+
{"role": "user", "content": ctx + "\n\n" + QUESTION.format(e=entity)}])
|
|
149
|
+
except Exception as e:
|
|
150
|
+
rows.append({"session": p["session"][:40], "entity": entity,
|
|
151
|
+
"arm": arm, "repeat": rep, "overlap": None,
|
|
152
|
+
"tokens": 0, "latency": round(
|
|
153
|
+
time.perf_counter() - t0, 1),
|
|
154
|
+
"answer": f"<call failed: {e}>"})
|
|
155
|
+
continue
|
|
156
|
+
lat = time.perf_counter() - t0
|
|
157
|
+
rows.append({"session": p["session"][:40], "entity": entity,
|
|
158
|
+
"arm": arm, "repeat": rep,
|
|
159
|
+
"overlap": fact_overlap(ans, gt_full),
|
|
160
|
+
"tokens": usage["prompt"] + usage["output"],
|
|
161
|
+
"latency": round(lat, 1), "answer": ans[:200]})
|
|
162
|
+
|
|
163
|
+
# ---------------- report ----------------
|
|
164
|
+
if not rows:
|
|
165
|
+
print("no QA pairs produced — nothing to score")
|
|
166
|
+
return
|
|
167
|
+
print(f"model: {model} repeats: {repeats} arms: {', '.join(arms)}")
|
|
168
|
+
print(f"\nper-question (mean coverage over repeats):")
|
|
169
|
+
seen = {}
|
|
170
|
+
for r in rows:
|
|
171
|
+
seen.setdefault((r["entity"], r["arm"]), []).append(r["overlap"])
|
|
172
|
+
for (entity, arm), ovs in seen.items():
|
|
173
|
+
mean = sum(ovs) / len(ovs)
|
|
174
|
+
print(f" {entity:<40} {arm:<10} {mean:>4.0%} (n={len(ovs)})")
|
|
175
|
+
|
|
176
|
+
base = None
|
|
177
|
+
q = {}
|
|
178
|
+
for r in rows:
|
|
179
|
+
if r["overlap"] is not None:
|
|
180
|
+
q.setdefault((r["arm"], r["session"], r["entity"]), []).append(r["overlap"])
|
|
181
|
+
|
|
182
|
+
# persist raw rows so analysis never needs another 144 LLM calls
|
|
183
|
+
out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
184
|
+
"bench_results.jsonl")
|
|
185
|
+
with open(out_path, "a", encoding="utf-8") as f:
|
|
186
|
+
for r in rows:
|
|
187
|
+
f.write(json.dumps(r) + "\n")
|
|
188
|
+
print(f"rows appended to {out_path}")
|
|
189
|
+
|
|
190
|
+
base = None
|
|
191
|
+
for arm in arms:
|
|
192
|
+
arm_rows = [r for r in rows if r["arm"] == arm]
|
|
193
|
+
if not arm_rows:
|
|
194
|
+
continue
|
|
195
|
+
failed = sum(1 for r in arm_rows if r["overlap"] is None)
|
|
196
|
+
valid = [r for r in arm_rows if r["overlap"] is not None]
|
|
197
|
+
if not valid:
|
|
198
|
+
continue
|
|
199
|
+
acc = sum(1 for r in valid if r["overlap"] >= 0.5) / len(valid)
|
|
200
|
+
ovs = [r["overlap"] for r in valid]
|
|
201
|
+
mean_overlap = sum(ovs) / len(ovs)
|
|
202
|
+
std_overlap = statistics.pstdev(ovs) if len(ovs) > 1 else 0.0
|
|
203
|
+
# true sampling wobble: mean of per-question std over repeats
|
|
204
|
+
wobbles = [statistics.pstdev(v) for (a, _, _), v in q.items()
|
|
205
|
+
if a == arm and len(v) > 1]
|
|
206
|
+
wobble = sum(wobbles) / len(wobbles) if wobbles else 0.0
|
|
207
|
+
toks = sum(r["tokens"] for r in valid) / len(valid)
|
|
208
|
+
if arm == "vanilla":
|
|
209
|
+
base = toks
|
|
210
|
+
delta = f"({(toks - base) / base:+.0%} vs vanilla)" if base else ""
|
|
211
|
+
print(f"\n{arm.upper()}: recall {acc:.0%}, fact coverage "
|
|
212
|
+
f"{mean_overlap:.0%} ± {std_overlap:.0%} pooled, "
|
|
213
|
+
f"sampling wobble ±{wobble:.0%}, "
|
|
214
|
+
f"{toks:.0f} tokens/question {delta}, "
|
|
215
|
+
f"n={len(valid)}" + (f" ({failed} failed calls)" if failed else ""))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run_sample(n):
|
|
219
|
+
arch, table, bm25, probes = build(DEFAULT_DB)
|
|
220
|
+
random.seed(7)
|
|
221
|
+
fires = []
|
|
222
|
+
for p in probes:
|
|
223
|
+
for e in fire_set(p["summary"]):
|
|
224
|
+
fires.append((e, table.get(e, {}).get("line", "")))
|
|
225
|
+
random.shuffle(fires)
|
|
226
|
+
print(f"{len(fires)} total fires; random sample of {n} with ground truth:\n")
|
|
227
|
+
for e, line in fires[:n]:
|
|
228
|
+
print(f" {e:<28} {line[:110]}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
import argparse
|
|
233
|
+
import sys
|
|
234
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # cp1252-safe
|
|
235
|
+
ap = argparse.ArgumentParser()
|
|
236
|
+
ap.add_argument("--sample", type=int, default=0)
|
|
237
|
+
ap.add_argument("--sessions", type=int, default=6)
|
|
238
|
+
ap.add_argument("--entities", type=int, default=4)
|
|
239
|
+
ap.add_argument("--repeats", type=int, default=3)
|
|
240
|
+
ap.add_argument("--arms", default="vanilla,selective")
|
|
241
|
+
ap.add_argument("--model", default="gemma4:26b")
|
|
242
|
+
ap.add_argument("--title", default=None, help="only probes whose session title contains this")
|
|
243
|
+
args = ap.parse_args()
|
|
244
|
+
if args.sample:
|
|
245
|
+
run_sample(args.sample)
|
|
246
|
+
else:
|
|
247
|
+
run_bench(args.sessions, args.entities, args.model,
|
|
248
|
+
repeats=args.repeats, arms=tuple(args.arms.split(",")),
|
|
249
|
+
title_filter=args.title)
|
anchor/extract.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Entity extraction. Offline, deterministic, stdlib only.
|
|
2
|
+
|
|
3
|
+
Finds the names an agent leaves behind in its own transcript: file paths,
|
|
4
|
+
snake_case / camelCase / UPPER_SNAKE identifiers, lowercase tokens containing
|
|
5
|
+
digits (p3c2, l9, zzq9), and "tool X" / "named X" style mentions.
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
PATH_RE = re.compile(r"(?:[A-Za-z]:[\\/])?(?:[\w.-]+[\\/]){0,6}[\w-]{2,}\.[A-Za-z]{1,5}\b")
|
|
10
|
+
SNAKE_RE = re.compile(r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b")
|
|
11
|
+
CAMEL_RE = re.compile(r"\b[a-z]+(?:[A-Z][a-z0-9]+)+\b")
|
|
12
|
+
UPPER_RE = re.compile(r"\b[A-Z][A-Z0-9]{2,}(?:_[A-Z0-9]+)+\b")
|
|
13
|
+
# lowercase token with at least one digit: p3c2, l9, zzq9
|
|
14
|
+
DIGIT_RE = re.compile(r"\b[a-z][a-z0-9]*\d[a-z0-9]*\b")
|
|
15
|
+
NAMED_RE = re.compile(
|
|
16
|
+
r"\b(?:tool|script|module|function|command|named|called)\s+([A-Za-z][\w-]{2,30})\b")
|
|
17
|
+
|
|
18
|
+
# generic words that match the loose patterns but are never entities
|
|
19
|
+
STOP = {
|
|
20
|
+
"the", "and", "for", "this", "that", "with", "from", "have", "not", "are",
|
|
21
|
+
"was", "but", "all", "can", "her", "she", "his", "has", "were", "been",
|
|
22
|
+
"will", "would", "could", "should", "than", "then", "them", "they", "its",
|
|
23
|
+
"our", "out", "about", "into", "over", "after", "under", "more", "most",
|
|
24
|
+
"some", "such", "only", "also", "just", "like", "when", "what", "which",
|
|
25
|
+
"who", "how", "why", "where", "while", "these", "those", "there", "here",
|
|
26
|
+
"one", "two", "three", "none", "true", "false", "self", "str", "int",
|
|
27
|
+
"float", "bool", "list", "dict", "set", "file", "path", "name", "tool",
|
|
28
|
+
"test", "data", "text", "line", "time", "make", "made", "used", "using",
|
|
29
|
+
"use", "new", "get", "put", "run", "ran", "see", "saw", "tmp", "temp",
|
|
30
|
+
"www", "com", "org", "http", "https", "utf", "bin", "usr", "var",
|
|
31
|
+
"e.g", "i.e", "etc",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _clean(cands):
|
|
36
|
+
out = []
|
|
37
|
+
for c in cands:
|
|
38
|
+
c = c.strip(".\\/")
|
|
39
|
+
if c.lower() in STOP or len(c) < 3:
|
|
40
|
+
continue
|
|
41
|
+
# JSON-escaped newline artifacts: "\nName" inside a string literal
|
|
42
|
+
# extracts as "nName", "\nOutput" as "nOutput" — strip the artifact
|
|
43
|
+
if len(c) > 2 and c[0] in "nr" and c[1].isupper():
|
|
44
|
+
c = c[1:]
|
|
45
|
+
if c.lower() in STOP or len(c) < 3 or not is_entity(c):
|
|
46
|
+
continue
|
|
47
|
+
out.append(c)
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# "tool named p3c2" makes NAMED_RE capture the keyword itself from the pair
|
|
52
|
+
NAMED_KEYWORDS = {"named", "called"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def extract_entities(text):
|
|
56
|
+
"""All candidate entities in `text`, most specific first (paths before names)."""
|
|
57
|
+
paths = _clean(m.group(0).replace("\\", "/") for m in PATH_RE.finditer(text))
|
|
58
|
+
# bare stems of paths are also entities (tools/p3c2.py -> p3c2)
|
|
59
|
+
stems = _clean(p.rsplit("/", 1)[-1].rsplit(".", 1)[0] for p in paths)
|
|
60
|
+
named = [m.group(1) for m in NAMED_RE.finditer(text)
|
|
61
|
+
if m.group(1).lower() not in NAMED_KEYWORDS]
|
|
62
|
+
# a captured "name" must be entity-shaped: digit, underscore or inner
|
|
63
|
+
# capital ("p3c2", "BreakInModMenu") — rejects plain verbs like "finishes"
|
|
64
|
+
named = [n for n in named if any(ch.isdigit() for ch in n)
|
|
65
|
+
or "_" in n or any(ch.isupper() for ch in n[1:])]
|
|
66
|
+
named = _clean(named)
|
|
67
|
+
upper = _clean(m.group(0) for m in UPPER_RE.finditer(text))
|
|
68
|
+
snake = _clean(m.group(0) for m in SNAKE_RE.finditer(text))
|
|
69
|
+
camel = _clean(m.group(0) for m in CAMEL_RE.finditer(text))
|
|
70
|
+
digits = _clean(m.group(0) for m in DIGIT_RE.finditer(text))
|
|
71
|
+
seen, out = set(), []
|
|
72
|
+
for group in (paths, stems, named, upper, snake, camel, digits):
|
|
73
|
+
for e in group:
|
|
74
|
+
if e not in seen:
|
|
75
|
+
seen.add(e)
|
|
76
|
+
out.append(e)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_entity(word):
|
|
81
|
+
"""Is this single token shaped like an entity we care about?"""
|
|
82
|
+
return bool(extract_entities(word)) and word not in STOP
|
anchor/index.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Index — the map of "what's explained where", plus BM25 over the archive.
|
|
2
|
+
Rebuilt from the archive JSONL on load; rebuild is cheap, no persistence needed."""
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from collections import Counter
|
|
6
|
+
|
|
7
|
+
TOKEN_RE = re.compile(r"[a-z0-9]+")
|
|
8
|
+
|
|
9
|
+
BM25_STOP = {
|
|
10
|
+
"the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "with", "at",
|
|
11
|
+
"by", "is", "are", "was", "were", "be", "been", "it", "its", "this", "that",
|
|
12
|
+
"as", "from", "was", "were", "but", "not", "so", "if", "then", "than",
|
|
13
|
+
"into", "about", "over", "after", "under", "out", "up", "down", "can",
|
|
14
|
+
"will", "would", "could", "should", "do", "does", "did", "done", "have",
|
|
15
|
+
"has", "had", "i", "we", "you", "he", "she", "they", "them", "their",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def tokenize(text):
|
|
20
|
+
return [t for t in TOKEN_RE.findall(text.lower())
|
|
21
|
+
if len(t) > 1 and t not in BM25_STOP]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def norm_word(w):
|
|
25
|
+
"""Light plural-fold for fuzzy fact matching."""
|
|
26
|
+
return w[:-1] if len(w) > 4 and w.endswith("s") else w
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
EXPLAIN_MARKERS = (" is ", " is a", " is the", "decided", "because", "purpose",
|
|
30
|
+
"wrote", "created", "made", "built", "added", "implemented",
|
|
31
|
+
"uses", "handles", "parses", "exposes", "fixed", "changed",
|
|
32
|
+
"summary")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _explain_score(rec, entity):
|
|
36
|
+
"""How much this record EXPLAINS the entity (vs merely mentioning it).
|
|
37
|
+
Conversation-only memory lives in decisions, compaction summaries and
|
|
38
|
+
definition-shaped prose — those must outrank bare tool-JSON mentions.
|
|
39
|
+
Markers count 0.5 each (uncapped) so purpose-rich records tie out thin ones."""
|
|
40
|
+
s = 0.0
|
|
41
|
+
low = rec["text"].lower()
|
|
42
|
+
el = entity.lower()
|
|
43
|
+
kind = rec.get("kind", "")
|
|
44
|
+
if kind == "decision":
|
|
45
|
+
s += 2
|
|
46
|
+
elif kind == "tool_call":
|
|
47
|
+
s += 1
|
|
48
|
+
if "[compaction" in low or kind == "compaction":
|
|
49
|
+
s += 2
|
|
50
|
+
if el in low:
|
|
51
|
+
s += 2
|
|
52
|
+
if low.find(el) < 200: # mentioned early = talked about, not passed by
|
|
53
|
+
s += 1
|
|
54
|
+
s += sum(0.5 for m in EXPLAIN_MARKERS if m in low)
|
|
55
|
+
return s
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_entity_table(records):
|
|
59
|
+
"""entity -> {type, line, explained_in, first_seen}
|
|
60
|
+
`line` comes from the BEST-EXPLAINING record, not the first mention.
|
|
61
|
+
Single pass: each record is scored once per entity it mentions."""
|
|
62
|
+
records = list(records)
|
|
63
|
+
table = {}
|
|
64
|
+
for rec in records:
|
|
65
|
+
text = rec["text"]
|
|
66
|
+
for e in rec.get("entities", []):
|
|
67
|
+
entry = table.setdefault(
|
|
68
|
+
e, {"type": "file" if "/" in e or "." in e else "name",
|
|
69
|
+
"line": "", "explained_in": [], "first_seen": rec["id"],
|
|
70
|
+
"_best": -1.0, "_best_id": None, "_best_i": -1})
|
|
71
|
+
entry["explained_in"].append(rec["id"])
|
|
72
|
+
variants = (e, e.replace("/", "\\"), e.replace("\\", "/"))
|
|
73
|
+
i = next((text.find(v) for v in variants if v in text), -1)
|
|
74
|
+
if i == -1:
|
|
75
|
+
continue
|
|
76
|
+
score = _explain_score(rec, e)
|
|
77
|
+
if score > entry["_best"]:
|
|
78
|
+
entry["_best"] = score
|
|
79
|
+
entry["_best_id"] = rec["id"]
|
|
80
|
+
entry["_best_i"] = i
|
|
81
|
+
snippet = text[max(0, i - 40):i + 160].replace("\n", " ").strip()
|
|
82
|
+
entry["line"] = ("..." + snippet + "...") if i > 40 else snippet
|
|
83
|
+
for entry in table.values():
|
|
84
|
+
entry["best_evidence"] = entry.pop("_best_id")
|
|
85
|
+
entry.pop("_best_i")
|
|
86
|
+
entry.pop("_best")
|
|
87
|
+
return table
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BM25:
|
|
91
|
+
"""Standard BM25 over archive record texts. k1=1.5, b=0.75."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, records, k1=1.5, b=0.75):
|
|
94
|
+
self.k1, self.b = k1, b
|
|
95
|
+
self.recs = list(records)
|
|
96
|
+
self.docs = [tokenize(r["text"]) for r in self.recs]
|
|
97
|
+
self.avgdl = (sum(len(d) for d in self.docs) / len(self.docs)) if self.docs else 1.0
|
|
98
|
+
self.tfs = [Counter(d) for d in self.docs]
|
|
99
|
+
self.df = Counter()
|
|
100
|
+
for d in self.docs:
|
|
101
|
+
for t in set(d):
|
|
102
|
+
self.df[t] += 1
|
|
103
|
+
self.N = len(self.recs)
|
|
104
|
+
|
|
105
|
+
def _idf(self, t):
|
|
106
|
+
df = self.df.get(t, 0)
|
|
107
|
+
return math.log((self.N - df + 0.5) / (df + 0.5) + 1.0)
|
|
108
|
+
|
|
109
|
+
def search(self, query, k=8):
|
|
110
|
+
q = tokenize(query)
|
|
111
|
+
if not q:
|
|
112
|
+
return []
|
|
113
|
+
scored = []
|
|
114
|
+
for i, (rec, tf) in enumerate(zip(self.recs, self.tfs)):
|
|
115
|
+
dl = len(self.docs[i]) or 1
|
|
116
|
+
s = 0.0
|
|
117
|
+
for t in q:
|
|
118
|
+
f = tf.get(t, 0)
|
|
119
|
+
if not f:
|
|
120
|
+
continue
|
|
121
|
+
s += self._idf(t) * f * (self.k1 + 1) / (
|
|
122
|
+
f + self.k1 * (1 - self.b + self.b * dl / self.avgdl))
|
|
123
|
+
if s > 0:
|
|
124
|
+
scored.append((s, rec))
|
|
125
|
+
scored.sort(key=lambda x: -x[0])
|
|
126
|
+
return scored[:k]
|