kankaku 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +103 -14
  2. package/dist/adapters/cached-catalog.d.ts +8 -2
  3. package/dist/adapters/cached-catalog.js +13 -3
  4. package/dist/adapters/pocketbase-catalog.d.ts +7 -5
  5. package/dist/adapters/pocketbase-catalog.js +22 -5
  6. package/dist/adapters/pocketbase-sink.d.ts +2 -1
  7. package/dist/adapters/pocketbase-sink.js +2 -1
  8. package/dist/domain/hub-entry.d.ts +12 -3
  9. package/dist/domain/hub-entry.js +14 -4
  10. package/dist/domain/task-view.d.ts +4 -0
  11. package/dist/domain/task-view.js +2 -0
  12. package/dist/domain/work-record.d.ts +18 -0
  13. package/dist/domain/work-record.js +18 -0
  14. package/dist/domain/work-target.d.ts +30 -1
  15. package/dist/domain/work-target.js +33 -11
  16. package/dist/ports/catalog.d.ts +4 -2
  17. package/package.json +1 -1
  18. package/src/adapters/cached-catalog.ts +22 -6
  19. package/src/adapters/hub-actions.ts +83 -0
  20. package/src/adapters/kankaku-command.ts +236 -244
  21. package/src/adapters/panel/kankaku-panel.ts +274 -0
  22. package/src/adapters/panel/panel-items.ts +77 -0
  23. package/src/adapters/panel/panel-lines.ts +13 -0
  24. package/src/adapters/panel/panel-theme.ts +32 -0
  25. package/src/adapters/panel/screens/about.ts +69 -0
  26. package/src/adapters/panel/screens/doctor.ts +85 -0
  27. package/src/adapters/panel/screens/export.ts +119 -0
  28. package/src/adapters/panel/screens/report.ts +141 -0
  29. package/src/adapters/panel/screens/sync.ts +126 -0
  30. package/src/adapters/panel/screens/target.ts +367 -0
  31. package/src/adapters/pi-tracker.ts +110 -6
  32. package/src/adapters/pocketbase-catalog.ts +36 -7
  33. package/src/adapters/pocketbase-sink.ts +15 -3
  34. package/src/adapters/report-views.ts +98 -0
  35. package/src/adapters/report.ts +66 -16
  36. package/src/adapters/session-target.ts +111 -4
  37. package/src/adapters/target-picker.ts +46 -1
  38. package/src/domain/hub-entry.ts +20 -5
  39. package/src/domain/panel-model.ts +267 -0
  40. package/src/domain/task-view.ts +6 -0
  41. package/src/domain/work-record.ts +23 -0
  42. package/src/domain/work-target.ts +60 -11
  43. package/src/extension.ts +19 -8
  44. package/src/ports/catalog.ts +4 -2
@@ -3,7 +3,7 @@ import { isValidClient } from "../domain/client-label.ts";
3
3
  import { formatWorkTargetLabel } from "../domain/work-target.ts";
4
4
  import type { RegistryClassification } from "../domain/registry-health.ts";
5
5
  import type { SubagentProfile } from "../domain/subagent-profile.ts";
6
- import type { RejectedChildEnvMarker } from "../config.ts";
6
+ import type { KankakuConfig, RejectedChildEnvMarker } from "../config.ts";
7
7
  import type { OrchestratorRef, WorkRecord, WorkRecordCore, WorkRole } from "../domain/work-record.ts";
8
8
  import type { WorkTracker } from "../domain/work-tracker.ts";
9
9
  import type { Catalog } from "../ports/catalog.ts";
@@ -13,8 +13,15 @@ import { createSessionClient } from "./session-client.ts";
13
13
  import { readNonDefaultSessionDir } from "./session-dir.ts";
14
14
  import type { SessionTarget } from "./session-target.ts";
15
15
  import { createStatusBar } from "./status-bar.ts";
16
- import { notifyError, registerKankakuCommand } from "./kankaku-command.ts";
17
- import type { SyncCommandDeps } from "./kankaku-command.ts";
16
+ import { appendReportEntry, notifyError, registerKankakuCommand } from "./kankaku-command.ts";
17
+ import type { KankakuCommandDeps, SyncCommandDeps } from "./kankaku-command.ts";
18
+ import { openKankakuPanel } from "./panel/kankaku-panel.ts";
19
+ import { createAboutScreen } from "./panel/screens/about.ts";
20
+ import { createDoctorScreen } from "./panel/screens/doctor.ts";
21
+ import { createExportScreen } from "./panel/screens/export.ts";
22
+ import { createReportScreen } from "./panel/screens/report.ts";
23
+ import { createSyncScreen } from "./panel/screens/sync.ts";
24
+ import { createTargetScreen } from "./panel/screens/target.ts";
18
25
  import type { SyncTrigger } from "./sync-runner.ts";
