@jitsusama/agentic-harness.core 0.2.0 → 0.3.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.
package/dist/bin/cli.js CHANGED
File without changes
@@ -7,7 +7,15 @@
7
7
  * own database file. Rows are queryable on demand, roll up
8
8
  * into periodic per-model and per-persona summaries before
9
9
  * they age out, and drive a compact status-line figure.
10
+ *
11
+ * The ledger beside it covers the other side of the bill: the
12
+ * main loop's own turns, addressed by content so a total can
13
+ * be trusted.
14
+ *
15
+ * Reading a harness's log format into those turns is that
16
+ * harness's own business and lives in its package.
10
17
  */
18
+ export { type CallScope, type CostDimension, type CostSlice, type DroppedCallRecord, type LedgerTotal, openTurnStore, type PaybackReplay, type RecordOutcome, type Regret, type RegretReport, type RepeatedCall, type SessionRecord, type ToolCallRecord, type TurnKind, type TurnRecord, type TurnStore, type VerifierKind, type VerifierOutcome, } from "./ledger/index.js";
11
19
  export { type RunRecorder, type RunRecordInput, recordRunEverywhere, registerRunRecorder, runRecordFrom, } from "./recorder.js";
12
20
  export { openRunStore, type RunQuery, type RunStore } from "./store.js";
13
21
  export type { RunCost, RunRecord, RunRollup, RunSummary, RunTokens, VerifyOutcome, } from "./types.js";
@@ -7,6 +7,14 @@
7
7
  * own database file. Rows are queryable on demand, roll up
8
8
  * into periodic per-model and per-persona summaries before
9
9
  * they age out, and drive a compact status-line figure.
10
+ *
11
+ * The ledger beside it covers the other side of the bill: the
12
+ * main loop's own turns, addressed by content so a total can
13
+ * be trusted.
14
+ *
15
+ * Reading a harness's log format into those turns is that
16
+ * harness's own business and lives in its package.
10
17
  */
18
+ export { openTurnStore, } from "./ledger/index.js";
11
19
  export { recordRunEverywhere, registerRunRecorder, runRecordFrom, } from "./recorder.js";
12
20
  export { openRunStore } from "./store.js";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The ledger: a store of billable turns, and what one is.
