agentwrangler 0.1.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 (157) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +116 -0
  3. package/dist/apply/jobs.js +429 -0
  4. package/dist/apply/open-terminal-child.mjs +98 -0
  5. package/dist/apply/open-terminal.js +221 -0
  6. package/dist/apply/settings-gen.js +35 -0
  7. package/dist/cli/agentwrangler.js +18 -0
  8. package/dist/daemon/config.js +51 -0
  9. package/dist/daemon/http.js +258 -0
  10. package/dist/daemon/index.js +372 -0
  11. package/dist/daemon/outcomes-pass.js +82 -0
  12. package/dist/daemon/readiness.js +15 -0
  13. package/dist/daemon/router.js +756 -0
  14. package/dist/daemon/static.js +146 -0
  15. package/dist/db/migrate.js +72 -0
  16. package/dist/db/migrations/001_observe.sql +196 -0
  17. package/dist/db/migrations/002_indexes.sql +6 -0
  18. package/dist/db/migrations/003_context_inventory_history.sql +20 -0
  19. package/dist/db/migrations/004_apply_jobs.sql +17 -0
  20. package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
  21. package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
  22. package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
  23. package/dist/db/migrations/008_thinking_tokens.sql +1 -0
  24. package/dist/db/migrations/009_user_turn_count.sql +1 -0
  25. package/dist/db/migrations/010_workspace_cwd.sql +1 -0
  26. package/dist/db/migrations/011_reports.sql +1 -0
  27. package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
  28. package/dist/db/migrations/013_friction_fields.sql +5 -0
  29. package/dist/db/migrations/014_session_churn.sql +11 -0
  30. package/dist/db/migrations/015_gap_aggregates.sql +6 -0
  31. package/dist/db/open.js +30 -0
  32. package/dist/detector/benchmark-anchors.js +36 -0
  33. package/dist/detector/calibration.js +302 -0
  34. package/dist/detector/context-history-retention.js +312 -0
  35. package/dist/detector/context-probe.js +574 -0
  36. package/dist/detector/d1-source-identity.js +25 -0
  37. package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
  38. package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
  39. package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
  40. package/dist/detector/detectors/d4_model_mismatch.js +258 -0
  41. package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
  42. package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
  43. package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
  44. package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
  45. package/dist/detector/detectors/d9_idle_background_session.js +101 -0
  46. package/dist/detector/engine.js +88 -0
  47. package/dist/detector/index.js +17 -0
  48. package/dist/detector/measurement.js +426 -0
  49. package/dist/detector/practice-registry.js +259 -0
  50. package/dist/detector/registry.js +32 -0
  51. package/dist/detector/savings.js +249 -0
  52. package/dist/detector/types.js +14 -0
  53. package/dist/evidence/common/approved-input.js +632 -0
  54. package/dist/evidence/common/boundary.js +84 -0
  55. package/dist/evidence/common/canonical.js +55 -0
  56. package/dist/evidence/common/redaction.js +321 -0
  57. package/dist/evidence/common/sqlite.js +25 -0
  58. package/dist/evidence/common/state.js +29 -0
  59. package/dist/evidence/cond1/cli.js +289 -0
  60. package/dist/evidence/cond1/packet.js +407 -0
  61. package/dist/evidence/cond1/prepare.js +295 -0
  62. package/dist/evidence/cond1/score.js +349 -0
  63. package/dist/evidence/cond1/types.js +1 -0
  64. package/dist/evidence/create-approval.js +365 -0
  65. package/dist/evidence/create-scratch.js +542 -0
  66. package/dist/evidence/d7/cli.js +113 -0
  67. package/dist/evidence/d7/measure.js +193 -0
  68. package/dist/evidence/d7/types.js +1 -0
  69. package/dist/evidence/discover-approval.js +492 -0
  70. package/dist/evidence/g2/adjudicate.js +20 -0
  71. package/dist/evidence/g2/cli.js +207 -0
  72. package/dist/evidence/g2/kappa.js +39 -0
  73. package/dist/evidence/g2/pipeline.js +92 -0
  74. package/dist/evidence/g2/store.js +14 -0
  75. package/dist/evidence/github/client.js +1 -0
  76. package/dist/evidence/github/gh-cli-client.js +301 -0
  77. package/dist/evidence/r3/cli.js +209 -0
  78. package/dist/evidence/r3/evaluate.js +417 -0
  79. package/dist/evidence/r3/packet.js +162 -0
  80. package/dist/evidence/r3/prepare.js +405 -0
  81. package/dist/evidence/r3/score.js +341 -0
  82. package/dist/evidence/r3/transcript.js +155 -0
  83. package/dist/evidence/r3/types.js +4 -0
  84. package/dist/hook/context-budget-hook.mjs +138 -0
  85. package/dist/hook/danger-guard-denylist.json +27 -0
  86. package/dist/hook/danger-guard-hook.mjs +167 -0
  87. package/dist/hook/install.js +0 -0
  88. package/dist/hook/limit-burn-hook.mjs +127 -0
  89. package/dist/hook/loop-guard-hook.mjs +104 -0
  90. package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
  91. package/dist/ingest/churn-collector.js +122 -0
  92. package/dist/ingest/detector-hook.js +52 -0
  93. package/dist/ingest/discovery.js +207 -0
  94. package/dist/ingest/health.js +43 -0
  95. package/dist/ingest/index.js +28 -0
  96. package/dist/ingest/ingestor.js +509 -0
  97. package/dist/ingest/parser.js +344 -0
  98. package/dist/ingest/pricing.js +153 -0
  99. package/dist/ingest/reconcile.js +52 -0
  100. package/dist/ingest/tail.js +152 -0
  101. package/dist/ingest/types.js +24 -0
  102. package/dist/ingest/workspace-mapping.js +114 -0
  103. package/dist/oauth/anthropic-api-key.js +88 -0
  104. package/dist/oauth/count-tokens.js +86 -0
  105. package/dist/oauth/credentials.js +171 -0
  106. package/dist/oauth/judge-g2-client.js +154 -0
  107. package/dist/oauth/usage.js +167 -0
  108. package/dist/outcomes/branch-key.js +49 -0
  109. package/dist/outcomes/conclusions.js +45 -0
  110. package/dist/outcomes/derive.js +94 -0
  111. package/dist/outcomes/finding-extractors.js +131 -0
  112. package/dist/outcomes/findings.js +237 -0
  113. package/dist/outcomes/github/client.js +367 -0
  114. package/dist/outcomes/github/credential.js +195 -0
  115. package/dist/outcomes/github/gh-cli-client.js +340 -0
  116. package/dist/outcomes/linker.js +486 -0
  117. package/dist/outcomes/pool.js +24 -0
  118. package/dist/outcomes/sync.js +276 -0
  119. package/dist/query/api/agents-liveness.js +182 -0
  120. package/dist/query/api/burn-status.js +50 -0
  121. package/dist/query/api/context-budget.js +114 -0
  122. package/dist/query/api/context-composition.js +67 -0
  123. package/dist/query/api/cost-per-success.js +104 -0
  124. package/dist/query/api/delivery.js +92 -0
  125. package/dist/query/api/effectiveness.js +254 -0
  126. package/dist/query/api/efficiency-headroom.js +74 -0
  127. package/dist/query/api/headroom-trend.js +105 -0
  128. package/dist/query/api/hook-config.js +75 -0
  129. package/dist/query/api/hook-install.js +8 -0
  130. package/dist/query/api/hot-sessions.js +17 -0
  131. package/dist/query/api/idle-sessions.js +52 -0
  132. package/dist/query/api/index.js +40 -0
  133. package/dist/query/api/loop-guard.js +90 -0
  134. package/dist/query/api/offload-share.js +41 -0
  135. package/dist/query/api/outcomes.js +218 -0
  136. package/dist/query/api/overview.js +535 -0
  137. package/dist/query/api/rec-prompt.js +138 -0
  138. package/dist/query/api/recommendations-ledger.js +111 -0
  139. package/dist/query/api/recommendations.js +514 -0
  140. package/dist/query/api/reports.js +78 -0
  141. package/dist/query/api/self-churn.js +77 -0
  142. package/dist/query/api/self-percentiles.js +109 -0
  143. package/dist/query/api/session-drivers.js +153 -0
  144. package/dist/query/api/settings.js +85 -0
  145. package/dist/query/api/spend-flavor.js +234 -0
  146. package/dist/query/api/trends.js +155 -0
  147. package/dist/query/cap-weighted.js +119 -0
  148. package/dist/query/db-context.js +42 -0
  149. package/dist/query/envelope.js +71 -0
  150. package/dist/query/forecast.js +191 -0
  151. package/dist/query/settings-store.js +441 -0
  152. package/dist/query/spend.js +171 -0
  153. package/dist/query/trends.js +194 -0
  154. package/dist/ui/assets/index-DnRKgc21.css +1 -0
  155. package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
  156. package/dist/ui/index.html +39 -0
  157. package/package.json +59 -0
