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
|
@@ -1,22 +1,64 @@
|
|
|
1
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
1
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { isValidClient, resolveClient, resolveClientSource } from "../domain/client-label.ts";
|
|
5
|
+
import { exportRows, toCsv, toJson } from "../domain/export.ts";
|
|
3
6
|
import { WorkTracker } from "../domain/work-tracker.ts";
|
|
4
7
|
import { buildSessions, buildTasks } from "../domain/task-view.ts";
|
|
5
|
-
import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
|
|
8
|
+
import type { WorkRecord, WorkRecordCore, WorkRole } from "../domain/work-record.ts";
|
|
9
|
+
import type { InflightStore } from "../ports/inflight-store.ts";
|
|
6
10
|
import type { WorkLog } from "../ports/work-log.ts";
|
|
7
|
-
import { formatReport, formatSessions, formatTasks, localDay, summarize } from "./report.ts";
|
|
11
|
+
import { formatClients, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient } from "./report.ts";
|
|
8
12
|
|
|
9
13
|
export interface PiTrackerDeps {
|
|
10
14
|
tracker: WorkTracker;
|
|
11
15
|
log: WorkLog;
|
|
16
|
+
/** Crash-recovery checkpoint store; see the "Crash recovery" README section. */
|
|
17
|
+
inflight: InflightStore;
|
|
12
18
|
role: WorkRole;
|
|
13
19
|
pid: number;
|
|
14
20
|
parentPid: number;
|
|
15
21
|
/** Status line refresh interval in ms. Defaults to 1000. */
|
|
16
22
|
statusIntervalMs?: number;
|
|
23
|
+
/** Whether a pid is still alive. Defaults to signal-probing with `process.kill(pid, 0)`. */
|
|
24
|
+
isAlive?: (pid: number) => boolean;
|
|
25
|
+
/** Default billing client for this project, from `KANKAKU_CLIENT` (config.ts). See `domain/client-label.ts`. */
|
|
26
|
+
envClient?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Lazily reads the project's default billing client from
|
|
29
|
+
* `<kankaku dir>/config.json`. Injected from `extension.ts` so this
|
|
30
|
+
* adapter stays free of filesystem code.
|
|
31
|
+
*/
|
|
32
|
+
resolveProjectClient?: () => string | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* Write an export file (name, content) under the kankaku dir and return
|
|
35
|
+
* its absolute path. Injected from `extension.ts` to keep this adapter
|
|
36
|
+
* free of filesystem code. `/kankaku export` notifies an error when this
|
|
37
|
+
* is not configured.
|
|
38
|
+
*/
|
|
39
|
+
writeExportFile?: (name: string, content: string) => string;
|
|
17
40
|
}
|
|
18
41
|
|
|
19
|
-
|
|
42
|
+
/** Persisted as a `kankaku-client` custom session entry so the session-level client survives a reload. */
|
|
43
|
+
interface KankakuClientEntryData {
|
|
44
|
+
client: string | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CLIENT_ENTRY_TYPE = "kankaku-client";
|
|
48
|
+
|
|
49
|
+
/** Default `isAlive`: probe with signal 0 — no signal is sent, only existence/permission is checked. */
|
|
50
|
+
function defaultIsAlive(pid: number): boolean {
|
|
51
|
+
try {
|
|
52
|
+
process.kill(pid, 0);
|
|
53
|
+
return true;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// EPERM means the process exists but we lack permission to signal it — still alive.
|
|
56
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Footer statuses are sorted alphabetically by key; "zz-" keeps kankaku last.
|
|
61
|
+
const STATUS_KEY = "zz-kankaku";
|
|
20
62
|
const REPORT_ENTRY_TYPE = "kankaku-report";
|
|
21
63
|
|
|
22
64
|
/** Durable report rendered inside the chat transcript; never sent to the LLM. */
|
|
@@ -25,11 +67,16 @@ export interface KankakuReportData {
|
|
|
25
67
|
lines: string[];
|
|
26
68
|
}
|
|
27
69
|
|
|
28
|
-
|
|
70
|
+
// U+FE0F forces emoji presentation so terminals do not fall back to monochrome text glyphs.
|
|
71
|
+
const CLOCK_EMOJI = "\u{1F552}\uFE0F";
|
|
72
|
+
const CLIENT_EMOJI = "\u{1F4BC}\uFE0F";
|
|
73
|
+
|
|
74
|
+
function formatElapsed(ms: number, client?: string): string {
|
|
29
75
|
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
30
76
|
const minutes = Math.floor(totalSeconds / 60);
|
|
31
77
|
const seconds = totalSeconds % 60;
|
|
32
|
-
|
|
78
|
+
const elapsed = `${CLOCK_EMOJI} ${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
79
|
+
return client ? `${elapsed} · ${client}` : elapsed;
|
|
33
80
|
}
|
|
34
81
|
|
|
35
82
|
function notifyError(ctx: ExtensionContext, error: unknown): void {
|
|
@@ -54,36 +101,86 @@ function guarded<E>(fn: (event: E, ctx: ExtensionContext) => void): (event: E, c
|
|
|
54
101
|
* records to a {@link WorkLog} and exposing the `/kankaku` report command.
|
|
55
102
|
*/
|
|
56
103
|
export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
57
|
-
const { tracker, log, role, pid, parentPid } = deps;
|
|
104
|
+
const { tracker, log, inflight, role, pid, parentPid } = deps;
|
|
58
105
|
const statusIntervalMs = deps.statusIntervalMs ?? 1000;
|
|
106
|
+
const isAlive = deps.isAlive ?? defaultIsAlive;
|
|
59
107
|
|
|
60
108
|
let runStartedAt: number | undefined;
|
|
61
109
|
let statusTimer: NodeJS.Timeout | undefined;
|
|
110
|
+
/** Session-level client override, set with `/kankaku client <name>` and restored on `session_start`. Highest precedence in `resolveClient`. */
|
|
111
|
+
let sessionClient: string | undefined;
|
|
112
|
+
/** Project client read once per run (first record build) so checkpoints do not hit the filesystem repeatedly. */
|
|
113
|
+
let runProjectClient: { value: string | undefined } | undefined;
|
|
62
114
|
|
|
63
115
|
function stopStatus(ctx: ExtensionContext): void {
|
|
64
116
|
if (statusTimer) {
|
|
65
117
|
clearInterval(statusTimer);
|
|
66
118
|
statusTimer = undefined;
|
|
67
119
|
}
|
|
68
|
-
if (ctx.hasUI) {
|
|
69
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
70
|
-
}
|
|
71
120
|
runStartedAt = undefined;
|
|
121
|
+
showIdleStatus(ctx);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** While idle, keep the billing client visible (`💼 <client>`), or clear the status when none resolves. */
|
|
125
|
+
function showIdleStatus(ctx: ExtensionContext): void {
|
|
126
|
+
if (!ctx.hasUI) return;
|
|
127
|
+
// Reuse the project client cached for the run when one is still held, so
|
|
128
|
+
// settling does not re-read config.json; otherwise resolve it fresh.
|
|
129
|
+
const sources = runProjectClient ? clientSources(runProjectClient.value) : clientSources();
|
|
130
|
+
const client = role === "orchestrator" ? resolveClient(sources) : undefined;
|
|
131
|
+
ctx.ui.setStatus(STATUS_KEY, client ? `${CLIENT_EMOJI} ${client}` : undefined);
|
|
72
132
|
}
|
|
73
133
|
|
|
74
134
|
function startStatus(ctx: ExtensionContext): void {
|
|
75
135
|
if (!ctx.hasUI) return;
|
|
76
136
|
runStartedAt = Date.now();
|
|
77
|
-
|
|
137
|
+
const client = role === "orchestrator" ? resolveClient(runClientSources()) : undefined;
|
|
138
|
+
ctx.ui.setStatus(STATUS_KEY, formatElapsed(0, client));
|
|
78
139
|
statusTimer = setInterval(() => {
|
|
79
140
|
if (runStartedAt === undefined) return;
|
|
80
|
-
ctx.ui.setStatus(STATUS_KEY, formatElapsed(Date.now() - runStartedAt));
|
|
141
|
+
ctx.ui.setStatus(STATUS_KEY, formatElapsed(Date.now() - runStartedAt, client));
|
|
81
142
|
}, statusIntervalMs);
|
|
82
143
|
statusTimer.unref?.();
|
|
83
144
|
}
|
|
84
145
|
|
|
85
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Scan the session's entries for the last `kankaku-client` custom entry
|
|
148
|
+
* and return the client it recorded (`undefined` when that entry cleared
|
|
149
|
+
* the label, or when no such entry exists yet).
|
|
150
|
+
*/
|
|
151
|
+
function restoreSessionClient(ctx: ExtensionContext): string | undefined {
|
|
152
|
+
const entries = ctx.sessionManager.getEntries();
|
|
153
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
154
|
+
const entry = entries[i] as { type: string; customType?: string; data?: unknown };
|
|
155
|
+
if (entry.type === "custom" && entry.customType === CLIENT_ENTRY_TYPE) {
|
|
156
|
+
const data = entry.data as KankakuClientEntryData | undefined;
|
|
157
|
+
return data?.client;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function clientSources(project: string | undefined = deps.resolveProjectClient?.()): { session?: string; env?: string; project?: string } {
|
|
164
|
+
return {
|
|
165
|
+
session: sessionClient,
|
|
166
|
+
env: deps.envClient,
|
|
167
|
+
project,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function runClientSources(): ReturnType<typeof clientSources> {
|
|
172
|
+
if (!runProjectClient) {
|
|
173
|
+
runProjectClient = { value: deps.resolveProjectClient?.() };
|
|
174
|
+
}
|
|
175
|
+
return clientSources(runProjectClient.value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildRecord(core: WorkRecordCore, ctx: ExtensionContext): WorkRecord {
|
|
86
179
|
const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
180
|
+
// Subagent children never carry their own client: they inherit the
|
|
181
|
+
// orchestrator's label at task level (see task-view.ts).
|
|
182
|
+
const client = role === "orchestrator" ? resolveClient(runClientSources()) : undefined;
|
|
183
|
+
const sessionName = pi.getSessionName();
|
|
87
184
|
return {
|
|
88
185
|
...core,
|
|
89
186
|
role,
|
|
@@ -94,9 +191,23 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
94
191
|
sessionFile: ctx.sessionManager.getSessionFile(),
|
|
95
192
|
mode: ctx.mode,
|
|
96
193
|
...(model !== undefined ? { model } : {}),
|
|
194
|
+
...(client !== undefined ? { client } : {}),
|
|
195
|
+
...(sessionName !== undefined ? { sessionName } : {}),
|
|
97
196
|
};
|
|
98
197
|
}
|
|
99
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Save an in-flight checkpoint of the run's current state, so a hard
|
|
201
|
+
* crash before the next one (or the final settle) still leaves a
|
|
202
|
+
* recoverable `interrupted` record. A no-op while idle.
|
|
203
|
+
*/
|
|
204
|
+
function checkpoint(ctx: ExtensionContext): void {
|
|
205
|
+
const core = tracker.peek("interrupted");
|
|
206
|
+
if (core) {
|
|
207
|
+
inflight.save(buildRecord(core, ctx));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
100
211
|
pi.on(
|
|
101
212
|
"before_agent_start",
|
|
102
213
|
guarded((event, ctx) => {
|
|
@@ -114,9 +225,10 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
114
225
|
|
|
115
226
|
pi.on(
|
|
116
227
|
"turn_end",
|
|
117
|
-
guarded((event) => {
|
|
228
|
+
guarded((event, ctx) => {
|
|
118
229
|
const message = event.message;
|
|
119
230
|
const usage = message && "usage" in message ? message.usage : undefined;
|
|
231
|
+
const cost = usage && typeof usage.cost === "object" && usage.cost !== null ? usage.cost.total : undefined;
|
|
120
232
|
tracker.onTurnEnd(
|
|
121
233
|
usage
|
|
122
234
|
? {
|
|
@@ -124,10 +236,11 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
124
236
|
output: usage.output,
|
|
125
237
|
cacheRead: usage.cacheRead,
|
|
126
238
|
cacheWrite: usage.cacheWrite,
|
|
127
|
-
cost
|
|
239
|
+
cost,
|
|
128
240
|
}
|
|
129
241
|
: undefined,
|
|
130
242
|
);
|
|
243
|
+
checkpoint(ctx);
|
|
131
244
|
}),
|
|
132
245
|
);
|
|
133
246
|
|
|
@@ -140,8 +253,9 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
140
253
|
|
|
141
254
|
pi.on(
|
|
142
255
|
"tool_execution_end",
|
|
143
|
-
guarded((event) => {
|
|
256
|
+
guarded((event, ctx) => {
|
|
144
257
|
tracker.onToolEnd(event.toolCallId, event.result);
|
|
258
|
+
checkpoint(ctx);
|
|
145
259
|
}),
|
|
146
260
|
);
|
|
147
261
|
|
|
@@ -163,10 +277,17 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
163
277
|
"agent_settled",
|
|
164
278
|
guarded((_event, ctx) => {
|
|
165
279
|
const core = tracker.onSettled();
|
|
166
|
-
|
|
167
|
-
|
|
280
|
+
try {
|
|
281
|
+
if (core) {
|
|
282
|
+
log.append(buildRecord(core, ctx));
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
// Always clean up, even when log.append above threw: an unpersisted
|
|
286
|
+
// checkpoint must not linger, and the status timer must not leak.
|
|
287
|
+
inflight.clear();
|
|
288
|
+
stopStatus(ctx);
|
|
289
|
+
runProjectClient = undefined;
|
|
168
290
|
}
|
|
169
|
-
stopStatus(ctx);
|
|
170
291
|
}),
|
|
171
292
|
);
|
|
172
293
|
|
|
@@ -174,10 +295,31 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
174
295
|
"session_shutdown",
|
|
175
296
|
guarded((_event, ctx) => {
|
|
176
297
|
const core = tracker.onShutdown();
|
|
177
|
-
|
|
178
|
-
|
|
298
|
+
try {
|
|
299
|
+
if (core) {
|
|
300
|
+
log.append(buildRecord(core, ctx));
|
|
301
|
+
}
|
|
302
|
+
} finally {
|
|
303
|
+
inflight.clear();
|
|
304
|
+
stopStatus(ctx);
|
|
305
|
+
runProjectClient = undefined;
|
|
306
|
+
}
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
pi.on(
|
|
311
|
+
"session_start",
|
|
312
|
+
guarded((_event, ctx) => {
|
|
313
|
+
sessionClient = restoreSessionClient(ctx);
|
|
314
|
+
showIdleStatus(ctx);
|
|
315
|
+
|
|
316
|
+
const recovered = inflight.recoverStale(isAlive);
|
|
317
|
+
for (const record of recovered) {
|
|
318
|
+
log.append(record);
|
|
319
|
+
}
|
|
320
|
+
if (recovered.length > 0 && ctx.hasUI) {
|
|
321
|
+
ctx.ui.notify(`kankaku: recovered ${recovered.length} interrupted record(s)`, "warning");
|
|
179
322
|
}
|
|
180
|
-
stopStatus(ctx);
|
|
181
323
|
}),
|
|
182
324
|
);
|
|
183
325
|
|
|
@@ -199,17 +341,107 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
199
341
|
ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
|
|
200
342
|
}
|
|
201
343
|
|
|
344
|
+
/** Handle `/kankaku client [<name> | --clear]`; `rest` excludes the leading `client` token. */
|
|
345
|
+
function handleClientCommand(rest: string[], ctx: ExtensionContext): void {
|
|
346
|
+
if (rest.length === 1 && rest[0] === "--clear") {
|
|
347
|
+
sessionClient = undefined;
|
|
348
|
+
pi.appendEntry<KankakuClientEntryData>(CLIENT_ENTRY_TYPE, { client: undefined });
|
|
349
|
+
showIdleStatus(ctx);
|
|
350
|
+
showReport(ctx, { title: "client", lines: ["client label cleared for this session"] });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (rest.length === 0) {
|
|
355
|
+
const sources = clientSources();
|
|
356
|
+
const client = resolveClient(sources);
|
|
357
|
+
const source = resolveClientSource(sources);
|
|
358
|
+
const line = client !== undefined ? `client: ${client} (from ${source})` : "client: none";
|
|
359
|
+
showReport(ctx, { title: "client", lines: [line] });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const name = rest.join(" ");
|
|
364
|
+
if (!isValidClient(name)) {
|
|
365
|
+
notifyError(ctx, new Error(`invalid client name: ${name}`));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
sessionClient = name;
|
|
369
|
+
pi.appendEntry<KankakuClientEntryData>(CLIENT_ENTRY_TYPE, { client: name });
|
|
370
|
+
showIdleStatus(ctx);
|
|
371
|
+
showReport(ctx, { title: "client", lines: [`client set to ${name}`] });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Handle `/kankaku export [csv|json] [all]`; `rest` excludes the leading `export` token. Default format is csv. */
|
|
375
|
+
function handleExportCommand(rest: string[], ctx: ExtensionContext): void {
|
|
376
|
+
if (!deps.writeExportFile) {
|
|
377
|
+
notifyError(ctx, new Error("export is not configured"));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const all = rest.includes("all");
|
|
382
|
+
const format: "csv" | "json" = rest.includes("json") ? "json" : "csv";
|
|
383
|
+
const records = log.readAll();
|
|
384
|
+
const today = localDay(new Date().toISOString());
|
|
385
|
+
const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
|
|
386
|
+
const rows = exportRows(tasks);
|
|
387
|
+
const content = format === "json" ? toJson(rows) : toCsv(rows);
|
|
388
|
+
const name = `tasks-${all ? "all" : today}.${format}`;
|
|
389
|
+
const path = deps.writeExportFile(name, content);
|
|
390
|
+
showReport(ctx, { title: "export", lines: [`wrote ${rows.length} row(s) to ${path}`] });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const COMMAND_TOKENS = ["all", "tasks", "sessions", "client", "clients", "export"];
|
|
394
|
+
|
|
202
395
|
pi.registerCommand("kankaku", {
|
|
203
396
|
description:
|
|
204
397
|
"Show kankaku work-time totals for today. Args (any order): 'all' for every record, " +
|
|
205
|
-
"'tasks' for this session's tasks ('tasks all' for every session), 'sessions' for today's sessions
|
|
398
|
+
"'tasks' for this session's tasks ('tasks all' for every session), 'sessions' for today's sessions, " +
|
|
399
|
+
"'client <name>' to set the session billing client, 'client' to show the effective one and its source, " +
|
|
400
|
+
"'client --clear' to clear it, 'clients' for per-client totals today ('clients all' for every day), " +
|
|
401
|
+
"'export [csv|json] [all]' to write today's (or every) task as a file.",
|
|
402
|
+
getArgumentCompletions: (argumentPrefix: string): AutocompleteItem[] => {
|
|
403
|
+
const clientMatch = /^client\s+(\S*)$/.exec(argumentPrefix);
|
|
404
|
+
if (clientMatch) {
|
|
405
|
+
const prefix = clientMatch[1] ?? "";
|
|
406
|
+
const names = Array.from(
|
|
407
|
+
new Set(
|
|
408
|
+
log
|
|
409
|
+
.readAll()
|
|
410
|
+
.map((record) => record.client)
|
|
411
|
+
.filter((client): client is string => typeof client === "string"),
|
|
412
|
+
),
|
|
413
|
+
).sort();
|
|
414
|
+
return names.filter((name) => name.startsWith(prefix)).map((name) => ({ value: name, label: name }));
|
|
415
|
+
}
|
|
416
|
+
return COMMAND_TOKENS.filter((value) => value.startsWith(argumentPrefix)).map((value) => ({ value, label: value }));
|
|
417
|
+
},
|
|
206
418
|
handler: async (args, ctx) => {
|
|
207
419
|
try {
|
|
208
420
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
421
|
+
|
|
422
|
+
if (tokens[0] === "client") {
|
|
423
|
+
handleClientCommand(tokens.slice(1), ctx);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (tokens[0] === "export") {
|
|
428
|
+
handleExportCommand(tokens.slice(1), ctx);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
209
432
|
const all = tokens.includes("all");
|
|
210
433
|
const records = log.readAll();
|
|
211
434
|
const today = localDay(new Date().toISOString());
|
|
212
435
|
|
|
436
|
+
if (tokens.includes("clients")) {
|
|
437
|
+
const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
|
|
438
|
+
showReport(ctx, {
|
|
439
|
+
title: all ? "clients (all days)" : "clients (today)",
|
|
440
|
+
lines: formatClients(summarizeByClient(tasks)).split("\n"),
|
|
441
|
+
});
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
213
445
|
if (tokens.includes("tasks")) {
|
|
214
446
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
215
447
|
const scoped = all || !sessionId;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { resolveKankakuDir } from "./kankaku-dir.ts";
|
|
4
|
+
|
|
5
|
+
const CONFIG_FILE_NAME = "config.json";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read the project's default billing client from `<dir>/config.json`
|
|
9
|
+
* (`{ "client": "acme" }`), the lowest-precedence source in
|
|
10
|
+
* `domain/client-label.ts#resolveClient`. `dir` is the kankaku dir (same
|
|
11
|
+
* directory as the work log).
|
|
12
|
+
*
|
|
13
|
+
* Tolerates a missing file, malformed JSON, a non-object document, or a
|
|
14
|
+
* `client` field that is not a string — all return `undefined` rather than
|
|
15
|
+
* throwing, since this file is optional and hand-edited.
|
|
16
|
+
*/
|
|
17
|
+
export function readProjectClient(dir: string): string | undefined {
|
|
18
|
+
const filePath = join(dir, CONFIG_FILE_NAME);
|
|
19
|
+
if (!existsSync(filePath)) return undefined;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8"));
|
|
23
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
24
|
+
const client = (parsed as Record<string, unknown>)["client"];
|
|
25
|
+
return typeof client === "string" ? client : undefined;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Reads the project client from a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
|
|
32
|
+
export class LazyProjectClientSource {
|
|
33
|
+
private readonly dirOrRelative: string;
|
|
34
|
+
private readonly fallbackCwd: () => string;
|
|
35
|
+
|
|
36
|
+
constructor(dirOrRelative: string, fallbackCwd: () => string = () => process.cwd()) {
|
|
37
|
+
this.dirOrRelative = dirOrRelative;
|
|
38
|
+
this.fallbackCwd = fallbackCwd;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
read(): string | undefined {
|
|
42
|
+
return readProjectClient(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()));
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/adapters/report.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { buildTasks } from "../domain/task-view.ts";
|
|
2
2
|
import type { SessionView, TaskView } from "../domain/task-view.ts";
|
|
3
|
+
import { localDay } from "../domain/day.ts";
|
|
4
|
+
import { finiteOrZero } from "../domain/work-record.ts";
|
|
3
5
|
import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
|
|
4
6
|
|
|
7
|
+
export { localDay } from "../domain/day.ts";
|
|
8
|
+
|
|
5
9
|
export interface RoleTotals {
|
|
6
10
|
workMs: number;
|
|
7
11
|
waitingMs: number;
|
|
@@ -9,6 +13,8 @@ export interface RoleTotals {
|
|
|
9
13
|
count: number;
|
|
10
14
|
/** Estimated cost in USD, as priced by pi's model table. */
|
|
11
15
|
cost: number;
|
|
16
|
+
/** Per-tag total milliseconds summed across every record of this role. */
|
|
17
|
+
segments: Record<string, number>;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
export interface TaskTotals {
|
|
@@ -17,6 +23,8 @@ export interface TaskTotals {
|
|
|
17
23
|
workMs: number;
|
|
18
24
|
/** Estimated cost in USD, orchestrator and subagents combined. */
|
|
19
25
|
cost: number;
|
|
26
|
+
/** Per-tag total milliseconds summed across every task (orchestrator and subagents). */
|
|
27
|
+
segments: Record<string, number>;
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
export type Summary = Record<WorkRole, RoleTotals> & { tasks: TaskTotals };
|
|
@@ -30,17 +38,15 @@ export interface SummarizeOptions {
|
|
|
30
38
|
|
|
31
39
|
const ROLES: WorkRole[] = ["orchestrator", "subagent"];
|
|
32
40
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const date = new Date(iso);
|
|
36
|
-
const year = date.getFullYear();
|
|
37
|
-
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
38
|
-
const day = String(date.getDate()).padStart(2, "0");
|
|
39
|
-
return `${year}-${month}-${day}`;
|
|
41
|
+
function emptyTotals(): RoleTotals {
|
|
42
|
+
return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, segments: {} };
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
/** Add per-tag milliseconds from `segments` (missing on older records) into `into`. */
|
|
46
|
+
function addSegments(into: Record<string, number>, segments: Record<string, number> | undefined): void {
|
|
47
|
+
for (const [tag, ms] of Object.entries(segments ?? {})) {
|
|
48
|
+
into[tag] = (into[tag] ?? 0) + ms;
|
|
49
|
+
}
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/**
|
|
@@ -55,7 +61,7 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
|
|
|
55
61
|
const summary: Summary = {
|
|
56
62
|
orchestrator: emptyTotals(),
|
|
57
63
|
subagent: emptyTotals(),
|
|
58
|
-
tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0 },
|
|
64
|
+
tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0, segments: {} },
|
|
59
65
|
};
|
|
60
66
|
|
|
61
67
|
for (const record of records) {
|
|
@@ -65,7 +71,8 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
|
|
|
65
71
|
totals.waitingMs += record.waitingMs;
|
|
66
72
|
totals.wallMs += record.wallMs;
|
|
67
73
|
totals.count += 1;
|
|
68
|
-
totals.cost += record.usage.cost;
|
|
74
|
+
totals.cost += finiteOrZero(record.usage.cost);
|
|
75
|
+
addSegments(totals.segments, record.segments);
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
const tasks = buildTasks(records).filter((task) => targetDay === undefined || localDay(task.startedAt) === targetDay);
|
|
@@ -74,6 +81,7 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
|
|
|
74
81
|
summary.tasks.wallMs += task.wallMs;
|
|
75
82
|
summary.tasks.workMs += task.workMs;
|
|
76
83
|
summary.tasks.cost += task.usage.cost;
|
|
84
|
+
addSegments(summary.tasks.segments, task.segments);
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
return summary;
|
|
@@ -98,6 +106,15 @@ function formatTime(iso: string): string {
|
|
|
98
106
|
return `${hours}:${minutes}`;
|
|
99
107
|
}
|
|
100
108
|
|
|
109
|
+
/** Render non-zero segment tags as `tag Xm00s` pairs, sorted alphabetically, joined by `, `. Undefined when none are non-zero. */
|
|
110
|
+
function formatSegmentTags(segments: Record<string, number>): string | undefined {
|
|
111
|
+
const tags = Object.keys(segments)
|
|
112
|
+
.filter((tag) => segments[tag]! > 0)
|
|
113
|
+
.sort();
|
|
114
|
+
if (tags.length === 0) return undefined;
|
|
115
|
+
return tags.map((tag) => `${tag} ${formatMinutes(segments[tag]!)}`).join(", ");
|
|
116
|
+
}
|
|
117
|
+
|
|
101
118
|
/** Render a short, human-readable summary for the `/kankaku` command. */
|
|
102
119
|
export function formatReport(summary: Summary): string {
|
|
103
120
|
const lines = ROLES.map((role) => {
|
|
@@ -107,27 +124,81 @@ export function formatReport(summary: Summary): string {
|
|
|
107
124
|
lines.push(
|
|
108
125
|
`tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}`,
|
|
109
126
|
);
|
|
127
|
+
const segmentTags = formatSegmentTags(summary.tasks.segments);
|
|
128
|
+
if (segmentTags !== undefined) {
|
|
129
|
+
lines.push(`segments: ${segmentTags}`);
|
|
130
|
+
}
|
|
110
131
|
return lines.join(" | ");
|
|
111
132
|
}
|
|
112
133
|
|
|
113
|
-
/** Render one line per task: time, union-based wall/work, cost, subagent count, and a truncated prompt. */
|
|
134
|
+
/** Render one line per task: time, client (when present), union-based wall/work, cost, non-zero segment tags, subagent count, and a truncated prompt. */
|
|
114
135
|
export function formatTasks(tasks: TaskView[]): string {
|
|
115
136
|
if (tasks.length === 0) return "no tasks";
|
|
116
137
|
return tasks
|
|
117
138
|
.map((task) => {
|
|
118
139
|
const prompt = task.prompt.length > 60 ? task.prompt.slice(0, 60) : task.prompt;
|
|
119
|
-
|
|
140
|
+
const segmentTags = formatSegmentTags(task.segments);
|
|
141
|
+
const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
|
|
142
|
+
const clientPart = task.client !== undefined ? ` client:${task.client}` : "";
|
|
143
|
+
return `${formatTime(task.startedAt)}${clientPart} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)}${segmentPart} subagents ${task.subagents.length} ${prompt}`;
|
|
120
144
|
})
|
|
121
145
|
.join("\n");
|
|
122
146
|
}
|
|
123
147
|
|
|
124
|
-
|
|
148
|
+
export interface ClientTotals {
|
|
149
|
+
wallMs: number;
|
|
150
|
+
waitingMs: number;
|
|
151
|
+
workMs: number;
|
|
152
|
+
/** Estimated cost in USD, summed across this client's tasks. */
|
|
153
|
+
cost: number;
|
|
154
|
+
count: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Client name under which tasks without a resolved client are grouped. */
|
|
158
|
+
const NO_CLIENT = "(none)";
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Aggregate tasks by billing client (see `domain/client-label.ts`), summing
|
|
162
|
+
* work/waiting/wall time, cost, and task count. Tasks without a `client`
|
|
163
|
+
* are grouped under `"(none)"`. Returned as a `Map` rather than a plain
|
|
164
|
+
* object so an attacker-controlled client name can never repoint a
|
|
165
|
+
* prototype property.
|
|
166
|
+
*/
|
|
167
|
+
export function summarizeByClient(tasks: TaskView[]): Map<string, ClientTotals> {
|
|
168
|
+
const totals = new Map<string, ClientTotals>();
|
|
169
|
+
for (const task of tasks) {
|
|
170
|
+
const key = task.client ?? NO_CLIENT;
|
|
171
|
+
const entry = totals.get(key) ?? { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, count: 0 };
|
|
172
|
+
entry.wallMs += task.wallMs;
|
|
173
|
+
entry.waitingMs += task.waitingMs;
|
|
174
|
+
entry.workMs += task.workMs;
|
|
175
|
+
entry.cost += finiteOrZero(task.usage.cost);
|
|
176
|
+
entry.count += 1;
|
|
177
|
+
totals.set(key, entry);
|
|
178
|
+
}
|
|
179
|
+
return totals;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Render one line per client, sorted alphabetically, with work/waiting/wall time, cost, and task count. */
|
|
183
|
+
export function formatClients(totals: Map<string, ClientTotals>): string {
|
|
184
|
+
if (totals.size === 0) return "no clients";
|
|
185
|
+
return Array.from(totals.entries())
|
|
186
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
187
|
+
.map(
|
|
188
|
+
([client, t]) =>
|
|
189
|
+
`${client} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)} tasks ${t.count}`,
|
|
190
|
+
)
|
|
191
|
+
.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Render one line per session: truncated id, time range, union-based wall/work, cost, non-zero segment tags, and task count. */
|
|
125
195
|
export function formatSessions(sessions: SessionView[]): string {
|
|
126
196
|
if (sessions.length === 0) return "no sessions";
|
|
127
197
|
return sessions
|
|
128
|
-
.map(
|
|
129
|
-
(session)
|
|
130
|
-
|
|
131
|
-
|
|
198
|
+
.map((session) => {
|
|
199
|
+
const segmentTags = formatSegmentTags(session.segments);
|
|
200
|
+
const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
|
|
201
|
+
return `${session.sessionId.slice(0, 8)} ${formatTime(session.startedAt)}–${formatTime(session.endedAt)} wall ${formatMinutes(session.wallMs)} work ${formatMinutes(session.workMs)} ${formatCost(session.usage.cost)}${segmentPart} tasks ${session.tasks.length}`;
|
|
202
|
+
})
|
|
132
203
|
.join("\n");
|
|
133
204
|
}
|