19
26
 
20
27
  export type { KankakuReportData } from "./kankaku-command.ts";
@@ -174,11 +181,37 @@ export interface PiTrackerDeps {
174
181
  * real timer.
175
182
  */
176
183
  shutdownSyncTimeoutMs?: number;
184
+ /**
185
+ * The full active kankaku configuration (`config.ts#loadConfig`), for the
186
+ * panel's `about` screen (P3). Optional so every existing direct
187
+ * `createPiTracker` caller (e.g. `tests/pi-tracker.test.ts`) keeps
188
+ * working unchanged — falls back to {@link FALLBACK_ABOUT_CONFIG} (an
189
+ * empty configuration) when absent. `extension.ts` always supplies the
190
+ * real one.
191
+ */
192
+ config?: KankakuConfig;
193
+ /** This session's resolved kankaku directory (`adapters/kankaku-dir.ts#resolveKankakuDir`), for the `about` screen. */
194
+ kankakuDir?: string;
195
+ /** pi's version (`adapters/agent-info.ts#resolveAgentVersion`), for the `about` screen. */
196
+ agentVersion?: string;
197
+ /** kankaku's own version (`adapters/agent-info.ts#resolvePluginVersion`), for the `about` screen. */
198
+ pluginVersion?: string;
199
+ /** The hub (PocketBase) URL, when configured, for the `about` screen. */
200
+ hubUrl?: string;
177
201
  }
178
202
 
179
203
  /** Default for {@link PiTrackerDeps.shutdownSyncTimeoutMs}. */
180
204
  const DEFAULT_SHUTDOWN_SYNC_TIMEOUT_MS = 3000;
181
205
 
206
+ /** {@link PiTrackerDeps.config}'s fallback for a caller that has not wired the real one yet. */
207
+ const FALLBACK_ABOUT_CONFIG: KankakuConfig = {
208
+ dir: ".kankaku",
209
+ interactiveTools: [],
210
+ subagentProfiles: [],
211
+ segmentRules: [],
212
+ rejectedSubagentChildEnvMarkers: [],
213
+ };
214
+
182
215
  /** Default `isAlive`: probe with signal 0 — no signal is sent, only existence/permission is checked. */
183
216
  function defaultIsAlive(pid: number): boolean {
184
217
  try {
@@ -252,10 +285,19 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
252
285
  resolveIdleClient: idleDisplayLabel,
253
286
  });
254
287
 
