claude-mem 13.26.1 → 13.27.0

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 (111) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/dist/npx-cli/index.js +1 -1
  3. package/openclaw/openclaw.plugin.json +1 -1
  4. package/openclaw/skills/agent-cost-report/CHECKSUMS.txt +47 -0
  5. package/openclaw/skills/agent-cost-report/SKILL.md +240 -0
  6. package/openclaw/skills/agent-cost-report/scripts/acr/__init__.py +1 -0
  7. package/openclaw/skills/agent-cost-report/scripts/acr/behavior.py +313 -0
  8. package/openclaw/skills/agent-cost-report/scripts/acr/classify.py +83 -0
  9. package/openclaw/skills/agent-cost-report/scripts/acr/costs.py +121 -0
  10. package/openclaw/skills/agent-cost-report/scripts/acr/devices.py +88 -0
  11. package/openclaw/skills/agent-cost-report/scripts/acr/evidence.py +165 -0
  12. package/openclaw/skills/agent-cost-report/scripts/acr/labels.py +112 -0
  13. package/openclaw/skills/agent-cost-report/scripts/acr/measure.py +89 -0
  14. package/openclaw/skills/agent-cost-report/scripts/acr/mistakes.py +321 -0
  15. package/openclaw/skills/agent-cost-report/scripts/acr/patterns.py +394 -0
  16. package/openclaw/skills/agent-cost-report/scripts/acr/period.py +101 -0
  17. package/openclaw/skills/agent-cost-report/scripts/acr/prices.py +102 -0
  18. package/openclaw/skills/agent-cost-report/scripts/acr/render.py +516 -0
  19. package/openclaw/skills/agent-cost-report/scripts/acr/rollup.py +364 -0
  20. package/openclaw/skills/agent-cost-report/scripts/acr/rules.json +26 -0
  21. package/openclaw/skills/agent-cost-report/scripts/acr/rules.py +78 -0
  22. package/openclaw/skills/agent-cost-report/scripts/acr/sample.py +45 -0
  23. package/openclaw/skills/agent-cost-report/scripts/acr/snapshot.py +55 -0
  24. package/openclaw/skills/agent-cost-report/scripts/acr/sync.py +78 -0
  25. package/openclaw/skills/agent-cost-report/scripts/acr/transcripts.py +130 -0
  26. package/openclaw/skills/agent-cost-report/scripts/acr/wins.py +286 -0
  27. package/openclaw/skills/agent-cost-report/scripts/acr.py +206 -0
  28. package/openclaw/skills/agent-cost-report/scripts/tests/_db.py +49 -0
  29. package/openclaw/skills/agent-cost-report/scripts/tests/_paths.py +9 -0
  30. package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/codex/sessions/2026/09/18/rollout-codex-1.jsonl +4 -0
  31. package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/make_report_fixtures.py +46 -0
  32. package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/prices_sample.json +6 -0
  33. package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/projects/-workspace-proj-a/sess-aaaa.jsonl +8 -0
  34. package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/projects/-workspace-proj-b/sess-bbbb.jsonl +1 -0
  35. package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/report-1day.json +823 -0
  36. package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/report-30day.json +2213 -0
  37. package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/report-7day.json +1252 -0
  38. package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/report-empty.json +536 -0
  39. package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/report-session.json +662 -0
  40. package/openclaw/skills/agent-cost-report/scripts/tests/test_behavior.py +275 -0
  41. package/openclaw/skills/agent-cost-report/scripts/tests/test_devices.py +99 -0
  42. package/openclaw/skills/agent-cost-report/scripts/tests/test_labels.py +70 -0
  43. package/openclaw/skills/agent-cost-report/scripts/tests/test_measure.py +118 -0
  44. package/openclaw/skills/agent-cost-report/scripts/tests/test_period.py +91 -0
  45. package/openclaw/skills/agent-cost-report/scripts/tests/test_prices.py +126 -0
  46. package/openclaw/skills/agent-cost-report/scripts/tests/test_render.py +114 -0
  47. package/openclaw/skills/agent-cost-report/scripts/tests/test_rollup.py +264 -0
  48. package/openclaw/skills/agent-cost-report/scripts/tests/test_snapshot.py +64 -0
  49. package/openclaw/skills/agent-cost-report/scripts/tests/test_sync.py +52 -0
  50. package/openclaw/skills/agent-cost-report/scripts/tests/test_transcripts.py +154 -0
  51. package/openclaw/skills/agent-cost-report/scripts/tests/test_wins.py +181 -0
  52. package/package.json +1 -1
  53. package/plugin/.claude-plugin/plugin.json +1 -1
  54. package/plugin/.codex-plugin/plugin.json +1 -1
  55. package/plugin/package.json +1 -1
  56. package/plugin/scripts/mcp-server.cjs +1 -1
  57. package/plugin/scripts/server-service.cjs +2 -2
  58. package/plugin/scripts/transcript-watcher.cjs +2 -2
  59. package/plugin/scripts/transcript-watcher.cjs.map +1 -1
  60. package/plugin/scripts/worker-service.cjs +5 -5
  61. package/plugin/scripts/worker-service.cjs.map +1 -1
  62. package/plugin/skills/agent-cost-report/CHECKSUMS.txt +47 -0
  63. package/plugin/skills/agent-cost-report/SKILL.md +240 -0
  64. package/plugin/skills/agent-cost-report/VERIFICATION.md +106 -0
  65. package/plugin/skills/agent-cost-report/scripts/acr/__init__.py +1 -0
  66. package/plugin/skills/agent-cost-report/scripts/acr/behavior.py +313 -0
  67. package/plugin/skills/agent-cost-report/scripts/acr/classify.py +83 -0
  68. package/plugin/skills/agent-cost-report/scripts/acr/costs.py +121 -0
  69. package/plugin/skills/agent-cost-report/scripts/acr/devices.py +88 -0
  70. package/plugin/skills/agent-cost-report/scripts/acr/evidence.py +165 -0
  71. package/plugin/skills/agent-cost-report/scripts/acr/labels.py +112 -0
  72. package/plugin/skills/agent-cost-report/scripts/acr/measure.py +89 -0
  73. package/plugin/skills/agent-cost-report/scripts/acr/mistakes.py +321 -0
  74. package/plugin/skills/agent-cost-report/scripts/acr/patterns.py +394 -0
  75. package/plugin/skills/agent-cost-report/scripts/acr/period.py +101 -0
  76. package/plugin/skills/agent-cost-report/scripts/acr/prices.py +102 -0
  77. package/plugin/skills/agent-cost-report/scripts/acr/render.py +516 -0
  78. package/plugin/skills/agent-cost-report/scripts/acr/rollup.py +364 -0
  79. package/plugin/skills/agent-cost-report/scripts/acr/rules.json +26 -0
  80. package/plugin/skills/agent-cost-report/scripts/acr/rules.py +78 -0
  81. package/plugin/skills/agent-cost-report/scripts/acr/sample.py +45 -0
  82. package/plugin/skills/agent-cost-report/scripts/acr/snapshot.py +55 -0
  83. package/plugin/skills/agent-cost-report/scripts/acr/sync.py +78 -0
  84. package/plugin/skills/agent-cost-report/scripts/acr/transcripts.py +130 -0
  85. package/plugin/skills/agent-cost-report/scripts/acr/wins.py +286 -0
  86. package/plugin/skills/agent-cost-report/scripts/acr.py +206 -0
  87. package/plugin/skills/agent-cost-report/scripts/tests/_db.py +49 -0
  88. package/plugin/skills/agent-cost-report/scripts/tests/_paths.py +9 -0
  89. package/plugin/skills/agent-cost-report/scripts/tests/fixtures/codex/sessions/2026/09/18/rollout-codex-1.jsonl +4 -0
  90. package/plugin/skills/agent-cost-report/scripts/tests/fixtures/make_report_fixtures.py +46 -0
  91. package/plugin/skills/agent-cost-report/scripts/tests/fixtures/prices_sample.json +6 -0
  92. package/plugin/skills/agent-cost-report/scripts/tests/fixtures/projects/-workspace-proj-a/sess-aaaa.jsonl +8 -0
  93. package/plugin/skills/agent-cost-report/scripts/tests/fixtures/projects/-workspace-proj-b/sess-bbbb.jsonl +1 -0
  94. package/plugin/skills/agent-cost-report/scripts/tests/fixtures/report-1day.json +823 -0
  95. package/plugin/skills/agent-cost-report/scripts/tests/fixtures/report-30day.json +2213 -0
  96. package/plugin/skills/agent-cost-report/scripts/tests/fixtures/report-7day.json +1252 -0
  97. package/plugin/skills/agent-cost-report/scripts/tests/fixtures/report-empty.json +536 -0
  98. package/plugin/skills/agent-cost-report/scripts/tests/fixtures/report-session.json +662 -0
  99. package/plugin/skills/agent-cost-report/scripts/tests/test_behavior.py +275 -0
  100. package/plugin/skills/agent-cost-report/scripts/tests/test_devices.py +99 -0
  101. package/plugin/skills/agent-cost-report/scripts/tests/test_labels.py +70 -0
  102. package/plugin/skills/agent-cost-report/scripts/tests/test_measure.py +118 -0
  103. package/plugin/skills/agent-cost-report/scripts/tests/test_period.py +91 -0
  104. package/plugin/skills/agent-cost-report/scripts/tests/test_prices.py +126 -0
  105. package/plugin/skills/agent-cost-report/scripts/tests/test_render.py +114 -0
  106. package/plugin/skills/agent-cost-report/scripts/tests/test_rollup.py +264 -0
  107. package/plugin/skills/agent-cost-report/scripts/tests/test_snapshot.py +64 -0
  108. package/plugin/skills/agent-cost-report/scripts/tests/test_sync.py +52 -0
  109. package/plugin/skills/agent-cost-report/scripts/tests/test_transcripts.py +154 -0
  110. package/plugin/skills/agent-cost-report/scripts/tests/test_wins.py +181 -0
  111. package/plugin/skills/agent-cost-report/verification/precision-2026-09-26.json +7 -0
