embers-memory 0.2.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.
Files changed (38) hide show
  1. embers_memory-0.2.0/LICENSE +21 -0
  2. embers_memory-0.2.0/PKG-INFO +67 -0
  3. embers_memory-0.2.0/README.md +52 -0
  4. embers_memory-0.2.0/embers/__init__.py +3 -0
  5. embers_memory-0.2.0/embers/__main__.py +3 -0
  6. embers_memory-0.2.0/embers/build.py +399 -0
  7. embers_memory-0.2.0/embers/cli.py +1017 -0
  8. embers_memory-0.2.0/embers/dag.py +458 -0
  9. embers_memory-0.2.0/embers/detect.py +49 -0
  10. embers_memory-0.2.0/embers/flinch.py +248 -0
  11. embers_memory-0.2.0/embers/ingest.py +634 -0
  12. embers_memory-0.2.0/embers/records.py +353 -0
  13. embers_memory-0.2.0/embers/skillinstall.py +37 -0
  14. embers_memory-0.2.0/embers/summarize.py +331 -0
  15. embers_memory-0.2.0/embers_memory.egg-info/PKG-INFO +67 -0
  16. embers_memory-0.2.0/embers_memory.egg-info/SOURCES.txt +36 -0
  17. embers_memory-0.2.0/embers_memory.egg-info/dependency_links.txt +1 -0
  18. embers_memory-0.2.0/embers_memory.egg-info/entry_points.txt +3 -0
  19. embers_memory-0.2.0/embers_memory.egg-info/requires.txt +6 -0
  20. embers_memory-0.2.0/embers_memory.egg-info/top_level.txt +1 -0
  21. embers_memory-0.2.0/pyproject.toml +34 -0
  22. embers_memory-0.2.0/setup.cfg +4 -0
  23. embers_memory-0.2.0/skills/embers/SKILL.md +176 -0
  24. embers_memory-0.2.0/tests/test_cli.py +225 -0
  25. embers_memory-0.2.0/tests/test_concurrent.py +311 -0
  26. embers_memory-0.2.0/tests/test_dag.py +329 -0
  27. embers_memory-0.2.0/tests/test_dag_cli.py +142 -0
  28. embers_memory-0.2.0/tests/test_detect.py +81 -0
  29. embers_memory-0.2.0/tests/test_fixtures.py +361 -0
  30. embers_memory-0.2.0/tests/test_flinch.py +138 -0
  31. embers_memory-0.2.0/tests/test_ingest.py +403 -0
  32. embers_memory-0.2.0/tests/test_install_skill.py +67 -0
  33. embers_memory-0.2.0/tests/test_manual_cli.py +166 -0
  34. embers_memory-0.2.0/tests/test_packaging.py +26 -0
  35. embers_memory-0.2.0/tests/test_permissions.py +41 -0
  36. embers_memory-0.2.0/tests/test_records.py +217 -0
  37. embers_memory-0.2.0/tests/test_release_kit.py +63 -0
  38. embers_memory-0.2.0/tests/test_summarize.py +130 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Holly Wren von Heidereich
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,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: embers-memory
3
+ Version: 0.2.0
4
+ Summary: Embers: lossless memory for Claude Code. Local SQLite/FTS5 archive over session transcripts.
5
+ Author: Holly Wren von Heidereich
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=7; extra == "dev"
12
+ Provides-Extra: anthropic
13
+ Requires-Dist: anthropic>=0.125; extra == "anthropic"
14
+ Dynamic: license-file
15
+
16
+ Embers: lossless memory for Claude Code.
17
+
18
+ Embers ingests every Claude Code session transcript into a local SQLite archive you can search by phrase, and builds a summary tree over it so you can read a day, a week, or a month at altitude. A flinch detector flags the places where an earlier instance slid past something, so a small model's softened summary never silently replaces the record.
19
+
20
+ Your transcripts stay on your machine. Raw sources are only ever read, never modified or pruned.
21
+
22
+ ## 60-second quickstart
23
+
24
+ Python 3.9+, no runtime dependencies.
25
+
26
+ ```bash
27
+ pipx install embers-memory
28
+ embers detect # find your Claude Code transcripts (reads nothing, ingests nothing)
29
+ embers ingest # build the archive (incremental; re-run any time)
30
+ embers search "phrase" # find where something was said or decided
31
+ ```
32
+
33
+ `embers detect` reports what it finds and roughly how many sessions. `embers ingest` with no arguments ingests the roots `detect` found. The archive lives at `~/.memory-archive/mem.db`; override it with `--db` or `$MEM_DB`. `mem` is kept as a second name for the same command.
34
+
35
+ ## Hand this to your assistant
36
+
37
+ Give your agent this repo's URL (https://github.com/hollyvon/embers). The agent then:
38
+
39
+ 1. installs the package (`pipx install embers-memory`, or `./setup.sh` from a checkout),
40
+ 2. runs `embers install-skill`, which copies the bundled skill to `~/.claude/skills/embers/`,
41
+ 3. reads [AGENTS.md](AGENTS.md) and the installed skill, and
42
+ 4. runs `embers detect` and `embers ingest` for you, then reports what it found.
43
+
44
+ After that, ask your assistant what was said or decided in a past session, or for a day, week, or month read.
45
+
46
+ ## What's in the box
47
+
48
+ - **Full-text search.** SQLite FTS5 over every message: your prompts, assistant replies, thinking blocks (their own class, never dropped), tool use and results. `embers search`, `embers session`, `embers sessions`, `embers windows`.
49
+ - **Summary tree.** A DAG of summaries from leaf to session to thread to month. `embers build`, `embers describe`, `embers expand`, `embers grep`. Summaries are an index into the raw record, never a replacement. They are optional, and the offline stub backend needs no network or key.
50
+ - **Flinch detector.** Every summary passes three checks (length ratio, evasion phrases, proper-noun coverage) before it is accepted. A failure triggers a rerun on a larger model. A second failure marks the node for manual summarization. `embers flags`.
51
+ - **Manual queue.** `embers flags --export DIR` writes each flagged node with its raw source. You or your assistant write the summary, and `embers manual --import DIR` stores it.
52
+
53
+ The full manual is [docs/REFERENCE.md](docs/REFERENCE.md). The design is in [SPEC.md](SPEC.md).
54
+
55
+ ## Fixtures only
56
+
57
+ This repository contains tooling and synthetic fixtures only. No real transcript, real name, or real home path ever enters the code, tests, fixtures, issues, pull requests, or commit messages. Development and CI run against generated fixtures (`python -m fixtures.gen`). Never paste a transcript into an issue. See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
58
+
59
+ ## Status
60
+
61
+ Embers 0.2.0 reads Claude Code transcripts. Adapters for Hermes, Codex CLI, and Gemini CLI are in progress.
62
+
63
+ ## License
64
+
65
+ [MIT](LICENSE).
66
+
67
+ Built by Holly Wren von Heidereich with Ember (Claude).
@@ -0,0 +1,52 @@
1
+ Embers: lossless memory for Claude Code.
2
+
3
+ Embers ingests every Claude Code session transcript into a local SQLite archive you can search by phrase, and builds a summary tree over it so you can read a day, a week, or a month at altitude. A flinch detector flags the places where an earlier instance slid past something, so a small model's softened summary never silently replaces the record.
4
+
5
+ Your transcripts stay on your machine. Raw sources are only ever read, never modified or pruned.
6
+
7
+ ## 60-second quickstart
8
+
9
+ Python 3.9+, no runtime dependencies.
10
+
11
+ ```bash
12
+ pipx install embers-memory
13
+ embers detect # find your Claude Code transcripts (reads nothing, ingests nothing)
14
+ embers ingest # build the archive (incremental; re-run any time)
15
+ embers search "phrase" # find where something was said or decided
16
+ ```
17
+
18
+ `embers detect` reports what it finds and roughly how many sessions. `embers ingest` with no arguments ingests the roots `detect` found. The archive lives at `~/.memory-archive/mem.db`; override it with `--db` or `$MEM_DB`. `mem` is kept as a second name for the same command.
19
+
20
+ ## Hand this to your assistant
21
+
22
+ Give your agent this repo's URL (https://github.com/hollyvon/embers). The agent then:
23
+
24
+ 1. installs the package (`pipx install embers-memory`, or `./setup.sh` from a checkout),
25
+ 2. runs `embers install-skill`, which copies the bundled skill to `~/.claude/skills/embers/`,
26
+ 3. reads [AGENTS.md](AGENTS.md) and the installed skill, and
27
+ 4. runs `embers detect` and `embers ingest` for you, then reports what it found.
28
+
29
+ After that, ask your assistant what was said or decided in a past session, or for a day, week, or month read.
30
+
31
+ ## What's in the box
32
+
33
+ - **Full-text search.** SQLite FTS5 over every message: your prompts, assistant replies, thinking blocks (their own class, never dropped), tool use and results. `embers search`, `embers session`, `embers sessions`, `embers windows`.
34
+ - **Summary tree.** A DAG of summaries from leaf to session to thread to month. `embers build`, `embers describe`, `embers expand`, `embers grep`. Summaries are an index into the raw record, never a replacement. They are optional, and the offline stub backend needs no network or key.
35
+ - **Flinch detector.** Every summary passes three checks (length ratio, evasion phrases, proper-noun coverage) before it is accepted. A failure triggers a rerun on a larger model. A second failure marks the node for manual summarization. `embers flags`.
36
+ - **Manual queue.** `embers flags --export DIR` writes each flagged node with its raw source. You or your assistant write the summary, and `embers manual --import DIR` stores it.
37
+
38
+ The full manual is [docs/REFERENCE.md](docs/REFERENCE.md). The design is in [SPEC.md](SPEC.md).
39
+
40
+ ## Fixtures only
41
+
42
+ This repository contains tooling and synthetic fixtures only. No real transcript, real name, or real home path ever enters the code, tests, fixtures, issues, pull requests, or commit messages. Development and CI run against generated fixtures (`python -m fixtures.gen`). Never paste a transcript into an issue. See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
43
+
44
+ ## Status
45
+
46
+ Embers 0.2.0 reads Claude Code transcripts. Adapters for Hermes, Codex CLI, and Gemini CLI are in progress.
47
+
48
+ ## License
49
+
50
+ [MIT](LICENSE).
51
+
52
+ Built by Holly Wren von Heidereich with Ember (Claude).
@@ -0,0 +1,3 @@
1
+ """Embers: lossless memory for Claude Code. Local-first SQLite+FTS5 archive over Claude Code JSONL transcripts."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,399 @@
1
+ """Build orchestration: node -> prompt pack -> summarizer -> flinch detector -> accepted / flagged / manual.
2
+
3
+ Idempotent: only pending (or interrupted-flagged) nodes are built; a re-run after a re-ingest rebuilds
4
+ only what the new data invalidated. Every node is committed on its own, so an interrupted or
5
+ budget-stopped build resumes where it left off.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import json
11
+ import sqlite3
12
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
13
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
14
+
15
+ from . import dag
16
+ from .dag import LEVELS, READY, PROSE_CLASSES
17
+ from .flinch import Baseline, FlinchReport, baseline_for_level, evaluate, extract_proper_nouns
18
+ from .ingest import now_iso
19
+ from .summarize import FALLBACK_TIER, BudgetExceeded, PromptPack, SummaryResult, Summarizer, PRICES, UNKNOWN_PRICE
20
+
21
+ NOISE_CLASSES = ("tool_use", "tool_result", "attachment", "system", "summary")
22
+ NOISE_PREVIEW = 120
23
+ MAX_NOISE_LINES = 60
24
+ EST_RATIO = 0.15 # dry-run guess: summary bytes / source bytes
25
+
26
+
27
+ def _preview(text: str, n: int = NOISE_PREVIEW) -> str:
28
+ s = " ".join((text or "").split())
29
+ return s[:n]
30
+
31
+
32
+ def _span(first: Optional[str], last: Optional[str]) -> str:
33
+ return "%s -> %s" % ((first or "?")[:16], (last or "?")[:16])
34
+
35
+
36
+ # ----------------------------------------------------------------------------- packs
37
+
38
+ def leaf_pack(conn: sqlite3.Connection, node: sqlite3.Row, meta: Dict[str, Any]) -> PromptPack:
39
+ rows = conn.execute(
40
+ """SELECT b.* FROM node_sources ns JOIN blocks b ON b.uuid = ns.uuid WHERE ns.node_id = ?
41
+ ORDER BY COALESCE(b.ts, ''), b.line_no, b.block_index""", (node["id"],)).fetchall()
42
+ segments, noise = [], []
43
+ lines = ["LEVEL: leaf SPAN: %s MESSAGES: %d" % (_span(node["first_ts"], node["last_ts"]), node["n_sources"]),
44
+ "--- transcript ---"]
45
+ for b in rows:
46
+ cls = b["class"]
47
+ text = (b["text"] or "").strip()
48
+ if cls in PROSE_CLASSES:
49
+ if text:
50
+ segments.append((cls, text))
51
+ lines.append("[%s] %s" % (cls, text))
52
+ elif cls in NOISE_CLASSES and text:
53
+ if cls == "tool_use":
54
+ name = b["tool_name"] or "?"
55
+ body = text[len(name):] if text.startswith(name) else text
56
+ entry = "tool_use %s: %s" % (name, _preview(body))
57
+ else:
58
+ entry = "%s%s: %s" % (cls, "[%s]" % b["block_type"] if b["block_type"] else "", _preview(text))
59
+ noise.append(entry)
60
+ elif b["skipped_kind"] in ("image", "document"):
61
+ noise.append("%s attached (%d bytes, not shown)" % (b["skipped_kind"], b["skipped_bytes"]))
62
+ if len(noise) > MAX_NOISE_LINES:
63
+ extra = len(noise) - MAX_NOISE_LINES
64
+ noise = noise[:MAX_NOISE_LINES] + ["(+%d more tool/system items)" % extra]
65
+ lines += ["· " + n for n in noise]
66
+ lines.append("--- end ---")
67
+ prose = "\n".join(t for _, t in segments)
68
+ return PromptPack(level="leaf", text="\n".join(lines), source_bytes=node["source_bytes"],
69
+ proper_nouns=extract_proper_nouns(prose), sensitivity=meta.get("sensitivity", "normal"),
70
+ segments=segments, noise=noise, span=_span(node["first_ts"], node["last_ts"]),
71
+ n_sources=node["n_sources"])
72
+
73
+
74
+ def children_of(conn: sqlite3.Connection, node_id: str) -> List[sqlite3.Row]:
75
+ return conn.execute("SELECT * FROM nodes WHERE parent_id = ? ORDER BY COALESCE(first_ts, ''), id", (node_id,)).fetchall()
76
+
77
+
78
+ def upper_pack(node: sqlite3.Row, children: List[sqlite3.Row], meta: Dict[str, Any]) -> PromptPack:
79
+ segments = []
80
+ kind = meta.get("kind")
81
+ kind_note = {"hearth": " KIND: hearth (live conversations)",
82
+ "scheduled": " KIND: scheduled (unattended automated runs)"}.get(kind, "")
83
+ lines = ["LEVEL: %s SPAN: %s PARTS: %d%s" % (node["level"], _span(node["first_ts"], node["last_ts"]),
84
+ len(children), kind_note),
85
+ "--- parts ---"]
86
+ for i, ch in enumerate(children, start=1):
87
+ if ch["status"] == "ok":
88
+ body = ch["summary"].strip()
89
+ else:
90
+ body = "[summary unavailable: this part was flagged for manual summarization (%d sources)]" % ch["n_sources"]
91
+ head = "[part %d, %s, %s]" % (i, ch["level"], _span(ch["first_ts"], ch["last_ts"]))
92
+ segments.append(("child", body))
93
+ lines.append(head + "\n" + body)
94
+ lines.append("--- end ---")
95
+ joined = "\n".join(t for _, t in segments)
96
+ return PromptPack(level=node["level"], text="\n".join(lines), source_bytes=len(joined.encode("utf-8")),
97
+ proper_nouns=extract_proper_nouns(joined), sensitivity=meta.get("sensitivity", "normal"),
98
+ segments=segments, span=_span(node["first_ts"], node["last_ts"]), n_sources=len(children))
99
+
100
+
101
+ def make_pack(conn: sqlite3.Connection, node: sqlite3.Row) -> Optional[PromptPack]:
102
+ """The prompt pack for a node, or None while any child is not yet accepted/manual."""
103
+ meta = json.loads(node["metrics_json"] or "{}")
104
+ if node["level"] == "leaf":
105
+ return leaf_pack(conn, node, meta)
106
+ kids = children_of(conn, node["id"])
107
+ if not kids or any(k["status"] not in READY for k in kids):
108
+ return None
109
+ return upper_pack(node, kids, meta)
110
+
111
+
112
+ # ----------------------------------------------------------------------------- build
113
+
114
+ @dataclasses.dataclass
115
+ class BuildOptions:
116
+ levels: Sequence[str] = LEVELS
117
+ thread_ids: Optional[List[str]] = None
118
+ chunk_bytes: int = dag.DEFAULT_CHUNK_BYTES
119
+ limit: int = 0
120
+ dry_run: bool = False
121
+ workers: int = 1 # concurrent summarizer calls within a level (network-bound; SQLite stays single-writer)
122
+
123
+
124
+ def _pending(conn: sqlite3.Connection, level: str, scope: Optional[List[str]]) -> List[sqlite3.Row]:
125
+ rows = conn.execute("""SELECT * FROM nodes WHERE level = ? AND status IN ('pending', 'flagged')
126
+ ORDER BY COALESCE(first_ts, ''), id""", (level,)).fetchall()
127
+ if scope is not None and level != "month":
128
+ keep = set(scope)
129
+ rows = [r for r in rows if r["thread_id"] in keep]
130
+ return rows
131
+
132
+
133
+ def _store(conn: sqlite3.Connection, node: sqlite3.Row, pack: PromptPack, res: SummaryResult, status: str,
134
+ attempts: int, report: FlinchReport, history: List[Dict[str, Any]], kids: Optional[List[sqlite3.Row]]) -> None:
135
+ meta = json.loads(node["metrics_json"] or "{}")
136
+ meta.update(sensitivity=pack.sensitivity, report=report.as_dict(), history=history[-4:],
137
+ usage={"input_tokens": res.input_tokens, "output_tokens": res.output_tokens, "cost_usd": res.cost_usd})
138
+ if res.refused:
139
+ meta["refused"] = res.detail
140
+ if kids is not None:
141
+ meta["children_sig"] = dag.children_sig(kids)
142
+ conn.execute("""UPDATE nodes SET summary=?, summary_bytes=?, model=?, backend=?, status=?, attempts=?, metrics_json=?,
143
+ source_bytes=?, created_at=? WHERE id=?""",
144
+ (res.text, len(res.text.encode("utf-8")), res.model, res.backend, status, attempts, json.dumps(meta),
145
+ pack.source_bytes, now_iso(), node["id"]))
146
+ conn.execute("DELETE FROM nodes_fts WHERE node_id = ?", (node["id"],))
147
+ if status == "ok" and res.text:
148
+ conn.execute("INSERT INTO nodes_fts(node_id, summary) VALUES (?, ?)", (node["id"], res.text))
149
+
150
+
151
+ def set_manual_summary(conn: sqlite3.Connection, node: sqlite3.Row, text: str) -> None:
152
+ """Store an operator-written summary: backend `manual`, model `human`, status `ok`, indexed in nodes_fts.
153
+ The metrics (report, earlier history) are kept and a {"tier": "manual"} entry is appended. Ancestors built
154
+ over this node's placeholder are rebuilt by the next `mem build` (their child signature changes)."""
155
+ text = text.strip()
156
+ if not text:
157
+ raise ValueError("empty summary")
158
+ meta = json.loads(node["metrics_json"] or "{}")
159
+ nbytes = len(text.encode("utf-8"))
160
+ meta["history"] = (meta.get("history") or []) + [{"tier": "manual", "model": "human", "summary_bytes": nbytes,
161
+ "failed": [], "detail": {}, "evasion_hits": None}]
162
+ meta["manual"] = True
163
+ conn.execute("""UPDATE nodes SET summary=?, summary_bytes=?, model='human', backend='manual', status='ok',
164
+ metrics_json=?, created_at=? WHERE id=?""", (text, nbytes, json.dumps(meta), now_iso(), node["id"]))
165
+ conn.execute("DELETE FROM nodes_fts WHERE node_id = ?", (node["id"],))
166
+ conn.execute("INSERT INTO nodes_fts(node_id, summary) VALUES (?, ?)", (node["id"], text))
167
+
168
+
169
+ @dataclasses.dataclass
170
+ class _Job:
171
+ """One node moving through its attempts. Owned by the main thread; workers only see snapshots."""
172
+ node: sqlite3.Row
173
+ pack: PromptPack
174
+ kids: Optional[List[sqlite3.Row]]
175
+ failures: int
176
+ history: List[Dict[str, Any]]
177
+
178
+
179
+ class Builder:
180
+ """Escalation per node: small tier (leaf) / mid (upper) -> rerun tier -> (only if the rerun was refused)
181
+ small tier at effort medium -> manual. Every outcome is committed by the MAIN thread; worker threads
182
+ only call `summarizer.summarize()` and `flinch.evaluate()`, so SQLite has a single writer and an
183
+ interruption leaves finished nodes stored and unfinished ones untouched."""
184
+
185
+ def __init__(self, conn: sqlite3.Connection, summarizer: Summarizer, log: Callable[[str], None] = lambda s: None,
186
+ workers: int = 1):
187
+ self.conn = conn
188
+ self.summarizer = summarizer
189
+ self.log = log
190
+ self.workers = max(1, int(workers or 1))
191
+ self._baselines: Dict[str, Optional[Baseline]] = {}
192
+ self._since: Dict[str, int] = {}
193
+
194
+ def baseline(self, level: str) -> Optional[Baseline]:
195
+ """Read from the DB (main thread) once per batch of 25 accepted nodes, not per node."""
196
+ n = self._since.get(level, 10 ** 9)
197
+ if level not in self._baselines or n >= 25:
198
+ self._baselines[level] = baseline_for_level(self.conn, level)
199
+ self._since[level] = 0
200
+ return self._baselines[level]
201
+
202
+ # ---- the pieces
203
+ def _tier(self, level: str, step: int) -> str:
204
+ if step <= 0:
205
+ return "small" if level == "leaf" else "mid"
206
+ if step == 1:
207
+ return self.summarizer.rerun_tier(level)
208
+ return FALLBACK_TIER
209
+
210
+ def _attempt(self, job_pack: PromptPack, node_level: str, tier: str,
211
+ baseline: Optional[Baseline]) -> Tuple[SummaryResult, FlinchReport]:
212
+ """WORKER THREAD. No database access."""
213
+ res = self.summarizer.summarize(job_pack, tier)
214
+ report = evaluate(res.text, level=node_level, source_bytes=job_pack.source_bytes,
215
+ proper_nouns=job_pack.proper_nouns, sensitivity=job_pack.sensitivity,
216
+ baseline=baseline, source_text=job_pack.text)
217
+ return res, report
218
+
219
+ def _submit(self, ex: ThreadPoolExecutor, inflight: Dict[Any, Tuple[_Job, int, str]], job: _Job) -> None:
220
+ step = min(job.failures, 2)
221
+ tier = self._tier(job.node["level"], step)
222
+ p = job.pack
223
+ if job.history:
224
+ if "truncated" in (job.history[-1].get("failed") or []):
225
+ from .summarize import MAX_WORDS
226
+ p = dataclasses.replace(p, feedback="it was CUT OFF by the output length limit. Write at most %d words "
227
+ "this time: raise the altitude, group rather than enumerate, keep "
228
+ "every concrete name that matters."
229
+ % MAX_WORDS.get(job.node["level"], 900))
230
+ else:
231
+ p = dataclasses.replace(p, feedback="it failed the quality checks (%s); cover every concrete name, "
232
+ "decision and event, and do not omit or soften anything."
233
+ % ", ".join(job.history[-1].get("failed") or ["?"]))
234
+ fut = ex.submit(self._attempt, p, job.node["level"], tier, self.baseline(job.node["level"]))
235
+ inflight[fut] = (job, step, tier)
236
+
237
+ def _apply(self, job: _Job, step: int, tier: str, res: SummaryResult, report: FlinchReport) -> str:
238
+ """MAIN THREAD. Record the attempt, store and commit. Returns ok | flagged (rerun pending) | manual."""
239
+ node, conn = job.node, self.conn
240
+ entry: Dict[str, Any] = {
241
+ "tier": tier, "model": res.model, "summary_bytes": len(res.text.encode("utf-8")),
242
+ "failed": report.failed, "detail": res.detail,
243
+ "evasion_hits": next((c.detail.get("hits") for c in report.checks if c.name == "evasion"), None)}
244
+ if step >= 2:
245
+ entry.update(effort="medium", reason="rerun refused; one more try on the small tier at effort medium")
246
+ if res.refused:
247
+ entry["refused"] = True
248
+ truncated = bool(res.detail.get("truncated")) if isinstance(res.detail, dict) else False
249
+ if truncated:
250
+ entry["failed"] = list(report.failed) + ["truncated"] # a cut-off summary never passes, whatever the checks say
251
+ job.history.append(entry)
252
+ if report.passed and not truncated:
253
+ _store(conn, node, job.pack, res, "ok", job.failures, report, job.history, job.kids)
254
+ self._since[node["level"]] = self._since.get(node["level"], 0) + 1
255
+ conn.commit()
256
+ return "ok"
257
+ job.failures += 1
258
+ if step == 0:
259
+ status = "flagged"
260
+ elif step == 1 and res.refused:
261
+ status = "flagged"
262
+ entry["note"] = "refused; next try: small tier at effort medium"
263
+ else:
264
+ status = "manual"
265
+ _store(conn, node, job.pack, res, status, job.failures, report, job.history, job.kids)
266
+ conn.commit()
267
+ self.log("%s %s %s: failed %s" % (node["level"], node["id"],
268
+ "-> manual" if status == "manual" else "-> rerun on " + self._tier(node["level"], job.failures),
269
+ ",".join(entry["failed"])))
270
+ return status
271
+
272
+ # ---- one level
273
+ def run_level(self, level: str, pending: List[sqlite3.Row], remaining: Optional[int],
274
+ lv: Dict[str, int], out: Dict[str, Any]) -> int:
275
+ """Build `pending` with up to `self.workers` calls in flight. Returns the number of nodes started.
276
+ Sets out['stopped'] to 'limit' or 'budget' when it stops early. A worker exception stops dispatch,
277
+ lets the calls already in flight finish and be stored, then re-raises (the failed node stays as it was)."""
278
+ conn = self.conn
279
+ nodes = iter(pending)
280
+ inflight: Dict[Any, Tuple[_Job, int, str]] = {}
281
+ started = 0
282
+ exhausted = False
283
+ first_exc: Optional[BaseException] = None
284
+ ex = ThreadPoolExecutor(max_workers=self.workers)
285
+ try:
286
+ while True:
287
+ while not out["stopped"] and first_exc is None and not exhausted and len(inflight) < self.workers:
288
+ node = next(nodes, None)
289
+ if node is None:
290
+ exhausted = True
291
+ break
292
+ if remaining is not None and started >= remaining:
293
+ out["stopped"] = "limit"
294
+ break
295
+ pack = make_pack(conn, node)
296
+ if pack is None:
297
+ lv["not_ready"] += 1
298
+ out["not_ready"] += 1
299
+ continue
300
+ kids = children_of(conn, node["id"]) if level != "leaf" else None
301
+ failures = node["attempts"]
302
+ history = json.loads(node["metrics_json"] or "{}").get("history", []) if failures else []
303
+ self._submit(ex, inflight, _Job(node, pack, kids, failures, history))
304
+ started += 1
305
+ if not inflight:
306
+ break
307
+ done, _ = wait(list(inflight), return_when=FIRST_COMPLETED)
308
+ for fut in done:
309
+ job, step, tier = inflight.pop(fut)
310
+ try:
311
+ res, report = fut.result()
312
+ except BudgetExceeded as e:
313
+ out["stopped"], out["stop_reason"] = "budget", str(e) # node untouched: still pending/flagged
314
+ continue
315
+ except Exception as e: # noqa: BLE001
316
+ first_exc = first_exc or e
317
+ continue
318
+ status = self._apply(job, step, tier, res, report)
319
+ if status == "flagged":
320
+ if out["stopped"] != "budget" and first_exc is None:
321
+ self._submit(ex, inflight, job)
322
+ continue # else left flagged: next build resumes it
323
+ lv[status] += 1
324
+ out["built"] += 1 if status == "ok" else 0
325
+ out["manual"] += 1 if status == "manual" else 0
326
+ if status == "ok" and job.failures:
327
+ out["flagged_then_ok"] += 1
328
+ except BaseException:
329
+ # Ctrl-C or an unexpected error in the main thread: stop everything not yet running, keep what is stored.
330
+ ex.shutdown(wait=False, cancel_futures=True)
331
+ conn.rollback()
332
+ raise
333
+ ex.shutdown(wait=True)
334
+ if first_exc is not None:
335
+ raise first_exc
336
+ return started
337
+
338
+
339
+ def run_build(conn: sqlite3.Connection, summarizer: Summarizer, opts: BuildOptions,
340
+ log: Callable[[str], None] = lambda s: None) -> Dict[str, Any]:
341
+ """Sync the node structure, then build pending nodes level by level. Returns a stats dict."""
342
+ if opts.dry_run and not conn.in_transaction:
343
+ conn.execute("BEGIN")
344
+ dag.ensure_schema(conn)
345
+ scope = opts.thread_ids
346
+ sync = dag.sync_structure(conn, opts.chunk_bytes, scope, months=scope is None)
347
+ out: Dict[str, Any] = {"sync": sync, "levels": {}, "built": 0, "flagged_then_ok": 0, "manual": 0,
348
+ "not_ready": 0, "stopped": None, "dry_run": opts.dry_run, "workers": max(1, opts.workers)}
349
+ if opts.dry_run:
350
+ out["plan"] = plan_stats(conn, opts.levels, scope, summarizer)
351
+ conn.rollback()
352
+ return out
353
+ conn.commit()
354
+ b = Builder(conn, summarizer, log, workers=opts.workers)
355
+ done = 0
356
+ for level in LEVELS: # a level starts only when every node of the one below has finished
357
+ if level not in opts.levels:
358
+ continue
359
+ lv = out["levels"].setdefault(level, {"ok": 0, "manual": 0, "not_ready": 0})
360
+ done += b.run_level(level, _pending(conn, level, scope), (opts.limit - done) if opts.limit else None, lv, out)
361
+ if out["stopped"]:
362
+ break
363
+ out["spent_usd"] = round(getattr(summarizer, "spent_usd", 0.0), 6)
364
+ return out
365
+
366
+
367
+ # ----------------------------------------------------------------------------- dry-run plan
368
+
369
+ def plan_stats(conn: sqlite3.Connection, levels: Sequence[str], scope: Optional[List[str]],
370
+ summarizer: Summarizer) -> Dict[str, Any]:
371
+ """What a build would do right now: per level, nodes pending, source bytes and estimated tokens.
372
+ Upper-level sources are estimated from existing summaries or EST_RATIO of their own sources."""
373
+ est: Dict[str, float] = {} # node id -> estimated summary bytes
374
+ plan: Dict[str, Any] = {}
375
+ keep = set(scope) if scope is not None else None
376
+ for level in LEVELS:
377
+ rows = conn.execute("SELECT * FROM nodes WHERE level = ?", (level,)).fetchall()
378
+ n_pending = 0
379
+ src_total = 0.0
380
+ for r in rows:
381
+ if level == "leaf":
382
+ src = float(r["source_bytes"])
383
+ else:
384
+ src = sum(est.get(k["id"], 0.0) for k in children_of(conn, r["id"]))
385
+ est[r["id"]] = float(r["summary_bytes"]) if r["status"] in READY and r["summary_bytes"] else src * EST_RATIO
386
+ is_pending = r["status"] in ("pending", "flagged")
387
+ in_scope = keep is None or (level != "month" and r["thread_id"] in keep)
388
+ if is_pending and in_scope:
389
+ n_pending += 1
390
+ src_total += src
391
+ if level in levels:
392
+ tokens = int(src_total / 4)
393
+ tier = "small" if level == "leaf" else "mid"
394
+ model = getattr(summarizer, "models", {}).get(tier)
395
+ pin, pout = PRICES.get(model, UNKNOWN_PRICE) if model else (0.0, 0.0)
396
+ plan[level] = {"nodes": len(rows), "pending": n_pending, "source_bytes": int(src_total),
397
+ "est_input_tokens": tokens,
398
+ "est_cost_usd": round((tokens * pin + tokens * EST_RATIO * pout) / 1e6, 4)}
399
+ return plan