255
- const kankakuCommand = registerKankakuCommand(pi, {
288
+ const refreshIdleStatus = (ctx: ExtensionContext) => statusBar.showIdle(ctx);
289
+
290
+ // Built as a named const (rather than inlined into `registerKankakuCommand`
291
+ // below) so `openPanel`'s doctor screen can reuse the exact same
292
+ // `KankakuCommandDeps` `buildDoctorLines` needs — the panel and
293
+ // `/kankaku doctor` can then never drift. Safe to reference `commandDeps`
294
+ // from within its own `openPanel` closure: the closure body only runs
295
+ // once `/kankaku` (no args) is actually invoked, long after this `const`
296
+ // has finished initializing.
297
+ const commandDeps: KankakuCommandDeps = {
256
298
  log,
257
299
  sessionClient,
258
- refreshIdleStatus: (ctx) => statusBar.showIdle(ctx),
300
+ refreshIdleStatus,
259
301
  writeExportFile: deps.writeExportFile,
260
302
  sessionTarget: deps.sessionTarget,
261
303
  catalog: deps.catalog,
@@ -270,7 +312,67 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
270
312
  rejectedSubagentChildEnvMarkers: deps.rejectedSubagentChildEnvMarkers,
271
313
  workLogRouting: deps.workLogRouting,
272
314
  subagentProfiles: deps.subagentProfiles,
273
- });
315
+ // The hub is configured exactly when a session target is wired (see
316
+ // PiTrackerDeps.sessionTarget's doc comment): the panel's root menu
317
+ // uses the same signal to decide whether to offer `sync` — `target`
318
+ // itself is offered either way, since the legacy `/kankaku client
319
+ // <name>` label lives on that same screen and works with no hub at
320
+ // all (see `domain/panel-model.ts#rootMenu`'s `target` row).
321
+ openPanel: (ctx) =>
322
+ openKankakuPanel(ctx, {
323
+ hubConfigured: deps.sessionTarget !== undefined,
324
+ screens: {
325
+ target: createTargetScreen({
326
+ pi,
327
+ ctx,
328
+ role,
329
+ sessionTarget: deps.sessionTarget,
330
+ sessionClient,
331
+ catalog: deps.catalog,
332
+ refreshIdleStatus,
333
+ }),
334
+ report: createReportScreen({
335
+ log,
336
+ ctx,
337
+ pi,
338
+ sessionId: () => ctx.sessionManager.getSessionId(),
339
+ pinReport: (report) => appendReportEntry(pi, ctx, report),
340
+ }),
341
+ doctor: createDoctorScreen({
342
+ commandDeps,
343
+ ctx,
344
+ pinReport: (report) => appendReportEntry(pi, ctx, report),
345
+ }),
346
+ about: createAboutScreen({
347
+ config: deps.config ?? FALLBACK_ABOUT_CONFIG,
348
+ kankakuDir: deps.kankakuDir ?? deps.config?.dir ?? FALLBACK_ABOUT_CONFIG.dir,
349
+ agentVersion: deps.agentVersion,
350
+ pluginVersion: deps.pluginVersion,
351
+ hubUrl: deps.hubUrl,
352
+ }),
353
+ // Present only when the hub is configured — mirrors `sync`'s own
354
+ // hub-only root-menu row (`domain/panel-model.ts#rootMenu`).
355
+ ...(deps.sync
356
+ ? {
357
+ sync: createSyncScreen({
358
+ sync: deps.sync,
359
+ catalog: deps.catalog,
360
+ ctx,
361
+ refreshIdleStatus,
362
+ pinReport: (report) => appendReportEntry(pi, ctx, report),
363
+ }),
364
+ }
365
+ : {}),
366
+ export: createExportScreen({
367
+ log,
368
+ writeExportFile: deps.writeExportFile,
369
+ ctx,
370
+ pinReport: (report) => appendReportEntry(pi, ctx, report),
371
+ }),
372
+ },
373
+ }),
374
+ };
375
+ const kankakuCommand = registerKankakuCommand(pi, commandDeps);
274
376
 
275
377
  /** At most one quiet auto-sync failure notification per session; never notified on success. Shared by the fire-and-forget `triggerAutoSync` and the awaited shutdown sync below. */
276
378
  let autoSyncErrorNotified = false;
@@ -386,6 +488,8 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
386
488
  clientName: target.clientName,
387
489
  ...(target.projectId !== undefined ? { projectId: target.projectId } : {}),
388
490
  ...(target.projectName !== undefined ? { projectName: target.projectName } : {}),
491
+ ...(target.hubTaskId !== undefined ? { hubTaskId: target.hubTaskId } : {}),
492
+ ...(target.hubTaskTitle !== undefined ? { hubTaskTitle: target.hubTaskTitle } : {}),
389
493
  }
390
494
  : {}),
391
495
  ...(deps.machine !== undefined ? { machine: deps.machine } : {}),
@@ -1,4 +1,4 @@
1
- import type { Client, Project } from "../domain/work-target.ts";
1
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
2
2
  import type { PocketBaseClient, PocketBaseRecord } from "./pocketbase-client.ts";
3
3
 