3
+ *
4
+ * Cost is derived from the logs a harness already writes rather than
5
+ * recorded a second time, so there is exactly one writer of the truth
6
+ * and the ledger can be rebuilt from scratch whenever its shape
7
+ * changes.
8
+ *
9
+ * Reading those logs is not here. A turn is a portable idea; the format
10
+ * it was written in is not, so each harness parses its own and hands
11
+ * over records. This module knows what a turn is and where to keep it.
12
+ */
13
+ export { type CostDimension, type CostSlice, type LedgerTotal, openTurnStore, type RecordOutcome, type TurnStore, } from "./store.js";
14
+ export type { CallScope, DroppedCallRecord, PaybackReplay, Regret, RegretReport, RepeatedCall, SessionRecord, ToolCallRecord, TurnKind, TurnRecord, VerifierKind, VerifierOutcome, } from "./types.js";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The ledger: a store of billable turns, and what one is.
3
+ *
4
+ * Cost is derived from the logs a harness already writes rather than
5
+ * recorded a second time, so there is exactly one writer of the truth
6
+ * and the ledger can be rebuilt from scratch whenever its shape
7
+ * changes.
8
+ *
9
+ * Reading those logs is not here. A turn is a portable idea; the format
10
+ * it was written in is not, so each harness parses its own and hands
11
+ * over records. This module knows what a turn is and where to keep it.
12
+ */
13
+ export { openTurnStore, } from "./store.js";
@@ -0,0 +1,50 @@
1
+ import type { CallScope, DroppedCallRecord, PaybackReplay, RegretReport, RepeatedCall, SessionRecord, ToolCallRecord, TurnRecord, VerifierOutcome } from "./types.js";
2
+ /** What a dimension's slice of spend came to. */
3
+ export interface CostSlice {
4
+ readonly key: string;
5
+ readonly cost: number;
6
+ readonly turns: number;
7
+ }
8
+ /** Everything the ledger holds, with its own blind spots stated. */
9
+ export interface LedgerTotal {
10
+ readonly cost: number;
11
+ readonly turns: number;
12
+ /** Turns held with no cost, so a total can say what it is missing. */
13
+ readonly unmetered: number;
14
+ readonly cacheWriteTokens: number;
15
+ /** Of those, the ones billed at the one-hour rate. */
16
+ readonly cacheWrite1hTokens: number;
17
+ }
18
+ /** How many turns a write added, and how many it had already seen. */
19
+ export interface RecordOutcome {
20
+ readonly inserted: number;
21
+ readonly duplicates: number;
22
+ }
23
+ /** What a total or a slice may be narrowed to. */
24
+ export type CostDimension = "model" | "session" | "kind" | "day" | "repo" | "quest";
25
+ /** A content-addressed store of billable turns. */
26
+ export interface TurnStore {
27
+ recordTurns(turns: readonly TurnRecord[]): Promise<RecordOutcome>;
28
+ recordSession(session: SessionRecord): Promise<void>;
29
+ recordCalls(calls: readonly ToolCallRecord[]): Promise<RecordOutcome>;
30
+ queryCalls(): Promise<ToolCallRecord[]>;
31
+ /** Arguments asked more than once in one session, heaviest first. */
32
+ repeatedCalls(scope?: CallScope): Promise<RepeatedCall[]>;
33
+ recordDropped(dropped: readonly DroppedCallRecord[]): Promise<RecordOutcome>;
34
+ queryDropped(): Promise<DroppedCallRecord[]>;
35
+ /** Dropped calls asked again after the drop, with how many could have been. */
36
+ regret(scope?: CallScope): Promise<RegretReport>;
37
+ /** Pass, fail and unknown counts per verifier kind that ran at all. */
38
+ verifierOutcomes(): Promise<VerifierOutcome[]>;
39
+ /** How real compactions compare against the payback test. */
40
+ paybackReplay(): Promise<PaybackReplay>;
41
+ total(): Promise<LedgerTotal>;
42
+ costBy(dimension: CostDimension): Promise<CostSlice[]>;
43
+ close(): Promise<void>;
44
+ }
45
+ /**
46
+ * Open (creating if needed) a turn ledger at the given path. Safe to
47
+ * point at the same file as the run store: the tables are disjoint and
48
+ * WAL keeps readers clear of the single writer.
49
+ */
50
+ export declare function openTurnStore(dbPath: string): Promise<TurnStore>;
@@ -0,0 +1,573 @@
1
+ import { openDb } from "../../internal/sqlite/db.js";
2
+ /**
3
+ * `digest` is the primary key rather than the session and entry pair,
4
+ * which is what makes recording idempotent. `sightings` keeps the fact
5
+ * that several logs held the same turn, so nothing is hidden by
6
+ * deduplicating it: the turn is billed once and known to have been seen
7
+ * more than once.
8
+ */
9
+ const SCHEMA = `
10
+ CREATE TABLE IF NOT EXISTS turns (
11
+ digest TEXT PRIMARY KEY,
12
+ entry_id TEXT NOT NULL,
13
+ session_id TEXT NOT NULL,
14
+ timestamp TEXT NOT NULL,
15
+ kind TEXT NOT NULL,
16
+ model TEXT NOT NULL,
17
+ tokens_input INTEGER NOT NULL,
18
+ tokens_output INTEGER NOT NULL,
19
+ tokens_cache_read INTEGER NOT NULL,
20
+ tokens_cache_write INTEGER NOT NULL,
21
+ tokens_total INTEGER NOT NULL,
22
+ cache_write_1h INTEGER NOT NULL,
23
+ cost_input REAL,
24
+ cost_output REAL,
25
+ cost_cache_read REAL,
26
+ cost_cache_write REAL,
27
+ cost_total REAL,
28
+ dropped_before INTEGER,
29
+ first_kept_entry_id TEXT
30
+ );
31
+ CREATE INDEX IF NOT EXISTS turns_timestamp ON turns (timestamp);
32
+ CREATE INDEX IF NOT EXISTS turns_session ON turns (session_id);
33
+ CREATE TABLE IF NOT EXISTS sightings (
34
+ digest TEXT NOT NULL,
35
+ session_id TEXT NOT NULL,
36
+ PRIMARY KEY (digest, session_id)
37
+ );
38
+ CREATE TABLE IF NOT EXISTS tool_calls (
39
+ digest TEXT PRIMARY KEY,
40
+ session_id TEXT NOT NULL,
41
+ entry_id TEXT NOT NULL,
42
+ call_id TEXT NOT NULL,
43
+ timestamp TEXT NOT NULL,
44
+ name TEXT NOT NULL,
45
+ args_digest TEXT NOT NULL,
46
+ path TEXT,
47
+ result_chars INTEGER,
48
+ result_digest TEXT,
49
+ is_error INTEGER,
50
+ verifier_kind TEXT
51
+ );
52
+ CREATE INDEX IF NOT EXISTS tool_calls_verifier ON tool_calls (verifier_kind);
53
+ CREATE INDEX IF NOT EXISTS tool_calls_args ON tool_calls (session_id, args_digest);
54
+ CREATE INDEX IF NOT EXISTS tool_calls_path ON tool_calls (path);
55
+ CREATE TABLE IF NOT EXISTS dropped_calls (
56
+ digest TEXT PRIMARY KEY,
57
+ session_id TEXT NOT NULL,
58
+ dropped_at_entry_id TEXT NOT NULL,
59
+ dropped_at_timestamp TEXT NOT NULL
60
+ );
61
+ CREATE TABLE IF NOT EXISTS sessions (
62
+ session_id TEXT PRIMARY KEY,
63
+ cwd TEXT,
64
+ repo TEXT,
65
+ quest TEXT,
66
+ first_seen TEXT,
67
+ last_seen TEXT
68
+ );
69
+ `;
70
+ /** Bound variables per probe, well inside SQLite's default ceiling. */
71
+ const PROBE_CHUNK = 500;
72
+ const GROUP_BY = {
73
+ model: "turns.model",
74
+ session: "turns.session_id",
75
+ kind: "turns.kind",
76
+ day: "substr(turns.timestamp, 1, 10)",
77
+ // A left join, and coalesced to the empty string, so spend whose
78
+ // session named no repo or quest stays a visible slice. An inner join
79
+ // would drop it, and every share would then be a fraction of a total
80
+ // that quietly excluded most of the money.
81
+ repo: "COALESCE(sessions.repo, '')",
82
+ quest: "COALESCE(sessions.quest, '')",
83
+ };
84
+ /**
85
+ * Open (creating if needed) a turn ledger at the given path. Safe to
86
+ * point at the same file as the run store: the tables are disjoint and
87
+ * WAL keeps readers clear of the single writer.
88
+ */
89
+ export async function openTurnStore(dbPath) {
90
+ const db = await openDb(dbPath);
91
+ await db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
92
+ await db.exec(SCHEMA);
93
+ return new SqliteTurnStore(db);
94
+ }
95
+ class SqliteTurnStore {
96
+ db;
97
+ constructor(db) {
98
+ this.db = db;
99
+ }
100
+ async recordTurns(turns) {
101
+ // Collapse the batch against itself first, then ask the table once
102
+ // which of the survivors it already holds. Counting rows per insert
103
+ // instead would make a corpus pass quadratic: 40,000 turns is 1.6
104
+ // billion scanned rows, which does not finish.
105
+ const fresh = new Map();
106
+ for (const t of turns)
107
+ if (!fresh.has(t.digest))
108
+ fresh.set(t.digest, t);
109
+ const known = await this.known([...fresh.keys()]);
110
+ // One transaction per batch rather than one per row. Each row is
111
+ // otherwise its own durable commit, which is disk-sync bound: a
112
+ // first pass over the corpus took 13 minutes on that shape.
113
+ await this.db.exec("BEGIN");
114
+ try {
115
+ const inserted = await this.writeAll(fresh, known);
116
+ await this.db.exec("COMMIT");
117
+ return { inserted, duplicates: turns.length - inserted };
118
+ }
119
+ catch (error) {
120
+ await this.db.exec("ROLLBACK");
121
+ throw error;
122
+ }
123
+ }
124
+ async writeAll(fresh, known) {
125
+ let inserted = 0;
126
+ for (const t of fresh.values()) {
127
+ if (known.has(t.digest)) {
128
+ // Seen before, so not billed again, but the sighting is still
129
+ // recorded: deduplicating must not hide that it happened.
130
+ await this.sight(t);
131
+ continue;
132
+ }
133
+ inserted += 1;
134
+ await this.db.run(`INSERT OR IGNORE INTO turns (
135
+ digest, entry_id, session_id, timestamp, kind, model,
136
+ tokens_input, tokens_output, tokens_cache_read,
137
+ tokens_cache_write, tokens_total, cache_write_1h,
138
+ cost_input, cost_output, cost_cache_read, cost_cache_write,
139
+ cost_total, dropped_before, first_kept_entry_id
140
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
141
+ t.digest,
142
+ t.entryId,
143
+ t.sessionId,
144
+ t.timestamp,
145
+ t.kind,
146
+ t.model,
147
+ t.tokens.input,
148
+ t.tokens.output,
149
+ t.tokens.cacheRead,
150
+ t.tokens.cacheWrite,
151
+ t.tokens.total,
152
+ t.cacheWrite1h,
153
+ t.cost?.input ?? null,
154
+ t.cost?.output ?? null,
155
+ t.cost?.cacheRead ?? null,
156
+ t.cost?.cacheWrite ?? null,
157
+ t.cost?.total ?? null,
158
+ t.droppedBefore,
159
+ t.firstKeptEntryId,
160
+ ]);
161
+ await this.sight(t);
162
+ }
163
+ return inserted;
164
+ }
165
+ async total() {
166
+ const rows = await this.db.all(`SELECT
167
+ SUM(cost_total) AS cost,
168
+ COUNT(*) AS turns,
169
+ SUM(CASE WHEN cost_total IS NULL THEN 1 ELSE 0 END) AS unmetered,
170
+ SUM(tokens_cache_write) AS cache_write,
171
+ SUM(cache_write_1h) AS cache_write_1h
172
+ FROM turns`);
173
+ const row = rows[0];
174
+ return {
175
+ cost: row?.cost ?? 0,
176
+ turns: row?.turns ?? 0,
177
+ unmetered: row?.unmetered ?? 0,
178
+ cacheWriteTokens: row?.cache_write ?? 0,
179
+ cacheWrite1hTokens: row?.cache_write_1h ?? 0,
180
+ };
181
+ }
182
+ /**
183
+ * Replace what is known about a session. The row is a current fact
184
+ * rather than an append-only history: a re-index of a log that moved
185
+ * quest should report where it ended up.
186
+ */
187
+ async recordCalls(calls) {
188
+ const fresh = new Map();
189
+ for (const c of calls)
190
+ if (!fresh.has(c.digest))
191
+ fresh.set(c.digest, c);
192
+ const known = await this.knownCalls([...fresh.keys()]);
193
+ await this.db.exec("BEGIN");
194
+ try {
195
+ let inserted = 0;
196
+ for (const c of fresh.values()) {
197
+ if (known.has(c.digest))
198
+ continue;
199
+ inserted += 1;
200
+ await this.db.run(`INSERT INTO tool_calls (
201
+ digest, session_id, entry_id, call_id, timestamp, name,
202
+ args_digest, path, result_chars, result_digest, is_error,
203
+ verifier_kind
204
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
205
+ c.digest,
206
+ c.sessionId,
207
+ c.entryId,
208
+ c.callId,
209
+ c.timestamp,
210
+ c.name,
211
+ c.argsDigest,
212
+ c.path,
213
+ c.resultChars,
214
+ c.resultDigest,
215
+ // Null is not false: a call whose result never arrived
216
+ // did not come back succeeding.
217
+ c.isError === null ? null : c.isError ? 1 : 0,
218
+ c.verifierKind,
219
+ ]);
220
+ }
221
+ await this.db.exec("COMMIT");
222
+ return { inserted, duplicates: calls.length - inserted };
223
+ }
224
+ catch (error) {
225
+ await this.db.exec("ROLLBACK");
226
+ throw error;
227
+ }
228
+ }
229
+ async queryCalls() {
230
+ const rows = await this.db.all("SELECT * FROM tool_calls ORDER BY timestamp ASC");
231
+ return rows.map((row) => ({
232
+ digest: row.digest,
233
+ sessionId: row.session_id,
234
+ entryId: row.entry_id,
235
+ callId: row.call_id,
236
+ timestamp: row.timestamp,
237
+ name: row.name,
238
+ argsDigest: row.args_digest,
239
+ path: row.path,
240
+ resultChars: row.result_chars,
241
+ resultDigest: row.result_digest,
242
+ isError: row.is_error === null ? null : row.is_error === 1,
243
+ verifierKind: row.verifier_kind,
244
+ }));
245
+ }
246
+ /**
247
+ * Pass, fail and unknown counts per verifier kind that ran at all. A
248
+ * kind nothing ever ran is left out rather than reported as zero,
249
+ * since zero-and-never-ran read the same on a dashboard but mean
250
+ * opposite things.
251
+ */
252
+ async verifierOutcomes() {
253
+ const rows = await this.db.all(`SELECT verifier_kind,
254
+ SUM(CASE WHEN is_error = 0 THEN 1 ELSE 0 END) AS passed,
255
+ SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) AS failed,
256
+ SUM(CASE WHEN is_error IS NULL THEN 1 ELSE 0 END) AS unknown
257
+ FROM tool_calls
258
+ WHERE verifier_kind IS NOT NULL
259
+ GROUP BY verifier_kind`);
260
+ return rows.map((row) => ({
261
+ kind: row.verifier_kind,
262
+ passed: row.passed,
263
+ failed: row.failed,
264
+ unknown: row.unknown,
265
+ }));
266
+ }
267
+ /**
268
+ * Arguments asked more than once inside one session.
269
+ *
270
+ * Grouped by session as well as by arguments, because a second
271
+ * session has none of the first one's context: asking again is the
272
+ * only way it could know. Only repetition within a session is a
273
+ * question whose answer was already there.
274
+ *
275
+ * Each call is compared with the previous asking of the same
276
+ * arguments, and counts as a repeat only when no writer touched its
277
+ * file in between: re-reading unchanged bytes is waste, re-reading a
278
+ * file that was just edited is how the edit gets checked. `asked` is
279
+ * therefore the repeats plus the asking they repeated, not every call
280
+ * ever made with these arguments.
281
+ */
282
+ async repeatedCalls(scope = {}) {
283
+ const retrieval = inList("c.name", scope.retrieval);
284
+ const changed = writtenBetween("o", "o.previous_at", "o.timestamp", scope.writers);
285
+ const rows = await this.db.all(`WITH ordered AS (
286
+ SELECT c.session_id, c.args_digest, c.name, c.path,
287
+ c.timestamp, c.result_chars,
288
+ LAG(c.timestamp) OVER (
289
+ PARTITION BY c.session_id, c.args_digest
290
+ ORDER BY c.timestamp, c.digest
291
+ ) AS previous_at
292
+ FROM tool_calls AS c
293
+ WHERE ${retrieval.sql}
294
+ )
295
+ SELECT args_digest, name, COUNT(*) AS repeated,
296
+ SUM(COALESCE(result_chars, 0)) AS repeated_chars
297
+ FROM ordered AS o
298
+ WHERE o.previous_at IS NOT NULL AND NOT (${changed.sql})
299
+ GROUP BY session_id, args_digest
300
+ ORDER BY repeated_chars DESC`, [...retrieval.params, ...changed.params]);
301
+ return rows.map((row) => ({
302
+ argsDigest: row.args_digest,
303
+ name: row.name,
304
+ asked: row.repeated + 1,
305
+ repeated: row.repeated,
306
+ repeatedChars: row.repeated_chars ?? 0,
307
+ }));
308
+ }
309
+ async recordDropped(dropped) {
310
+ const fresh = new Map();
311
+ for (const d of dropped) {
312
+ if (!fresh.has(d.callDigest))
313
+ fresh.set(d.callDigest, d);
314
+ }
315
+ const known = await this.alreadyHeld("dropped_calls", [...fresh.keys()]);
316
+ await this.db.exec("BEGIN");
317
+ try {
318
+ let inserted = 0;
319
+ for (const d of fresh.values()) {
320
+ if (known.has(d.callDigest))
321
+ continue;
322
+ inserted += 1;
323
+ await this.db.run(`INSERT INTO dropped_calls (
324
+ digest, session_id, dropped_at_entry_id, dropped_at_timestamp
325
+ ) VALUES (?, ?, ?, ?)`, [d.callDigest, d.sessionId, d.droppedAtEntryId, d.droppedAtTimestamp]);
326
+ }
327
+ await this.db.exec("COMMIT");
328
+ return { inserted, duplicates: dropped.length - inserted };
329
+ }
330
+ catch (error) {
331
+ await this.db.exec("ROLLBACK");
332
+ throw error;
333
+ }
334
+ }
335
+ async queryDropped() {
336
+ const rows = await this.db.all("SELECT * FROM dropped_calls ORDER BY dropped_at_timestamp ASC");
337
+ return rows.map((row) => ({
338
+ callDigest: row.digest,
339
+ sessionId: row.session_id,
340
+ droppedAtEntryId: row.dropped_at_entry_id,
341
+ droppedAtTimestamp: row.dropped_at_timestamp,
342
+ }));
343
+ }
344
+ /**
345
+ * Dropped calls asked again after the drop.
346
+ *
347
+ * Joined on the dropped call's own arguments and session, restricted
348
+ * to a later call that came after the drop rather than before it: a
349
+ * repeat that predates the drop is an ordinary repeat, not a case of
350
+ * the context having to re-fetch what it lost.
351
+ *
352
+ * One row per dropped call, at its first re-ask. Joining every later
353
+ * asking as its own row once turned a single call re-issued 79,600
354
+ * times into 79,600 regrets. A re-ask after a writer touched the
355
+ * dropped call's file fetched something new and does not count, and
356
+ * neither does any re-ask after that, since each of them reads the
357
+ * changed file rather than the one the drop discarded.
358
+ */
359
+ async regret(scope = {}) {
360
+ const retrieval = inList("dropped.name", scope.retrieval);
361
+ const [counted] = await this.db.all(`SELECT COUNT(*) AS in_scope
362
+ FROM dropped_calls
363
+ JOIN tool_calls AS dropped ON dropped.digest = dropped_calls.digest
364
+ WHERE ${retrieval.sql}`, retrieval.params);
365
+ const changed = writtenBetween("dropped", "dropped.timestamp", "re_ask.timestamp", scope.writers);
366
+ // SQLite takes a bare column alongside MIN from the row MIN chose,
367
+ // which is what makes result_chars the first re-ask's own size.
368
+ const rows = await this.db.all(`SELECT dropped_calls.session_id AS session_id,
369
+ dropped.name AS name,
370
+ dropped.args_digest AS args_digest,
371
+ dropped_calls.dropped_at_timestamp AS dropped_at_timestamp,
372
+ MIN(re_ask.timestamp) AS re_asked_at_timestamp,
373
+ re_ask.result_chars AS result_chars
374
+ FROM dropped_calls
375
+ JOIN tool_calls AS dropped
376
+ ON dropped.digest = dropped_calls.digest
377
+ JOIN tool_calls AS re_ask
378
+ ON re_ask.session_id = dropped_calls.session_id
379
+ AND re_ask.args_digest = dropped.args_digest
380
+ AND re_ask.timestamp > dropped_calls.dropped_at_timestamp
381
+ WHERE ${retrieval.sql} AND NOT (${changed.sql})
382
+ GROUP BY dropped_calls.digest
383
+ ORDER BY re_asked_at_timestamp ASC`, [...retrieval.params, ...changed.params]);
384
+ return {
385
+ inScope: counted?.in_scope ?? 0,
386
+ reAsked: rows.map((row) => ({
387
+ name: row.name,
388
+ argsDigest: row.args_digest,
389
+ sessionId: row.session_id,
390
+ droppedAtTimestamp: row.dropped_at_timestamp,
391
+ reAskedAtTimestamp: row.re_asked_at_timestamp,
392
+ resultChars: row.result_chars,
393
+ })),
394
+ };
395
+ }
396
+ /**
397
+ * Replay the payback test over compactions already in the ledger.
398
+ *
399
+ * Retained tokens come from the first turn after each compaction
400
+ * (its resident input, cache read and cache write together), and
401
+ * remaining turns from how many turns actually followed. A model's
402
+ * rate is derived from its own billed dollars per token elsewhere in
403
+ * the ledger, never a hardcoded price table, since a price table
404
+ * goes stale the moment a provider changes its prices and this does
405
+ * not.
406
+ */
407
+ async paybackReplay() {
408
+ const rows = await this.db.all(`WITH rates AS (
409
+ SELECT model,
410
+ SUM(cost_cache_read) * 1.0 / SUM(tokens_cache_read) AS read_rate,
411
+ SUM(cost_cache_write) * 1.0 / SUM(tokens_cache_write) AS write_rate
412
+ FROM turns
413
+ WHERE kind = 'assistant'
414
+ AND tokens_cache_read > 0 AND tokens_cache_write > 0
415
+ GROUP BY model
416
+ ),
417
+ compactions AS (
418
+ SELECT digest, session_id, timestamp, dropped_before, model
419
+ FROM turns
420
+ WHERE kind = 'compaction' AND dropped_before IS NOT NULL
421
+ ),
422
+ after_turns AS (
423
+ SELECT c.digest AS compaction_digest,
424
+ t.tokens_input + t.tokens_cache_read + t.tokens_cache_write
425
+ AS resident,
426
+ ROW_NUMBER() OVER (
427
+ PARTITION BY c.digest ORDER BY t.timestamp ASC
428
+ ) AS rn
429
+ FROM compactions c
430
+ JOIN turns t
431
+ ON t.session_id = c.session_id AND t.timestamp > c.timestamp
432
+ ),
433
+ after_agg AS (
434
+ SELECT compaction_digest,
435
+ COUNT(*) AS remaining,
436
+ MAX(CASE WHEN rn = 1 THEN resident END) AS retained
437
+ FROM after_turns
438
+ GROUP BY compaction_digest
439
+ )
440
+ SELECT c.dropped_before AS dropped_before,
441
+ a.remaining AS remaining,
442
+ a.retained AS retained,
443
+ r.read_rate AS read_rate,
444
+ r.write_rate AS write_rate
445
+ FROM compactions c
446
+ LEFT JOIN after_agg a ON a.compaction_digest = c.digest
447
+ LEFT JOIN rates r ON r.model = c.model`);
448
+ let evaluable = 0;
449
+ let agreed = 0;
450
+ let disagreed = 0;
451
+ for (const row of rows) {
452
+ if (row.remaining === null ||
453
+ row.retained === null ||
454
+ row.read_rate === null ||
455
+ row.write_rate === null ||
456
+ row.read_rate <= 0) {
457
+ continue;
458
+ }
459
+ evaluable += 1;
460
+ const dropped = Math.max(0, row.dropped_before - row.retained);
461
+ const saved = dropped * row.remaining;
462
+ const ratio = row.write_rate / row.read_rate;
463
+ const cost = ratio * row.retained;
464
+ if (saved > cost)
465
+ agreed += 1;
466
+ else
467
+ disagreed += 1;
468
+ }
469
+ return { compactions: rows.length, evaluable, agreed, disagreed };
470
+ }
471
+ async recordSession(session) {
472
+ await this.db.run(`INSERT INTO sessions (
473
+ session_id, cwd, repo, quest, first_seen, last_seen
474
+ ) VALUES (?, ?, ?, ?, ?, ?)
475
+ ON CONFLICT(session_id) DO UPDATE SET
476
+ cwd = excluded.cwd,
477
+ repo = excluded.repo,
478
+ quest = excluded.quest,
479
+ first_seen = MIN(first_seen, excluded.first_seen),
480
+ last_seen = MAX(last_seen, excluded.last_seen)`, [
481
+ session.sessionId,
482
+ session.cwd,
483
+ session.repo,
484
+ session.quest,
485
+ session.firstSeen,
486
+ session.lastSeen,
487
+ ]);
488
+ }
489
+ async costBy(dimension) {
490
+ const column = GROUP_BY[dimension];
491
+ const rows = await this.db.all(`SELECT ${column} AS key, SUM(cost_total) AS cost, COUNT(*) AS turns
492
+ FROM turns
493
+ LEFT JOIN sessions ON sessions.session_id = turns.session_id
494
+ GROUP BY ${column} ORDER BY cost DESC`);
495
+ return rows.map((r) => ({
496
+ key: r.key,
497
+ cost: r.cost ?? 0,
498
+ turns: r.turns,
499
+ }));
500
+ }
501
+ async close() {
502
+ await this.db.close();
503
+ }
504
+ async sight(t) {
505
+ await this.db.run("INSERT OR IGNORE INTO sightings (digest, session_id) VALUES (?, ?)", [t.digest, t.sessionId]);
506
+ }
507
+ /** Which of these call addresses the table already holds. */
508
+ async knownCalls(digests) {
509
+ return this.alreadyHeld("tool_calls", digests);
510
+ }
511
+ /** Which of these addresses the table already holds. */
512
+ async known(digests) {
513
+ return this.alreadyHeld("turns", digests);
514
+ }
515
+ /**
516
+ * Which of these digests a table already holds, probed in chunks so a
517
+ * corpus-sized batch stays inside SQLite's bound-variable ceiling.
518
+ */
519
+ async alreadyHeld(table, digests) {
520
+ const found = new Set();
521
+ for (let i = 0; i < digests.length; i += PROBE_CHUNK) {
522
+ const chunk = digests.slice(i, i + PROBE_CHUNK);
523
+ const holes = chunk.map(() => "?").join(",");
524
+ // The table name is a literal from a two-member union, not
525
+ // caller input, so it cannot carry anything but itself.
526
+ const rows = await this.db.all(`SELECT digest FROM ${table} WHERE digest IN (${holes})`, chunk);
527
+ for (const r of rows)
528
+ found.add(r.digest);
529
+ }
530
+ return found;
531
+ }
532
+ }
533
+ /**
534
+ * `column IN (...)` over a caller's list, or a clause that holds for
535
+ * every row when there is no list. An empty list means the caller named
536
+ * nothing in scope, which is answered with nothing rather than
537
+ * silently widened to everything.
538
+ */
539
+ function inList(column, values) {
540
+ if (values === undefined)
541
+ return { sql: "1 = 1", params: [] };
542
+ if (values.length === 0)
543
+ return { sql: "1 = 0", params: [] };
544
+ return {
545
+ sql: `${column} IN (${values.map(() => "?").join(",")})`,
546
+ params: values,
547
+ };
548
+ }
549
+ /**
550
+ * Whether a writer touched the file a call declares, strictly between
551
+ * two moments in the same session. Holds for no row when no writers are
552
+ * named or the call declares no file, since then nothing can have
553
+ * changed underneath it that the ledger would know about.
554
+ *
555
+ * Column expressions are fixed fragments from this module, never caller
556
+ * input; only the writer names are bound.
557
+ */
558
+ function writtenBetween(call, after, before, writers) {
559
+ if (writers === undefined || writers.length === 0) {
560
+ return { sql: "1 = 0", params: [] };
561
+ }
562
+ return {
563
+ sql: `${call}.path IS NOT NULL AND EXISTS (
564
+ SELECT 1 FROM tool_calls AS written
565
+ WHERE written.session_id = ${call}.session_id
566
+ AND written.path = ${call}.path
567
+ AND written.name IN (${writers.map(() => "?").join(",")})
568
+ AND written.timestamp > ${after}
569
+ AND written.timestamp < ${before}
570
+ )`,
571
+ params: writers,
572
+ };
573
+ }