@@ -0,0 +1,313 @@
1
+ """Phase 2B shared machinery (plan 2B.1, 2B.2, 2B.9): a second streaming pass over the same
2
+ transcript files and window as the Phase 1 collector, building ordered turns per session, tagging
3
+ every user turn human | bot | unknown (prerequisite R2, plan 0.5), finding frustration episodes,
4
+ and pricing wasted turns with the Phase 1 rate() table in integer micro-dollars.
5
+
6
+ Sources: box Claude Code transcripts (agent turns plus tagged user turns) and claude-mem
7
+ `user_prompts` rows (local and replica, tagged). Grok Bot chats stay "unavailable" (R1).
8
+ Field names were verified on box transcripts 2026-09-25 (plan 2B.7): assistant lines carry
9
+ `message.content[] {type: text|tool_use {id, name, input}}`, user lines carry
10
+ `{type: tool_result {tool_use_id, is_error, content}}` or text; `entrypoint` is cli | sdk-cli | sdk-ts.
11
+ """
12
+ import collections
13
+ import datetime as dt
14
+ import glob
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import re
19
+
20
+ from . import costs, prices, transcripts
21
+ from .period import PT
22
+
23
+ # ---- constants (plan 2B.1) ----
24
+ GAP_MIN = 45 # episode grouping gap (MI:8)
25
+ WASTED_CAP_MS = 6 * 3600_000 # wasted window cap (MI:9)
26
+ REDO_MS = 60 * 60_000 # redo window after the episode's last message (MI:9)
27
+ ALEX_WAIT_CAP_MIN = 60 # Alex-minutes wait cap (MI:10)
28
+ EXCERPT_CHARS = 160
29
+ TEXT_KEEP = 6000 # chars of assistant text kept per turn (bounded memory)
30
+ RESULT_KEEP = 2048 # first 2 KB of a tool result (S1 rule)
31
+ INPUT_KEEP = 1500
32
+ HEADLESS = ("sdk-cli", "sdk-ts")
33
+
34
+ # Frustration Arc markers (MI:7) plus the agent markers in plan 2B.1 and the relay/automation
35
+ # prefixes seen in claude-mem user_prompts on this box (2026-09-26 probe).
36
+ BOT_MARKERS = re.compile(
37
+ r"^\s*(STEER from Alex|HOUSE LOCK|\[SAND_HIDDEN_PROMPT\]|<system-reminder>|<timestamp>|<cross-session-message|<command-|<local-command|"
38
+ r"\[Request interrupted|MAKE-PLAN\b|make-plan ONLY|# make-plan|Branch: [\w/.-]+ \(this worktree|"
39
+ r"\[Cross-session idle notice\]|\[Group chat:|\[Artifact comment|Agent sweep|AJ email check|Quiet overnight|"
40
+ r"You name new code workspaces|You are\b|/do\b|/make-plan\b|/loop\b|/claude-mem:|/ccs-|/[a-z][\w-]*:[a-z][\w-]*\b|"
41
+ r"(STEER|RESET|CORRECTION) from Alex|Resuming after|\[SYSTEM NOTIFICATION|<task-notification>|<pasted_content)"
42
+ r"|^.{0,200}\bAlex (asked|said|wants|greened|approved)\b" # third-person relays of Alex are agent-written
43
+ r"|^(?=.{400,})[^\n]{0,300}\bYou are (a|an|the|in|one|running|working)\b", re.I | re.S) # long briefs that address the agent
44
+ CORRECTION = re.compile(r"^\s*(STEER|RESET|CORRECTION) from Alex|\bSTEER\b.{0,10}\bfrom Alex\b|^\s*(RESET|CORRECTION):", re.I)
45
+ PROFANITY = re.compile(r"\b(fuck\w*|shit\w*|wtf|goddamn)\b", re.I)
46
+ FRUSTRATION_PHRASE = re.compile(r"\b(i told you|i said|why did you|who (said|made|told)|stop|never|again)\b", re.I)
47
+ SECRET_RE = re.compile(r"(sk-or-[\w-]+|sk-[\w-]{8,}|Bearer\s+\S+|ghp_\w+|gho_\w+|xox[abp]-[\w-]+|AKIA\w{12,}|[A-Za-z0-9+/=_-]{32,})")
48
+ # key=value / key: value credentials of any length (password=hunter2, token: abc, api_key="x"); the key stays, the value goes
49
+ KV_SECRET_RE = re.compile(r"\b(pass(?:word|wd|phrase|code)?|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|auth|credentials?)\b(\s*[:=]\s*)[\"']?[^\s\"',;]+[\"']?", re.I)
50
+ FENCE_RE = re.compile(r"```.*?```", re.S)
51
+ QUOTE_RE = re.compile(r"^\s*>.*$", re.M)
52
+ MISS_RE = re.compile(r"usage limit|rate.?limit|\b429\b|\b401\b|dead token|token (has )?expired|unauthori[sz]ed|quota", re.I)
53
+
54
+ FIELDS = ("input", "output", "cache_write_5m", "cache_write_1h", "cache_read")
55
+
56
+
57
+ def scrub(text, n=EXCERPT_CHARS):
58
+ """Excerpts: at most 160 chars after the secret scrubber (plan 2B.1)."""
59
+ t = KV_SECRET_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}[redacted]", (text or "").replace("\n", " ").strip())
60
+ t = SECRET_RE.sub("[redacted]", t)
61
+ return t[:n]
62
+
63
+
64
+ def user_facing(text):
65
+ """'User-facing text' = assistant text with code fences, block quotes and tool inputs removed."""
66
+ return QUOTE_RE.sub("", FENCE_RE.sub("", text or "")).strip()
67
+
68
+
69
+ def words(text):
70
+ return len(re.findall(r"[A-Za-z0-9'’]+", text or ""))
71
+
72
+
73
+ def is_frustrated(text):
74
+ """Heuristic frustration detector (MI:6, ~51% precision): profanity, ≥40% caps over ≥12 letters,
75
+ !!! or ???, or a phrase like 'i told you'."""
76
+ if not text: return False
77
+ if PROFANITY.search(text) or "!!!" in text or "???" in text or FRUSTRATION_PHRASE.search(text): return True
78
+ letters = [c for c in text if c.isalpha()]
79
+ return len(letters) >= 12 and sum(c.isupper() for c in letters) / len(letters) >= 0.4
80
+
81
+
82
+ def _ts_ms(iso):
83
+ t = transcripts.ts(iso)
84
+ return int(t.timestamp() * 1000)
85
+
86
+
87
+ def _usage_row(u):
88
+ """copied from transcripts.py:66-75 (the same 5m/1h split as Phase 1)."""
89
+ cc = u.get("cache_creation") or {}
90
+ cw = u.get("cache_creation_input_tokens", 0) or 0
91
+ cw1 = cc.get("ephemeral_1h_input_tokens", 0) or 0
92
+ cw5 = cc.get("ephemeral_5m_input_tokens")
93
+ return dict(input=u.get("input_tokens", 0) or 0, output=u.get("output_tokens", 0) or 0,
94
+ cache_write_5m=cw5 if cw5 is not None else cw - cw1, cache_write_1h=cw1,
95
+ cache_read=u.get("cache_read_input_tokens", 0) or 0)
96
+
97
+
98
+ def _blocks(content):
99
+ if isinstance(content, str): return [dict(type="text", text=content)]
100
+ return [b for b in (content or []) if isinstance(b, dict)]
101
+
102
+
103
+ def _result_text(content):
104
+ if isinstance(content, str): return content
105
+ out = []
106
+ for b in content or []:
107
+ if isinstance(b, dict) and b.get("type") == "text": out.append(b.get("text") or "")
108
+ elif isinstance(b, str): out.append(b)
109
+ return "\n".join(out)
110
+
111
+
112
+ def new_session():
113
+ return dict(entrypoint=None, turns={}, users=[], files=set(), sidechain_prompts=0)
114
+
115
+
116
+ def scan(s_ms, e_ms, session=None, pattern=transcripts.CLAUDE_GLOB):
117
+ """Stream the window's Claude Code transcripts (same glob and mtime prefilter as Phase 1).
118
+ Returns {sessionId: {entrypoint, turns: {key: turn}, users: [user event], files}}.
119
+ A turn = one assistant API reply keyed (message.id, requestId) with its text, tool_uses and usage."""
120
+ s_dt = dt.datetime.fromtimestamp(s_ms / 1000, PT) if s_ms is not None else None
121
+ files = [f for f in glob.glob(os.path.expanduser(pattern), recursive=True)
122
+ if s_dt is None or dt.datetime.fromtimestamp(os.path.getmtime(f), PT) >= s_dt]
123
+ sessions = collections.defaultdict(new_session)
124
+ for f in files:
125
+ with open(f, errors="ignore") as fh:
126
+ for line in fh:
127
+ if '"timestamp"' not in line or ('"assistant"' not in line and '"user"' not in line): continue
128
+ if session is not None and session not in line: continue
129
+ try: d = json.loads(line)
130
+ except Exception: continue
131
+ typ = d.get("type")
132
+ if typ not in ("assistant", "user") or not d.get("timestamp"): continue
133
+ sid = d.get("sessionId") or d.get("session_id")
134
+ if session is not None and sid != session: continue
135
+ try: t_ms = _ts_ms(d["timestamp"])
136
+ except Exception: continue
137
+ if s_ms is not None and not (s_ms <= t_ms < e_ms): continue
138
+ S = sessions[sid]; S["files"].add(f)
139
+ if d.get("entrypoint") and not S["entrypoint"]: S["entrypoint"] = d["entrypoint"]
140
+ m = d.get("message") or {}
141
+ if typ == "assistant":
142
+ key = (m.get("id"), d.get("requestId"))
143
+ T = S["turns"].get(key)
144
+ if T is None:
145
+ u = m.get("usage") or {}
146
+ row = _usage_row(u) if u else None
147
+ T = S["turns"][key] = dict(key=key, ts_ms=t_ms, model=m.get("model"), text="", tool_uses=[], usage=row,
148
+ sidechain=bool(d.get("isSidechain")), file=f, uuid=d.get("uuid"))
149
+ elif T["usage"] is None and m.get("usage"): T["usage"] = _usage_row(m["usage"])
150
+ for b in _blocks(m.get("content")):
151
+ if b.get("type") == "text" and len(T["text"]) < TEXT_KEEP: T["text"] += (b.get("text") or "")[:TEXT_KEEP]
152
+ elif b.get("type") == "tool_use":
153
+ inp = b.get("input"); inp_s = inp if isinstance(inp, str) else json.dumps(inp, sort_keys=True)
154
+ T["tool_uses"].append(dict(id=b.get("id"), name=b.get("name") or "", input=inp if isinstance(inp, dict) else {}, input_s=inp_s[:INPUT_KEEP]))
155
+ else:
156
+ blocks = _blocks(m.get("content")); results = []; texts = []
157
+ for b in blocks:
158
+ if b.get("type") == "tool_result":
159
+ results.append(dict(tool_use_id=b.get("tool_use_id"), is_error=bool(b.get("is_error")), content=_result_text(b.get("content"))[:RESULT_KEEP]))
160
+ elif b.get("type") == "text": texts.append(b.get("text") or "")
161
+ text = "\n".join(texts).strip()
162
+ if not results and not text: continue
163
+ if d.get("isMeta") and not results: continue # harness meta lines are not prompts
164
+ S["users"].append(dict(ts_ms=t_ms, text=text[:TEXT_KEEP], results=results, sidechain=bool(d.get("isSidechain")),
165
+ uuid=d.get("uuid"), turn_origin=d.get("turnOrigin")))
166
+ return dict(sessions), dict(files_seen=len(files))
167
+
168
+
169
+ # ---- human-or-bot tagging (R2) ----
170
+ def session_tag(S):
171
+ """alex_direct (interactive cli, no agent-prompt markers) | agent_relayed | unknown (plan 2B.1)."""
172
+ prompts = [u for u in S["users"] if u["text"] and not u["sidechain"] and not u["results"]]
173
+ if S["entrypoint"] in HEADLESS: return "agent_relayed"
174
+ marked = [bool(BOT_MARKERS.search(u["text"])) for u in prompts]
175
+ if marked and (marked[0] or all(marked)): return "agent_relayed" # launched by an agent, or nothing but relays
176
+ if S["entrypoint"] == "cli" and prompts: return "alex_direct" # a marker later on tags that prompt alone (tag_user_turns)
177
+ return "unknown"
178
+
179
+
180
+ def tag_user_turns(S):
181
+ tag = session_tag(S); S["tag"] = tag
182
+ for u in S["users"]:
183
+ if u["results"] and not u["text"]: u["author"] = "tool"; continue
184
+ if u["sidechain"] or tag == "agent_relayed" or BOT_MARKERS.search(u["text"]) or u.get("turn_origin") in ("task_notification", "sdk", "scheduled", "peer"):
185
+ u["author"] = "bot"
186
+ elif tag == "alex_direct": u["author"] = "human"
187
+ else: u["author"] = "unknown"
188
+ return tag
189
+
190
+
191
+ def tag_prompt_row(text):
192
+ """A claude-mem user_prompts row is human only when it passes the marker filter."""
193
+ return "bot" if BOT_MARKERS.search(text or "") else "human"
194
+
195
+
196
+ # ---- pricing turns ----
197
+ def price_turns(sessions, pricer):
198
+ for S in sessions.values():
199
+ for T in S["turns"].values():
200
+ rt = pricer.rate(T["model"]) if T["model"] else None
201
+ T["x1e6"] = costs.api_equiv_x1e6(T["usage"], rt) if (T["usage"] and rt) else None
202
+ T["model_status"] = "observed" if T["model"] else "assumed"
203
+
204
+
205
+ def ordered_turns(S):
206
+ return sorted(S["turns"].values(), key=lambda T: T["ts_ms"])
207
+
208
+
209
+ def turn_cost(turns):
210
+ """Sum of priced turns in micro-dollars; unpriced turns add nothing but are counted."""
211
+ return sum(T["x1e6"] or 0 for T in turns)
212
+
213
+
214
+ # ---- human messages: transcripts first, claude-mem user_prompts only for sessions without a box transcript ----
215
+ def load_prompt_rows(db, scope):
216
+ cols = "content_session_id, prompt_text, created_at_epoch, coalesce(origin_device_id,'local')"
217
+ if scope.kind == "session":
218
+ q = f"SELECT {cols} FROM user_prompts WHERE content_session_id = ?"; args = (scope.session,)
219
+ else:
220
+ q = f"SELECT {cols} FROM user_prompts WHERE created_at_epoch >= ? AND created_at_epoch < ?"; args = (scope.S, scope.E)
221
+ return [dict(session=r[0], text=r[1] or "", ts_ms=r[2], device=r[3]) for r in db.execute(q, args)]
222
+
223
+
224
+ def human_messages(sessions, prompt_rows):
225
+ """Per content_session_id: [{ts_ms, text, author, source}] ordered. Sessions with a box transcript
226
+ use its tagged user turns; other sessions (Mac replica, remote) use the tagged user_prompts rows."""
227
+ msgs = collections.defaultdict(list); tags = collections.Counter()
228
+ for sid, S in sessions.items():
229
+ for u in S["users"]:
230
+ if u["author"] == "tool": continue
231
+ tags[u["author"]] += 1
232
+ msgs[sid].append(dict(ts_ms=u["ts_ms"], text=u["text"], author=u["author"], source="transcript"))
233
+ for r in prompt_rows:
234
+ if r["session"] in sessions: continue
235
+ a = tag_prompt_row(r["text"]); tags[a] += 1
236
+ msgs[r["session"]].append(dict(ts_ms=r["ts_ms"], text=r["text"], author=a, source="user_prompts", device=r["device"]))
237
+ for v in msgs.values(): v.sort(key=lambda m: m["ts_ms"])
238
+ return msgs, dict(human=tags["human"], bot=tags["bot"], unknown=tags["unknown"])
239
+
240
+
241
+ # ---- episodes (plan 2B.1, 2B.2) ----
242
+ def episode_id(session, first_ts):
243
+ return "EP-" + hashlib.sha1(f"{session}|{first_ts}".encode()).hexdigest()[:10]
244
+
245
+
246
+ def find_episodes(msgs, phrase_hit=None):
247
+ """Frustrated human-tagged messages in one session with gaps ≤45 min form one episode. A human message
248
+ that matches a pattern's own human phrase (2B.3) is a candidate too; the pattern detector is its confirmation."""
249
+ eps = []
250
+ for sid, ms in msgs.items():
251
+ cur = None
252
+ for m in ms:
253
+ if m["author"] != "human" or not (is_frustrated(m["text"]) or (phrase_hit and phrase_hit(m["text"]))): continue
254
+ if cur and m["ts_ms"] - cur["messages"][-1]["ts_ms"] <= GAP_MIN * 60_000: cur["messages"].append(m)
255
+ else:
256
+ cur = dict(session=sid, messages=[m]); eps.append(cur)
257
+ for e in eps:
258
+ e["episode_id"] = episode_id(e["session"], e["messages"][0]["ts_ms"])
259
+ e["first_ts"] = e["messages"][0]["ts_ms"]; e["last_ts"] = e["messages"][-1]["ts_ms"]
260
+ return eps
261
+
262
+
263
+ def episode_windows(ep, S, all_msgs):
264
+ """wasted = turns from the previous human prompt to the first frustrated message (cap 6h);
265
+ redo = turns in the 60 min after the last message. Same session only (MI:9)."""
266
+ if S is None: return [], [], None
267
+ prev = [m["ts_ms"] for m in all_msgs.get(ep["session"], []) if m["ts_ms"] < ep["first_ts"] and m["author"] in ("human", "bot", "unknown")]
268
+ start = max(prev) if prev else ep["first_ts"] - WASTED_CAP_MS
269
+ start = max(start, ep["first_ts"] - WASTED_CAP_MS)
270
+ turns = ordered_turns(S)
271
+ wasted = [T for T in turns if start <= T["ts_ms"] < ep["first_ts"]]
272
+ redo = [T for T in turns if ep["last_ts"] < T["ts_ms"] <= ep["last_ts"] + REDO_MS]
273
+ before = [T["ts_ms"] for T in turns if T["ts_ms"] < ep["first_ts"]]
274
+ last_agent = max(before) if before else None
275
+ return wasted, redo, last_agent
276
+
277
+
278
+ def alex_minutes(ep, last_agent_ts):
279
+ """time since the last agent output before the complaint (cap 60) + episode span + 1 min per message (MI:10)."""
280
+ wait = min(ALEX_WAIT_CAP_MIN, (ep["first_ts"] - last_agent_ts) / 60_000) if last_agent_ts else 0
281
+ span = (ep["last_ts"] - ep["first_ts"]) / 60_000
282
+ return round(wait + span + len(ep["messages"]), 1)
283
+
284
+
285
+ def pt_iso(ms):
286
+ return dt.datetime.fromtimestamp(ms / 1000, PT).isoformat(timespec="minutes")
287
+
288
+
289
+ def day_pt(ms):
290
+ return dt.datetime.fromtimestamp(ms / 1000, PT).strftime("%Y-%m-%d")
291
+
292
+
293
+ # ---- failure-signal turn spans (plan 2B.9) ----
294
+ def failure_spans(S, msgs):
295
+ """Rework / Wrong turn = turns from the user message before the corrected work up to the
296
+ steer/reset/correction message; Recovery after miss = turns from the miss to the first successful
297
+ tool result after it; Looping is handled by S1. Returns {failure_type: set(keys)}."""
298
+ turns = ordered_turns(S); out = collections.defaultdict(set)
299
+ users = [u for u in S["users"] if u["text"] and not u["results"]]
300
+ for i, u in enumerate(users):
301
+ if CORRECTION.search(u["text"]):
302
+ prev = users[i - 1]["ts_ms"] if i else (u["ts_ms"] - WASTED_CAP_MS)
303
+ for T in turns:
304
+ if prev <= T["ts_ms"] < u["ts_ms"]: out["Rework"].add(T["key"])
305
+ miss_at = None
306
+ for u in S["users"]:
307
+ for r in u["results"]:
308
+ if miss_at is None and (r["is_error"] and MISS_RE.search(r["content"] or "")): miss_at = u["ts_ms"]
309
+ elif miss_at is not None and not r["is_error"]:
310
+ for T in turns:
311
+ if miss_at <= T["ts_ms"] <= u["ts_ms"]: out["Recovery after miss"].add(T["key"])
312
+ miss_at = None
313
+ return out
@@ -0,0 +1,83 @@
1
+ """Optional cheap classifier pass (plan 2B.4; G5 and G8 settled): OFF by default, enabled per run with
2
+ --classify, hard cap $2.00 per run, regular inference OPENROUTER_API_KEY only (never a management key,
3
+ never read from a settings file). It only sees candidates the heuristics could not settle. Its own
4
+ spend is reported separately ("classifier cost (separate)") and never added to agent cost.
5
+
6
+ Run path: OpenRouter chat completions (OpenAI-compatible, https://openrouter.ai/docs/api-reference/chat-completion),
7
+ urllib only. Input per candidate: the flagged text plus one message before and after, scrubbed, cut to
8
+ 1,500 characters. Output JSON {label: yes|no|unsure, pattern, reason (≤20 words)}.
9
+ """
10
+ import json
11
+ import os
12
+ import random
13
+ import urllib.request
14
+
15
+ from . import behavior, costs
16
+
17
+ DEFAULT_MODEL = "anthropic/claude-haiku-4.5"
18
+ CAP_USD = 2.00
19
+ URL = "https://openrouter.ai/api/v1/chat/completions"
20
+ CANDIDATE_CHARS = 1500
21
+ EST_IN_TOKENS = 1600 # plan 2B.4 sizing
22
+ EST_OUT_TOKENS = 60
23
+ QUESTIONS = {
24
+ "frustration": "Is this human message a genuine complaint about the agent's work? Answer yes or no.",
25
+ "P1_invented_gates": "Was this a real gate (something only Alex can do: cold Allow click, payment method, wet-ink signature, phone 2FA) or an invented one? yes = invented.",
26
+ "P4_over_engineering": "Was the extra scope asked for? yes = NOT asked for (over-engineering).",
27
+ "P5_not_asked": "Do the actions match the last human instruction? yes = they do NOT match.",
28
+ "P6_fake_output": "Is each number in this text sourced from a tool result shown? yes = unsourced.",
29
+ "P12_unclear": "Which pattern (P1..P11) does this complaint belong to, if any?",
30
+ "S2_hedging": "Was the answer already available to the agent when it hedged? yes = available (hedge counts).",
31
+ "praise": "Is this praise sincere (yes) or sarcastic (no)?",
32
+ }
33
+
34
+
35
+ class ClassifierError(RuntimeError):
36
+ pass
37
+
38
+
39
+ def estimate_usd(n, rate):
40
+ return costs.usd(int(n * (EST_IN_TOKENS * rate["input_usd_per_mtok"] + EST_OUT_TOKENS * rate["output_usd_per_mtok"])))
41
+
42
+
43
+ def _call(model, prompt, key, url=URL, timeout=60):
44
+ body = json.dumps(dict(model=model, max_tokens=120, temperature=0,
45
+ messages=[dict(role="system", content="Reply with one JSON object: {\"label\": \"yes|no|unsure\", \"pattern\": \"...\", \"reason\": \"<=20 words\"}"),
46
+ dict(role="user", content=prompt)])).encode()
47
+ req = urllib.request.Request(url, data=body, headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
48
+ with urllib.request.urlopen(req, timeout=timeout) as resp: d = json.load(resp)
49
+ u = d.get("usage") or {}
50
+ txt = ((d.get("choices") or [{}])[0].get("message") or {}).get("content") or "{}"
51
+ try: out = json.loads(txt[txt.find("{"): txt.rfind("}") + 1])
52
+ except Exception: out = dict(label="unsure", pattern=None, reason="unparseable")
53
+ return out, int(u.get("prompt_tokens") or 0), int(u.get("completion_tokens") or 0)
54
+
55
+
56
+ def run(candidates, rate, model=DEFAULT_MODEL, cap_usd=CAP_USD, key=None, call=_call, seed=0):
57
+ """candidates: [{id, kind, text, before, after}]. Returns (labels {id: {...}}, summary).
58
+ Prints the estimate first; over the cap it classifies a stratified random sample and scales up."""
59
+ key = key if key is not None else os.environ.get("OPENROUTER_API_KEY")
60
+ if not key: raise ClassifierError("OPENROUTER_API_KEY is not set (regular inference key, G5); classifier not run")
61
+ if not rate: raise ClassifierError(f"no list price for {model}; classifier not run")
62
+ est = estimate_usd(len(candidates), rate); sampled = False; chosen = list(candidates)
63
+ print(f"classifier: {len(candidates)} candidates, estimated ${est:.2f} at list, cap ${cap_usd:.2f}")
64
+ if est > cap_usd:
65
+ per = estimate_usd(1, rate) or 0.001; n = max(1, int(cap_usd / per * 0.9))
66
+ rnd = random.Random(seed); by_kind = {}
67
+ for c in candidates: by_kind.setdefault(c["kind"], []).append(c)
68
+ chosen = []
69
+ for kind, cs in by_kind.items():
70
+ k = max(1, round(n * len(cs) / len(candidates))); chosen += rnd.sample(cs, min(k, len(cs)))
71
+ sampled = True
72
+ labels = {}; spent = 0; in_tok = out_tok = 0; last = 0; stopped = False
73
+ cap_x1e6 = int(round(cap_usd * 1e6)); per_x1e6 = int(EST_IN_TOKENS * rate["input_usd_per_mtok"] + EST_OUT_TOKENS * rate["output_usd_per_mtok"])
74
+ for c in chosen:
75
+ if spent + max(per_x1e6, last) > cap_x1e6: stopped = True; break # hard stop: never send a request the cap cannot pay for
76
+ prompt = f"{QUESTIONS.get(c['kind'], QUESTIONS['P12_unclear'])}\n\nBEFORE: {behavior.scrub(c.get('before',''), 400)}\nFLAGGED: {behavior.scrub(c['text'], CANDIDATE_CHARS)}\nAFTER: {behavior.scrub(c.get('after',''), 400)}"
77
+ out, i, o = call(model, prompt, key); in_tok += i; out_tok += o
78
+ last = int(i * rate["input_usd_per_mtok"] + o * rate["output_usd_per_mtok"]); spent += last
79
+ labels[c["id"]] = dict(label=out.get("label", "unsure"), pattern=out.get("pattern"), reason=str(out.get("reason", ""))[:120], label_source="classifier")
80
+ scale = (len(candidates) / len(labels)) if (sampled and labels) else 1.0
81
+ return labels, dict(ran=True, model=model, spend_usd=costs.usd(spent), cap_usd=cap_usd, sampled_n=len(labels) if sampled else 0,
82
+ candidates_n=len(candidates), estimated_usd=est, scale=round(scale, 2), input_tokens=in_tok, output_tokens=out_tok, stopped_at_cap=stopped,
83
+ note="estimated from a sample of %d" % len(labels) if sampled else None)
@@ -0,0 +1,121 @@
1
+ """Agent cost from measured tokens, observer cost kept apart, extrapolation (plan Phase 2.2).
2
+
3
+ api_equiv() copied from /workspace/weekly-cost-workflow/weekly_report.py:91-105 with the Phase 1
4
+ rate() (1h cache writes at the listed cache_write_1h, else 2 x input). Money is accumulated in
5
+ integer micro-dollars (tokens x USD/MTok is exactly micro-dollars). The observer (note-taker) cost
6
+ is priced at the input rate only (:70-73) and lives only in spend.observer_note_taker_est_usd.
7
+ Extrapolation copied from :239-246. Unpriced = rate() None, output price None, or any negative price
8
+ (OpenRouter lists -1 for a few variable routers): such rows are never multiplied.
9
+ """
10
+ import collections
11
+
12
+ from . import prices as prices_mod
13
+
14
+ TOKEN_FIELDS = ("input", "output", "cache_write_5m", "cache_write_1h", "cache_read")
15
+ PRICE_FIELDS = ("input_usd_per_mtok", "output_usd_per_mtok", "cache_read", "cache_write", "cache_write_1h")
16
+
17
+
18
+ def usable_rate(rt):
19
+ """A rate is usable only when every price is present and non-negative."""
20
+ if not rt: return False
21
+ return all(rt.get(f) is not None and rt[f] >= 0 for f in PRICE_FIELDS)
22
+
23
+
24
+ def api_equiv_x1e6(r, rt):
25
+ """copied from weekly_report.py:91-105 (integer micro-dollars); None when unpriced."""
26
+ if not usable_rate(rt): return None
27
+ i = rt["input_usd_per_mtok"]
28
+ return int(round(r["input"] * i + r["output"] * rt["output_usd_per_mtok"] + r["cache_write_5m"] * rt["cache_write"]
29
+ + r["cache_write_1h"] * rt["cache_write_1h"] + r["cache_read"] * rt["cache_read"]))
30
+
31
+
32
+ def price_block(rt):
33
+ """model_prices_usd_per_mtok for a line item / by_model row."""
34
+ if not rt: return None
35
+ return dict(input=rt["input_usd_per_mtok"], output=rt["output_usd_per_mtok"], cache_read=rt["cache_read"],
36
+ cache_write=rt["cache_write"], cache_write_1h=rt["cache_write_1h"], key=rt["key"], price_source=rt["price_source"])
37
+
38
+
39
+ class Pricer:
40
+ """rate() with a per-model cache and the unpriced register."""
41
+ def __init__(self, prices):
42
+ self.models = prices["models"]; self._cache = {}
43
+ self.unpriced = collections.defaultdict(lambda: collections.defaultdict(collections.Counter)) # model -> role -> {calls, tokens}
44
+
45
+ def rate(self, model):
46
+ if model not in self._cache: self._cache[model] = prices_mod.rate(model, self.models)
47
+ return self._cache[model]
48
+
49
+ def usable(self, model):
50
+ return usable_rate(self.rate(model))
51
+
52
+ def reason(self, model):
53
+ rt = self.rate(model)
54
+ if rt is None: return "not in price list"
55
+ if rt.get("output_usd_per_mtok") is None: return "no output price listed"
56
+ return "negative (variable) price listed"
57
+
58
+ def observer_input_rate(self, model):
59
+ """USD/MTok input rate for the note-taker model, or None (then the tokens are registered as unpriced)."""
60
+ rt = self.rate(model)
61
+ if rt is None or rt["input_usd_per_mtok"] is None or rt["input_usd_per_mtok"] < 0: return None
62
+ return rt["input_usd_per_mtok"]
63
+
64
+ def unpriced_list(self):
65
+ return [dict(model=m, role=role, calls=c["calls"], tokens=c["tokens"], reason=self.reason(m))
66
+ for m, roles in sorted(self.unpriced.items()) for role, c in sorted(roles.items())]
67
+
68
+
69
+ def _zero():
70
+ c = collections.Counter(); c.update({f: 0 for f in TOKEN_FIELDS}); return c
71
+
72
+
73
+ def price_rows(rows, pricer):
74
+ """Accumulate usage rows per transcript session and per (src, model) (weekly_report.py:98-105).
75
+ Returns (by_session, by_model, stamps_by_session). Unpriced rows count tokens and calls but no dollars."""
76
+ by_session = collections.defaultdict(_zero); by_model = collections.defaultdict(_zero); stamps = collections.defaultdict(list)
77
+ for r in rows:
78
+ rt = pricer.rate(r["model"]); c = api_equiv_x1e6(r, rt)
79
+ for tgt in (by_session[r["session"]], by_model[(r["src"], r["model"])]):
80
+ for f in TOKEN_FIELDS: tgt[f] += r[f]
81
+ tgt["calls"] += 1
82
+ if c is None: tgt["unpriced_calls"] += 1
83
+ else: tgt["usd_x1e6"] += c
84
+ if c is None:
85
+ u = pricer.unpriced[r["model"] or "<none>"]["agent"]; u["calls"] += 1; u["tokens"] += sum(r[f] for f in TOKEN_FIELDS)
86
+ stamps[r["session"]].append(r["ts"])
87
+ # per-session dominant model (by output tokens) for the line item's `model` field
88
+ by_session[r["session"]]["model:" + (r["model"] or "")] += r["output"]
89
+ return by_session, by_model, stamps
90
+
91
+
92
+ def dominant_model(counter):
93
+ ms = [(v, k[6:]) for k, v in counter.items() if k.startswith("model:")]
94
+ return max(ms)[1] if ms else None
95
+
96
+
97
+ def usd(x1e6):
98
+ return round(x1e6 / 1e6, 2)
99
+
100
+
101
+ def extrapolation(items):
102
+ """copied from weekly_report.py:239-246: ratio = measured agent USD per 1M observer tokens over sessions
103
+ with a transcript, applied to the observer tokens of sessions with none. Only those sessions are touched.
104
+ items: line-item dicts with has_transcript, observer_tokens, cost_x1e6 (measured micro-dollars)."""
105
+ # transcripts with no claude-mem session (orphan=True) have no observer tokens and stay out of the ratio (weekly_report.py:239-246 basis)
106
+ m_obs = sum(i["observer_tokens"] for i in items if i["has_transcript"] and not i.get("orphan"))
107
+ m_usd = sum(i["cost_x1e6"] for i in items if i["has_transcript"] and not i.get("orphan")) / 1e6
108
+ u_obs = sum(i["observer_tokens"] for i in items if not i["has_transcript"])
109
+ ratio = m_usd / m_obs * 1e6 if m_obs else None
110
+ total = 0
111
+ for i in items:
112
+ if not i["has_transcript"] and ratio is not None:
113
+ i["extrapolated_x1e6"] = int(round(i["observer_tokens"] * ratio)); total += i["extrapolated_x1e6"]
114
+ else:
115
+ i["extrapolated_x1e6"] = 0
116
+ n_un = sum(1 for i in items if not i["has_transcript"])
117
+ if n_un == 0 and items: return 0.0, "all sessions measured (no extrapolation needed)", ratio # plan 5.4: say so, never drop the line silently
118
+ basis = (f"EXTRAPOLATED (low confidence): on this box, measured sessions cost ${ratio:.2f} API-equivalent per 1M observer tokens; "
119
+ f"applied to {u_obs:,} observer tokens from {n_un} sessions with no transcript here (remote devices). "
120
+ f"Assumes remote work looks like box work.") if ratio is not None else "n/a (no measured session to derive a ratio from)"
121
+ return (round(total / 1e6, 2) if ratio is not None else None), basis, ratio
@@ -0,0 +1,88 @@
1
+ """Phase 5: per-device transcript export and merge (plan 5.2), so Mac sessions can move from
2
+ "extrapolated (low confidence)" to measured tokens without any transcript leaving the machine.
3
+
4
+ Export (runs on the machine that owns the transcripts): the Phase 1 collector rows for the window plus
5
+ that machine's local sdk_sessions map {memory_session_id -> content_session_id, project, started_at_epoch},
6
+ read from a read-only snapshot of its local claude-mem.db. Only ids, timestamps, token counts and model
7
+ names: no prompt text, no observation text, no settings, no file paths.
8
+
9
+ Merge (runs on the box): rows join to box-side sessions on memory_session_id (fallback content_session_id);
10
+ joined sessions flip from cost_basis extrapolated to estimated_usage with device=<label>. Whatever stays
11
+ unmeasured is extrapolated again and keeps its low-confidence label. Rows that match no box session are
12
+ counted as unmatched, like any other transcript.
13
+ """
14
+ import datetime as dt
15
+ import json
16
+ import os
17
+ import platform
18
+
19
+ from . import period, snapshot
20
+
21
+ ROW_FIELDS = ("src", "session", "sidechain", "model", "ts", "input", "output", "cache_write", "cache_write_1h", "cache_write_5m", "cache_read", "cost_usd_reported")
22
+ FORBIDDEN = ("text", "prompt", "content", "title", "file", "cwd", "settings")
23
+
24
+
25
+ def export(label, usage_doc, outdir, window_block, live_db=None):
26
+ """Write <outdir>/device-usage-<label>.json from a collect() document and the local DB. The DB
27
+ snapshot is removed afterwards so the export directory holds only the small file."""
28
+ rows = [dict({k: r.get(k) for k in ROW_FIELDS}, device=label) for r in usage_doc["rows"]]
29
+ snap = snapshot.snapshot(outdir, live=live_db); db = snapshot.open_snapshot(snap)
30
+ try:
31
+ ids = sorted({r["session"] for r in rows if r["session"]})
32
+ q = "select memory_session_id, content_session_id, project, started_at_epoch from sdk_sessions where "
33
+ if window_block.get("start_epoch_ms") is not None:
34
+ cur = db.execute(q + "started_at_epoch >= ? and started_at_epoch < ?", (window_block["start_epoch_ms"], window_block["end_epoch_ms"]))
35
+ else:
36
+ cur = db.execute(q + f"content_session_id in ({','.join('?' * len(ids))})", ids) if ids else []
37
+ sessions = {r[0]: dict(content_session_id=r[1], project=r[2], started_at_epoch=r[3]) for r in cur if r[0]}
38
+ finally:
39
+ db.close()
40
+ try: os.remove(snap)
41
+ except OSError: pass
42
+ doc = dict(device=label, host_label=platform.node(), exported_at_pt=period.now_pt().strftime("%Y-%m-%d %H:%M PT"), window=window_block,
43
+ rows=rows, sessions=sessions, contents="usage rows (ids, timestamps, token counts, model names) and the local session id map; no text",
44
+ behavior_export=None) # per-session pattern counts / episode windows may be added later (plan 5.2, numbers only)
45
+ assert not any(k in r for r in rows for k in FORBIDDEN)
46
+ path = os.path.join(outdir, f"device-usage-{label}.json")
47
+ with open(path, "w") as fh: json.dump(doc, fh)
48
+ return path, doc
49
+
50
+
51
+ def load(path):
52
+ with open(path) as fh: d = json.load(fh)
53
+ for k in ("device", "window", "rows", "sessions"):
54
+ if k not in d: raise ValueError(f"{path}: not a device-usage export (missing {k})")
55
+ bad = [k for r in d["rows"] for k in FORBIDDEN if k in r]
56
+ if bad: raise ValueError(f"{path}: export carries forbidden fields {sorted(set(bad))}")
57
+ return d
58
+
59
+
60
+ def merge(rows, exports, sess, window_block):
61
+ """Append exported rows to `rows`, re-keyed to the box session's content_session_id when the export's
62
+ memory_session_id (or content id) is known on the box. Returns a summary per export."""
63
+ by_mem = {mid: v["content_session_id"] for mid, v in sess.items()}
64
+ by_cs = {v["content_session_id"] for v in sess.values()}
65
+ seen = {(r["session"], r["ts"], r["input"], r["output"], r["cache_read"]) for r in rows}
66
+ S, E = window_block.get("start_epoch_ms"), window_block.get("end_epoch_ms") # None for a --session run (no period)
67
+ out = []
68
+ for d in exports:
69
+ label = d["device"]; mem_by_cs = {v["content_session_id"]: mid for mid, v in d["sessions"].items()}
70
+ joined = unmatched = dup = outside = 0
71
+ for r in d["rows"]:
72
+ if S is not None and not (S <= _ts_ms(r["ts"]) < E): outside += 1; continue # a wrong-period export never inflates the headline
73
+ cs = r["session"]; mid = mem_by_cs.get(cs)
74
+ if mid in by_mem: cs2 = by_mem[mid]; joined += 1
75
+ elif cs in by_cs: cs2 = cs; joined += 1
76
+ else: cs2 = cs; unmatched += 1
77
+ key = (cs2, r["ts"], r["input"], r["output"], r["cache_read"])
78
+ if key in seen: dup += 1; continue
79
+ seen.add(key)
80
+ rows.append(dict(r, session=cs2, device=label, file=None, dir=f"device:{label}", cwd=None, export_memory_session_id=mid, export_session=cs))
81
+ same_window = (d["window"].get("start_pt"), d["window"].get("end_exclusive_pt")) == (window_block.get("start_pt"), window_block.get("end_exclusive_pt"))
82
+ out.append(dict(device=label, host_label=d.get("host_label"), exported_at_pt=d.get("exported_at_pt"), rows=len(d["rows"]), joined=joined, unmatched=unmatched, duplicates_skipped=dup,
83
+ outside_window=outside, window=d["window"], window_matches_report=same_window))
84
+ return out
85
+
86
+
87
+ def _ts_ms(iso):
88
+ return int(dt.datetime.fromisoformat(iso).timestamp() * 1000)