@jitsusama/agentic-harness.core 0.3.1 → 0.5.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.
@@ -0,0 +1,11 @@
1
+ import type { LedgerScan } from "./types.js";
2
+ /**
3
+ * Read billable turns out of a session log's lines.
4
+ *
5
+ * Every line is offered to the parser and every outcome is counted, so a
6
+ * malformed line costs one entry rather than the remainder of the file.
7
+ * Both places a turn can carry usage are read: assistant turns hold it
8
+ * under `message`, and compactions hold it at the top level beside
9
+ * `type`.
10
+ */
11
+ export declare function readTurns(sessionId: string, lines: Iterable<string>): LedgerScan;
@@ -0,0 +1,159 @@
1
+ import { createHash } from "node:crypto";
2
+ import { SessionCollector } from "./session.js";
3
+ /** Width of a stored content address. 96 bits is ample for a corpus of
4
+ * a few million turns and keeps the index small. */
5
+ const DIGEST_CHARS = 24;
6
+ /**
7
+ * Read billable turns out of a session log's lines.
8
+ *
9
+ * Every line is offered to the parser and every outcome is counted, so a
10
+ * malformed line costs one entry rather than the remainder of the file.
11
+ * Both places a turn can carry usage are read: assistant turns hold it
12
+ * under `message`, and compactions hold it at the top level beside
13
+ * `type`.
14
+ */
15
+ export function readTurns(sessionId, lines) {
16
+ const turns = [];
17
+ const session = new SessionCollector(sessionId);
18
+ let count = 0;
19
+ let parsed = 0;
20
+ let unparseable = 0;
21
+ let billable = 0;
22
+ let unmetered = 0;
23
+ for (const line of lines) {
24
+ count += 1;
25
+ if (!line.trim())
26
+ continue;
27
+ let entry;
28
+ try {
29
+ const value = JSON.parse(line);
30
+ if (typeof value !== "object" || value === null) {
31
+ unparseable += 1;
32
+ continue;
33
+ }
34
+ entry = value;
35
+ }
36
+ catch {
37
+ // A truncated or interleaved write. Counted, never fatal: one
38
+ // unreadable line once reduced a corpus-wide total to a tenth
39
+ // of the truth by aborting the pipeline that met it.
40
+ unparseable += 1;
41
+ continue;
42
+ }
43
+ parsed += 1;
44
+ if (entry.type === "session") {
45
+ session.observeHeader(entry);
46
+ continue;
47
+ }
48
+ if (entry.customType === "quest-workflow") {
49
+ const data = asRecord(entry.data);
50
+ if (data)
51
+ session.observeWorkflow(data);
52
+ continue;
53
+ }
54
+ const turn = turnFrom(sessionId, entry);
55
+ if (!turn)
56
+ continue;
57
+ session.observeTurn(turn.timestamp);
58
+ turns.push(turn);
59
+ if (turn.cost)
60
+ billable += 1;
61
+ else
62
+ unmetered += 1;
63
+ }
64
+ return {
65
+ turns,
66
+ coverage: { lines: count, parsed, unparseable, billable, unmetered },
67
+ session: session.record(),
68
+ };
69
+ }
70
+ function turnFrom(sessionId, entry) {
71
+ const kind = kindOf(entry);
72
+ if (!kind)
73
+ return null;
74
+ const message = asRecord(entry.message);
75
+ const usage = asRecord(kind === "compaction" ? entry.usage : message?.usage);
76
+ const entryId = typeof entry.id === "string" ? entry.id : "";
77
+ const timestamp = typeof entry.timestamp === "string" ? entry.timestamp : "";
78
+ const model = typeof message?.model === "string" ? message.model : "";
79
+ return {
80
+ entryId,
81
+ sessionId,
82
+ timestamp,
83
+ kind,
84
+ model,
85
+ tokens: tokensFrom(usage),
86
+ cost: costFrom(usage),
87
+ cacheWrite1h: usage?.cacheWrite1h ?? 0,
88
+ droppedBefore: kind === "compaction" && typeof entry.tokensBefore === "number"
89
+ ? entry.tokensBefore
90
+ : null,
91
+ firstKeptEntryId: typeof entry.firstKeptEntryId === "string"
92
+ ? entry.firstKeptEntryId
93
+ : null,
94
+ digest: digestOf(entryId, timestamp, kind, usage),
95
+ };
96
+ }
97
+ /**
98
+ * Which turns are billable at all. An assistant turn and a compaction
99
+ * both cost money; a user message, a tool result and a state change do
100
+ * not. Enumerated rather than filtered, so a new entry type is ignored
101
+ * by omission instead of silently swept into a total.
102
+ */
103
+ function kindOf(entry) {
104
+ if (entry.type === "compaction")
105
+ return "compaction";
106
+ const message = asRecord(entry.message);
107
+ if (message?.role === "assistant")
108
+ return "assistant";
109
+ return null;
110
+ }
111
+ function tokensFrom(usage) {
112
+ const input = usage?.input ?? 0;
113
+ const output = usage?.output ?? 0;
114
+ const cacheRead = usage?.cacheRead ?? 0;
115
+ const cacheWrite = usage?.cacheWrite ?? 0;
116
+ return {
117
+ input,
118
+ output,
119
+ cacheRead,
120
+ cacheWrite,
121
+ total: usage?.totalTokens ?? input + output + cacheRead + cacheWrite,
122
+ };
123
+ }
124
+ /** Null when the entry reported no cost, which is not the same as free. */
125
+ function costFrom(usage) {
126
+ const cost = usage?.cost;
127
+ if (!cost || typeof cost.total !== "number")
128
+ return null;
129
+ return {
130
+ input: cost.input ?? 0,
131
+ output: cost.output ?? 0,
132
+ cacheRead: cost.cacheRead ?? 0,
133
+ cacheWrite: cost.cacheWrite ?? 0,
134
+ total: cost.total,
135
+ };
136
+ }
137
+ /**
138
+ * Address a turn by what it is rather than where it was found. Forking a
139
+ * session copies entries verbatim, ids included, so the same turn appears
140
+ * in several files and a naive count bills it more than once.
141
+ */
142
+ function digestOf(entryId, timestamp, kind, usage) {
143
+ const canonical = JSON.stringify([
144
+ entryId,
145
+ timestamp,
146
+ kind,
147
+ usage?.cost?.total ?? null,
148
+ usage?.totalTokens ?? null,
149
+ ]);
150
+ return createHash("sha256")
151
+ .update(canonical)
152
+ .digest("hex")
153
+ .slice(0, DIGEST_CHARS);
154
+ }
155
+ function asRecord(value) {
156
+ return typeof value === "object" && value !== null
157
+ ? value
158
+ : null;
159
+ }
@@ -0,0 +1,38 @@
1
+ import type { SessionRecord } from "./types.js";
2
+ /**
3
+ * Name the repo a working directory belongs to, or nothing when the path
4
+ * names none.
5
+ *
6
+ * A monorepo zone is named by the zone rather than the worktree it was
7
+ * cut into, because two trees of the same zone are the same subject and
8
+ * naming the tree would split one zone's spend across every tree ever
9
+ * cut for it.
10
+ */
11
+ export declare function repoOf(cwd: string | null): string | null;
12
+ /**
13
+ * Accumulates what a log says about its session as the log is read, so
14
+ * one pass serves both the turns and their attribution.
15
+ */
16
+ export declare class SessionCollector {
17
+ private readonly sessionId;
18
+ private cwd;
19
+ private quest;
20
+ private first;
21
+ private last;
22
+ constructor(sessionId: string);
23
+ /**
24
+ * Take the working directory from a session's header entry. Every log
25
+ * opens with one, which is what makes attribution complete rather than
26
+ * limited to the quarter of sessions that also name a quest.
27
+ */
28
+ observeHeader(entry: Record<string, unknown>): void;
29
+ /**
30
+ * Take the working directory and quest a workflow entry names. The
31
+ * last one wins, because a session can be re-pointed at another quest
32
+ * part way through and the later statement is the current one.
33
+ */
34
+ observeWorkflow(data: Record<string, unknown>): void;
35
+ /** Widen the span to include a billed turn. */
36
+ observeTurn(timestamp: string): void;
37
+ record(): SessionRecord;
38
+ }
@@ -0,0 +1,89 @@
1
+ /** Where a per-host checkout tree begins, as `src/{host}/{owner}/{repo}`. */
2
+ const CHECKOUT_MARKER = "/src/";
3
+ /** Where a monorepo worktree begins, as `world/trees/{tree}/src/{zone}`. */
4
+ const MONOREPO_MARKER = "/world/trees/";
5
+ /** Segments naming a repo under the checkout marker: host, owner, name. */
6
+ const REPO_SEGMENTS = 3;
7
+ /**
8
+ * Name the repo a working directory belongs to, or nothing when the path
9
+ * names none.
10
+ *
11
+ * A monorepo zone is named by the zone rather than the worktree it was
12
+ * cut into, because two trees of the same zone are the same subject and
13
+ * naming the tree would split one zone's spend across every tree ever
14
+ * cut for it.
15
+ */
16
+ export function repoOf(cwd) {
17
+ if (!cwd)
18
+ return null;
19
+ const monorepo = cwd.indexOf(MONOREPO_MARKER);
20
+ if (monorepo >= 0) {
21
+ const tail = cwd.slice(monorepo + MONOREPO_MARKER.length);
22
+ const zone = tail.split("/src/")[1];
23
+ return zone ? `world/${zone}` : null;
24
+ }
25
+ const checkout = cwd.indexOf(CHECKOUT_MARKER);
26
+ if (checkout >= 0) {
27
+ const parts = cwd
28
+ .slice(checkout + CHECKOUT_MARKER.length)
29
+ .split("/")
30
+ .filter(Boolean);
31
+ if (parts.length >= REPO_SEGMENTS) {
32
+ return parts.slice(0, REPO_SEGMENTS).join("/");
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * Accumulates what a log says about its session as the log is read, so
39
+ * one pass serves both the turns and their attribution.
40
+ */
41
+ export class SessionCollector {
42
+ sessionId;
43
+ cwd = null;
44
+ quest = null;
45
+ first = null;
46
+ last = null;
47
+ constructor(sessionId) {
48
+ this.sessionId = sessionId;
49
+ }
50
+ /**
51
+ * Take the working directory from a session's header entry. Every log
52
+ * opens with one, which is what makes attribution complete rather than
53
+ * limited to the quarter of sessions that also name a quest.
54
+ */
55
+ observeHeader(entry) {
56
+ if (typeof entry.cwd === "string")
57
+ this.cwd = entry.cwd;
58
+ }
59
+ /**
60
+ * Take the working directory and quest a workflow entry names. The
61
+ * last one wins, because a session can be re-pointed at another quest
62
+ * part way through and the later statement is the current one.
63
+ */
64
+ observeWorkflow(data) {
65
+ if (typeof data.cwd === "string")
66
+ this.cwd = data.cwd;
67
+ if (typeof data.questId === "string")
68
+ this.quest = data.questId;
69
+ }
70
+ /** Widen the span to include a billed turn. */
71
+ observeTurn(timestamp) {
72
+ if (!timestamp)
73
+ return;
74
+ if (!this.first || timestamp < this.first)
75
+ this.first = timestamp;
76
+ if (!this.last || timestamp > this.last)
77
+ this.last = timestamp;
78
+ }
79
+ record() {
80
+ return {
81
+ sessionId: this.sessionId,
82
+ cwd: this.cwd,
83
+ repo: repoOf(this.cwd),
84
+ quest: this.quest,
85
+ firstSeen: this.first,
86
+ lastSeen: this.last,
87
+ };
88
+ }
89
+ }
@@ -21,7 +21,7 @@ export interface RecordOutcome {
21
21
  readonly duplicates: number;
22
22
  }
23
23
  /** What a total or a slice may be narrowed to. */
24
- export type CostDimension = "model" | "session" | "kind" | "day" | "repo" | "quest";
24
+ export type CostDimension = "model" | "session" | "kind" | "day" | "repo" | "quest" | "thinking";
25
25
  /** A content-addressed store of billable turns. */
26
26
  export interface TurnStore {
27
27
  recordTurns(turns: readonly TurnRecord[]): Promise<RecordOutcome>;
@@ -40,6 +40,8 @@ export interface TurnStore {
40
40
  paybackReplay(): Promise<PaybackReplay>;
41
41
  total(): Promise<LedgerTotal>;
42
42
  costBy(dimension: CostDimension): Promise<CostSlice[]>;
43
+ /** Every session the ledger holds, for another store to join to. */
44
+ sessions(): Promise<SessionRecord[]>;
43
45
  close(): Promise<void>;
44
46
  }
45
47
  /**
@@ -52,6 +52,8 @@ CREATE TABLE IF NOT EXISTS tool_calls (
52
52
  CREATE INDEX IF NOT EXISTS tool_calls_verifier ON tool_calls (verifier_kind);
53
53
  CREATE INDEX IF NOT EXISTS tool_calls_args ON tool_calls (session_id, args_digest);
54
54
  CREATE INDEX IF NOT EXISTS tool_calls_path ON tool_calls (path);
55
+ CREATE INDEX IF NOT EXISTS tool_calls_session_verifier
56
+ ON tool_calls (session_id, timestamp) WHERE verifier_kind IS NOT NULL;
55
57
  CREATE TABLE IF NOT EXISTS dropped_calls (
56
58
  digest TEXT PRIMARY KEY,
57
59
  session_id TEXT NOT NULL,
@@ -80,6 +82,7 @@ const GROUP_BY = {
80
82
  // that quietly excluded most of the money.
81
83
  repo: "COALESCE(sessions.repo, '')",
82
84
  quest: "COALESCE(sessions.quest, '')",
85
+ thinking: "COALESCE(turns.thinking_level, '')",
83
86
  };
84
87
  /**
85
88
  * Open (creating if needed) a turn ledger at the given path. Safe to
@@ -90,8 +93,22 @@ export async function openTurnStore(dbPath) {
90
93
  const db = await openDb(dbPath);
91
94
  await db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
92
95
  await db.exec(SCHEMA);
96
+ await migrate(db);
93
97
  return new SqliteTurnStore(db);
94
98
  }
99
+ /**
100
+ * Bring a ledger written at an older shape up to this one. A new ledger
101
+ * is created at the original shape and migrated like any other, so every
102
+ * test that opens a fresh store also exercises the path an existing file
103
+ * meets. Every step is additive, so nothing a ledger holds can be lost by
104
+ * opening it.
105
+ */
106
+ async function migrate(db) {
107
+ const columns = new Set((await db.all("PRAGMA table_info(turns)")).map((column) => column.name));
108
+ if (!columns.has("thinking_level")) {
109
+ await db.exec("ALTER TABLE turns ADD COLUMN thinking_level TEXT");
110
+ }
111
+ }
95
112
  class SqliteTurnStore {
96
113
  db;
97
114
  constructor(db) {
@@ -128,6 +145,7 @@ class SqliteTurnStore {
128
145
  // Seen before, so not billed again, but the sighting is still
129
146
  // recorded: deduplicating must not hide that it happened.
130
147
  await this.sight(t);
148
+ await this.fillThinkingLevel(t);
131
149
  continue;
132
150
  }
133
151
  inserted += 1;
@@ -136,8 +154,8 @@ class SqliteTurnStore {
136
154
  tokens_input, tokens_output, tokens_cache_read,
137
155
  tokens_cache_write, tokens_total, cache_write_1h,
138
156
  cost_input, cost_output, cost_cache_read, cost_cache_write,
139
- cost_total, dropped_before, first_kept_entry_id
140
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
157
+ cost_total, dropped_before, first_kept_entry_id, thinking_level
158
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
141
159
  t.digest,
142
160
  t.entryId,
143
161
  t.sessionId,
@@ -157,6 +175,7 @@ class SqliteTurnStore {
157
175
  t.cost?.total ?? null,
158
176
  t.droppedBefore,
159
177
  t.firstKeptEntryId,
178
+ t.thinkingLevel,
160
179
  ]);
161
180
  await this.sight(t);
162
181
  }
@@ -284,18 +303,32 @@ class SqliteTurnStore {
284
303
  const changed = writtenBetween("o", "o.previous_at", "o.timestamp", scope.writers);
285
304
  const rows = await this.db.all(`WITH ordered AS (
286
305
  SELECT c.session_id, c.args_digest, c.name, c.path,
287
- c.timestamp, c.result_chars,
306
+ c.timestamp, c.result_chars, c.verifier_kind,
288
307
  LAG(c.timestamp) OVER (
289
308
  PARTITION BY c.session_id, c.args_digest
290
309
  ORDER BY c.timestamp, c.digest
291
310
  ) AS previous_at
292
311
  FROM tool_calls AS c
293
312
  WHERE ${retrieval.sql}
313
+ ),
314
+ repeats AS (
315
+ SELECT o.session_id, o.args_digest, o.name, o.result_chars,
316
+ (o.verifier_kind IS NOT NULL OR EXISTS (
317
+ SELECT 1 FROM tool_calls AS v
318
+ WHERE v.session_id = o.session_id
319
+ AND v.verifier_kind IS NOT NULL
320
+ AND v.timestamp > o.previous_at
321
+ AND v.timestamp < o.timestamp
322
+ )) AS is_appraisal
323
+ FROM ordered AS o
324
+ WHERE o.previous_at IS NOT NULL AND NOT (${changed.sql})
294
325
  )
295
326
  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})
327
+ SUM(COALESCE(result_chars, 0)) AS repeated_chars,
328
+ SUM(is_appraisal) AS appraisal,
329
+ SUM(CASE WHEN is_appraisal THEN COALESCE(result_chars, 0) ELSE 0 END)
330
+ AS appraisal_chars
331
+ FROM repeats
299
332
  GROUP BY session_id, args_digest
300
333
  ORDER BY repeated_chars DESC`, [...retrieval.params, ...changed.params]);
301
334
  return rows.map((row) => ({
@@ -304,6 +337,10 @@ class SqliteTurnStore {
304
337
  asked: row.repeated + 1,
305
338
  repeated: row.repeated,
306
339
  repeatedChars: row.repeated_chars ?? 0,
340
+ rework: row.repeated - row.appraisal,
341
+ reworkChars: (row.repeated_chars ?? 0) - (row.appraisal_chars ?? 0),
342
+ appraisal: row.appraisal,
343
+ appraisalChars: row.appraisal_chars ?? 0,
307
344
  }));
308
345
  }
309
346
  async recordDropped(dropped) {
@@ -498,9 +535,32 @@ class SqliteTurnStore {
498
535
  turns: r.turns,
499
536
  }));
500
537
  }
538
+ async sessions() {
539
+ const rows = await this.db.all(`SELECT session_id, cwd, repo, quest, first_seen, last_seen
540
+ FROM sessions ORDER BY session_id`);
541
+ return rows.map((r) => ({
542
+ sessionId: r.session_id,
543
+ cwd: r.cwd,
544
+ repo: r.repo,
545
+ quest: r.quest,
546
+ firstSeen: r.first_seen,
547
+ lastSeen: r.last_seen,
548
+ }));
549
+ }
501
550
  async close() {
502
551
  await this.db.close();
503
552
  }
553
+ /**
554
+ * Give a held turn the thinking level a later scan learned, when it
555
+ * had none. This is how a ledger indexed before the column existed
556
+ * gets it on the next rescan, without billing anything twice. A level
557
+ * already known is left alone: the same entry cannot have run at two.
558
+ */
559
+ async fillThinkingLevel(t) {
560
+ if (t.thinkingLevel === null)
561
+ return;
562
+ await this.db.run("UPDATE turns SET thinking_level = ? WHERE digest = ? AND thinking_level IS NULL", [t.thinkingLevel, t.digest]);
563
+ }
504
564
  async sight(t) {
505
565
  await this.db.run("INSERT OR IGNORE INTO sightings (digest, session_id) VALUES (?, ?)", [t.digest, t.sessionId]);
506
566
  }
@@ -17,6 +17,13 @@ export interface TurnRecord {
17
17
  readonly kind: TurnKind;
18
18
  /** Resolved model id, or empty when the entry did not say. */
19
19
  readonly model: string;
20
+ /**
21
+ * The thinking level the turn ran at, as the log last set it before
22
+ * the turn, or null when the log never said. Unknown is not a level:
23
+ * guessing the harness default would put spend on a setting that may
24
+ * not have been in force.
25
+ */
26
+ readonly thinkingLevel: string | null;
20
27
  readonly tokens: RunTokens;
21
28
  /** Null when unmetered. Never coerced to zero. */
22
29
  readonly cost: RunCost | null;
@@ -174,6 +181,18 @@ export interface RepeatedCall {
174
181
  readonly repeated: number;
175
182
  /** Characters the repeats re-admitted to the context. */
176
183
  readonly repeatedChars: number;
184
+ /** Repeats with no verifier since the last ask: the waste bucket. */
185
+ readonly rework: number;
186
+ /** Characters the rework repeats re-admitted. */
187
+ readonly reworkChars: number;
188
+ /**
189
+ * Repeats that are themselves a verifier, or that follow one run
190
+ * since the last ask: the model checking its work, which is cost of
191
+ * quality rather than waste.
192
+ */
193
+ readonly appraisal: number;
194
+ /** Characters the appraisal repeats re-admitted. */
195
+ readonly appraisalChars: number;
177
196
  }
178
197
  /**
179
198
  * What a session log says about itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jitsusama/agentic-harness.core",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Pi-agnostic business logic for agentic-harness: state machines, guardian decisions, quest/TDD domain model.",
5
5
  "license": "MIT",
6
6
  "type": "module",