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.
@@ -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 ?? 0;
87
- this.state.usage.output += usage.output ?? 0;
88
- this.state.usage.cacheRead += usage.cacheRead ?? 0;
89
- this.state.usage.cacheWrite += usage.cacheWrite ?? 0;
90
- this.state.usage.cost += usage.cost ?? 0;
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
- ms: this.clock.now() - openSubagent.start,
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.finalize(this.state.status);
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.finalize("interrupted");
209
+ const record = this.buildRecord("interrupted", this.clock.now());
175
210
  this.state = undefined;
176
211
  return record;
177
212
  }
178
213
 
179
- private finalize(status: WorkStatus): WorkRecordCore {
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("finalize called without an open run");
231
+ throw new Error("buildRecord called without an open run");
183
232
  }
184
- const settledAt = this.clock.now();
185
- const wallMs = settledAt - state.startedAt;
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
- for (const span of state.waitingSpans) {
188
- if (span.end === undefined) {
189
- span.end = settledAt;
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
- const closedSpans = state.waitingSpans.map((span) => ({ start: span.start, end: span.end as number }));
194
- const waitingMs = unionMs(clampIntervals(closedSpans, state.startedAt, settledAt));
195
- const workMs = wallMs - waitingMs;
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: randomUUID(),
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
+ }
@@ -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
  }