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.
- package/README.md +103 -14
- package/dist/adapters/cached-catalog.d.ts +8 -2
- package/dist/adapters/cached-catalog.js +13 -3
- package/dist/adapters/pocketbase-catalog.d.ts +7 -5
- package/dist/adapters/pocketbase-catalog.js +22 -5
- package/dist/adapters/pocketbase-sink.d.ts +2 -1
- package/dist/adapters/pocketbase-sink.js +2 -1
- package/dist/domain/hub-entry.d.ts +12 -3
- package/dist/domain/hub-entry.js +14 -4
- package/dist/domain/task-view.d.ts +4 -0
- package/dist/domain/task-view.js +2 -0
- package/dist/domain/work-record.d.ts +18 -0
- package/dist/domain/work-record.js +18 -0
- package/dist/domain/work-target.d.ts +30 -1
- package/dist/domain/work-target.js +33 -11
- package/dist/ports/catalog.d.ts +4 -2
- package/package.json +1 -1
- package/src/adapters/cached-catalog.ts +22 -6
- package/src/adapters/hub-actions.ts +83 -0
- package/src/adapters/kankaku-command.ts +236 -244
- package/src/adapters/panel/kankaku-panel.ts +274 -0
- package/src/adapters/panel/panel-items.ts +77 -0
- package/src/adapters/panel/panel-lines.ts +13 -0
- package/src/adapters/panel/panel-theme.ts +32 -0
- package/src/adapters/panel/screens/about.ts +69 -0
- package/src/adapters/panel/screens/doctor.ts +85 -0
- package/src/adapters/panel/screens/export.ts +119 -0
- package/src/adapters/panel/screens/report.ts +141 -0
- package/src/adapters/panel/screens/sync.ts +126 -0
- package/src/adapters/panel/screens/target.ts +367 -0
- package/src/adapters/pi-tracker.ts +110 -6
- package/src/adapters/pocketbase-catalog.ts +36 -7
- package/src/adapters/pocketbase-sink.ts +15 -3
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/report.ts +66 -16
- package/src/adapters/session-target.ts +111 -4
- package/src/adapters/target-picker.ts +46 -1
- package/src/domain/hub-entry.ts +20 -5
- package/src/domain/panel-model.ts +267 -0
- package/src/domain/task-view.ts +6 -0
- package/src/domain/work-record.ts +23 -0
- package/src/domain/work-target.ts +60 -11
- package/src/extension.ts +19 -8
- 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
|
|
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
|
|
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
|
|
43
|
-
* `projects` record (no `active` filter,
|
|
44
|
-
* "deleted" from "inactive"; see
|
|
45
|
-
* `
|
|
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(
|
|
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(): {
|
|
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
|
+
}
|
package/src/adapters/report.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
270
|
-
|
|
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
|
|