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.
- anchor_memory-0.7.0/LICENSE +21 -0
- anchor_memory-0.7.0/PKG-INFO +116 -0
- anchor_memory-0.7.0/README.md +95 -0
- anchor_memory-0.7.0/anchor/__init__.py +3 -0
- anchor_memory-0.7.0/anchor/archive.py +72 -0
- anchor_memory-0.7.0/anchor/bench.py +249 -0
- anchor_memory-0.7.0/anchor/extract.py +82 -0
- anchor_memory-0.7.0/anchor/index.py +126 -0
- anchor_memory-0.7.0/anchor/ladder.py +342 -0
- anchor_memory-0.7.0/anchor/mcp_server.py +326 -0
- anchor_memory-0.7.0/anchor/ollama_client.py +30 -0
- anchor_memory-0.7.0/anchor/replay.py +201 -0
- anchor_memory-0.7.0/anchor/selftest.py +114 -0
- anchor_memory-0.7.0/anchor/trigger.py +54 -0
- anchor_memory-0.7.0/anchor_memory.egg-info/PKG-INFO +116 -0
- anchor_memory-0.7.0/anchor_memory.egg-info/SOURCES.txt +19 -0
- anchor_memory-0.7.0/anchor_memory.egg-info/dependency_links.txt +1 -0
- anchor_memory-0.7.0/anchor_memory.egg-info/entry_points.txt +2 -0
- anchor_memory-0.7.0/anchor_memory.egg-info/top_level.txt +1 -0
- anchor_memory-0.7.0/pyproject.toml +36 -0
- anchor_memory-0.7.0/setup.cfg +4 -0
|
@@ -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,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
|