kankaku 0.1.0 → 0.4.5
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/README.md +120 -3
- package/package.json +1 -1
- package/src/adapters/export-writer.ts +33 -0
- package/src/adapters/file-inflight-store.ts +83 -0
- package/src/adapters/jsonl-work-log.ts +7 -1
- package/src/adapters/kankaku-dir.ts +10 -0
- package/src/adapters/lazy-file-inflight-store.ts +43 -0
- package/src/adapters/lazy-jsonl-work-log.ts +2 -3
- package/src/adapters/pi-tracker.ts +254 -22
- package/src/adapters/project-config.ts +44 -0
- package/src/adapters/report.ts +89 -18
- package/src/config.ts +65 -0
- package/src/domain/client-label.ts +56 -0
- package/src/domain/day.ts +8 -0
- package/src/domain/export.ts +107 -0
- package/src/domain/segment-rule.ts +10 -0
- package/src/domain/task-view.ts +43 -7
- package/src/domain/work-record.ts +54 -0
- package/src/domain/work-tracker.ts +79 -19
- package/src/extension.ts +12 -0
- package/src/ports/inflight-store.ts +16 -0
package/src/config.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SegmentRule } from "./domain/segment-rule.ts";
|
|
2
|
+
|
|
1
3
|
export interface KankakuConfig {
|
|
2
4
|
/** Directory for the work log, relative to the project cwd unless absolute. */
|
|
3
5
|
dir: string;
|
|
@@ -5,12 +7,68 @@ export interface KankakuConfig {
|
|
|
5
7
|
interactiveTools: string[];
|
|
6
8
|
/** Tool name used to run subagents. */
|
|
7
9
|
subagentTool: string;
|
|
10
|
+
/** Rules that tag a tool execution's span under a named segment (e.g. `review`). */
|
|
11
|
+
segmentRules: SegmentRule[];
|
|
12
|
+
/** Default billing client for this project, from `KANKAKU_CLIENT`. See `domain/client-label.ts`. */
|
|
13
|
+
client?: string;
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
const DEFAULT_DIR = ".kankaku";
|
|
11
17
|
const DEFAULT_INTERACTIVE_TOOLS = ["ask_user_question", "ask_user_choice"];
|
|
12
18
|
const SUBAGENT_TOOL = "subagent_run";
|
|
13
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Default segment rule: with gentle-ai, the review-with-receipts step runs
|
|
22
|
+
* as `gentle-ai review ...` commands through the `bash` tool, so tag that
|
|
23
|
+
* span `review`.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_SEGMENT_RULES: SegmentRule[] = [{ tag: "review", tool: "bash", pattern: /\bgentle-ai review\b/ }];
|
|
26
|
+
|
|
27
|
+
/** Tags allowed for a segment rule: letters, digits, `_` and `-`, 1-32 chars. */
|
|
28
|
+
const SAFE_TAG = /^[A-Za-z0-9_-]{1,32}$/;
|
|
29
|
+
/** Property names that behave specially on a plain object; never usable as a tag. */
|
|
30
|
+
const RESERVED_TAGS = new Set(["__proto__", "constructor", "prototype"]);
|
|
31
|
+
|
|
32
|
+
function isSafeTag(tag: string): boolean {
|
|
33
|
+
return SAFE_TAG.test(tag) && !RESERVED_TAGS.has(tag);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse `KANKAKU_SEGMENTS`, a `;`-separated list of `tag=tool:regex`
|
|
38
|
+
* entries (example: `review=bash:gentle-ai review;commit=bash:git commit`).
|
|
39
|
+
* Malformed entries (missing tag, tool or regex, an invalid regex source,
|
|
40
|
+
* or a tag that is not a safe identifier such as `__proto__`) are skipped
|
|
41
|
+
* rather than failing the whole variable.
|
|
42
|
+
*/
|
|
43
|
+
function parseSegmentRules(raw: string): SegmentRule[] {
|
|
44
|
+
const rules: SegmentRule[] = [];
|
|
45
|
+
|
|
46
|
+
for (const entry of raw.split(";")) {
|
|
47
|
+
const trimmed = entry.trim();
|
|
48
|
+
if (!trimmed) continue;
|
|
49
|
+
|
|
50
|
+
const eqIndex = trimmed.indexOf("=");
|
|
51
|
+
if (eqIndex <= 0) continue;
|
|
52
|
+
|
|
53
|
+
const tag = trimmed.slice(0, eqIndex).trim();
|
|
54
|
+
const rest = trimmed.slice(eqIndex + 1);
|
|
55
|
+
const colonIndex = rest.indexOf(":");
|
|
56
|
+
if (colonIndex <= 0) continue;
|
|
57
|
+
|
|
58
|
+
const tool = rest.slice(0, colonIndex).trim();
|
|
59
|
+
const regexSource = rest.slice(colonIndex + 1).trim();
|
|
60
|
+
if (!tag || !tool || !regexSource || !isSafeTag(tag)) continue;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
rules.push({ tag, tool, pattern: new RegExp(regexSource) });
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return rules;
|
|
70
|
+
}
|
|
71
|
+
|
|
14
72
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): KankakuConfig {
|
|
15
73
|
const dir = env["KANKAKU_DIR"]?.trim() || DEFAULT_DIR;
|
|
16
74
|
const interactiveToolsRaw = env["KANKAKU_INTERACTIVE_TOOLS"]?.trim();
|
|
@@ -21,10 +79,17 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): KankakuConfig
|
|
|
21
79
|
.filter((tool) => tool.length > 0)
|
|
22
80
|
: DEFAULT_INTERACTIVE_TOOLS;
|
|
23
81
|
|
|
82
|
+
const segmentsRaw = env["KANKAKU_SEGMENTS"]?.trim();
|
|
83
|
+
const segmentRules = segmentsRaw ? parseSegmentRules(segmentsRaw) : DEFAULT_SEGMENT_RULES;
|
|
84
|
+
|
|
85
|
+
const client = env["KANKAKU_CLIENT"]?.trim() || undefined;
|
|
86
|
+
|
|
24
87
|
return {
|
|
25
88
|
dir,
|
|
26
89
|
interactiveTools,
|
|
27
90
|
subagentTool: SUBAGENT_TOOL,
|
|
91
|
+
segmentRules,
|
|
92
|
+
...(client !== undefined ? { client } : {}),
|
|
28
93
|
};
|
|
29
94
|
}
|
|
30
95
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client (billing target) label resolution: pure, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* A client name identifies who a piece of work is billed to. It can come
|
|
5
|
+
* from three sources, in decreasing precedence: the pi session (set with
|
|
6
|
+
* `/kankaku client <name>`), the `KANKAKU_CLIENT` environment variable, or
|
|
7
|
+
* the project's `.kankaku/config.json`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Safe client name: letters, digits, `.`, `_`, `-`, 1-64 chars. */
|
|
11
|
+
const CLIENT_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
12
|
+
|
|
13
|
+
/** Property names that behave specially on a plain object; never usable as a client name. */
|
|
14
|
+
const RESERVED_NAMES = new Set(["__proto__", "constructor", "prototype"]);
|
|
15
|
+
|
|
16
|
+
export interface ClientSources {
|
|
17
|
+
/** Set for the current pi session via `/kankaku client <name>`. Highest precedence. */
|
|
18
|
+
session?: string;
|
|
19
|
+
/** From the `KANKAKU_CLIENT` environment variable. */
|
|
20
|
+
env?: string;
|
|
21
|
+
/** From the project's `.kankaku/config.json`. Lowest precedence. */
|
|
22
|
+
project?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ClientSourceName = "session" | "env" | "project";
|
|
26
|
+
|
|
27
|
+
/** `true` when `value` is a non-empty, safe client name. */
|
|
28
|
+
export function isValidClient(value: string): boolean {
|
|
29
|
+
return CLIENT_PATTERN.test(value) && !RESERVED_NAMES.has(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Trim and validate a candidate client name; `undefined` when absent or invalid. */
|
|
33
|
+
function normalize(value: string | undefined): string | undefined {
|
|
34
|
+
if (value === undefined) return undefined;
|
|
35
|
+
const trimmed = value.trim();
|
|
36
|
+
if (trimmed.length === 0) return undefined;
|
|
37
|
+
return isValidClient(trimmed) ? trimmed : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the effective client name from `session`, `env` and `project`
|
|
42
|
+
* sources, in that precedence order. Each candidate is trimmed; an empty
|
|
43
|
+
* string or a value that does not match the safe client-name pattern is
|
|
44
|
+
* treated as absent and resolution falls through to the next source.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveClient(sources: ClientSources): string | undefined {
|
|
47
|
+
return normalize(sources.session) ?? normalize(sources.env) ?? normalize(sources.project);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Which source produced {@link resolveClient}'s result, or `undefined` when none applies. */
|
|
51
|
+
export function resolveClientSource(sources: ClientSources): ClientSourceName | undefined {
|
|
52
|
+
if (normalize(sources.session) !== undefined) return "session";
|
|
53
|
+
if (normalize(sources.env) !== undefined) return "env";
|
|
54
|
+
if (normalize(sources.project) !== undefined) return "project";
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Local (not UTC) calendar day of an ISO timestamp, as `YYYY-MM-DD`. */
|
|
2
|
+
export function localDay(iso: string): string {
|
|
3
|
+
const date = new Date(iso);
|
|
4
|
+
const year = date.getFullYear();
|
|
5
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
6
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
7
|
+
return `${year}-${month}-${day}`;
|
|
8
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { localDay } from "./day.ts";
|
|
2
|
+
import { finiteOrZero } from "./work-record.ts";
|
|
3
|
+
import type { WorkStatus } from "./work-record.ts";
|
|
4
|
+
import type { TaskView } from "./task-view.ts";
|
|
5
|
+
|
|
6
|
+
/** One flat, spreadsheet-friendly row per {@link TaskView}. */
|
|
7
|
+
export interface ExportRow {
|
|
8
|
+
id: string;
|
|
9
|
+
/** Local calendar day (`YYYY-MM-DD`) the task started on. */
|
|
10
|
+
day: string;
|
|
11
|
+
startedAt: string;
|
|
12
|
+
endedAt: string;
|
|
13
|
+
/** Empty string when the task has no resolved client. */
|
|
14
|
+
client: string;
|
|
15
|
+
/** Empty string when the orchestrator record has no session name. */
|
|
16
|
+
sessionName: string;
|
|
17
|
+
/** Empty string when the orchestrator record has no session id. */
|
|
18
|
+
sessionId: string;
|
|
19
|
+
project: string;
|
|
20
|
+
status: WorkStatus;
|
|
21
|
+
/** First 200 chars of the task's prompt, with newlines collapsed to spaces. */
|
|
22
|
+
prompt: string;
|
|
23
|
+
wallMs: number;
|
|
24
|
+
waitingMs: number;
|
|
25
|
+
workMs: number;
|
|
26
|
+
cost: number;
|
|
27
|
+
tokensIn: number;
|
|
28
|
+
tokensOut: number;
|
|
29
|
+
cacheRead: number;
|
|
30
|
+
subagentCount: number;
|
|
31
|
+
/** JSON-encoded `TaskView.segments` map. */
|
|
32
|
+
segments: string;
|
|
33
|
+
/** Empty string when the orchestrator record has no model. */
|
|
34
|
+
model: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** First 200 chars of `prompt`, with `\n`/`\r` collapsed to a single space. */
|
|
38
|
+
function truncatePrompt(prompt: string): string {
|
|
39
|
+
return prompt.slice(0, 200).replace(/\r\n|\r|\n/g, " ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Build one flat {@link ExportRow} per task, in the same order as `tasks`. */
|
|
43
|
+
export function exportRows(tasks: TaskView[]): ExportRow[] {
|
|
44
|
+
return tasks.map((task) => ({
|
|
45
|
+
id: task.id,
|
|
46
|
+
day: localDay(task.startedAt),
|
|
47
|
+
startedAt: task.startedAt,
|
|
48
|
+
endedAt: task.endedAt,
|
|
49
|
+
client: task.client ?? "",
|
|
50
|
+
sessionName: task.sessionName ?? "",
|
|
51
|
+
sessionId: task.sessionId ?? "",
|
|
52
|
+
project: task.project,
|
|
53
|
+
status: task.status,
|
|
54
|
+
prompt: truncatePrompt(task.prompt),
|
|
55
|
+
wallMs: task.wallMs,
|
|
56
|
+
waitingMs: task.waitingMs,
|
|
57
|
+
workMs: task.workMs,
|
|
58
|
+
cost: finiteOrZero(task.usage.cost),
|
|
59
|
+
tokensIn: finiteOrZero(task.usage.input),
|
|
60
|
+
tokensOut: finiteOrZero(task.usage.output),
|
|
61
|
+
cacheRead: finiteOrZero(task.usage.cacheRead),
|
|
62
|
+
subagentCount: task.subagents.length,
|
|
63
|
+
segments: JSON.stringify(task.segments),
|
|
64
|
+
model: task.orchestrator.model ?? "",
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Column order for {@link toCsv}'s header row, matching {@link ExportRow}'s field order. */
|
|
69
|
+
const COLUMNS: Array<keyof ExportRow> = [
|
|
70
|
+
"id",
|
|
71
|
+
"day",
|
|
72
|
+
"startedAt",
|
|
73
|
+
"endedAt",
|
|
74
|
+
"client",
|
|
75
|
+
"sessionName",
|
|
76
|
+
"sessionId",
|
|
77
|
+
"project",
|
|
78
|
+
"status",
|
|
79
|
+
"prompt",
|
|
80
|
+
"wallMs",
|
|
81
|
+
"waitingMs",
|
|
82
|
+
"workMs",
|
|
83
|
+
"cost",
|
|
84
|
+
"tokensIn",
|
|
85
|
+
"tokensOut",
|
|
86
|
+
"cacheRead",
|
|
87
|
+
"subagentCount",
|
|
88
|
+
"segments",
|
|
89
|
+
"model",
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/** RFC 4180 field quoting: quote a field containing `,`, `"`, or a newline; double any embedded quote. */
|
|
93
|
+
function csvField(value: string | number): string {
|
|
94
|
+
const text = String(value);
|
|
95
|
+
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Render `rows` as RFC 4180 CSV: a header row, one row per record, `\n` line endings. */
|
|
99
|
+
export function toCsv(rows: ExportRow[]): string {
|
|
100
|
+
const lines = [COLUMNS.join(","), ...rows.map((row) => COLUMNS.map((column) => csvField(row[column])).join(","))];
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Render `rows` as pretty-printed (2-space indent) JSON. */
|
|
105
|
+
export function toJson(rows: ExportRow[]): string {
|
|
106
|
+
return JSON.stringify(rows, null, 2);
|
|
107
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A rule that tags a tool execution as belonging to a named segment (e.g.
|
|
3
|
+
* `review`) when the tool name matches `tool` and the tool's argument text
|
|
4
|
+
* matches `pattern`.
|
|
5
|
+
*/
|
|
6
|
+
export interface SegmentRule {
|
|
7
|
+
tag: string;
|
|
8
|
+
tool: string;
|
|
9
|
+
pattern: RegExp;
|
|
10
|
+
}
|
package/src/domain/task-view.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { unionMs } from "./intervals.ts";
|
|
2
|
-
import { emptyUsage } from "./work-record.ts";
|
|
2
|
+
import { emptyUsage, finiteOrZero } from "./work-record.ts";
|
|
3
3
|
import type { UsageTotals, WorkRecord, WorkStatus } from "./work-record.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -22,6 +22,17 @@ export interface TaskView {
|
|
|
22
22
|
orchestrator: WorkRecord;
|
|
23
23
|
subagents: WorkRecord[];
|
|
24
24
|
usage: UsageTotals;
|
|
25
|
+
/** Who this task is billed to, from the orchestrator record only — subagent children do not carry their own. */
|
|
26
|
+
client?: string;
|
|
27
|
+
/** pi's session display name, from the orchestrator record. */
|
|
28
|
+
sessionName?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Per-tag total milliseconds across the orchestrator and every subagent,
|
|
31
|
+
* summed rather than unioned: unlike `wallMs`, segment intervals are not
|
|
32
|
+
* persisted on disk, so once a record settles its per-tag total is all
|
|
33
|
+
* that remains, and there is nothing left to union across records.
|
|
34
|
+
*/
|
|
35
|
+
segments: Record<string, number>;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
/** One or more tasks grouped by their pi session, with the same union rule. */
|
|
@@ -35,20 +46,39 @@ export interface SessionView {
|
|
|
35
46
|
workMs: number;
|
|
36
47
|
tasks: TaskView[];
|
|
37
48
|
usage: UsageTotals;
|
|
49
|
+
/** Per-tag total milliseconds summed across the session's tasks. See {@link TaskView.segments}. */
|
|
50
|
+
segments: Record<string, number>;
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
function toMs(iso: string): number {
|
|
41
54
|
return Date.parse(iso);
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
|
|
57
|
+
/** Sum per-tag milliseconds across several segment maps (older records without one count as `{}`). */
|
|
58
|
+
function sumSegments(segmentMaps: Array<Record<string, number> | undefined>): Record<string, number> {
|
|
59
|
+
const result: Record<string, number> = {};
|
|
60
|
+
for (const segments of segmentMaps) {
|
|
61
|
+
for (const [tag, ms] of Object.entries(segments ?? {})) {
|
|
62
|
+
result[tag] = (result[tag] ?? 0) + ms;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sum several {@link UsageTotals}, tolerating a missing entry (a record
|
|
70
|
+
* without a `usage` field) and missing or non-finite numeric fields on an
|
|
71
|
+
* entry — both treated as zero rather than corrupting the sum with
|
|
72
|
+
* `undefined`/`NaN`.
|
|
73
|
+
*/
|
|
74
|
+
export function sumUsage(totals: Array<UsageTotals | undefined>): UsageTotals {
|
|
45
75
|
const usage = emptyUsage();
|
|
46
76
|
for (const total of totals) {
|
|
47
|
-
usage.input += total
|
|
48
|
-
usage.output += total
|
|
49
|
-
usage.cacheRead += total
|
|
50
|
-
usage.cacheWrite += total
|
|
51
|
-
usage.cost += total
|
|
77
|
+
usage.input += finiteOrZero(total?.input);
|
|
78
|
+
usage.output += finiteOrZero(total?.output);
|
|
79
|
+
usage.cacheRead += finiteOrZero(total?.cacheRead);
|
|
80
|
+
usage.cacheWrite += finiteOrZero(total?.cacheWrite);
|
|
81
|
+
usage.cost += finiteOrZero(total?.cost);
|
|
52
82
|
}
|
|
53
83
|
return usage;
|
|
54
84
|
}
|
|
@@ -117,10 +147,13 @@ function buildTaskView(orchestrator: WorkRecord, subagents: WorkRecord[]): TaskV
|
|
|
117
147
|
const waitingMs = orchestrator.waitingMs;
|
|
118
148
|
const workMs = wallMs - waitingMs;
|
|
119
149
|
const usage = sumUsage([orchestrator.usage, ...subagents.map((child) => child.usage)]);
|
|
150
|
+
const segments = sumSegments([orchestrator.segments, ...subagents.map((child) => child.segments)]);
|
|
120
151
|
|
|
121
152
|
return {
|
|
122
153
|
id: orchestrator.id,
|
|
123
154
|
...(orchestrator.sessionId !== undefined ? { sessionId: orchestrator.sessionId } : {}),
|
|
155
|
+
...(orchestrator.client !== undefined ? { client: orchestrator.client } : {}),
|
|
156
|
+
...(orchestrator.sessionName !== undefined ? { sessionName: orchestrator.sessionName } : {}),
|
|
124
157
|
project: orchestrator.project,
|
|
125
158
|
prompt: orchestrator.prompt,
|
|
126
159
|
startedAt: orchestrator.startedAt,
|
|
@@ -132,6 +165,7 @@ function buildTaskView(orchestrator: WorkRecord, subagents: WorkRecord[]): TaskV
|
|
|
132
165
|
orchestrator,
|
|
133
166
|
subagents,
|
|
134
167
|
usage,
|
|
168
|
+
segments,
|
|
135
169
|
};
|
|
136
170
|
}
|
|
137
171
|
|
|
@@ -182,6 +216,7 @@ export function buildSessions(tasks: TaskView[]): SessionView[] {
|
|
|
182
216
|
const startedAtMs = Math.min(...sessionTasks.map((task) => toMs(task.startedAt)));
|
|
183
217
|
const endedAtMs = Math.max(...sessionTasks.map((task) => toMs(task.endedAt)));
|
|
184
218
|
const usage = sumUsage(sessionTasks.map((task) => task.usage));
|
|
219
|
+
const segments = sumSegments(sessionTasks.map((task) => task.segments));
|
|
185
220
|
|
|
186
221
|
sessions.push({
|
|
187
222
|
sessionId,
|
|
@@ -193,6 +228,7 @@ export function buildSessions(tasks: TaskView[]): SessionView[] {
|
|
|
193
228
|
workMs,
|
|
194
229
|
tasks: sessionTasks,
|
|
195
230
|
usage,
|
|
231
|
+
segments,
|
|
196
232
|
});
|
|
197
233
|
}
|
|
198
234
|
|
|
@@ -38,6 +38,13 @@ export interface WorkRecordCore {
|
|
|
38
38
|
turns: number;
|
|
39
39
|
tools: Record<string, number>;
|
|
40
40
|
subagents: SubagentSpan[];
|
|
41
|
+
/**
|
|
42
|
+
* Union milliseconds per tag spent in tool calls matched by a
|
|
43
|
+
* {@link SegmentRule} (e.g. `review`). Optional so older persisted
|
|
44
|
+
* records without this field still satisfy the type; callers reading
|
|
45
|
+
* from disk should treat a missing value as `{}`.
|
|
46
|
+
*/
|
|
47
|
+
segments?: Record<string, number>;
|
|
41
48
|
usage: UsageTotals;
|
|
42
49
|
status: WorkStatus;
|
|
43
50
|
}
|
|
@@ -52,6 +59,10 @@ export interface WorkRecordMetadata {
|
|
|
52
59
|
sessionFile?: string;
|
|
53
60
|
mode?: string;
|
|
54
61
|
model?: string;
|
|
62
|
+
/** Who this work is billed to. See {@link resolveClient} in `client-label.ts`. */
|
|
63
|
+
client?: string;
|
|
64
|
+
/** pi's session display name at the time this record settled. */
|
|
65
|
+
sessionName?: string;
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
|
|
@@ -59,3 +70,46 @@ export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
|
|
|
59
70
|
export function emptyUsage(): UsageTotals {
|
|
60
71
|
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
61
72
|
}
|
|
73
|
+
|
|
74
|
+
/** A finite number, or `0` for `undefined`/`NaN`/`Infinity`/non-numbers. */
|
|
75
|
+
export function finiteOrZero(value: unknown): number {
|
|
76
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ROLES = new Set<WorkRole>(["orchestrator", "subagent"]);
|
|
80
|
+
const STATUSES = new Set<WorkStatus>(["completed", "aborted", "interrupted"]);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Runtime guard for a {@link WorkRecord} read back from disk. `readAll`
|
|
84
|
+
* skips lines that parse as JSON but fail this check, so a torn write or a
|
|
85
|
+
* record from an incompatible schema does not crash task/session views.
|
|
86
|
+
*/
|
|
87
|
+
export function isWorkRecord(value: unknown): value is WorkRecord {
|
|
88
|
+
if (!value || typeof value !== "object") return false;
|
|
89
|
+
const record = value as Record<string, unknown>;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
typeof record["schema"] === "number" &&
|
|
93
|
+
typeof record["id"] === "string" &&
|
|
94
|
+
ROLES.has(record["role"] as WorkRole) &&
|
|
95
|
+
typeof record["pid"] === "number" &&
|
|
96
|
+
typeof record["parentPid"] === "number" &&
|
|
97
|
+
typeof record["project"] === "string" &&
|
|
98
|
+
typeof record["prompt"] === "string" &&
|
|
99
|
+
typeof record["startedAt"] === "string" &&
|
|
100
|
+
typeof record["settledAt"] === "string" &&
|
|
101
|
+
Number.isFinite(record["wallMs"]) &&
|
|
102
|
+
Number.isFinite(record["waitingMs"]) &&
|
|
103
|
+
Number.isFinite(record["workMs"]) &&
|
|
104
|
+
typeof record["runs"] === "number" &&
|
|
105
|
+
typeof record["turns"] === "number" &&
|
|
106
|
+
typeof record["tools"] === "object" &&
|
|
107
|
+
record["tools"] !== null &&
|
|
108
|
+
Array.isArray(record["subagents"]) &&
|
|
109
|
+
typeof record["usage"] === "object" &&
|
|
110
|
+
record["usage"] !== null &&
|
|
111
|
+
STATUSES.has(record["status"] as WorkStatus) &&
|
|
112
|
+
(record["client"] === undefined || typeof record["client"] === "string") &&
|
|
113
|
+
(record["sessionName"] === undefined || typeof record["sessionName"] === "string")
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { Clock } from "../ports/clock.ts";
|
|
3
3
|
import { clampIntervals, unionMs } from "./intervals.ts";
|
|
4
|
-
import { emptyUsage, WORK_RECORD_SCHEMA } from "./work-record.ts";
|
|
4
|
+
import { emptyUsage, finiteOrZero, WORK_RECORD_SCHEMA } from "./work-record.ts";
|
|
5
5
|
import type { SubagentSpan, UsageTotals, WorkRecordCore, WorkStatus } from "./work-record.ts";
|
|
6
|
+
import type { SegmentRule } from "./segment-rule.ts";
|
|
6
7
|
|
|
7
8
|
export interface WorkTrackerOptions {
|
|
8
9
|
clock: Clock;
|
|
9
10
|
interactiveTools: string[];
|
|
10
11
|
subagentTool: string;
|
|
12
|
+
/** Rules that tag a tool execution's span under a named segment. Defaults to none. */
|
|
13
|
+
segmentRules?: SegmentRule[];
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
interface Interval {
|
|
@@ -23,6 +26,8 @@ interface OpenSubagentSpan {
|
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
interface RunState {
|
|
29
|
+
/** Generated once when the run opens, so it stays stable across `peek` and the final `onSettled`/`onShutdown`. */
|
|
30
|
+
id: string;
|
|
26
31
|
startedAt: number;
|
|
27
32
|
prompt: string;
|
|
28
33
|
runs: number;
|
|
@@ -35,6 +40,14 @@ interface RunState {
|
|
|
35
40
|
openToolWaits: Map<string, Interval>;
|
|
36
41
|
subagents: SubagentSpan[];
|
|
37
42
|
openSubagents: Map<string, OpenSubagentSpan>;
|
|
43
|
+
/**
|
|
44
|
+
* Segment spans opened by a matching {@link SegmentRule}, keyed by tag.
|
|
45
|
+
* A `Map` rather than a plain object so a tag such as `__proto__` cannot
|
|
46
|
+
* pollute the object prototype while the run is in progress.
|
|
47
|
+
*/
|
|
48
|
+
segmentSpans: Map<string, Interval[]>;
|
|
49
|
+
/** The still-open segment span for a tool call id, if any. */
|
|
50
|
+
openSegments: Map<string, Interval>;
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
interface RunEndMessage {
|
|
@@ -51,17 +64,20 @@ export class WorkTracker {
|
|
|
51
64
|
private readonly clock: Clock;
|
|
52
65
|
private readonly interactiveTools: Set<string>;
|
|
53
66
|
private readonly subagentTool: string;
|
|
67
|
+
private readonly segmentRules: SegmentRule[];
|
|
54
68
|
private state: RunState | undefined;
|
|
55
69
|
|
|
56
70
|
constructor(options: WorkTrackerOptions) {
|
|
57
71
|
this.clock = options.clock;
|
|
58
72
|
this.interactiveTools = new Set(options.interactiveTools);
|
|
59
73
|
this.subagentTool = options.subagentTool;
|
|
74
|
+
this.segmentRules = options.segmentRules ?? [];
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
onRunStart(prompt: string): void {
|
|
63
78
|
if (!this.state) {
|
|
64
79
|
this.state = {
|
|
80
|
+
id: randomUUID(),
|
|
65
81
|
startedAt: this.clock.now(),
|
|
66
82
|
prompt,
|
|
67
83
|
runs: 1,
|
|
@@ -73,6 +89,8 @@ export class WorkTracker {
|
|
|
73
89
|
openToolWaits: new Map(),
|
|
74
90
|
subagents: [],
|
|
75
91
|
openSubagents: new Map(),
|
|
92
|
+
segmentSpans: new Map(),
|
|
93
|
+
openSegments: new Map(),
|
|
76
94
|
};
|
|
77
95
|
return;
|
|
78
96
|
}
|
|
@@ -83,17 +101,26 @@ export class WorkTracker {
|
|
|
83
101
|
if (!this.state) return;
|
|
84
102
|
this.state.turns++;
|
|
85
103
|
if (!usage) return;
|
|
86
|
-
this.state.usage.input += usage.input
|
|
87
|
-
this.state.usage.output += usage.output
|
|
88
|
-
this.state.usage.cacheRead += usage.cacheRead
|
|
89
|
-
this.state.usage.cacheWrite += usage.cacheWrite
|
|
90
|
-
this.state.usage.cost += usage.cost
|
|
104
|
+
this.state.usage.input += finiteOrZero(usage.input);
|
|
105
|
+
this.state.usage.output += finiteOrZero(usage.output);
|
|
106
|
+
this.state.usage.cacheRead += finiteOrZero(usage.cacheRead);
|
|
107
|
+
this.state.usage.cacheWrite += finiteOrZero(usage.cacheWrite);
|
|
108
|
+
this.state.usage.cost += finiteOrZero(usage.cost);
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
onToolStart(toolCallId: string, toolName: string, args: Record<string, unknown> | undefined): void {
|
|
94
112
|
if (!this.state) return;
|
|
95
113
|
this.state.tools[toolName] = (this.state.tools[toolName] ?? 0) + 1;
|
|
96
114
|
|
|
115
|
+
const rule = this.segmentRules.find((candidate) => candidate.tool === toolName && candidate.pattern.test(segmentText(args)));
|
|
116
|
+
if (rule) {
|
|
117
|
+
const span: Interval = { start: this.clock.now(), end: undefined };
|
|
118
|
+
const spans = this.state.segmentSpans.get(rule.tag) ?? [];
|
|
119
|
+
spans.push(span);
|
|
120
|
+
this.state.segmentSpans.set(rule.tag, spans);
|
|
121
|
+
this.state.openSegments.set(toolCallId, span);
|
|
122
|
+
}
|
|
123
|
+
|
|
97
124
|
if (this.interactiveTools.has(toolName)) {
|
|
98
125
|
const span: Interval = { start: this.clock.now(), end: undefined };
|
|
99
126
|
this.state.waitingSpans.push(span);
|
|
@@ -116,6 +143,12 @@ export class WorkTracker {
|
|
|
116
143
|
onToolEnd(toolCallId: string, result: unknown): void {
|
|
117
144
|
if (!this.state) return;
|
|
118
145
|
|
|
146
|
+
const openSegment = this.state.openSegments.get(toolCallId);
|
|
147
|
+
if (openSegment) {
|
|
148
|
+
this.state.openSegments.delete(toolCallId);
|
|
149
|
+
openSegment.end = this.clock.now();
|
|
150
|
+
}
|
|
151
|
+
|
|
119
152
|
const openSubagent = this.state.openSubagents.get(toolCallId);
|
|
120
153
|
if (openSubagent) {
|
|
121
154
|
this.state.openSubagents.delete(toolCallId);
|
|
@@ -164,39 +197,59 @@ export class WorkTracker {
|
|
|
164
197
|
|
|
165
198
|
onSettled(): WorkRecordCore | undefined {
|
|
166
199
|
if (!this.state) return undefined;
|
|
167
|
-
const record = this.
|
|
200
|
+
const record = this.buildRecord(this.state.status, this.clock.now());
|
|
168
201
|
this.state = undefined;
|
|
169
202
|
return record;
|
|
170
203
|
}
|
|
171
204
|
|
|
172
205
|
onShutdown(): WorkRecordCore | undefined {
|
|
173
206
|
if (!this.state) return undefined;
|
|
174
|
-
const record = this.
|
|
207
|
+
const record = this.buildRecord("interrupted", this.clock.now());
|
|
175
208
|
this.state = undefined;
|
|
176
209
|
return record;
|
|
177
210
|
}
|
|
178
211
|
|
|
179
|
-
|
|
212
|
+
/**
|
|
213
|
+
* Returns what {@link onSettled}/{@link onShutdown} would produce right
|
|
214
|
+
* now, without mutating any state: open spans are truncated only in the
|
|
215
|
+
* returned snapshot, so the tracker keeps running unaffected and a later
|
|
216
|
+
* `peek` or the eventual settle still sees the spans' true open-ended
|
|
217
|
+
* state. `undefined` when idle. The returned `id` matches the id the
|
|
218
|
+
* eventual settled record will carry, since both are generated once
|
|
219
|
+
* in {@link onRunStart}.
|
|
220
|
+
*/
|
|
221
|
+
peek(status: WorkStatus): WorkRecordCore | undefined {
|
|
222
|
+
if (!this.state) return undefined;
|
|
223
|
+
return this.buildRecord(status, this.clock.now());
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private buildRecord(status: WorkStatus, settledAt: number): WorkRecordCore {
|
|
180
227
|
const state = this.state;
|
|
181
228
|
if (!state) {
|
|
182
|
-
throw new Error("
|
|
229
|
+
throw new Error("buildRecord called without an open run");
|
|
183
230
|
}
|
|
184
|
-
const settledAt = this.clock.now();
|
|
185
231
|
const wallMs = settledAt - state.startedAt;
|
|
186
232
|
|
|
187
|
-
|
|
188
|
-
if (span.end === undefined) {
|
|
189
|
-
span.end = settledAt;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const closedSpans = state.waitingSpans.map((span) => ({ start: span.start, end: span.end as number }));
|
|
233
|
+
const closedSpans = state.waitingSpans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
|
|
194
234
|
const waitingMs = unionMs(clampIntervals(closedSpans, state.startedAt, settledAt));
|
|
195
235
|
const workMs = wallMs - waitingMs;
|
|
196
236
|
|
|
237
|
+
const segmentEntries: Array<[string, number]> = [];
|
|
238
|
+
for (const [tag, spans] of state.segmentSpans) {
|
|
239
|
+
const closedTagSpans = spans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
|
|
240
|
+
const tagMs = unionMs(clampIntervals(closedTagSpans, state.startedAt, settledAt));
|
|
241
|
+
if (tagMs > 0) {
|
|
242
|
+
segmentEntries.push([tag, tagMs]);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Built via Object.fromEntries (never `segments[tag] = ...`) so a tag
|
|
246
|
+
// such as `__proto__` becomes an own data property instead of silently
|
|
247
|
+
// repointing the object's prototype.
|
|
248
|
+
const segments = Object.fromEntries(segmentEntries);
|
|
249
|
+
|
|
197
250
|
return {
|
|
198
251
|
schema: WORK_RECORD_SCHEMA,
|
|
199
|
-
id:
|
|
252
|
+
id: state.id,
|
|
200
253
|
prompt: state.prompt,
|
|
201
254
|
startedAt: new Date(state.startedAt).toISOString(),
|
|
202
255
|
settledAt: new Date(settledAt).toISOString(),
|
|
@@ -207,12 +260,19 @@ export class WorkTracker {
|
|
|
207
260
|
turns: state.turns,
|
|
208
261
|
tools: state.tools,
|
|
209
262
|
subagents: state.subagents,
|
|
263
|
+
segments,
|
|
210
264
|
usage: state.usage,
|
|
211
265
|
status,
|
|
212
266
|
};
|
|
213
267
|
}
|
|
214
268
|
}
|
|
215
269
|
|
|
270
|
+
/** Text to match a {@link SegmentRule} pattern against: the `command` string arg when present, else the whole args object as JSON. */
|
|
271
|
+
function segmentText(args: Record<string, unknown> | undefined): string {
|
|
272
|
+
const command = args?.["command"];
|
|
273
|
+
return typeof command === "string" ? command : JSON.stringify(args ?? {});
|
|
274
|
+
}
|
|
275
|
+
|
|
216
276
|
function extractTaskId(result: unknown): string | undefined {
|
|
217
277
|
if (!result || typeof result !== "object") return undefined;
|
|
218
278
|
const details = (result as { details?: unknown }).details;
|