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.
- embers_memory-0.2.0/LICENSE +21 -0
- embers_memory-0.2.0/PKG-INFO +67 -0
- embers_memory-0.2.0/README.md +52 -0
- embers_memory-0.2.0/embers/__init__.py +3 -0
- embers_memory-0.2.0/embers/__main__.py +3 -0
- embers_memory-0.2.0/embers/build.py +399 -0
- embers_memory-0.2.0/embers/cli.py +1017 -0
- embers_memory-0.2.0/embers/dag.py +458 -0
- embers_memory-0.2.0/embers/detect.py +49 -0
- embers_memory-0.2.0/embers/flinch.py +248 -0
- embers_memory-0.2.0/embers/ingest.py +634 -0
- embers_memory-0.2.0/embers/records.py +353 -0
- embers_memory-0.2.0/embers/skillinstall.py +37 -0
- embers_memory-0.2.0/embers/summarize.py +331 -0
- embers_memory-0.2.0/embers_memory.egg-info/PKG-INFO +67 -0
- embers_memory-0.2.0/embers_memory.egg-info/SOURCES.txt +36 -0
- embers_memory-0.2.0/embers_memory.egg-info/dependency_links.txt +1 -0
- embers_memory-0.2.0/embers_memory.egg-info/entry_points.txt +3 -0
- embers_memory-0.2.0/embers_memory.egg-info/requires.txt +6 -0
- embers_memory-0.2.0/embers_memory.egg-info/top_level.txt +1 -0
- embers_memory-0.2.0/pyproject.toml +34 -0
- embers_memory-0.2.0/setup.cfg +4 -0
- embers_memory-0.2.0/skills/embers/SKILL.md +176 -0
- embers_memory-0.2.0/tests/test_cli.py +225 -0
- embers_memory-0.2.0/tests/test_concurrent.py +311 -0
- embers_memory-0.2.0/tests/test_dag.py +329 -0
- embers_memory-0.2.0/tests/test_dag_cli.py +142 -0
- embers_memory-0.2.0/tests/test_detect.py +81 -0
- embers_memory-0.2.0/tests/test_fixtures.py +361 -0
- embers_memory-0.2.0/tests/test_flinch.py +138 -0
- embers_memory-0.2.0/tests/test_ingest.py +403 -0
- embers_memory-0.2.0/tests/test_install_skill.py +67 -0
- embers_memory-0.2.0/tests/test_manual_cli.py +166 -0
- embers_memory-0.2.0/tests/test_packaging.py +26 -0
- embers_memory-0.2.0/tests/test_permissions.py +41 -0
- embers_memory-0.2.0/tests/test_records.py +217 -0
- embers_memory-0.2.0/tests/test_release_kit.py +63 -0
- 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,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
|