kankaku 0.1.0 → 0.4.6
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 +129 -3
- package/package.json +1 -1
- package/src/adapters/export-writer.ts +33 -0
- package/src/adapters/file-inflight-store.ts +115 -0
- package/src/adapters/jsonl-work-log.ts +22 -2
- package/src/adapters/kankaku-command.ts +226 -0
- 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 +6 -3
- package/src/adapters/pi-tracker.ts +120 -116
- package/src/adapters/project-config.ts +44 -0
- package/src/adapters/report.ts +98 -19
- package/src/adapters/session-client.ts +116 -0
- package/src/adapters/status-bar.ts +86 -0
- 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 +59 -0
- package/src/domain/work-tracker.ts +85 -21
- package/src/extension.ts +12 -0
- package/src/ports/inflight-store.ts +16 -0
- package/src/ports/work-log.ts +9 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
// U+FE0F forces emoji presentation so terminals do not fall back to monochrome text glyphs.
|
|
4
|
+
const CLOCK_EMOJI = "\u{1F552}️";
|
|
5
|
+
const CLIENT_EMOJI = "\u{1F4BC}️";
|
|
6
|
+
|
|
7
|
+
/** Footer status key; footer statuses are sorted alphabetically by key, "zz-" keeps kankaku last. */
|
|
8
|
+
export const STATUS_KEY = "zz-kankaku";
|
|
9
|
+
|
|
10
|
+
export function formatElapsed(ms: number, client?: string): string {
|
|
11
|
+
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
12
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
13
|
+
const seconds = totalSeconds % 60;
|
|
14
|
+
const elapsed = `${CLOCK_EMOJI} ${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
15
|
+
return client ? `${elapsed} · ${client}` : elapsed;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StatusBarDeps {
|
|
19
|
+
/** Status line refresh interval in ms while a run is active. Defaults to 1000. */
|
|
20
|
+
intervalMs?: number;
|
|
21
|
+
/** Resolve the client label to show for the run that is starting now. */
|
|
22
|
+
resolveRunClient: () => string | undefined;
|
|
23
|
+
/** Resolve the client label to show while idle. */
|
|
24
|
+
resolveIdleClient: () => string | undefined;
|
|
25
|
+
/** Injectable for tests; defaults to the global timer functions. */
|
|
26
|
+
setInterval?: (handler: () => void, ms: number) => NodeJS.Timeout;
|
|
27
|
+
clearInterval?: (timer: NodeJS.Timeout) => void;
|
|
28
|
+
/** Injectable clock for tests; defaults to `Date.now`. */
|
|
29
|
+
now?: () => number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface StatusBar {
|
|
33
|
+
/** Start (or, if already running, leave untouched) the elapsed-time status; a no-op without a UI. */
|
|
34
|
+
start(ctx: ExtensionContext): void;
|
|
35
|
+
/** Stop the elapsed-time status and fall back to the idle status. */
|
|
36
|
+
stop(ctx: ExtensionContext): void;
|
|
37
|
+
/** While idle, keep the billing client visible (`💼 <client>`), or clear the status when none resolves. */
|
|
38
|
+
showIdle(ctx: ExtensionContext): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Owns the `/kankaku` footer status: the running elapsed-time clock (with
|
|
43
|
+
* its refresh timer) while a run is active, and the idle billing-client
|
|
44
|
+
* label otherwise.
|
|
45
|
+
*/
|
|
46
|
+
export function createStatusBar(deps: StatusBarDeps): StatusBar {
|
|
47
|
+
const intervalMs = deps.intervalMs ?? 1000;
|
|
48
|
+
const scheduleInterval = deps.setInterval ?? setInterval;
|
|
49
|
+
const cancelInterval = deps.clearInterval ?? clearInterval;
|
|
50
|
+
const now = deps.now ?? Date.now;
|
|
51
|
+
|
|
52
|
+
let runStartedAt: number | undefined;
|
|
53
|
+
let statusTimer: NodeJS.Timeout | undefined;
|
|
54
|
+
|
|
55
|
+
function showIdle(ctx: ExtensionContext): void {
|
|
56
|
+
if (!ctx.hasUI) return;
|
|
57
|
+
const client = deps.resolveIdleClient();
|
|
58
|
+
ctx.ui.setStatus(STATUS_KEY, client ? `${CLIENT_EMOJI} ${client}` : undefined);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function start(ctx: ExtensionContext): void {
|
|
62
|
+
// Retries within the same run fire before_agent_start again; only the
|
|
63
|
+
// first one starts the timer.
|
|
64
|
+
if (runStartedAt !== undefined) return;
|
|
65
|
+
if (!ctx.hasUI) return;
|
|
66
|
+
runStartedAt = now();
|
|
67
|
+
const client = deps.resolveRunClient();
|
|
68
|
+
ctx.ui.setStatus(STATUS_KEY, formatElapsed(0, client));
|
|
69
|
+
statusTimer = scheduleInterval(() => {
|
|
70
|
+
if (runStartedAt === undefined) return;
|
|
71
|
+
ctx.ui.setStatus(STATUS_KEY, formatElapsed(now() - runStartedAt, client));
|
|
72
|
+
}, intervalMs);
|
|
73
|
+
statusTimer.unref?.();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stop(ctx: ExtensionContext): void {
|
|
77
|
+
if (statusTimer) {
|
|
78
|
+
cancelInterval(statusTimer);
|
|
79
|
+
statusTimer = undefined;
|
|
80
|
+
}
|
|
81
|
+
runStartedAt = undefined;
|
|
82
|
+
showIdle(ctx);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { start, stop, showIdle };
|
|
86
|
+
}
|
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,51 @@ 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
|
+
/** A finite, non-negative number: durations such as `wallMs` can never be negative. */
|
|
83
|
+
function isNonNegativeFinite(value: unknown): boolean {
|
|
84
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Runtime guard for a {@link WorkRecord} read back from disk. `readAll`
|
|
89
|
+
* skips lines that parse as JSON but fail this check, so a torn write or a
|
|
90
|
+
* record from an incompatible schema does not crash task/session views.
|
|
91
|
+
*/
|
|
92
|
+
export function isWorkRecord(value: unknown): value is WorkRecord {
|
|
93
|
+
if (!value || typeof value !== "object") return false;
|
|
94
|
+
const record = value as Record<string, unknown>;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
typeof record["schema"] === "number" &&
|
|
98
|
+
typeof record["id"] === "string" &&
|
|
99
|
+
ROLES.has(record["role"] as WorkRole) &&
|
|
100
|
+
typeof record["pid"] === "number" &&
|
|
101
|
+
typeof record["parentPid"] === "number" &&
|
|
102
|
+
typeof record["project"] === "string" &&
|
|
103
|
+
typeof record["prompt"] === "string" &&
|
|
104
|
+
typeof record["startedAt"] === "string" &&
|
|
105
|
+
typeof record["settledAt"] === "string" &&
|
|
106
|
+
isNonNegativeFinite(record["wallMs"]) &&
|
|
107
|
+
isNonNegativeFinite(record["waitingMs"]) &&
|
|
108
|
+
isNonNegativeFinite(record["workMs"]) &&
|
|
109
|
+
typeof record["runs"] === "number" &&
|
|
110
|
+
typeof record["turns"] === "number" &&
|
|
111
|
+
typeof record["tools"] === "object" &&
|
|
112
|
+
record["tools"] !== null &&
|
|
113
|
+
Array.isArray(record["subagents"]) &&
|
|
114
|
+
typeof record["usage"] === "object" &&
|
|
115
|
+
record["usage"] !== null &&
|
|
116
|
+
STATUSES.has(record["status"] as WorkStatus) &&
|
|
117
|
+
(record["client"] === undefined || typeof record["client"] === "string") &&
|
|
118
|
+
(record["sessionName"] === undefined || typeof record["sessionName"] === "string")
|
|
119
|
+
);
|
|
120
|
+
}
|