anchor-memory 0.7.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 anchor contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: anchor-memory
3
+ Version: 0.7.0
4
+ Summary: anchor — agents that remember their own work. mm + ms memory for AI agents: an append-only session archive, an entity index, a dangling-reference trigger and a search ladder that recovers what context compaction destroys.
5
+ Author: anchor contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://zacharyol.github.io/anchor-memory/
8
+ Project-URL: Documentation, https://zacharyol.github.io/anchor-memory/#docs
9
+ Project-URL: Repository, https://github.com/zacharyol/anchor-memory
10
+ Keywords: ai,agent,memory,context-window,compaction,context-limit,llm,mcp,model-context-protocol,rag,retrieval,claude,opencode,agent-memory,long-running-agents
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # anchor — agents that remember their own work
23
+
24
+ **anchor = mm + ms.** Memory management plus memory search for AI agents.
25
+
26
+ When a long-running agent hits its context limit, compaction summarizes the
27
+ history — and the summary silently drops the details: the tool the agent built
28
+ (`p3c2`), the file it wrote, the decision it made, the approach it tried and
29
+ rejected. The agent then *rebuilds things it already built*. anchor fixes this
30
+ in two halves:
31
+
32
+ - **mm (memory management)** — every raw session event lands in an append-only
33
+ archive *before* compaction can destroy it, and every entity (tool names,
34
+ file paths, decisions) is extracted into an index: `entity → where it's
35
+ explained`.
36
+ - **ms (memory search)** — a dangling-reference trigger notices *"the agent
37
+ sees `p3c2` and doesn't know what it is"* **for the agent** (agents are bad
38
+ at noticing this themselves), then a search ladder recovers the answer with
39
+ citations: index → workspace files → BM25 over raw history → LLM rewording
40
+ → piece-by-piece reconstruction. Misses return a **tried-log** (the negative
41
+ result), which the relay passes to the next searcher.
42
+
43
+ Pure **stdlib Python** — no pip dependencies to run, works offline, optional
44
+ local-Ollama for one rung. Ships as an **MCP server** so Claude Code, OpenCode
45
+ and any MCP client can use it in one config block.
46
+
47
+ ## install
48
+
49
+ ```
50
+ pip install anchor-memory # from PyPI (once published — see site for status)
51
+ ```
52
+
53
+ or straight from this repo — works right now:
54
+
55
+ ```
56
+ pip install git+https://github.com/zacharyol/anchor-memory.git
57
+ ```
58
+
59
+ Website & full setup guide: https://zacharyol.github.io/anchor-memory/
60
+
61
+ ## quickstart
62
+
63
+ ```
64
+ anchor-mcp # starts the MCP server (stdio)
65
+ python -m anchor.selftest # offline end-to-end test with numbers
66
+ python -m anchor.replay # ingest your own OpenCode session history
67
+ ```
68
+
69
+ Wire it into your agent (one block, then restart the session):
70
+
71
+ ```json
72
+ { "mcpServers": { "anchor": { "command": "anchor-mcp" } } }
73
+ ```
74
+
75
+ Your agent gains: `anchor_resolve` (what was p3c2?), `anchor_check` (which
76
+ names in this summary are unexplained?), `anchor_search` (BM25 over raw
77
+ history), `anchor_sessions`, `anchor_sync` (incremental ingest from the live
78
+ session database), `anchor_stats`, and `anchor_relay` (shard-searchers with
79
+ negative-result merge — the last resort before "unfindable").
80
+
81
+ ## measured results (real session history, llama3.1:8B judge-free agent)
82
+
83
+ | version | change | vanilla → anchored recall | token overhead |
84
+ |---|---|---|---|
85
+ | v0.3 | bulk cards | 33% → 44% | +48% |
86
+ | v0.4 | selective card | 33% → 44% | **+5%** |
87
+ | v0.5 | junk filter, repeats (n=36) | 19% → **47%** | +5% |
88
+ | v0.6 | best-explanation cards (n=72) | 19% → **42%** | +5% |
89
+
90
+ Trigger fire precision on labeled real data: 58% (firing is free — junk is
91
+ filtered at the card layer). Sampling wobble ±5–6%: model randomness is small;
92
+ question difficulty dominates. Full methodology:
93
+ [docs/BENCHMARK.md](docs/BENCHMARK.md).
94
+
95
+ ## how it's made
96
+
97
+ - [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md) — the architecture walk-through:
98
+ the two memory classes, the write path, the trigger rules, every ladder rung,
99
+ the relay, the data formats.
100
+ - [docs/BENCHMARK.md](docs/BENCHMARK.md) — how the numbers above are produced
101
+ and how to reproduce them on your own session history.
102
+ - [DESIGN.md](anchor/DESIGN.md) — the original design doc, kept as written.
103
+
104
+ ## why open source
105
+
106
+ Memory loss after compaction is the defining production failure of long
107
+ running agents ("context rot"). anchor's answer is deliberately simple:
108
+ archive raw, index entities, detect the gap automatically, search in a cost
109
+ ladder, and never trust a summary when the primary source still exists.
110
+ MIT licensed — take it, wire it, fork it.
111
+
112
+ ## works with
113
+
114
+ Claude Code · OpenCode · any MCP client · local Ollama (optional, rung 2)
115
+
116
+ MIT License · repo: https://github.com/zacharyol/anchor-memory
@@ -0,0 +1,95 @@
1
+ # anchor — agents that remember their own work
2
+
3
+ **anchor = mm + ms.** Memory management plus memory search for AI agents.
4
+
5
+ When a long-running agent hits its context limit, compaction summarizes the
6
+ history — and the summary silently drops the details: the tool the agent built
7
+ (`p3c2`), the file it wrote, the decision it made, the approach it tried and
8
+ rejected. The agent then *rebuilds things it already built*. anchor fixes this
9
+ in two halves:
10
+
11
+ - **mm (memory management)** — every raw session event lands in an append-only
12
+ archive *before* compaction can destroy it, and every entity (tool names,
13
+ file paths, decisions) is extracted into an index: `entity → where it's
14
+ explained`.
15
+ - **ms (memory search)** — a dangling-reference trigger notices *"the agent
16
+ sees `p3c2` and doesn't know what it is"* **for the agent** (agents are bad
17
+ at noticing this themselves), then a search ladder recovers the answer with
18
+ citations: index → workspace files → BM25 over raw history → LLM rewording
19
+ → piece-by-piece reconstruction. Misses return a **tried-log** (the negative
20
+ result), which the relay passes to the next searcher.
21
+
22
+ Pure **stdlib Python** — no pip dependencies to run, works offline, optional
23
+ local-Ollama for one rung. Ships as an **MCP server** so Claude Code, OpenCode
24
+ and any MCP client can use it in one config block.
25
+
26
+ ## install
27
+
28
+ ```
29
+ pip install anchor-memory # from PyPI (once published — see site for status)
30
+ ```
31
+
32
+ or straight from this repo — works right now:
33
+
34
+ ```
35
+ pip install git+https://github.com/zacharyol/anchor-memory.git
36
+ ```
37
+
38
+ Website & full setup guide: https://zacharyol.github.io/anchor-memory/
39
+
40
+ ## quickstart
41
+
42
+ ```
43
+ anchor-mcp # starts the MCP server (stdio)
44
+ python -m anchor.selftest # offline end-to-end test with numbers
45
+ python -m anchor.replay # ingest your own OpenCode session history
46
+ ```
47
+
48
+ Wire it into your agent (one block, then restart the session):
49
+
50
+ ```json
51
+ { "mcpServers": { "anchor": { "command": "anchor-mcp" } } }
52
+ ```
53
+
54
+ Your agent gains: `anchor_resolve` (what was p3c2?), `anchor_check` (which
55
+ names in this summary are unexplained?), `anchor_search` (BM25 over raw
56
+ history), `anchor_sessions`, `anchor_sync` (incremental ingest from the live
57
+ session database), `anchor_stats`, and `anchor_relay` (shard-searchers with
58
+ negative-result merge — the last resort before "unfindable").
59
+
60
+ ## measured results (real session history, llama3.1:8B judge-free agent)
61
+
62
+ | version | change | vanilla → anchored recall | token overhead |
63
+ |---|---|---|---|
64
+ | v0.3 | bulk cards | 33% → 44% | +48% |
65
+ | v0.4 | selective card | 33% → 44% | **+5%** |
66
+ | v0.5 | junk filter, repeats (n=36) | 19% → **47%** | +5% |
67
+ | v0.6 | best-explanation cards (n=72) | 19% → **42%** | +5% |
68
+
69
+ Trigger fire precision on labeled real data: 58% (firing is free — junk is
70
+ filtered at the card layer). Sampling wobble ±5–6%: model randomness is small;
71
+ question difficulty dominates. Full methodology:
72
+ [docs/BENCHMARK.md](docs/BENCHMARK.md).
73
+
74
+ ## how it's made
75
+
76
+ - [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md) — the architecture walk-through:
77
+ the two memory classes, the write path, the trigger rules, every ladder rung,
78
+ the relay, the data formats.
79
+ - [docs/BENCHMARK.md](docs/BENCHMARK.md) — how the numbers above are produced
80
+ and how to reproduce them on your own session history.
81
+ - [DESIGN.md](anchor/DESIGN.md) — the original design doc, kept as written.
82
+
83
+ ## why open source
84
+
85
+ Memory loss after compaction is the defining production failure of long
86
+ running agents ("context rot"). anchor's answer is deliberately simple:
87
+ archive raw, index entities, detect the gap automatically, search in a cost
88
+ ladder, and never trust a summary when the primary source still exists.
89
+ MIT licensed — take it, wire it, fork it.
90
+
91
+ ## works with
92
+
93
+ Claude Code · OpenCode · any MCP client · local Ollama (optional, rung 2)
94
+
95
+ MIT License · repo: https://github.com/zacharyol/anchor-memory
@@ -0,0 +1,3 @@
1
+ """anchor — mm + ms for AI agents. See DESIGN.md."""
2
+
3
+ __version__ = "0.7.0"
@@ -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()
@@ -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)
@@ -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