@@ -0,0 +1,344 @@
1
+ /**
2
+ * src/ingest/parser.ts — per-line SEC-101 projection.
3
+ *
4
+ * projectLine() parses one JSONL line and returns a structural projection:
5
+ * tokens, ids, sizes, model, and markers only. Content (message text, tool
6
+ * inputs/outputs) is read solely to measure size or extract a commit SHA, then
7
+ * discarded — nothing content-bearing is returned or stored.
8
+ *
9
+ * Failure handling (Spec §1.2): a line that fails JSON parse, or a usage record
10
+ * missing a required field (dedupe key or timestamp), becomes a quarantine
11
+ * pointer (no content). Unknown top-level fields are tolerated and counted.
12
+ */
13
+ import * as crypto from "node:crypto";
14
+ import { PARSER_VERSION, } from "./types.js";
15
+ export { PARSER_VERSION };
16
+ /** Top-level fields the parser recognises. Anything else is tolerated + counted. */
17
+ const KNOWN_TOP_LEVEL = new Set([
18
+ "type",
19
+ "subtype",
20
+ "timestamp",
21
+ "uuid",
22
+ "parentUuid",
23
+ "logicalParentUuid",
24
+ "leafUuid",
25
+ "sessionId",
26
+ "session_id",
27
+ "isSidechain",
28
+ "message",
29
+ "command",
30
+ "name",
31
+ "content",
32
+ "summary",
33
+ "cwd",
34
+ "version",
35
+ "gitBranch",
36
+ "userType",
37
+ "requestId",
38
+ "toolUseResult",
39
+ "toolUseID",
40
+ "promptSource",
41
+ "isMeta",
42
+ "isCompactSummary",
43
+ "isApiErrorMessage",
44
+ "isVisibleInTranscriptOnly",
45
+ "level",
46
+ ]);
47
+ const SHA_RE = /\b[0-9a-f]{40}\b/;
48
+ const GIT_WRITE_RE = /\bgit\s+(commit|push)\b/;
49
+ const TEST_COMMAND_RE = /^(?:(?:npm|pnpm|yarn)\s+(?:test|run\s+test(?::[\w.-]+)?)|bun\s+test|(?:npx\s+)?vitest(?:\s+run)?|pytest|python\s+-m\s+pytest|cargo\s+test|go\s+test|dotnet\s+test|mvn\s+test|gradle\s+test|\.\/gradlew\s+test)(?:\s|$)/;
50
+ /** Project one JSONL line into its structural parts. */
51
+ export function projectLine(rawLine, ctx) {
52
+ let d;
53
+ try {
54
+ d = JSON.parse(rawLine);
55
+ }
56
+ catch (e) {
57
+ return { kind: "quarantine", errorClass: classifyJsonError(e) };
58
+ }
59
+ if (typeof d !== "object" || d === null || Array.isArray(d)) {
60
+ return { kind: "quarantine", errorClass: "NOT_OBJECT" };
61
+ }
62
+ const rec = d;
63
+ const unknownFields = Object.keys(rec).filter((k) => !KNOWN_TOP_LEVEL.has(k));
64
+ const sessionId = resolveSessionId(rec, ctx);
65
+ const ts = typeof rec.timestamp === "string" ? rec.timestamp : null;
66
+ const cwd = typeof rec.cwd === "string" ? rec.cwd : null;
67
+ const message = isObject(rec.message) ? rec.message : null;
68
+ const isCompactSummary = rec.isCompactSummary === true;
69
+ const isApiErrorMessage = rec.isApiErrorMessage === true;
70
+ const isInterrupt = false;
71
+ const toolEvents = [];
72
+ const toolResults = [];
73
+ let command = extractCommand(rec, sessionId, ts);
74
+ // Tool blocks live in message.content arrays (assistant tool_use, user tool_result).
75
+ if (message && Array.isArray(message.content)) {
76
+ let idx = 0;
77
+ for (const block of message.content) {
78
+ if (!isObject(block)) {
79
+ idx++;
80
+ continue;
81
+ }
82
+ const b = block;
83
+ if (b.type === "tool_use") {
84
+ toolEvents.push(projectToolUse(b, sessionId, ts, idx, resolveMessageId(message, rec)));
85
+ }
86
+ else if (b.type === "tool_result") {
87
+ toolResults.push(projectToolResult(b, sessionId));
88
+ }
89
+ idx++;
90
+ }
91
+ }
92
+ const isUserTurn = rec.type === "user" &&
93
+ (rec.promptSource === "typed" || rec.promptSource === "queued") &&
94
+ toolResults.length === 0 &&
95
+ rec.isMeta !== true;
96
+ // A user record whose content is a bare slash command is also a hygiene marker.
97
+ if (command === null && rec.type === "user" && message && typeof message.content === "string") {
98
+ const c = message.content;
99
+ if (c === "/clear" || c === "/compact") {
100
+ command = { sessionId, ts: ts ?? "", command: c };
101
+ }
102
+ }
103
+ // Usage record → candidate turn.
104
+ const usage = message && isObject(message.usage) ? message.usage : null;
105
+ if (usage === null) {
106
+ return {
107
+ kind: "record",
108
+ sessionId,
109
+ turn: null,
110
+ toolEvents,
111
+ toolResults,
112
+ command,
113
+ synthetic: false,
114
+ isUserTurn,
115
+ isCompactSummary,
116
+ isApiErrorMessage,
117
+ isInterrupt,
118
+ ts,
119
+ unknownFields,
120
+ cwd,
121
+ };
122
+ }
123
+ const model = typeof message?.model === "string" ? message.model : null;
124
+ if (model === null || model === "" || model === "<synthetic>") {
125
+ // Synthetic / empty-model: excluded from cost, counted in health. Not a turn.
126
+ return {
127
+ kind: "record",
128
+ sessionId,
129
+ turn: null,
130
+ toolEvents,
131
+ toolResults,
132
+ command,
133
+ synthetic: true,
134
+ isUserTurn,
135
+ isCompactSummary,
136
+ isApiErrorMessage,
137
+ isInterrupt,
138
+ ts,
139
+ unknownFields,
140
+ cwd,
141
+ };
142
+ }
143
+ const dedupeId = typeof message?.id === "string" ? message.id : typeof rec.uuid === "string" ? rec.uuid : null;
144
+ if (dedupeId === null || ts === null) {
145
+ return { kind: "quarantine", errorClass: "MISSING_REQUIRED" };
146
+ }
147
+ const turn = projectTurn(dedupeId, sessionId, ts, model, rec, usage);
148
+ return {
149
+ kind: "record",
150
+ sessionId,
151
+ turn,
152
+ toolEvents,
153
+ toolResults,
154
+ command,
155
+ synthetic: false,
156
+ isUserTurn,
157
+ isCompactSummary,
158
+ isApiErrorMessage,
159
+ isInterrupt,
160
+ ts,
161
+ unknownFields,
162
+ cwd,
163
+ };
164
+ }
165
+ function projectTurn(messageId, sessionId, ts, model, rec, usage) {
166
+ const cacheCreation = isObject(usage.cache_creation)
167
+ ? usage.cache_creation
168
+ : null;
169
+ const c5 = num(cacheCreation?.ephemeral_5m_input_tokens);
170
+ const c1 = num(cacheCreation?.ephemeral_1h_input_tokens);
171
+ const cwTotal = num(usage.cache_creation_input_tokens);
172
+ const cwOther = Math.max(0, cwTotal - c5 - c1);
173
+ const outputTokensDetails = isObject(usage.output_tokens_details)
174
+ ? usage.output_tokens_details
175
+ : null;
176
+ const message = rec.message;
177
+ const effort = typeof message.effort === "string"
178
+ ? message.effort
179
+ : typeof rec.effort === "string"
180
+ ? rec.effort
181
+ : null;
182
+ return {
183
+ messageId,
184
+ sessionId,
185
+ ts,
186
+ model,
187
+ isSidechain: rec.isSidechain === true,
188
+ inputTokens: num(usage.input_tokens),
189
+ outputTokens: num(usage.output_tokens),
190
+ thinkingTokens: num(outputTokensDetails?.thinking_tokens, null),
191
+ cacheReadTokens: num(usage.cache_read_input_tokens),
192
+ cacheWrite5m: c5,
193
+ cacheWrite1h: c1,
194
+ cacheWriteOther: cwOther,
195
+ effort,
196
+ };
197
+ }
198
+ function projectToolUse(b, sessionId, ts, idx, ownerMessageId) {
199
+ const toolName = typeof b.name === "string" ? b.name : "";
200
+ const toolUseId = typeof b.id === "string" ? b.id : null;
201
+ const inputBytes = b.input != null ? JSON.stringify(b.input).length : null;
202
+ const inputHash = b.input != null ? hashSha256(canonicalJson(normalizeToolInputPaths(b.input))) : null;
203
+ const input = isObject(b.input) ? b.input : null;
204
+ const filePathHash = typeof input?.file_path === "string" ? hashSha256(normalizeFilePath(input.file_path)) : null;
205
+ let gitCommandHint = false;
206
+ let testCommandHint = false;
207
+ if (toolName === "Bash" && input !== null) {
208
+ const cmd = input.command;
209
+ if (typeof cmd === "string")
210
+ gitCommandHint = GIT_WRITE_RE.test(cmd);
211
+ if (typeof cmd === "string")
212
+ testCommandHint = isTestCommand(cmd);
213
+ }
214
+ const eventId = toolUseId ?? hashId(`${sessionId}|${ts ?? ""}|${toolName}|${idx}`);
215
+ return {
216
+ eventId,
217
+ toolUseId,
218
+ sessionId,
219
+ ts: ts ?? "",
220
+ toolName,
221
+ inputBytes,
222
+ inputHash,
223
+ filePathHash,
224
+ ownerMessageId,
225
+ blockIndex: idx,
226
+ gitCommandHint,
227
+ testCommandHint,
228
+ };
229
+ }
230
+ function projectToolResult(b, sessionId) {
231
+ const toolUseId = typeof b.tool_use_id === "string" ? b.tool_use_id : null;
232
+ let resultBytes = 0;
233
+ let commitSha = null;
234
+ const content = b.content;
235
+ if (typeof content === "string") {
236
+ resultBytes = content.length;
237
+ const m = SHA_RE.exec(content);
238
+ if (m)
239
+ commitSha = m[0];
240
+ }
241
+ else if (Array.isArray(content)) {
242
+ for (const c of content) {
243
+ if (isObject(c) && typeof c.text === "string") {
244
+ const text = c.text;
245
+ resultBytes += text.length;
246
+ if (commitSha === null) {
247
+ const m = SHA_RE.exec(text);
248
+ if (m)
249
+ commitSha = m[0];
250
+ }
251
+ }
252
+ }
253
+ }
254
+ return { toolUseId, sessionId, resultBytes, commitSha, isError: b.is_error === true };
255
+ }
256
+ /** Deliberately anchored: prose and compound shell setup are not classified as tests. */
257
+ export function isTestCommand(command) {
258
+ return TEST_COMMAND_RE.test(command.trim());
259
+ }
260
+ /** Stable JSON representation for privacy-safe near-duplicate hashes. */
261
+ function canonicalJson(value) {
262
+ if (Array.isArray(value))
263
+ return `[${value.map((item) => canonicalJson(item ?? null)).join(",")}]`;
264
+ if (isObject(value)) {
265
+ return `{${Object.keys(value)
266
+ .filter((key) => value[key] !== undefined)
267
+ .sort()
268
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
269
+ .join(",")}}`;
270
+ }
271
+ return JSON.stringify(value) ?? "null";
272
+ }
273
+ /** Normalize path-bearing fields before hashing while retaining no raw projection. */
274
+ function normalizeToolInputPaths(value) {
275
+ if (Array.isArray(value))
276
+ return value.map((item) => normalizeToolInputPaths(item));
277
+ if (!isObject(value))
278
+ return value;
279
+ const normalized = {};
280
+ for (const [key, item] of Object.entries(value)) {
281
+ normalized[key] =
282
+ key === "file_path" && typeof item === "string"
283
+ ? normalizeFilePath(item)
284
+ : normalizeToolInputPaths(item);
285
+ }
286
+ return normalized;
287
+ }
288
+ /** Lexically normalize separators and dot segments without retaining the path. */
289
+ function normalizeFilePath(filePath) {
290
+ const slashPath = filePath.replace(/\\/g, "/");
291
+ const prefix = slashPath.startsWith("/") ? "/" : "";
292
+ const parts = [];
293
+ for (const part of slashPath.split("/")) {
294
+ if (part === "" || part === ".")
295
+ continue;
296
+ const atDriveRoot = parts.length === 1 && /^[A-Za-z]:$/.test(parts[0] ?? "");
297
+ if (part === ".." && parts.length > 0 && parts.at(-1) !== ".." && !atDriveRoot) {
298
+ parts.pop();
299
+ }
300
+ else if (part !== ".." || (prefix === "" && !atDriveRoot)) {
301
+ parts.push(part);
302
+ }
303
+ }
304
+ if (/^[A-Za-z]:$/.test(parts[0] ?? ""))
305
+ parts[0] = parts[0]?.toLowerCase() ?? "";
306
+ return `${prefix}${parts.join("/")}` || prefix;
307
+ }
308
+ function hashSha256(value) {
309
+ return crypto.createHash("sha256").update(value).digest("hex");
310
+ }
311
+ function resolveMessageId(message, rec) {
312
+ if (typeof message.id === "string")
313
+ return message.id;
314
+ return typeof rec.uuid === "string" ? rec.uuid : null;
315
+ }
316
+ function extractCommand(rec, sessionId, ts) {
317
+ if (rec.type === "system" &&
318
+ (rec.subtype === "local_command" || rec.subtype === "away_summary")) {
319
+ const raw = typeof rec.command === "string" ? rec.command : String(rec.subtype);
320
+ return { sessionId, ts: ts ?? "", command: raw };
321
+ }
322
+ return null;
323
+ }
324
+ function resolveSessionId(rec, ctx) {
325
+ // Prefer session_id, then sessionId, else the filename stem.
326
+ if (typeof rec.session_id === "string" && rec.session_id.length > 0)
327
+ return rec.session_id;
328
+ if (typeof rec.sessionId === "string" && rec.sessionId.length > 0)
329
+ return rec.sessionId;
330
+ return ctx.defaultSessionId;
331
+ }
332
+ function classifyJsonError(e) {
333
+ const msg = e instanceof Error ? e.message : String(e);
334
+ return `JSON_${msg.slice(0, 24).replace(/[^\w]/g, "_")}`.slice(0, 40);
335
+ }
336
+ function isObject(v) {
337
+ return typeof v === "object" && v !== null && !Array.isArray(v);
338
+ }
339
+ function num(v, fallback = 0) {
340
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
341
+ }
342
+ function hashId(input) {
343
+ return crypto.createHash("sha1").update(input).digest("hex").slice(0, 24);
344
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * src/ingest/pricing.ts — PricingSnapshotStore + per-turn pricing.
3
+ *
4
+ * Prices are stored as a snapshot per model tier in `pricing_snapshots`.
5
+ * unit_prices_json = [input, output, cacheRead, cacheWrite5m, cacheWrite1h] in
6
+ * $/MTok, which is numerically μUSD/token — so cost_equiv_u (integer micro-USD)
7
+ * is a direct token×price sum, no scaling. This matches the proven reference
8
+ * scripts (token-usage-analyze.mjs / s1-all.mjs) exactly.
9
+ *
10
+ * Pricing rules (Ingestion Spec §1.2):
11
+ * - price each turn against the freshest NON-STALE snapshot for its tier;
12
+ * - no snapshot for the tier ⇒ cost_equiv_u = NULL (tokens still counted);
13
+ * - only a stale snapshot available ⇒ cost computed, cost_claim = LIST_EQUIV_STALE.
14
+ *
15
+ * PRICE SOURCE (BM0, re-fetched 2026-09-03 from platform.claude.com models
16
+ * overview — the authoritative Anthropic docs): current per-model list prices.
17
+ * - Base $/MTok: opus 5/4.8 $5/$25 · sonnet-5 $2/$10 · sonnet-4.x $3/$15 ·
18
+ * haiku-4.5 $1/$5 · fable 5/5.1 $10/$50.
19
+ * - Cache multipliers (all families): 5m write 1.25×, 1h write 2.0×, read 0.1×
20
+ * of base input — EXCEPT the Fable/Mythos 5.1 family, whose cache read is
21
+ * 0.025× (docs footnote), giving the split fable-5 vs fable-5-1 tiers.
22
+ * Tiers are per generation only where the corpus carries both AND prices differ:
23
+ * - opus stays a single tier (4.8 and 5 are priced identically → no split);
24
+ * - sonnet splits sonnet-5 vs sonnet-4 ($2 vs $3 base);
25
+ * - fable splits fable-5-1 vs fable-5 (0.025× vs 0.1× cache read).
26
+ * Every haiku model maps to the single `haiku` tier (only 4.5 in the corpus).
27
+ * Re-pricing bumps snapshot ids to list-<tier>-v2; historical turns keep their
28
+ * old snapshot provenance until the next full re-scan (RV2a backfill stance).
29
+ */
30
+ /**
31
+ * Canonical list-price table ($/MTok = μUSD/token), one row per model tier.
32
+ * [input, output, cacheRead, cacheWrite5m, cacheWrite1h].
33
+ */
34
+ export const LIST_PRICES = {
35
+ opus: [5, 25, 0.5, 6.25, 10],
36
+ "sonnet-5": [2, 10, 0.2, 2.5, 4],
37
+ "sonnet-4": [3, 15, 0.3, 3.75, 6],
38
+ haiku: [1, 5, 0.1, 1.25, 2],
39
+ "fable-5": [10, 50, 1, 12.5, 20],
40
+ "fable-5-1": [10, 50, 0.25, 12.5, 20], // 0.025× cache read (5.1 family)
41
+ };
42
+ /** Default snapshot staleness horizon (days) when seeding list prices. */
43
+ const DEFAULT_STALE_DAYS = 30;
44
+ /**
45
+ * Resolve a model string to a pricing tier, or null if unpriceable.
46
+ * Generation-sensitive families (sonnet, fable) match the more specific
47
+ * generation first; opus and haiku are single-tier (see LIST_PRICES doc).
48
+ */
49
+ export function modelTier(model) {
50
+ if (!model)
51
+ return null;
52
+ if (model.includes("opus"))
53
+ return "opus";
54
+ if (model.includes("sonnet"))
55
+ return model.includes("sonnet-5") ? "sonnet-5" : "sonnet-4";
56
+ if (model.includes("haiku"))
57
+ return "haiku";
58
+ // Fable and Mythos share the same per-token pricing per generation; the 5.1
59
+ // family (fable-5-1 / mythos-5-1) has the 0.025× cache-read exception.
60
+ if (model.includes("fable") || model.includes("mythos")) {
61
+ return model.includes("fable-5-1") || model.includes("mythos-5-1") ? "fable-5-1" : "fable-5";
62
+ }
63
+ return null;
64
+ }
65
+ function addDaysIso(iso, days) {
66
+ const d = new Date(iso);
67
+ d.setUTCDate(d.getUTCDate() + days);
68
+ return d.toISOString();
69
+ }
70
+ /**
71
+ * Seed `pricing_snapshots` with the canonical list-price table (one snapshot per
72
+ * tier) if not already present. Deterministic snapshot ids keep re-seeding and
73
+ * rebuild-equality idempotent. Returns the number of snapshots inserted.
74
+ */
75
+ export function seedListPrices(db, capturedAtIso) {
76
+ const insert = db.prepare(`INSERT OR IGNORE INTO pricing_snapshots
77
+ (snapshot_id, model_tier, unit_prices_json, captured_at, stale_after)
78
+ VALUES (?,?,?,?,?)`);
79
+ const staleAfter = addDaysIso(capturedAtIso, DEFAULT_STALE_DAYS);
80
+ let inserted = 0;
81
+ for (const [tier, prices] of Object.entries(LIST_PRICES)) {
82
+ const res = insert.run(`list-${tier}-v2`, tier, JSON.stringify(prices), capturedAtIso, staleAfter);
83
+ inserted += res.changes;
84
+ }
85
+ return inserted;
86
+ }
87
+ /**
88
+ * Prices turns against the snapshots present in the DB at construction time.
89
+ * Load once, price many; the `now` reference fixes staleness for the batch.
90
+ */
91
+ export class PricingSnapshotStore {
92
+ nowIso;
93
+ /** tier → chosen snapshot (freshest non-stale, else freshest stale). */
94
+ byTier = new Map();
95
+ constructor(db, nowIso = new Date().toISOString()) {
96
+ this.nowIso = nowIso;
97
+ const rows = db
98
+ .prepare(`SELECT snapshot_id, model_tier, unit_prices_json, stale_after
99
+ FROM pricing_snapshots`)
100
+ .all();
101
+ // Group by tier, then choose: prefer non-stale with max captured proxy.
102
+ // We select the freshest by stale_after (later horizon ⇒ newer capture).
103
+ const grouped = new Map();
104
+ for (const r of rows) {
105
+ const list = grouped.get(r.model_tier) ?? [];
106
+ list.push(r);
107
+ grouped.set(r.model_tier, list);
108
+ }
109
+ for (const [tier, list] of grouped) {
110
+ const nonStale = list.filter((r) => r.stale_after > this.nowIso);
111
+ const pool = nonStale.length > 0 ? nonStale : list;
112
+ // Freshest = latest stale_after horizon.
113
+ let chosen = pool[0];
114
+ if (chosen === undefined)
115
+ continue;
116
+ for (const r of pool) {
117
+ if (r.stale_after > chosen.stale_after)
118
+ chosen = r;
119
+ }
120
+ this.byTier.set(tier, {
121
+ id: chosen.snapshot_id,
122
+ prices: parsePrices(chosen.unit_prices_json),
123
+ stale: nonStale.length === 0,
124
+ });
125
+ }
126
+ }
127
+ /** Price a single turn. Returns cost in micro-USD, the snapshot used, and the claim. */
128
+ price(model, tokens) {
129
+ const tier = modelTier(model);
130
+ if (tier === null) {
131
+ return { costU: null, snapshotId: null, claim: "LIST_EQUIV" };
132
+ }
133
+ const snap = this.byTier.get(tier);
134
+ if (snap === undefined) {
135
+ return { costU: null, snapshotId: null, claim: "LIST_EQUIV" };
136
+ }
137
+ const [pin, pout, pcr, pcw5, pcw1] = snap.prices;
138
+ const costU = Math.round(tokens.inputTokens * pin +
139
+ tokens.outputTokens * pout +
140
+ tokens.cacheReadTokens * pcr +
141
+ (tokens.cacheWrite5m + tokens.cacheWriteOther) * pcw5 +
142
+ tokens.cacheWrite1h * pcw1);
143
+ return {
144
+ costU,
145
+ snapshotId: snap.id,
146
+ claim: snap.stale ? "LIST_EQUIV_STALE" : "LIST_EQUIV",
147
+ };
148
+ }
149
+ }
150
+ function parsePrices(json) {
151
+ const arr = JSON.parse(json);
152
+ return [arr[0] ?? 0, arr[1] ?? 0, arr[2] ?? 0, arr[3] ?? 0, arr[4] ?? 0];
153
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * src/ingest/reconcile.ts — LIVE → RECONCILED session lifecycle.
3
+ *
4
+ * A session is LIVE while its last_turn_at is within the activity window of the
5
+ * reference time; otherwise it closes to RECONCILED. On close, provisional turn
6
+ * flags are cleared and hygiene flags are evaluated from settled DB state
7
+ * (deterministic, so rebuild-equality holds). Turns are inserted provisional=1
8
+ * by the ingestor; this pass is the sole authority that clears them.
9
+ *
10
+ * sessions.cost_equiv_u rollforward is maintained incrementally by the ingestor
11
+ * (sum of each session's turn costs) and is unaffected by reconciliation — the
12
+ * running total is the final total once the session settles.
13
+ */
14
+ export const DEFAULT_RECONCILE_OPTIONS = {
15
+ longContextThreshold: 150_000,
16
+ longContextTurnCount: 20,
17
+ };
18
+ /**
19
+ * Reconcile every session against `cutoffIso` (= referenceTime − activityWindow).
20
+ * Sessions with last_turn_at ≥ cutoff stay LIVE (provisional); older sessions
21
+ * close to RECONCILED with provisional cleared and hygiene flags evaluated.
22
+ * Idempotent: re-running with the same cutoff yields the same state.
23
+ */
24
+ export function reconcileSessions(db, cutoffIso, opts = DEFAULT_RECONCILE_OPTIONS) {
25
+ const sessions = db
26
+ .prepare("SELECT session_id, last_turn_at, turn_count FROM sessions WHERE state != 'RECONCILED' OR last_turn_at >= :cutoff")
27
+ .all({ cutoff: cutoffIso });
28
+ const setProvisional = db.prepare("UPDATE turns SET provisional = ? WHERE session_id = ?");
29
+ const setSession = db.prepare("UPDATE sessions SET state = ?, hygiene_flags = ? WHERE session_id = ?");
30
+ const countLong = db.prepare("SELECT COUNT(*) AS n FROM turns WHERE session_id = ? AND context_tokens > ?");
31
+ const countCompact = db.prepare("SELECT COUNT(*) AS n FROM tool_events WHERE session_id = ? AND tool_name = 'local_command' AND input_hash = '/compact'");
32
+ const tx = db.transaction(() => {
33
+ for (const s of sessions) {
34
+ const live = s.last_turn_at !== null && s.last_turn_at >= cutoffIso;
35
+ if (live) {
36
+ setProvisional.run(1, s.session_id);
37
+ setSession.run("LIVE", "[]", s.session_id);
38
+ continue;
39
+ }
40
+ setProvisional.run(0, s.session_id);
41
+ const flags = [];
42
+ const longN = countLong.get(s.session_id, opts.longContextThreshold).n;
43
+ if (longN > opts.longContextTurnCount)
44
+ flags.push("LONG_FULL_CONTEXT");
45
+ const compactN = countCompact.get(s.session_id).n;
46
+ if (compactN > 0 && s.turn_count > 1)
47
+ flags.push("COMPACT_MID_TASK");
48
+ setSession.run("RECONCILED", JSON.stringify(flags), s.session_id);
49
+ }
50
+ });
51
+ tx();
52
+ }