@jitsusama/agentic-harness.core 0.5.0 → 0.6.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/attribution/commit-hook.d.ts +8 -5
- package/dist/attribution/commit-hook.js +57 -32
- package/dist/internal/quest/process-liveness.d.ts +5 -0
- package/dist/internal/quest/process-liveness.js +12 -0
- package/dist/observability/recorder.d.ts +7 -0
- package/dist/observability/recorder.js +2 -0
- package/dist/observability/store.js +43 -4
- package/dist/observability/types.d.ts +16 -0
- package/package.json +1 -1
- package/dist/observability/ledger/scan.d.ts +0 -11
- package/dist/observability/ledger/scan.js +0 -159
- package/dist/observability/ledger/session.d.ts +0 -38
- package/dist/observability/ledger/session.js +0 -89
|
@@ -42,11 +42,14 @@ export interface HookInstall {
|
|
|
42
42
|
export declare function installCommitHook(repoRoot: string, options: CommitHookOptions): HookInstall;
|
|
43
43
|
/**
|
|
44
44
|
* Ensure the hook is installed in the repo containing dir, at most
|
|
45
|
-
* once per repo
|
|
46
|
-
* so later
|
|
47
|
-
* best-effort. A directory outside any git repo is a no-op
|
|
48
|
-
*
|
|
49
|
-
*
|
|
45
|
+
* once per repo. Records both dir and its repo root in `installed`,
|
|
46
|
+
* so a later command from either asks git nothing, and installs
|
|
47
|
+
* best-effort. A directory outside any git repo is a no-op and is
|
|
48
|
+
* not remembered, so a repo initialised there later is still
|
|
49
|
+
* covered. This is how hook coverage follows the session into repos
|
|
50
|
+
* it later cds into, rather than only the repo the session started
|
|
51
|
+
* in. Each git call is a synchronous spawn on the command path, so
|
|
52
|
+
* a first visit costs one and a repeat costs none.
|
|
50
53
|
*/
|
|
51
54
|
export declare function ensureCommitHook(dir: string, installed: Set<string>, options: CommitHookOptions): void;
|
|
52
55
|
/** The git repository root containing dir, or null when there is none. */
|
|
@@ -46,20 +46,20 @@ git interpret-trailers --in-place --trailer ${options.trailerExpr} "$msg_file"
|
|
|
46
46
|
* hook. A no-op when this adapter's hook is already installed.
|
|
47
47
|
*/
|
|
48
48
|
export function installCommitHook(repoRoot, options) {
|
|
49
|
+
const layout = locateHooks(repoRoot);
|
|
50
|
+
if (!layout)
|
|
51
|
+
return { installed: false, reason: "not a git repo" };
|
|
52
|
+
return installInto(layout, options);
|
|
53
|
+
}
|
|
54
|
+
/** Install the hook into a repo whose hooks have been located. */
|
|
55
|
+
function installInto({ hooksDir, customHooksPath }, options) {
|
|
49
56
|
// A custom core.hooksPath means a hook manager (husky and the
|
|
50
57
|
// like) or a shared, possibly version-controlled hooks directory
|
|
51
58
|
// owns the hooks. Leave it alone rather than write this adapter's
|
|
52
59
|
// hook into a directory it does not own.
|
|
53
|
-
if (
|
|
60
|
+
if (customHooksPath) {
|
|
54
61
|
return { installed: false, reason: "custom core.hooksPath configured" };
|
|
55
62
|
}
|
|
56
|
-
let hooksDir;
|
|
57
|
-
try {
|
|
58
|
-
hooksDir = resolveHooksDir(repoRoot);
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
return { installed: false, reason: `not a git repo: ${String(error)}` };
|
|
62
|
-
}
|
|
63
63
|
const target = join(hooksDir, "prepare-commit-msg");
|
|
64
64
|
if (existsSync(target) &&
|
|
65
65
|
readFileSync(target, "utf8").includes(options.marker)) {
|
|
@@ -82,41 +82,66 @@ export function installCommitHook(repoRoot, options) {
|
|
|
82
82
|
chmodSync(target, 0o755);
|
|
83
83
|
return { installed: true };
|
|
84
84
|
}
|
|
85
|
-
/** Whether the repo configures a custom core.hooksPath. */
|
|
86
|
-
function hasCustomHooksPath(repoRoot) {
|
|
87
|
-
try {
|
|
88
|
-
const value = execFileSync("git", ["-C", repoRoot, "config", "--get", "core.hooksPath"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
89
|
-
return value.length > 0;
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
// git config exits non-zero when the key is unset: no custom path.
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
85
|
/**
|
|
97
86
|
* Ensure the hook is installed in the repo containing dir, at most
|
|
98
|
-
* once per repo
|
|
99
|
-
* so later
|
|
100
|
-
* best-effort. A directory outside any git repo is a no-op
|
|
101
|
-
*
|
|
102
|
-
*
|
|
87
|
+
* once per repo. Records both dir and its repo root in `installed`,
|
|
88
|
+
* so a later command from either asks git nothing, and installs
|
|
89
|
+
* best-effort. A directory outside any git repo is a no-op and is
|
|
90
|
+
* not remembered, so a repo initialised there later is still
|
|
91
|
+
* covered. This is how hook coverage follows the session into repos
|
|
92
|
+
* it later cds into, rather than only the repo the session started
|
|
93
|
+
* in. Each git call is a synchronous spawn on the command path, so
|
|
94
|
+
* a first visit costs one and a repeat costs none.
|
|
103
95
|
*/
|
|
104
96
|
export function ensureCommitHook(dir, installed, options) {
|
|
105
|
-
|
|
106
|
-
|
|
97
|
+
if (installed.has(dir))
|
|
98
|
+
return;
|
|
99
|
+
const layout = locateHooks(dir);
|
|
100
|
+
if (!layout)
|
|
107
101
|
return;
|
|
108
|
-
installed.add(
|
|
102
|
+
installed.add(dir);
|
|
103
|
+
if (installed.has(layout.root))
|
|
104
|
+
return;
|
|
105
|
+
installed.add(layout.root);
|
|
109
106
|
try {
|
|
110
|
-
|
|
107
|
+
installInto(layout, options);
|
|
111
108
|
}
|
|
112
109
|
catch {
|
|
113
110
|
// Best-effort: never let hook installation break a command.
|
|
114
111
|
}
|
|
115
112
|
}
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Locate the hooks of the repo containing dir with one git call, or
|
|
115
|
+
* null when dir is in no working tree. Git prints the default hooks
|
|
116
|
+
* path as the common dir plus `/hooks`, in the same form, so any
|
|
117
|
+
* other answer means core.hooksPath points somewhere else.
|
|
118
|
+
*/
|
|
119
|
+
function locateHooks(dir) {
|
|
120
|
+
let answer;
|
|
121
|
+
try {
|
|
122
|
+
answer = execFileSync("git", [
|
|
123
|
+
"-C",
|
|
124
|
+
dir,
|
|
125
|
+
"rev-parse",
|
|
126
|
+
"--show-toplevel",
|
|
127
|
+
"--git-common-dir",
|
|
128
|
+
"--git-path",
|
|
129
|
+
"hooks",
|
|
130
|
+
], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Not in a working tree (or git unavailable): nothing to hook.
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const [root, commonDir, hooks] = answer.trimEnd().split("\n");
|
|
137
|
+
if (!root || !commonDir || !hooks)
|
|
138
|
+
return null;
|
|
139
|
+
return {
|
|
140
|
+
root,
|
|
141
|
+
// Relative paths are relative to the directory git ran in.
|
|
142
|
+
hooksDir: isAbsolute(hooks) ? hooks : join(dir, hooks),
|
|
143
|
+
customHooksPath: hooks !== `${commonDir}/hooks`,
|
|
144
|
+
};
|
|
120
145
|
}
|
|
121
146
|
/** The git repository root containing dir, or null when there is none. */
|
|
122
147
|
export function repoRootOf(dir) {
|
|
@@ -79,6 +79,11 @@ export declare function identityFromInspection(hostId: string, pid: number, insp
|
|
|
79
79
|
* read a real start token: a session then carries no process identity
|
|
80
80
|
* and its liveness falls back to recency, rather than a synthetic
|
|
81
81
|
* token that a later probe would read as a dead mismatch.
|
|
82
|
+
*
|
|
83
|
+
* A process's own start token cannot change while it runs, so the
|
|
84
|
+
* first real reading is remembered: a session start attaches more
|
|
85
|
+
* than once and each reading is a synchronous `ps`. A failed reading
|
|
86
|
+
* is not remembered, so a transient failure is retried next time.
|
|
82
87
|
*/
|
|
83
88
|
export declare function currentProcessIdentity(): ProcessIdentity | undefined;
|
|
84
89
|
/**
|
|
@@ -95,14 +95,26 @@ export function identityFromInspection(hostId, pid, inspection) {
|
|
|
95
95
|
? { hostId, pid, startToken: inspection.startToken }
|
|
96
96
|
: undefined;
|
|
97
97
|
}
|
|
98
|
+
/** Memoized identity of this process, once one has been read. */
|
|
99
|
+
let processIdentityCache;
|
|
98
100
|
/**
|
|
99
101
|
* Identity of the currently running pi process, for capture onto the
|
|
100
102
|
* session it is attached to. Undefined when the OS reader could not
|
|
101
103
|
* read a real start token: a session then carries no process identity
|
|
102
104
|
* and its liveness falls back to recency, rather than a synthetic
|
|
103
105
|
* token that a later probe would read as a dead mismatch.
|
|
106
|
+
*
|
|
107
|
+
* A process's own start token cannot change while it runs, so the
|
|
108
|
+
* first real reading is remembered: a session start attaches more
|
|
109
|
+
* than once and each reading is a synchronous `ps`. A failed reading
|
|
110
|
+
* is not remembered, so a transient failure is retried next time.
|
|
104
111
|
*/
|
|
105
112
|
export function currentProcessIdentity() {
|
|
113
|
+
processIdentityCache ??= readProcessIdentity();
|
|
114
|
+
return processIdentityCache;
|
|
115
|
+
}
|
|
116
|
+
/** Read this process's identity from the OS. */
|
|
117
|
+
function readProcessIdentity() {
|
|
106
118
|
const identity = identityFromInspection(hostname(), process.pid, readStartToken(process.pid));
|
|
107
119
|
if (!identity)
|
|
108
120
|
return undefined;
|
|
@@ -13,9 +13,16 @@ export interface RunRecordInput {
|
|
|
13
13
|
readonly kind: string;
|
|
14
14
|
readonly model: string;
|
|
15
15
|
readonly persona: string;
|
|
16
|
+
/** The thinking level the run was launched at, null when unknown. */
|
|
17
|
+
readonly thinkingLevel: string | null;
|
|
16
18
|
readonly startedAt: number;
|
|
17
19
|
readonly result: {
|
|
18
20
|
readonly exitCode: number;
|
|
21
|
+
/**
|
|
22
|
+
* The session ids the run's child processes announced, in order.
|
|
23
|
+
* Empty when there was no child process, null when not known.
|
|
24
|
+
*/
|
|
25
|
+
readonly sessionIds: readonly string[] | null;
|
|
19
26
|
readonly warnings: readonly string[];
|
|
20
27
|
readonly usage?: {
|
|
21
28
|
readonly tokens: RunTokens;
|
|
@@ -34,6 +34,8 @@ export function runRecordFrom(input) {
|
|
|
34
34
|
tokens: result.usage?.tokens ?? null,
|
|
35
35
|
cost: result.usage?.cost ?? null,
|
|
36
36
|
startedAt: input.startedAt,
|
|
37
|
+
thinkingLevel: input.thinkingLevel,
|
|
38
|
+
subagentSessionIds: result.sessionIds,
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
// Process-global so a producer extension's recordRunEverywhere
|
|
@@ -77,7 +77,7 @@ async function migrate(db) {
|
|
|
77
77
|
// a later metered run cannot be reclassified.
|
|
78
78
|
await db.exec("UPDATE runs SET metered = 0 WHERE tokens_total = 0 AND cost_total = 0");
|
|
79
79
|
}
|
|
80
|
-
for (const [name, type] of ATTRIBUTION_COLUMNS) {
|
|
80
|
+
for (const [name, type] of [...ATTRIBUTION_COLUMNS, ...LAUNCH_COLUMNS]) {
|
|
81
81
|
if (!columns.has(name)) {
|
|
82
82
|
await db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
83
83
|
}
|
|
@@ -110,6 +110,15 @@ const ATTRIBUTION_COLUMNS = [
|
|
|
110
110
|
["repo", "TEXT"],
|
|
111
111
|
["ended_at", "INTEGER"],
|
|
112
112
|
];
|
|
113
|
+
/**
|
|
114
|
+
* How a run was launched, nullable for the same reason: rows written
|
|
115
|
+
* before these existed never said.
|
|
116
|
+
*/
|
|
117
|
+
const LAUNCH_COLUMNS = [
|
|
118
|
+
["thinking_level", "TEXT"],
|
|
119
|
+
// A JSON array of session ids; null when not known.
|
|
120
|
+
["subagent_session_ids", "TEXT"],
|
|
121
|
+
];
|
|
113
122
|
class SqliteRunStore {
|
|
114
123
|
db;
|
|
115
124
|
constructor(db) {
|
|
@@ -121,8 +130,9 @@ class SqliteRunStore {
|
|
|
121
130
|
retries_to_valid, warning_count, exit_code,
|
|
122
131
|
tokens_input, tokens_output, tokens_cache_read, tokens_cache_write, tokens_total,
|
|
123
132
|
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total,
|
|
124
|
-
started_at, metered, session_id, cwd, repo, ended_at
|
|
125
|
-
|
|
133
|
+
started_at, metered, session_id, cwd, repo, ended_at,
|
|
134
|
+
thinking_level, subagent_session_ids
|
|
135
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
126
136
|
ON CONFLICT (run_id, subagent_id) DO UPDATE SET
|
|
127
137
|
kind = excluded.kind, model = excluded.model,
|
|
128
138
|
persona = excluded.persona, verify_outcome = excluded.verify_outcome,
|
|
@@ -141,7 +151,9 @@ class SqliteRunStore {
|
|
|
141
151
|
cost_total = excluded.cost_total,
|
|
142
152
|
started_at = excluded.started_at, metered = excluded.metered,
|
|
143
153
|
session_id = excluded.session_id, cwd = excluded.cwd,
|
|
144
|
-
repo = excluded.repo, ended_at = excluded.ended_at
|
|
154
|
+
repo = excluded.repo, ended_at = excluded.ended_at,
|
|
155
|
+
thinking_level = excluded.thinking_level,
|
|
156
|
+
subagent_session_ids = excluded.subagent_session_ids`, [
|
|
145
157
|
record.runId,
|
|
146
158
|
record.subagentId,
|
|
147
159
|
record.kind,
|
|
@@ -170,6 +182,10 @@ class SqliteRunStore {
|
|
|
170
182
|
record.cwd ?? null,
|
|
171
183
|
record.repo ?? null,
|
|
172
184
|
record.endedAt ?? null,
|
|
185
|
+
record.thinkingLevel ?? null,
|
|
186
|
+
record.subagentSessionIds
|
|
187
|
+
? JSON.stringify(record.subagentSessionIds)
|
|
188
|
+
: null,
|
|
173
189
|
]);
|
|
174
190
|
}
|
|
175
191
|
async queryRuns(filter = {}) {
|
|
@@ -272,6 +288,27 @@ class SqliteRunStore {
|
|
|
272
288
|
await this.db.close();
|
|
273
289
|
}
|
|
274
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Read the stored session list back. Anything that is not an array of
|
|
293
|
+
* strings reads as unknown rather than as a partial list, since a list
|
|
294
|
+
* with a session missing would join a job to less of its bill without
|
|
295
|
+
* saying so.
|
|
296
|
+
*/
|
|
297
|
+
function sessionIdsFrom(stored) {
|
|
298
|
+
if (stored === null)
|
|
299
|
+
return null;
|
|
300
|
+
try {
|
|
301
|
+
const parsed = JSON.parse(stored);
|
|
302
|
+
return Array.isArray(parsed) &&
|
|
303
|
+
parsed.every((id) => typeof id === "string")
|
|
304
|
+
? parsed
|
|
305
|
+
: null;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// Not JSON, so not something this store wrote: unknown.
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
275
312
|
function rowToRecord(row) {
|
|
276
313
|
return {
|
|
277
314
|
runId: row.run_id,
|
|
@@ -306,5 +343,7 @@ function rowToRecord(row) {
|
|
|
306
343
|
cwd: row.cwd ?? null,
|
|
307
344
|
repo: row.repo ?? null,
|
|
308
345
|
endedAt: row.ended_at ?? null,
|
|
346
|
+
thinkingLevel: row.thinking_level ?? null,
|
|
347
|
+
subagentSessionIds: sessionIdsFrom(row.subagent_session_ids),
|
|
309
348
|
};
|
|
310
349
|
}
|
|
@@ -59,6 +59,22 @@ export interface RunRecord {
|
|
|
59
59
|
readonly cost: RunCost | null;
|
|
60
60
|
/** When the run started, epoch milliseconds. */
|
|
61
61
|
readonly startedAt: number;
|
|
62
|
+
/**
|
|
63
|
+
* The thinking level the run was launched at, or null when the
|
|
64
|
+
* launch did not name one and the level it inherited is not known.
|
|
65
|
+
* Required so a producer has to say, since a level left out reads
|
|
66
|
+
* the same as one nobody knew.
|
|
67
|
+
*/
|
|
68
|
+
readonly thinkingLevel: string | null;
|
|
69
|
+
/**
|
|
70
|
+
* Every pi session the run's own processes announced, in order,
|
|
71
|
+
* which are the ids billing rows carry. Usually one; a stopped
|
|
72
|
+
* reviewer asked for its findings is a second process with a second
|
|
73
|
+
* session. Distinct from {@link sessionId}, the parent that
|
|
74
|
+
* dispatched the run. Empty when the run had no child process, as an
|
|
75
|
+
* in-process run does not; null when nobody knows.
|
|
76
|
+
*/
|
|
77
|
+
readonly subagentSessionIds: readonly string[] | null;
|
|
62
78
|
/**
|
|
63
79
|
* The parent session that dispatched the run, the directory it was
|
|
64
80
|
* working in and the repo that directory belongs to. Stamped by the
|
package/package.json
CHANGED
|
@@ -1,11 +0,0 @@
|
|
|
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;
|
|
@@ -1,159 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
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
|
-
}
|