embers-memory 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
embers/__init__.py ADDED
@@ -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"
embers/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
embers/build.py ADDED
@@ -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