kankaku-pi 1.0.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/LICENSE +21 -0
- package/README.md +1438 -0
- package/dist/adapters/cached-catalog.d.ts +42 -0
- package/dist/adapters/cached-catalog.js +121 -0
- package/dist/adapters/export-writer.d.ts +13 -0
- package/dist/adapters/export-writer.js +28 -0
- package/dist/adapters/file-modes.d.ts +20 -0
- package/dist/adapters/file-modes.js +34 -0
- package/dist/adapters/hub-actions.d.ts +35 -0
- package/dist/adapters/hub-actions.js +70 -0
- package/dist/adapters/hub-credentials.d.ts +35 -0
- package/dist/adapters/hub-credentials.js +58 -0
- package/dist/adapters/jsonl-work-log.d.ts +20 -0
- package/dist/adapters/jsonl-work-log.js +62 -0
- package/dist/adapters/kankaku-dir.d.ts +38 -0
- package/dist/adapters/kankaku-dir.js +85 -0
- package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
- package/dist/adapters/lazy-jsonl-work-log.js +31 -0
- package/dist/adapters/pocketbase-catalog.d.ts +16 -0
- package/dist/adapters/pocketbase-catalog.js +56 -0
- package/dist/adapters/pocketbase-client.d.ts +81 -0
- package/dist/adapters/pocketbase-client.js +148 -0
- package/dist/adapters/pocketbase-sink.d.ts +53 -0
- package/dist/adapters/pocketbase-sink.js +181 -0
- package/dist/adapters/project-config.d.ts +42 -0
- package/dist/adapters/project-config.js +108 -0
- package/dist/adapters/report-data.d.ts +12 -0
- package/dist/adapters/report-data.js +8 -0
- package/dist/adapters/report-views.d.ts +45 -0
- package/dist/adapters/report-views.js +73 -0
- package/dist/adapters/report.d.ts +112 -0
- package/dist/adapters/report.js +236 -0
- package/dist/adapters/sync-runner.d.ts +114 -0
- package/dist/adapters/sync-runner.js +273 -0
- package/dist/adapters/sync-state-store.d.ts +62 -0
- package/dist/adapters/sync-state-store.js +188 -0
- package/dist/config.d.ts +168 -0
- package/dist/config.js +392 -0
- package/dist/domain/ancestry-match.d.ts +49 -0
- package/dist/domain/ancestry-match.js +82 -0
- package/dist/domain/client-label.d.ts +28 -0
- package/dist/domain/client-label.js +44 -0
- package/dist/domain/day.d.ts +2 -0
- package/dist/domain/day.js +8 -0
- package/dist/domain/export.d.ts +38 -0
- package/dist/domain/export.js +68 -0
- package/dist/domain/hub-entry.d.ts +234 -0
- package/dist/domain/hub-entry.js +265 -0
- package/dist/domain/index.d.ts +19 -0
- package/dist/domain/index.js +19 -0
- package/dist/domain/intervals.d.ts +17 -0
- package/dist/domain/intervals.js +43 -0
- package/dist/domain/registry-health.d.ts +49 -0
- package/dist/domain/registry-health.js +58 -0
- package/dist/domain/segment-rule.d.ts +10 -0
- package/dist/domain/segment-rule.js +1 -0
- package/dist/domain/subagent-profile.d.ts +278 -0
- package/dist/domain/subagent-profile.js +418 -0
- package/dist/domain/sync-plan.d.ts +151 -0
- package/dist/domain/sync-plan.js +196 -0
- package/dist/domain/task-view.d.ts +117 -0
- package/dist/domain/task-view.js +428 -0
- package/dist/domain/work-record.d.ts +236 -0
- package/dist/domain/work-record.js +91 -0
- package/dist/domain/work-target.d.ts +101 -0
- package/dist/domain/work-target.js +149 -0
- package/dist/domain/work-tracker.d.ts +90 -0
- package/dist/domain/work-tracker.js +405 -0
- package/dist/hub/index.d.ts +25 -0
- package/dist/hub/index.js +25 -0
- package/dist/ports/catalog.d.ts +31 -0
- package/dist/ports/catalog.js +1 -0
- package/dist/ports/clock.d.ts +3 -0
- package/dist/ports/clock.js +1 -0
- package/dist/ports/index.d.ts +11 -0
- package/dist/ports/index.js +1 -0
- package/dist/ports/inflight-store.d.ts +15 -0
- package/dist/ports/inflight-store.js +1 -0
- package/dist/ports/process-registry.d.ts +72 -0
- package/dist/ports/process-registry.js +1 -0
- package/dist/ports/work-log.d.ts +14 -0
- package/dist/ports/work-log.js +1 -0
- package/dist/ports/work-sink.d.ts +39 -0
- package/dist/ports/work-sink.js +1 -0
- package/package.json +66 -0
- package/src/adapters/agent-info.ts +86 -0
- package/src/adapters/ancestry.ts +260 -0
- package/src/adapters/cached-catalog.ts +147 -0
- package/src/adapters/export-writer.ts +33 -0
- package/src/adapters/file-inflight-store.ts +115 -0
- package/src/adapters/file-modes.ts +35 -0
- package/src/adapters/hub-actions.ts +82 -0
- package/src/adapters/hub-credentials.ts +95 -0
- package/src/adapters/jsonl-work-log.ts +67 -0
- package/src/adapters/kankaku-command.ts +717 -0
- package/src/adapters/kankaku-dir.ts +102 -0
- package/src/adapters/lazy-file-inflight-store.ts +43 -0
- package/src/adapters/lazy-jsonl-work-log.ts +39 -0
- package/src/adapters/machine-process-registry.ts +256 -0
- package/src/adapters/panel/kankaku-panel.ts +419 -0
- package/src/adapters/panel/panel-items.ts +87 -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 +89 -0
- package/src/adapters/panel/screens/export.ts +123 -0
- package/src/adapters/panel/screens/report.ts +143 -0
- package/src/adapters/panel/screens/sync.ts +136 -0
- package/src/adapters/panel/screens/target.ts +384 -0
- package/src/adapters/pi-tracker.ts +753 -0
- package/src/adapters/pocketbase-catalog.ts +89 -0
- package/src/adapters/pocketbase-client.ts +197 -0
- package/src/adapters/pocketbase-sink.ts +236 -0
- package/src/adapters/process-identity-memo.ts +102 -0
- package/src/adapters/process-identity.ts +162 -0
- package/src/adapters/project-config.ts +116 -0
- package/src/adapters/report-data.ts +13 -0
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/report.ts +335 -0
- package/src/adapters/session-client.ts +116 -0
- package/src/adapters/session-dir.ts +28 -0
- package/src/adapters/session-target.ts +431 -0
- package/src/adapters/status-bar.ts +86 -0
- package/src/adapters/subagent-startup.ts +66 -0
- package/src/adapters/sync-runner.ts +340 -0
- package/src/adapters/sync-state-store.ts +227 -0
- package/src/adapters/target-picker.ts +127 -0
- package/src/config.ts +536 -0
- package/src/domain/ancestry-match.ts +84 -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/hub-entry.ts +433 -0
- package/src/domain/index.ts +19 -0
- package/src/domain/intervals.ts +53 -0
- package/src/domain/panel-model.ts +270 -0
- package/src/domain/registry-health.ts +87 -0
- package/src/domain/segment-rule.ts +10 -0
- package/src/domain/subagent-profile.ts +495 -0
- package/src/domain/sync-plan.ts +266 -0
- package/src/domain/task-view.ts +526 -0
- package/src/domain/work-record.ts +320 -0
- package/src/domain/work-target.ts +234 -0
- package/src/domain/work-tracker.ts +485 -0
- package/src/extension.ts +346 -0
- package/src/hub/index.ts +25 -0
- package/src/ports/catalog.ts +33 -0
- package/src/ports/clock.ts +3 -0
- package/src/ports/index.ts +11 -0
- package/src/ports/inflight-store.ts +16 -0
- package/src/ports/process-registry.ts +75 -0
- package/src/ports/work-log.ts +15 -0
- package/src/ports/work-sink.ts +35 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { buildTasks, uncertainRecords } from "../domain/task-view.ts";
|
|
2
|
+
import type { SessionView, TaskView } from "../domain/task-view.ts";
|
|
3
|
+
import { localDay } from "../domain/day.ts";
|
|
4
|
+
import { cacheHitRatio, finiteOrZero } from "../domain/work-record.ts";
|
|
5
|
+
import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
|
|
6
|
+
|
|
7
|
+
export { localDay } from "../domain/day.ts";
|
|
8
|
+
|
|
9
|
+
export interface RoleTotals {
|
|
10
|
+
workMs: number;
|
|
11
|
+
waitingMs: number;
|
|
12
|
+
wallMs: number;
|
|
13
|
+
count: number;
|
|
14
|
+
/** Estimated cost in USD, as priced by pi's model table. */
|
|
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;
|
|
22
|
+
/** Per-tag total milliseconds summed across every record of this role. */
|
|
23
|
+
segments: Record<string, number>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TaskTotals {
|
|
27
|
+
count: number;
|
|
28
|
+
wallMs: number;
|
|
29
|
+
workMs: number;
|
|
30
|
+
/** Estimated cost in USD, orchestrator and subagents combined. */
|
|
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;
|
|
38
|
+
/** Per-tag total milliseconds summed across every task (orchestrator and subagents). */
|
|
39
|
+
segments: Record<string, number>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type Summary = Record<WorkRole, RoleTotals> & { tasks: TaskTotals };
|
|
43
|
+
|
|
44
|
+
export interface SummarizeOptions {
|
|
45
|
+
/** Local day in `YYYY-MM-DD` format. Defaults to today when `all` is not set. */
|
|
46
|
+
day?: string;
|
|
47
|
+
/** Include every record regardless of day. */
|
|
48
|
+
all?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ROLES: WorkRole[] = ["orchestrator", "subagent"];
|
|
52
|
+
|
|
53
|
+
function emptyTotals(): RoleTotals {
|
|
54
|
+
return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Add per-tag milliseconds from `segments` (missing on older records) into
|
|
59
|
+
* `into`, a `Map` rather than a plain object so a tag from a hand-edited
|
|
60
|
+
* worklog line named `__proto__` or `constructor` accumulates as a normal
|
|
61
|
+
* entry instead of silently reading (and arithmetically corrupting) an
|
|
62
|
+
* inherited `Object.prototype` value.
|
|
63
|
+
*/
|
|
64
|
+
function addSegments(into: Map<string, number>, segments: Record<string, number> | undefined): void {
|
|
65
|
+
for (const [tag, ms] of Object.entries(segments ?? {})) {
|
|
66
|
+
into.set(tag, (into.get(tag) ?? 0) + ms);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Aggregate work records by role, restricted to one local day unless `all`
|
|
72
|
+
* is set. Also computes a `tasks` segment (union-based `wallMs`/`workMs`
|
|
73
|
+
* over one orchestrator run and its subagents) for tasks whose `startedAt`
|
|
74
|
+
* falls on the same day.
|
|
75
|
+
*/
|
|
76
|
+
export function summarize(records: WorkRecord[], options: SummarizeOptions): Summary {
|
|
77
|
+
const targetDay = options.all ? undefined : (options.day ?? localDay(new Date().toISOString()));
|
|
78
|
+
|
|
79
|
+
const summary: Summary = {
|
|
80
|
+
orchestrator: emptyTotals(),
|
|
81
|
+
subagent: emptyTotals(),
|
|
82
|
+
tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} },
|
|
83
|
+
};
|
|
84
|
+
// Accumulated in Maps (see `addSegments`) and only converted to the
|
|
85
|
+
// returned plain objects at the very end, via `Object.fromEntries`.
|
|
86
|
+
const segmentsByRole: Record<WorkRole, Map<string, number>> = { orchestrator: new Map(), subagent: new Map() };
|
|
87
|
+
const taskSegments = new Map<string, number>();
|
|
88
|
+
|
|
89
|
+
for (const record of records) {
|
|
90
|
+
if (targetDay !== undefined && localDay(record.startedAt) !== targetDay) continue;
|
|
91
|
+
const totals = summary[record.role];
|
|
92
|
+
totals.workMs += record.workMs;
|
|
93
|
+
totals.waitingMs += record.waitingMs;
|
|
94
|
+
totals.wallMs += record.wallMs;
|
|
95
|
+
totals.count += 1;
|
|
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);
|
|
100
|
+
addSegments(segmentsByRole[record.role], record.segments);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const tasks = buildTasks(records).filter((task) => targetDay === undefined || localDay(task.startedAt) === targetDay);
|
|
104
|
+
for (const task of tasks) {
|
|
105
|
+
summary.tasks.count += 1;
|
|
106
|
+
summary.tasks.wallMs += task.wallMs;
|
|
107
|
+
summary.tasks.workMs += task.workMs;
|
|
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);
|
|
112
|
+
addSegments(taskSegments, task.segments);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
summary.orchestrator.segments = Object.fromEntries(segmentsByRole.orchestrator);
|
|
116
|
+
summary.subagent.segments = Object.fromEntries(segmentsByRole.subagent);
|
|
117
|
+
summary.tasks.segments = Object.fromEntries(taskSegments);
|
|
118
|
+
|
|
119
|
+
return summary;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Count of `uncertain` records (ADR 0022) within the same day scope
|
|
124
|
+
* `summarize` uses, so the `/kankaku` report can surface a one-line hint
|
|
125
|
+
* when some records are silently excluded from the task count
|
|
126
|
+
* (SUBAGENT-REQ-017): an undercount must never be silent.
|
|
127
|
+
*/
|
|
128
|
+
export function countUncertain(records: WorkRecord[], options: SummarizeOptions): number {
|
|
129
|
+
const targetDay = options.all ? undefined : (options.day ?? localDay(new Date().toISOString()));
|
|
130
|
+
return uncertainRecords(records).filter((record) => targetDay === undefined || localDay(record.startedAt) === targetDay).length;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatMinutes(ms: number): string {
|
|
134
|
+
const totalSeconds = Math.round(ms / 1000);
|
|
135
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
136
|
+
const seconds = totalSeconds % 60;
|
|
137
|
+
return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Estimated USD cost with two decimals, e.g. `$1.23`. */
|
|
141
|
+
function formatCost(cost: number): string {
|
|
142
|
+
return `$${cost.toFixed(2)}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatTime(iso: string): string {
|
|
146
|
+
const date = new Date(iso);
|
|
147
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
148
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
149
|
+
return `${hours}:${minutes}`;
|
|
150
|
+
}
|
|
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
|
+
|
|
158
|
+
/** Render non-zero segment tags as `tag Xm00s` pairs, sorted alphabetically, joined by `, `. Undefined when none are non-zero. */
|
|
159
|
+
function formatSegmentTags(segments: Record<string, number>): string | undefined {
|
|
160
|
+
const tags = Object.keys(segments)
|
|
161
|
+
.filter((tag) => segments[tag]! > 0)
|
|
162
|
+
.sort();
|
|
163
|
+
if (tags.length === 0) return undefined;
|
|
164
|
+
return tags.map((tag) => `${tag} ${formatMinutes(segments[tag]!)}`).join(", ");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Render a short, human-readable summary for the `/kankaku` command. */
|
|
168
|
+
export function formatReport(summary: Summary): string {
|
|
169
|
+
const lines = ROLES.map((role) => {
|
|
170
|
+
const totals = summary[role];
|
|
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}`;
|
|
174
|
+
});
|
|
175
|
+
const tasksCacheHit = formatCacheHit(summary.tasks);
|
|
176
|
+
const tasksCacheHitPart = tasksCacheHit !== undefined ? `, ${tasksCacheHit}` : "";
|
|
177
|
+
lines.push(
|
|
178
|
+
`tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}${tasksCacheHitPart}`,
|
|
179
|
+
);
|
|
180
|
+
const segmentTags = formatSegmentTags(summary.tasks.segments);
|
|
181
|
+
if (segmentTags !== undefined) {
|
|
182
|
+
lines.push(`segments: ${segmentTags}`);
|
|
183
|
+
}
|
|
184
|
+
return lines.join(" | ");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Maximum visible width of a task prompt before it is truncated with an ellipsis marker. */
|
|
188
|
+
const PROMPT_DISPLAY_LIMIT = 60;
|
|
189
|
+
|
|
190
|
+
/** Truncate `text` to `limit` visible characters, appending `…` (counted within the limit) when it was cut. */
|
|
191
|
+
function truncateWithEllipsis(text: string, limit: number): string {
|
|
192
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Render one line per task: time, client (when present), union-based wall/work, cost, non-zero segment tags, subagent count, and a truncated prompt (`…` marks a cut). */
|
|
196
|
+
export function formatTasks(tasks: TaskView[]): string {
|
|
197
|
+
if (tasks.length === 0) return "no tasks";
|
|
198
|
+
return tasks
|
|
199
|
+
.map((task) => {
|
|
200
|
+
const prompt = truncateWithEllipsis(task.prompt, PROMPT_DISPLAY_LIMIT);
|
|
201
|
+
const segmentTags = formatSegmentTags(task.segments);
|
|
202
|
+
const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
|
|
203
|
+
const clientPart = task.client !== undefined ? ` client:${task.client}` : "";
|
|
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}`;
|
|
207
|
+
})
|
|
208
|
+
.join("\n");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface ClientTotals {
|
|
212
|
+
wallMs: number;
|
|
213
|
+
waitingMs: number;
|
|
214
|
+
workMs: number;
|
|
215
|
+
/** Estimated cost in USD, summed across this client's tasks. */
|
|
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;
|
|
223
|
+
count: number;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Client name under which tasks without a resolved client are grouped. */
|
|
227
|
+
const NO_CLIENT = "(none)";
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Aggregate tasks by billing client (see `domain/client-label.ts`), summing
|
|
231
|
+
* work/waiting/wall time, cost, and task count. Tasks without a `client`
|
|
232
|
+
* are grouped under `"(none)"`. Returned as a `Map` rather than a plain
|
|
233
|
+
* object so an attacker-controlled client name can never repoint a
|
|
234
|
+
* prototype property.
|
|
235
|
+
*/
|
|
236
|
+
export function summarizeByClient(tasks: TaskView[]): Map<string, ClientTotals> {
|
|
237
|
+
const totals = new Map<string, ClientTotals>();
|
|
238
|
+
for (const task of tasks) {
|
|
239
|
+
const key = task.client ?? NO_CLIENT;
|
|
240
|
+
const entry = totals.get(key) ?? { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
|
|
241
|
+
entry.wallMs += task.wallMs;
|
|
242
|
+
entry.waitingMs += task.waitingMs;
|
|
243
|
+
entry.workMs += task.workMs;
|
|
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);
|
|
248
|
+
entry.count += 1;
|
|
249
|
+
totals.set(key, entry);
|
|
250
|
+
}
|
|
251
|
+
return totals;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface ProjectTotals {
|
|
255
|
+
/** Display name: the project's `projectName`, or `"(no project)"` for the ungrouped bucket. */
|
|
256
|
+
name: string;
|
|
257
|
+
wallMs: number;
|
|
258
|
+
waitingMs: number;
|
|
259
|
+
workMs: number;
|
|
260
|
+
/** Estimated cost in USD, summed across this project's tasks. */
|
|
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;
|
|
268
|
+
count: number;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Key (and display name) under which tasks without a resolved project are grouped. */
|
|
272
|
+
const NO_PROJECT = "(no project)";
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Aggregate tasks by hub project (see `domain/work-target.ts`), keyed by
|
|
276
|
+
* `projectId` (so two projects that happen to share a display name are
|
|
277
|
+
* never merged) with the name denormalised alongside for display. Tasks
|
|
278
|
+
* without a `projectId` are grouped under `"(no project)"`.
|
|
279
|
+
*/
|
|
280
|
+
export function summarizeByProject(tasks: TaskView[]): Map<string, ProjectTotals> {
|
|
281
|
+
const totals = new Map<string, ProjectTotals>();
|
|
282
|
+
for (const task of tasks) {
|
|
283
|
+
const key = task.projectId ?? NO_PROJECT;
|
|
284
|
+
const name = task.projectId !== undefined ? (task.projectName ?? task.projectId) : NO_PROJECT;
|
|
285
|
+
const entry = totals.get(key) ?? { name, wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
|
|
286
|
+
entry.wallMs += task.wallMs;
|
|
287
|
+
entry.waitingMs += task.waitingMs;
|
|
288
|
+
entry.workMs += task.workMs;
|
|
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);
|
|
293
|
+
entry.count += 1;
|
|
294
|
+
totals.set(key, entry);
|
|
295
|
+
}
|
|
296
|
+
return totals;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Render one line per project, sorted alphabetically by display name, with work/waiting/wall time, cost, and task count. */
|
|
300
|
+
export function formatProjects(totals: Map<string, ProjectTotals>): string {
|
|
301
|
+
if (totals.size === 0) return "no projects";
|
|
302
|
+
return Array.from(totals.values())
|
|
303
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
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
|
+
})
|
|
309
|
+
.join("\n");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Render one line per client, sorted alphabetically, with work/waiting/wall time, cost, and task count. */
|
|
313
|
+
export function formatClients(totals: Map<string, ClientTotals>): string {
|
|
314
|
+
if (totals.size === 0) return "no clients";
|
|
315
|
+
return Array.from(totals.entries())
|
|
316
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
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
|
+
})
|
|
322
|
+
.join("\n");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Render one line per session: truncated id, time range, union-based wall/work, cost, non-zero segment tags, and task count. */
|
|
326
|
+
export function formatSessions(sessions: SessionView[]): string {
|
|
327
|
+
if (sessions.length === 0) return "no sessions";
|
|
328
|
+
return sessions
|
|
329
|
+
.map((session) => {
|
|
330
|
+
const segmentTags = formatSegmentTags(session.segments);
|
|
331
|
+
const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
|
|
332
|
+
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}`;
|
|
333
|
+
})
|
|
334
|
+
.join("\n");
|
|
335
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { resolveClient, resolveClientSource } from "../domain/client-label.ts";
|
|
3
|
+
import type { ClientSourceName, ClientSources } from "../domain/client-label.ts";
|
|
4
|
+
import type { WorkRole } from "../domain/work-record.ts";
|
|
5
|
+
|
|
6
|
+
/** Persisted as a `kankaku-client` custom session entry so the session-level client survives a reload. */
|
|
7
|
+
export interface KankakuClientEntryData {
|
|
8
|
+
client: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const CLIENT_ENTRY_TYPE = "kankaku-client";
|
|
12
|
+
|
|
13
|
+
export interface SessionClientDeps {
|
|
14
|
+
role: WorkRole;
|
|
15
|
+
/** Default billing client for this project, from `KANKAKU_CLIENT` (config.ts). See `domain/client-label.ts`. */
|
|
16
|
+
envClient?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Lazily reads the project's default billing client from
|
|
19
|
+
* `<kankaku dir>/config.json`. Injected from `extension.ts` so this
|
|
20
|
+
* adapter stays free of filesystem code.
|
|
21
|
+
*/
|
|
22
|
+
resolveProjectClient?: () => string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SessionClient {
|
|
26
|
+
/** Restore the session-level client from the last `kankaku-client` custom entry; call on `session_start`. */
|
|
27
|
+
restore(ctx: ExtensionContext): void;
|
|
28
|
+
/** Set (or clear with `undefined`) the session client and persist it as a durable session entry. */
|
|
29
|
+
set(pi: ExtensionAPI, client: string | undefined): void;
|
|
30
|
+
/** Current client sources (session > env > project), reading the project client fresh unless `project` is given. */
|
|
31
|
+
sources(project?: string | undefined): ClientSources;
|
|
32
|
+
/** The effective client from {@link sources}, regardless of role. */
|
|
33
|
+
effectiveClient(): string | undefined;
|
|
34
|
+
/** Which source produced {@link effectiveClient}. */
|
|
35
|
+
effectiveSource(): ClientSourceName | undefined;
|
|
36
|
+
/** Role-gated client for the in-progress run (`undefined` for a subagent); caches the project client for the run. */
|
|
37
|
+
runClient(): string | undefined;
|
|
38
|
+
/** Role-gated client to show while idle, reusing the run's cached project client when still held. */
|
|
39
|
+
idleClient(): string | undefined;
|
|
40
|
+
/** Drop the per-run cached project client; call when a run settles or the session shuts down. */
|
|
41
|
+
endRun(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Owns the session-level billing client override (`/kankaku client <name>`),
|
|
46
|
+
* its restore/persist round-trip through session entries, and client-source
|
|
47
|
+
* resolution for both the in-progress run and the idle status line. See
|
|
48
|
+
* README "Billing labels" and `domain/client-label.ts#resolveClient`.
|
|
49
|
+
*/
|
|
50
|
+
export function createSessionClient(deps: SessionClientDeps): SessionClient {
|
|
51
|
+
/** Session-level client override, set with `/kankaku client <name>` and restored on `session_start`. Highest precedence. */
|
|
52
|
+
let sessionClient: string | undefined;
|
|
53
|
+
/** Project client read once per run (first record build) so checkpoints do not hit the filesystem repeatedly. */
|
|
54
|
+
let runProjectClient: { value: string | undefined } | undefined;
|
|
55
|
+
|
|
56
|
+
function sources(project: string | undefined = deps.resolveProjectClient?.()): ClientSources {
|
|
57
|
+
return {
|
|
58
|
+
session: sessionClient,
|
|
59
|
+
env: deps.envClient,
|
|
60
|
+
project,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runSources(): ClientSources {
|
|
65
|
+
if (!runProjectClient) {
|
|
66
|
+
runProjectClient = { value: deps.resolveProjectClient?.() };
|
|
67
|
+
}
|
|
68
|
+
return sources(runProjectClient.value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Scan the session's entries for the last `kankaku-client` custom entry
|
|
73
|
+
* and restore the client it recorded (`undefined` when that entry cleared
|
|
74
|
+
* the label, or when no such entry exists yet).
|
|
75
|
+
*/
|
|
76
|
+
function restore(ctx: ExtensionContext): void {
|
|
77
|
+
const entries = ctx.sessionManager.getEntries();
|
|
78
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
79
|
+
const entry = entries[i] as { type: string; customType?: string; data?: unknown };
|
|
80
|
+
if (entry.type === "custom" && entry.customType === CLIENT_ENTRY_TYPE) {
|
|
81
|
+
const data = entry.data as KankakuClientEntryData | undefined;
|
|
82
|
+
sessionClient = data?.client;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
sessionClient = undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function set(pi: ExtensionAPI, client: string | undefined): void {
|
|
90
|
+
sessionClient = client;
|
|
91
|
+
pi.appendEntry<KankakuClientEntryData>(CLIENT_ENTRY_TYPE, { client });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function effectiveClient(): string | undefined {
|
|
95
|
+
return resolveClient(sources());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function effectiveSource(): ClientSourceName | undefined {
|
|
99
|
+
return resolveClientSource(sources());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function runClient(): string | undefined {
|
|
103
|
+
return deps.role === "orchestrator" ? resolveClient(runSources()) : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function idleClient(): string | undefined {
|
|
107
|
+
const idleSources = runProjectClient ? sources(runProjectClient.value) : sources();
|
|
108
|
+
return deps.role === "orchestrator" ? resolveClient(idleSources) : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function endRun(): void {
|
|
112
|
+
runProjectClient = undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { restore, set, sources, effectiveClient, effectiveSource, runClient, idleClient, endRun };
|
|
116
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SessionManager#usesDefaultSessionDir()`/`#getSessionDir()` are real
|
|
3
|
+
* methods on pi's session manager (mirrors what pi's own
|
|
4
|
+
* `formatResumeCommand` does to decide whether to print `--session-dir`),
|
|
5
|
+
* but `usesDefaultSessionDir` is not part of the `ReadonlySessionManager`
|
|
6
|
+
* type `ctx.sessionManager` is typed as — so this is a guarded duck-typed
|
|
7
|
+
* call, not a typed one: an older pi version (or any future shape change)
|
|
8
|
+
* that lacks the method degrades to "no sessionDir," never a crash.
|
|
9
|
+
* Returns the session dir only when it is genuinely non-default, exactly
|
|
10
|
+
* the condition under which pi itself would print `--session-dir`. Shared
|
|
11
|
+
* by `pi-tracker.ts` (record metadata) and `kankaku-command.ts` (the
|
|
12
|
+
* `/kankaku doctor` display) — kept as its own module so neither adapter
|
|
13
|
+
* has to import the other.
|
|
14
|
+
*/
|
|
15
|
+
export function readNonDefaultSessionDir(sessionManager: unknown): string | undefined {
|
|
16
|
+
const candidate = sessionManager as { usesDefaultSessionDir?: () => boolean; getSessionDir?: () => string };
|
|
17
|
+
if (typeof candidate.usesDefaultSessionDir !== "function" || typeof candidate.getSessionDir !== "function") return undefined;
|
|
18
|
+
try {
|
|
19
|
+
if (candidate.usesDefaultSessionDir()) return undefined;
|
|
20
|
+
const dir = candidate.getSessionDir();
|
|
21
|
+
// An unpersisted session (e.g. `--no-session`) can report
|
|
22
|
+
// usesDefaultSessionDir() === false with an empty getSessionDir() — not
|
|
23
|
+
// a real custom directory, so there is nothing meaningful to carry.
|
|
24
|
+
return dir ? dir : undefined;
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|