@jitsusama/agentic-harness.core 0.2.0 → 0.3.1
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 +0 -0
- package/dist/google/apis/calendar.js +1 -1
- package/dist/google/apis/client.d.ts +10 -0
- package/dist/google/apis/client.js +23 -0
- package/dist/google/apis/docs.js +1 -1
- package/dist/google/apis/drive.js +1 -1
- package/dist/google/apis/gmail.js +1 -1
- package/dist/google/apis/sheets.js +1 -1
- package/dist/google/apis/slides.js +1 -1
- package/dist/observability/index.d.ts +8 -0
- package/dist/observability/index.js +8 -0
- package/dist/observability/ledger/index.d.ts +14 -0
- package/dist/observability/ledger/index.js +13 -0
- package/dist/observability/ledger/store.d.ts +50 -0
- package/dist/observability/ledger/store.js +573 -0
- package/dist/observability/ledger/types.d.ts +197 -0
- package/dist/observability/ledger/types.js +1 -0
- package/dist/observability/recorder.d.ts +9 -2
- package/dist/observability/recorder.js +11 -18
- package/dist/observability/store.d.ts +5 -3
- package/dist/observability/store.js +152 -55
- package/dist/observability/types.d.ts +32 -4
- package/dist/slack/auth/browser-extract.js +3 -1
- package/dist/web/browser.d.ts +1 -1
- package/dist/web/browser.js +10 -3
- package/dist/web/reader.js +10 -5
- package/dist/web/session/emulation.js +3 -1
- package/package.json +153 -144
|
@@ -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
|
+
}
|