kankaku 0.1.0 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -3
- package/package.json +1 -1
- package/src/adapters/export-writer.ts +33 -0
- package/src/adapters/file-inflight-store.ts +115 -0
- package/src/adapters/jsonl-work-log.ts +22 -2
- package/src/adapters/kankaku-command.ts +226 -0
- package/src/adapters/kankaku-dir.ts +10 -0
- package/src/adapters/lazy-file-inflight-store.ts +43 -0
- package/src/adapters/lazy-jsonl-work-log.ts +6 -3
- package/src/adapters/pi-tracker.ts +120 -116
- package/src/adapters/project-config.ts +44 -0
- package/src/adapters/report.ts +98 -19
- package/src/adapters/session-client.ts +116 -0
- package/src/adapters/status-bar.ts +86 -0
- package/src/config.ts +65 -0
- package/src/domain/client-label.ts +56 -0
- package/src/domain/day.ts +8 -0
- package/src/domain/export.ts +107 -0
- package/src/domain/segment-rule.ts +10 -0
- package/src/domain/task-view.ts +43 -7
- package/src/domain/work-record.ts +59 -0
- package/src/domain/work-tracker.ts +85 -21
- package/src/extension.ts +12 -0
- package/src/ports/inflight-store.ts +16 -0
- package/src/ports/work-log.ts +9 -0
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { Clock } from "../ports/clock.ts";
|
|
3
3
|
import { clampIntervals, unionMs } from "./intervals.ts";
|
|
4
|
-
import { emptyUsage, WORK_RECORD_SCHEMA } from "./work-record.ts";
|
|
4
|
+
import { emptyUsage, finiteOrZero, WORK_RECORD_SCHEMA } from "./work-record.ts";
|
|
5
5
|
import type { SubagentSpan, UsageTotals, WorkRecordCore, WorkStatus } from "./work-record.ts";
|
|
6
|
+
import type { SegmentRule } from "./segment-rule.ts";
|
|
6
7
|
|
|
7
8
|
export interface WorkTrackerOptions {
|
|
8
9
|
clock: Clock;
|
|
9
10
|
interactiveTools: string[];
|
|
10
11
|
subagentTool: string;
|
|
12
|
+
/** Rules that tag a tool execution's span under a named segment. Defaults to none. */
|
|
13
|
+
segmentRules?: SegmentRule[];
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
interface Interval {
|
|
@@ -23,6 +26,8 @@ interface OpenSubagentSpan {
|
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
interface RunState {
|
|
29
|
+
/** Generated once when the run opens, so it stays stable across `peek` and the final `onSettled`/`onShutdown`. */
|
|
30
|
+
id: string;
|
|
26
31
|
startedAt: number;
|
|
27
32
|
prompt: string;
|
|
28
33
|
runs: number;
|
|
@@ -35,6 +40,14 @@ interface RunState {
|
|
|
35
40
|
openToolWaits: Map<string, Interval>;
|
|
36
41
|
subagents: SubagentSpan[];
|
|
37
42
|
openSubagents: Map<string, OpenSubagentSpan>;
|
|
43
|
+
/**
|
|
44
|
+
* Segment spans opened by a matching {@link SegmentRule}, keyed by tag.
|
|
45
|
+
* A `Map` rather than a plain object so a tag such as `__proto__` cannot
|
|
46
|
+
* pollute the object prototype while the run is in progress.
|
|
47
|
+
*/
|
|
48
|
+
segmentSpans: Map<string, Interval[]>;
|
|
49
|
+
/** The still-open segment span for a tool call id, if any. */
|
|
50
|
+
openSegments: Map<string, Interval>;
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
interface RunEndMessage {
|
|
@@ -51,17 +64,20 @@ export class WorkTracker {
|
|
|
51
64
|
private readonly clock: Clock;
|
|
52
65
|
private readonly interactiveTools: Set<string>;
|
|
53
66
|
private readonly subagentTool: string;
|
|
67
|
+
private readonly segmentRules: SegmentRule[];
|
|
54
68
|
private state: RunState | undefined;
|
|
55
69
|
|
|
56
70
|
constructor(options: WorkTrackerOptions) {
|
|
57
71
|
this.clock = options.clock;
|
|
58
72
|
this.interactiveTools = new Set(options.interactiveTools);
|
|
59
73
|
this.subagentTool = options.subagentTool;
|
|
74
|
+
this.segmentRules = options.segmentRules ?? [];
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
onRunStart(prompt: string): void {
|
|
63
78
|
if (!this.state) {
|
|
64
79
|
this.state = {
|
|
80
|
+
id: randomUUID(),
|
|
65
81
|
startedAt: this.clock.now(),
|
|
66
82
|
prompt,
|
|
67
83
|
runs: 1,
|
|
@@ -73,6 +89,8 @@ export class WorkTracker {
|
|
|
73
89
|
openToolWaits: new Map(),
|
|
74
90
|
subagents: [],
|
|
75
91
|
openSubagents: new Map(),
|
|
92
|
+
segmentSpans: new Map(),
|
|
93
|
+
openSegments: new Map(),
|
|
76
94
|
};
|
|
77
95
|
return;
|
|
78
96
|
}
|
|
@@ -83,17 +101,26 @@ export class WorkTracker {
|
|
|
83
101
|
if (!this.state) return;
|
|
84
102
|
this.state.turns++;
|
|
85
103
|
if (!usage) return;
|
|
86
|
-
this.state.usage.input += usage.input
|
|
87
|
-
this.state.usage.output += usage.output
|
|
88
|
-
this.state.usage.cacheRead += usage.cacheRead
|
|
89
|
-
this.state.usage.cacheWrite += usage.cacheWrite
|
|
90
|
-
this.state.usage.cost += usage.cost
|
|
104
|
+
this.state.usage.input += finiteOrZero(usage.input);
|
|
105
|
+
this.state.usage.output += finiteOrZero(usage.output);
|
|
106
|
+
this.state.usage.cacheRead += finiteOrZero(usage.cacheRead);
|
|
107
|
+
this.state.usage.cacheWrite += finiteOrZero(usage.cacheWrite);
|
|
108
|
+
this.state.usage.cost += finiteOrZero(usage.cost);
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
onToolStart(toolCallId: string, toolName: string, args: Record<string, unknown> | undefined): void {
|
|
94
112
|
if (!this.state) return;
|
|
95
113
|
this.state.tools[toolName] = (this.state.tools[toolName] ?? 0) + 1;
|
|
96
114
|
|
|
115
|
+
const rule = this.segmentRules.find((candidate) => candidate.tool === toolName && candidate.pattern.test(segmentText(args)));
|
|
116
|
+
if (rule) {
|
|
117
|
+
const span: Interval = { start: this.clock.now(), end: undefined };
|
|
118
|
+
const spans = this.state.segmentSpans.get(rule.tag) ?? [];
|
|
119
|
+
spans.push(span);
|
|
120
|
+
this.state.segmentSpans.set(rule.tag, spans);
|
|
121
|
+
this.state.openSegments.set(toolCallId, span);
|
|
122
|
+
}
|
|
123
|
+
|
|
97
124
|
if (this.interactiveTools.has(toolName)) {
|
|
98
125
|
const span: Interval = { start: this.clock.now(), end: undefined };
|
|
99
126
|
this.state.waitingSpans.push(span);
|
|
@@ -116,6 +143,12 @@ export class WorkTracker {
|
|
|
116
143
|
onToolEnd(toolCallId: string, result: unknown): void {
|
|
117
144
|
if (!this.state) return;
|
|
118
145
|
|
|
146
|
+
const openSegment = this.state.openSegments.get(toolCallId);
|
|
147
|
+
if (openSegment) {
|
|
148
|
+
this.state.openSegments.delete(toolCallId);
|
|
149
|
+
openSegment.end = this.clock.now();
|
|
150
|
+
}
|
|
151
|
+
|
|
119
152
|
const openSubagent = this.state.openSubagents.get(toolCallId);
|
|
120
153
|
if (openSubagent) {
|
|
121
154
|
this.state.openSubagents.delete(toolCallId);
|
|
@@ -125,7 +158,9 @@ export class WorkTracker {
|
|
|
125
158
|
agent: openSubagent.agent,
|
|
126
159
|
mode: openSubagent.mode,
|
|
127
160
|
...(taskId !== undefined ? { taskId } : {}),
|
|
128
|
-
|
|
161
|
+
// Clamped to >= 0: a backward clock jump while the subagent was
|
|
162
|
+
// running must never produce a negative duration.
|
|
163
|
+
ms: Math.max(0, this.clock.now() - openSubagent.start),
|
|
129
164
|
});
|
|
130
165
|
return;
|
|
131
166
|
}
|
|
@@ -164,39 +199,61 @@ export class WorkTracker {
|
|
|
164
199
|
|
|
165
200
|
onSettled(): WorkRecordCore | undefined {
|
|
166
201
|
if (!this.state) return undefined;
|
|
167
|
-
const record = this.
|
|
202
|
+
const record = this.buildRecord(this.state.status, this.clock.now());
|
|
168
203
|
this.state = undefined;
|
|
169
204
|
return record;
|
|
170
205
|
}
|
|
171
206
|
|
|
172
207
|
onShutdown(): WorkRecordCore | undefined {
|
|
173
208
|
if (!this.state) return undefined;
|
|
174
|
-
const record = this.
|
|
209
|
+
const record = this.buildRecord("interrupted", this.clock.now());
|
|
175
210
|
this.state = undefined;
|
|
176
211
|
return record;
|
|
177
212
|
}
|
|
178
213
|
|
|
179
|
-
|
|
214
|
+
/**
|
|
215
|
+
* Returns what {@link onSettled}/{@link onShutdown} would produce right
|
|
216
|
+
* now, without mutating any state: open spans are truncated only in the
|
|
217
|
+
* returned snapshot, so the tracker keeps running unaffected and a later
|
|
218
|
+
* `peek` or the eventual settle still sees the spans' true open-ended
|
|
219
|
+
* state. `undefined` when idle. The returned `id` matches the id the
|
|
220
|
+
* eventual settled record will carry, since both are generated once
|
|
221
|
+
* in {@link onRunStart}.
|
|
222
|
+
*/
|
|
223
|
+
peek(status: WorkStatus): WorkRecordCore | undefined {
|
|
224
|
+
if (!this.state) return undefined;
|
|
225
|
+
return this.buildRecord(status, this.clock.now());
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private buildRecord(status: WorkStatus, settledAt: number): WorkRecordCore {
|
|
180
229
|
const state = this.state;
|
|
181
230
|
if (!state) {
|
|
182
|
-
throw new Error("
|
|
231
|
+
throw new Error("buildRecord called without an open run");
|
|
183
232
|
}
|
|
184
|
-
|
|
185
|
-
|
|
233
|
+
// Clamped to >= 0: a backward clock jump (system clock adjustment, NTP
|
|
234
|
+
// correction) must never produce a negative duration.
|
|
235
|
+
const wallMs = Math.max(0, settledAt - state.startedAt);
|
|
236
|
+
|
|
237
|
+
const closedSpans = state.waitingSpans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
|
|
238
|
+
const waitingMs = Math.max(0, unionMs(clampIntervals(closedSpans, state.startedAt, settledAt)));
|
|
239
|
+
const workMs = Math.max(0, wallMs - waitingMs);
|
|
186
240
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
241
|
+
const segmentEntries: Array<[string, number]> = [];
|
|
242
|
+
for (const [tag, spans] of state.segmentSpans) {
|
|
243
|
+
const closedTagSpans = spans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
|
|
244
|
+
const tagMs = Math.max(0, unionMs(clampIntervals(closedTagSpans, state.startedAt, settledAt)));
|
|
245
|
+
if (tagMs > 0) {
|
|
246
|
+
segmentEntries.push([tag, tagMs]);
|
|
190
247
|
}
|
|
191
248
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
249
|
+
// Built via Object.fromEntries (never `segments[tag] = ...`) so a tag
|
|
250
|
+
// such as `__proto__` becomes an own data property instead of silently
|
|
251
|
+
// repointing the object's prototype.
|
|
252
|
+
const segments = Object.fromEntries(segmentEntries);
|
|
196
253
|
|
|
197
254
|
return {
|
|
198
255
|
schema: WORK_RECORD_SCHEMA,
|
|
199
|
-
id:
|
|
256
|
+
id: state.id,
|
|
200
257
|
prompt: state.prompt,
|
|
201
258
|
startedAt: new Date(state.startedAt).toISOString(),
|
|
202
259
|
settledAt: new Date(settledAt).toISOString(),
|
|
@@ -207,12 +264,19 @@ export class WorkTracker {
|
|
|
207
264
|
turns: state.turns,
|
|
208
265
|
tools: state.tools,
|
|
209
266
|
subagents: state.subagents,
|
|
267
|
+
segments,
|
|
210
268
|
usage: state.usage,
|
|
211
269
|
status,
|
|
212
270
|
};
|
|
213
271
|
}
|
|
214
272
|
}
|
|
215
273
|
|
|
274
|
+
/** Text to match a {@link SegmentRule} pattern against: the `command` string arg when present, else the whole args object as JSON. */
|
|
275
|
+
function segmentText(args: Record<string, unknown> | undefined): string {
|
|
276
|
+
const command = args?.["command"];
|
|
277
|
+
return typeof command === "string" ? command : JSON.stringify(args ?? {});
|
|
278
|
+
}
|
|
279
|
+
|
|
216
280
|
function extractTaskId(result: unknown): string | undefined {
|
|
217
281
|
if (!result || typeof result !== "object") return undefined;
|
|
218
282
|
const details = (result as { details?: unknown }).details;
|
package/src/extension.ts
CHANGED
|
@@ -2,7 +2,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { detectRole, loadConfig } from "./config.ts";
|
|
3
3
|
import { WorkTracker } from "./domain/work-tracker.ts";
|
|
4
4
|
import { LazyJsonlWorkLog } from "./adapters/lazy-jsonl-work-log.ts";
|
|
5
|
+
import { LazyFileInflightStore } from "./adapters/lazy-file-inflight-store.ts";
|
|
5
6
|
import { createPiTracker } from "./adapters/pi-tracker.ts";
|
|
7
|
+
import { LazyProjectClientSource } from "./adapters/project-config.ts";
|
|
8
|
+
import { LazyExportWriter } from "./adapters/export-writer.ts";
|
|
6
9
|
|
|
7
10
|
export default function kankaku(pi: ExtensionAPI): void {
|
|
8
11
|
const config = loadConfig();
|
|
@@ -12,15 +15,24 @@ export default function kankaku(pi: ExtensionAPI): void {
|
|
|
12
15
|
clock: { now: () => Date.now() },
|
|
13
16
|
interactiveTools: config.interactiveTools,
|
|
14
17
|
subagentTool: config.subagentTool,
|
|
18
|
+
segmentRules: config.segmentRules,
|
|
15
19
|
});
|
|
16
20
|
|
|
17
21
|
const log = new LazyJsonlWorkLog(config.dir);
|
|
22
|
+
const inflight = new LazyFileInflightStore(config.dir, process.pid);
|
|
23
|
+
|
|
24
|
+
const projectClient = new LazyProjectClientSource(config.dir);
|
|
25
|
+
const exportWriter = new LazyExportWriter(config.dir);
|
|
18
26
|
|
|
19
27
|
createPiTracker(pi, {
|
|
20
28
|
tracker,
|
|
21
29
|
log,
|
|
30
|
+
inflight,
|
|
22
31
|
role,
|
|
23
32
|
pid: process.pid,
|
|
24
33
|
parentPid: process.ppid,
|
|
34
|
+
...(config.client !== undefined ? { envClient: config.client } : {}),
|
|
35
|
+
resolveProjectClient: () => projectClient.read(),
|
|
36
|
+
writeExportFile: (name, content) => exportWriter.write(name, content),
|
|
25
37
|
});
|
|
26
38
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { WorkRecord } from "../domain/work-record.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Crash-recovery checkpoint store: one process periodically saves the
|
|
5
|
+
* record its {@link WorkTracker} would produce right now, so a hard crash
|
|
6
|
+
* (kill -9, power loss) still leaves a record behind — recovered as
|
|
7
|
+
* `interrupted` on the next pi start rather than lost entirely.
|
|
8
|
+
*/
|
|
9
|
+
export interface InflightStore {
|
|
10
|
+
/** Upsert this process's checkpoint. */
|
|
11
|
+
save(record: WorkRecord): void;
|
|
12
|
+
/** Remove this process's checkpoint (normal settle/shutdown). */
|
|
13
|
+
clear(): void;
|
|
14
|
+
/** Return and delete every checkpoint whose owning pid is no longer alive. */
|
|
15
|
+
recoverStale(isAlive: (pid: number) => boolean): WorkRecord[];
|
|
16
|
+
}
|
package/src/ports/work-log.ts
CHANGED
|
@@ -3,4 +3,13 @@ import type { WorkRecord } from "../domain/work-record.ts";
|
|
|
3
3
|
export interface WorkLog {
|
|
4
4
|
append(record: WorkRecord): void;
|
|
5
5
|
readAll(): WorkRecord[];
|
|
6
|
+
/**
|
|
7
|
+
* Optional cheap change signal: a value that changes whenever `append()`
|
|
8
|
+
* would change what `readAll()` returns, computable without reading the
|
|
9
|
+
* whole log (e.g. from file stat metadata). Callers that cache a view
|
|
10
|
+
* derived from `readAll()` (see `pi-tracker.ts`'s client-name completion
|
|
11
|
+
* cache) may use this to invalidate cheaply; a `WorkLog` that omits it
|
|
12
|
+
* simply leaves such callers relying on their own explicit invalidation.
|
|
13
|
+
*/
|
|
14
|
+
version?(): string | number;
|
|
6
15
|
}
|