4
4
  interface ClientRecord extends PocketBaseRecord {
@@ -16,6 +16,15 @@ interface ProjectRecord extends PocketBaseRecord {
16
16
  active?: boolean;
17
17
  }
18
18
 
19
+ interface TaskRecord extends PocketBaseRecord {
20
+ title?: string;
21
+ project: string;
22
+ status?: string;
23
+ external_ref?: string;
24
+ }
25
+
26
+ const TASK_STATUSES = new Set(["open", "doing", "done"]);
27
+
19
28
  function mapClient(record: ClientRecord): Client {
20
29
  return {
21
30
  id: record.id,
@@ -37,24 +46,44 @@ function mapProject(record: ProjectRecord): Project {
37
46
  };
38
47
  }
39
48
 
49
+ /** `open` on any unrecognised or missing status, rather than dropping the task. */
50
+ function mapTaskStatus(status: string | undefined): HubTask["status"] {
51
+ return status !== undefined && TASK_STATUSES.has(status) ? (status as HubTask["status"]) : "open";
52
+ }
53
+
54
+ function mapTask(record: TaskRecord): HubTask {
55
+ return {
56
+ id: record.id,
57
+ title: record.title ?? "",
58
+ projectId: record.project,
59
+ status: mapTaskStatus(record.status),
60
+ ...(typeof record.external_ref === "string" && record.external_ref !== "" ? { externalRef: record.external_ref } : {}),
61
+ };
62
+ }
63
+
40
64
  /**
41
65
  * Build the `fetchCatalog` function {@link CachedCatalog} (`cached-catalog.ts`)
42
- * needs, backed by a {@link PocketBaseClient}. Fetches every `clients` and
43
- * `projects` record (no `active` filter, so the domain resolver can tell
44
- * "deleted" from "inactive"; see `domain/work-target.ts`), sorted by name.
45
- * `signal`, when given, bounds both fetches (see
66
+ * needs, backed by a {@link PocketBaseClient}. Fetches every `clients`,
67
+ * `projects` and `tasks` record in parallel (no `active`/`status` filter,
68
+ * so the domain resolver can tell "deleted" from "inactive"/"done"; see
69
+ * `domain/work-target.ts`), clients/projects sorted by name and tasks by
70
+ * title. `signal`, when given, bounds every fetch (see
46
71
  * `pocketbase-client.ts#request`).
47
72
  */
48
- export function createPocketBaseCatalogFetcher(client: PocketBaseClient): (signal?: AbortSignal) => Promise<{ clients: Client[]; projects: Project[] }> {
73
+ export function createPocketBaseCatalogFetcher(
74
+ client: PocketBaseClient,
75
+ ): (signal?: AbortSignal) => Promise<{ clients: Client[]; projects: Project[]; tasks: HubTask[] }> {
49
76
  return async (signal?: AbortSignal) => {
50
- const [clientRecords, projectRecords] = await Promise.all([
77
+ const [clientRecords, projectRecords, taskRecords] = await Promise.all([
51
78
  client.list<ClientRecord>("clients", { sort: "name" }, signal),
52
79
  client.list<ProjectRecord>("projects", { sort: "name" }, signal),
80
+ client.list<TaskRecord>("tasks", { sort: "title" }, signal),
53
81
  ]);
54
82
 
55
83
  return {
56
84
  clients: clientRecords.map(mapClient),
57
85
  projects: projectRecords.map(mapProject),
86
+ tasks: taskRecords.map(mapTask),
58
87
  };
59
88
  };
60
89
  }
@@ -14,7 +14,7 @@
14
14
  import { buildTaskEntryCreatePayload, buildTaskEntryUpdatePayload, buildWorkRecordPayload, resolveTaskAssignment, taskWorkRecords } from "../domain/hub-entry.ts";
15
15
  import type { PromptPrivacyMode } from "../domain/hub-entry.ts";
16
16
  import type { TaskView } from "../domain/task-view.ts";
17
- import type { Client, Project } from "../domain/work-target.ts";
17
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
18
18
  import type { PushOutcome, PushTaskResult, WorkSink } from "../ports/work-sink.ts";
19
19
  import type { PocketBaseClient, PocketBaseRecord } from "./pocketbase-client.ts";
20
20
  import { PocketBaseError } from "./pocketbase-client.ts";
@@ -24,6 +24,7 @@ export interface PocketBaseSinkDeps {
24
24
  /** Catalog snapshot to resolve assignment against; see `domain/hub-entry.ts#resolveTaskAssignment`. */
25
25
  clients: Client[];
26
26
  projects: Project[];
27
+ tasks: HubTask[];
27
28
  machine: string;
28
29
  promptMode: PromptPrivacyMode;
29
30
  /** Coding agent that produced these rows; see `domain/hub-entry.ts#HubEntryContext.agent`. Defaults to `"pi"`. */
@@ -105,10 +106,21 @@ export class PocketBaseSink implements WorkSink {
105
106
  return found.get(value);
106
107
  }
107
108
 
108
- private get entryContext(): { clients: Client[]; projects: Project[]; machine: string; promptMode: PromptPrivacyMode; agent: string; agentVersion?: string; plugin: string; pluginVersion?: string } {
109
+ private get entryContext(): {
110
+ clients: Client[];
111
+ projects: Project[];
112
+ tasks: HubTask[];
113
+ machine: string;
114
+ promptMode: PromptPrivacyMode;
115
+ agent: string;
116
+ agentVersion?: string;
117
+ plugin: string;
118
+ pluginVersion?: string;
119
+ } {
109
120
  return {
110
121
  clients: this.deps.clients,
111
122
  projects: this.deps.projects,
123
+ tasks: this.deps.tasks,
112
124
  machine: this.deps.machine,
113
125
  promptMode: this.deps.promptMode,
114
126
  agent: this.deps.agent ?? "pi",
@@ -177,7 +189,7 @@ export class PocketBaseSink implements WorkSink {
177
189
  }
178
190
 
179
191
  private async pushOne(task: TaskView, existingId: string | undefined): Promise<PushOutcome> {
180
- const assignment = resolveTaskAssignment(task, this.deps.clients, this.deps.projects);
192
+ const assignment = resolveTaskAssignment(task, this.deps.clients, this.deps.projects, this.deps.tasks);
181
193
  const pushAssignment = { unassigned: assignment.routedToUnassigned, ...(assignment.legacyClientLabel ? { legacyLabel: assignment.legacyClientLabel } : {}) };
182
194
 
183
195
  try {
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The five `/kankaku` report views (summary/tasks/sessions/clients/
3
+ * projects), each as one pure `WorkRecord[] -> KankakuReportData` builder.
4
+ * Extracted from `kankaku-command.ts`'s subcommand handlers (see
5
+ * odd/tasks/kankaku-panel.md P3) so the `/kankaku` subcommands and the
6
+ * panel's report screen (`panel/screens/report.ts`) always compute the
7
+ * exact same lines — neither ever re-implements the other's logic.
8
+ */
9
+ import { buildSessions, buildTasks } from "../domain/task-view.ts";
10
+ import { exportRows, toCsv, toJson } from "../domain/export.ts";
11
+ import type { WorkRecord } from "../domain/work-record.ts";
12
+ import type { KankakuReportData } from "./kankaku-command.ts";
13
+ import { countUncertain, formatClients, formatProjects, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient, summarizeByProject } from "./report.ts";
14
+
15
+ /** Shared by every view except `buildTasksView`: `all` includes every day, otherwise only today's local day. */
16
+ export interface ReportViewOptions {
17
+ all: boolean;
18
+ }
19
+
20
+ /** `buildTasksView`'s own options: `all` scopes to every session instead of local-day range (tasks are never day-filtered — see `kankaku-command.ts`'s original `tasks` handler). */
21
+ export interface TasksViewOptions {
22
+ all: boolean;
23
+ sessionId: string | undefined;
24
+ }
25
+
26
+ /** The plain-text summary view (`/kankaku` with no view token): role/task totals, plus a one-line hint when uncertain records were excluded (SUBAGENT-REQ-017). */
27
+ export function buildSummaryView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData {
28
+ const { all } = options;
29
+ const summary = summarize(records, { all });
30
+ const lines = formatReport(summary).split(" | ");
31
+ const uncertainCount = countUncertain(records, { all });
32
+ if (uncertainCount > 0) {
33
+ lines.push(`kankaku: ${uncertainCount} uncertain record(s) excluded from tasks — run /kankaku doctor`);
34
+ }
35
+ return { title: all ? "summary (all days)" : "summary (today)", lines };
36
+ }
37
+
38
+ /** `/kankaku clients [all]`: per-client totals. */
39
+ export function buildClientsView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData {
40
+ const { all } = options;
41
+ const today = localDay(new Date().toISOString());
42
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
43
+ return { title: all ? "clients (all days)" : "clients (today)", lines: formatClients(summarizeByClient(tasks)).split("\n") };
44
+ }
45
+
46
+ /** `/kankaku projects [all]`: per-project totals. */
47
+ export function buildProjectsView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData {
48
+ const { all } = options;
49
+ const today = localDay(new Date().toISOString());
50
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
51
+ return { title: all ? "projects (all days)" : "projects (today)", lines: formatProjects(summarizeByProject(tasks)).split("\n") };
52
+ }
53
+
54
+ /** `/kankaku sessions [all]`: per-session totals. */
55
+ export function buildSessionsView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData {
56
+ const { all } = options;
57
+ const today = localDay(new Date().toISOString());
58
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
59
+ return { title: all ? "sessions (all days)" : "sessions (today)", lines: formatSessions(buildSessions(tasks)).split("\n") };
60
+ }
61
+
62
+ /**
63
+ * `/kankaku tasks [all]`: one line per task. Unlike every other view, tasks
64
+ * are never restricted by local day — `all` (or a missing `sessionId`)
65
+ * instead scopes from "this session" to "every session".
66
+ */
67
+ export function buildTasksView(records: WorkRecord[], options: TasksViewOptions): KankakuReportData {
68
+ const { all, sessionId } = options;
69
+ const scoped = all || !sessionId;
70
+ const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
71
+ return { title: scoped ? "tasks (every session)" : "tasks (this session)", lines: formatTasks(tasks).split("\n") };
72
+ }
73
+
74
+ /** {@link buildExportContent}'s options: `format` picks csv/json, `all` includes every day instead of just today's local day (mirrors every other view except `buildTasksView`). */
75
+ export interface ExportContentOptions {
76
+ format: "csv" | "json";
77
+ all: boolean;
78
+ }
79
+
80
+ /**
81
+ * `/kankaku export [csv|json] [all]`'s file content: the flat export rows
82
+ * for today's (or every) task, rendered as csv or json, with the file name
83
+ * `/kankaku export` and the panel's export screen (`panel/screens/
84
+ * export.ts`) both use. Extracted from `kankaku-command.ts`'s
85
+ * `handleExportCommand` (see odd/tasks/kankaku-panel.md P4) so the
86
+ * subcommand and the panel never drift. `rowCount` is exposed only for the
87
+ * subcommand's "wrote N row(s) to <path>" confirmation line — the panel's
88
+ * own confirmation is simpler ("wrote <path>").
89
+ */
90
+ export function buildExportContent(records: WorkRecord[], options: ExportContentOptions): { name: string; content: string; rowCount: number } {
91
+ const { format, all } = options;
92
+ const today = localDay(new Date().toISOString());
93
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
94
+ const rows = exportRows(tasks);
95
+ const content = format === "json" ? toJson(rows) : toCsv(rows);
96
+ const name = `tasks-${all ? "all" : today}.${format}`;
97
+ return { name, content, rowCount: rows.length };
98
+ }
@@ -1,7 +1,7 @@
1
1
  import { buildTasks, uncertainRecords } from "../domain/task-view.ts";
2
2
  import type { SessionView, TaskView } from "../domain/task-view.ts";
3
3
  import { localDay } from "../domain/day.ts";
4
- import { finiteOrZero } from "../domain/work-record.ts";
4
+ import { cacheHitRatio, finiteOrZero } from "../domain/work-record.ts";
5
5
  import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
6
6
 
7
7
  export { localDay } from "../domain/day.ts";
@@ -13,6 +13,12 @@ export interface RoleTotals {
13
13
  count: number;
14
14
  /** Estimated cost in USD, as priced by pi's model table. */
15
15
  cost: number;
16
+ /** Prompt input tokens, summed across every record of this role. */
17
+ input: number;
18
+ /** Prompt-cache read tokens, summed across every record of this role. */
19
+ cacheRead: number;
20
+ /** Prompt-cache write tokens, summed across every record of this role. */
21
+ cacheWrite: number;
16
22
  /** Per-tag total milliseconds summed across every record of this role. */
17
23
  segments: Record<string, number>;
18
24
  }
@@ -23,6 +29,12 @@ export interface TaskTotals {
23
29
  workMs: number;
24
30
  /** Estimated cost in USD, orchestrator and subagents combined. */
25
31
  cost: number;
32
+ /** Prompt input tokens, orchestrator and subagents combined. */
33
+ input: number;
34
+ /** Prompt-cache read tokens, orchestrator and subagents combined. */
35
+ cacheRead: number;
36
+ /** Prompt-cache write tokens, orchestrator and subagents combined. */
37
+ cacheWrite: number;
26
38
  /** Per-tag total milliseconds summed across every task (orchestrator and subagents). */
27
39
  segments: Record<string, number>;
28
40
  }
@@ -39,7 +51,7 @@ export interface SummarizeOptions {
39
51
  const ROLES: WorkRole[] = ["orchestrator", "subagent"];
40
52
 
41
53
  function emptyTotals(): RoleTotals {
42
- return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, segments: {} };
54
+ return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} };
43
55
  }
44
56
 
45
57
  /**
@@ -67,7 +79,7 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
67
79
  const summary: Summary = {
68
80
  orchestrator: emptyTotals(),
69
81
  subagent: emptyTotals(),
70
- tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0, segments: {} },
82
+ tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} },
71
83
  };
72
84
  // Accumulated in Maps (see `addSegments`) and only converted to the
73
85
  // returned plain objects at the very end, via `Object.fromEntries`.
@@ -82,6 +94,9 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
82
94
  totals.wallMs += record.wallMs;
83
95
  totals.count += 1;
84
96
  totals.cost += finiteOrZero(record.usage.cost);
97
+ totals.input += finiteOrZero(record.usage.input);
98
+ totals.cacheRead += finiteOrZero(record.usage.cacheRead);
99
+ totals.cacheWrite += finiteOrZero(record.usage.cacheWrite);
85
100
  addSegments(segmentsByRole[record.role], record.segments);
86
101
  }
87
102
 
@@ -91,6 +106,9 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
91
106
  summary.tasks.wallMs += task.wallMs;
92
107
  summary.tasks.workMs += task.workMs;
93
108
  summary.tasks.cost += task.usage.cost;
109
+ summary.tasks.input += finiteOrZero(task.usage.input);
110
+ summary.tasks.cacheRead += finiteOrZero(task.usage.cacheRead);
111
+ summary.tasks.cacheWrite += finiteOrZero(task.usage.cacheWrite);
94
112
  addSegments(taskSegments, task.segments);
95
113
  }
96
114
 
@@ -131,6 +149,12 @@ function formatTime(iso: string): string {
131
149
  return `${hours}:${minutes}`;
132
150
  }
133
151
 
152
+ /** Render `cache hit NN%` (integer percent) for a usage totals triple, or `undefined` when the ratio is undefined (nothing recorded). */
153
+ function formatCacheHit(usage: { input: number; cacheRead: number; cacheWrite: number }): string | undefined {
154
+ const ratio = cacheHitRatio(usage);
155
+ return ratio === undefined ? undefined : `cache hit ${Math.round(ratio * 100)}%`;
156
+ }
157
+
134
158
  /** Render non-zero segment tags as `tag Xm00s` pairs, sorted alphabetically, joined by `, `. Undefined when none are non-zero. */
135
159
  function formatSegmentTags(segments: Record<string, number>): string | undefined {
136
160
  const tags = Object.keys(segments)
@@ -144,10 +168,14 @@ function formatSegmentTags(segments: Record<string, number>): string | undefined
144
168
  export function formatReport(summary: Summary): string {
145
169
  const lines = ROLES.map((role) => {
146
170
  const totals = summary[role];
147
- return `${role}: work ${formatMinutes(totals.workMs)}, waiting ${formatMinutes(totals.waitingMs)}, ${totals.count} record(s), ${formatCost(totals.cost)}`;
171
+ const cacheHit = formatCacheHit(totals);
172
+ const cacheHitPart = cacheHit !== undefined ? `, ${cacheHit}` : "";
173
+ return `${role}: work ${formatMinutes(totals.workMs)}, waiting ${formatMinutes(totals.waitingMs)}, ${totals.count} record(s), ${formatCost(totals.cost)}${cacheHitPart}`;
148
174
  });
175
+ const tasksCacheHit = formatCacheHit(summary.tasks);
176
+ const tasksCacheHitPart = tasksCacheHit !== undefined ? `, ${tasksCacheHit}` : "";
149
177
  lines.push(
150
- `tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}`,
178
+ `tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}${tasksCacheHitPart}`,
151
179
  );
152
180
  const segmentTags = formatSegmentTags(summary.tasks.segments);
153
181
  if (segmentTags !== undefined) {
@@ -173,7 +201,9 @@ export function formatTasks(tasks: TaskView[]): string {
173
201
  const segmentTags = formatSegmentTags(task.segments);
174
202
  const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
175
203
  const clientPart = task.client !== undefined ? ` client:${task.client}` : "";
176
- return `${formatTime(task.startedAt)}${clientPart} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)}${segmentPart} subagents ${task.subagents.length} ${prompt}`;
204
+ const cacheHit = formatCacheHit(task.usage);
205
+ const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
206
+ return `${formatTime(task.startedAt)}${clientPart} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)}${cacheHitPart}${segmentPart} subagents ${task.subagents.length} ${prompt}`;
177
207
  })
178
208
  .join("\n");
179
209
  }
@@ -184,6 +214,12 @@ export interface ClientTotals {
184
214
  workMs: number;
185
215
  /** Estimated cost in USD, summed across this client's tasks. */
186
216
  cost: number;
217
+ /** Prompt input tokens, summed across this client's tasks. */
218
+ input: number;
219
+ /** Prompt-cache read tokens, summed across this client's tasks. */
220
+ cacheRead: number;
221
+ /** Prompt-cache write tokens, summed across this client's tasks. */
222
+ cacheWrite: number;
187
223
  count: number;
188
224
  }
189
225
 
@@ -201,11 +237,14 @@ export function summarizeByClient(tasks: TaskView[]): Map<string, ClientTotals>
201
237
  const totals = new Map<string, ClientTotals>();
202
238
  for (const task of tasks) {
203
239
  const key = task.client ?? NO_CLIENT;
204
- const entry = totals.get(key) ?? { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, count: 0 };
240
+ const entry = totals.get(key) ?? { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
205
241
  entry.wallMs += task.wallMs;
206
242
  entry.waitingMs += task.waitingMs;
207
243
  entry.workMs += task.workMs;
208
244
  entry.cost += finiteOrZero(task.usage.cost);
245
+ entry.input += finiteOrZero(task.usage.input);
246
+ entry.cacheRead += finiteOrZero(task.usage.cacheRead);
247
+ entry.cacheWrite += finiteOrZero(task.usage.cacheWrite);
209
248
  entry.count += 1;
210
249
  totals.set(key, entry);
211
250
  }
@@ -220,6 +259,12 @@ export interface ProjectTotals {
220
259
  workMs: number;
221
260
  /** Estimated cost in USD, summed across this project's tasks. */
222
261
  cost: number;
262
+ /** Prompt input tokens, summed across this project's tasks. */
263
+ input: number;
264
+ /** Prompt-cache read tokens, summed across this project's tasks. */
265
+ cacheRead: number;
266
+ /** Prompt-cache write tokens, summed across this project's tasks. */
267
+ cacheWrite: number;
223
268
  count: number;
224
269
  }
225
270
 
@@ -237,11 +282,14 @@ export function summarizeByProject(tasks: TaskView[]): Map<string, ProjectTotals
237
282
  for (const task of tasks) {
238
283
  const key = task.projectId ?? NO_PROJECT;
239
284
  const name = task.projectId !== undefined ? (task.projectName ?? task.projectId) : NO_PROJECT;
240
- const entry = totals.get(key) ?? { name, wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, count: 0 };
285
+ const entry = totals.get(key) ?? { name, wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
241
286
  entry.wallMs += task.wallMs;
242
287
  entry.waitingMs += task.waitingMs;
243
288
  entry.workMs += task.workMs;
244
289
  entry.cost += finiteOrZero(task.usage.cost);
290
+ entry.input += finiteOrZero(task.usage.input);
291
+ entry.cacheRead += finiteOrZero(task.usage.cacheRead);
292
+ entry.cacheWrite += finiteOrZero(task.usage.cacheWrite);
245
293
  entry.count += 1;
246
294
  totals.set(key, entry);
247
295
  }
@@ -253,10 +301,11 @@ export function formatProjects(totals: Map<string, ProjectTotals>): string {
253
301
  if (totals.size === 0) return "no projects";
254
302
  return Array.from(totals.values())
255
303
  .sort((a, b) => a.name.localeCompare(b.name))
256
- .map(
257
- (t) =>
258
- `${t.name} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)} tasks ${t.count}`,
259
- )
304
+ .map((t) => {
305
+ const cacheHit = formatCacheHit(t);
306
+ const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
307
+ return `${t.name} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)}${cacheHitPart} tasks ${t.count}`;
308
+ })
260
309
  .join("\n");
261
310
  }
262
311
 
@@ -265,10 +314,11 @@ export function formatClients(totals: Map<string, ClientTotals>): string {
265
314
  if (totals.size === 0) return "no clients";
266
315
  return Array.from(totals.entries())
267
316
  .sort(([a], [b]) => a.localeCompare(b))
268
- .map(
269
- ([client, t]) =>
270
- `${client} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)} tasks ${t.count}`,
271
- )
317
+ .map(([client, t]) => {
318
+ const cacheHit = formatCacheHit(t);
319
+ const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
320
+ return `${client} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)}${cacheHitPart} tasks ${t.count}`;
321
+ })
272
322
  .join("\n");
273
323
  }
274
324