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
package/README.md
CHANGED
|
@@ -55,6 +55,8 @@ Each line in `worklog.jsonl` is one JSON object:
|
|
|
55
55
|
"sessionFile": "…",
|
|
56
56
|
"mode": "tui",
|
|
57
57
|
"model": "anthropic/claude-opus",
|
|
58
|
+
"client": "acme",
|
|
59
|
+
"sessionName": "billing sprint",
|
|
58
60
|
"prompt": "first 200 chars of the first prompt",
|
|
59
61
|
"startedAt": "2026-09-10T16:00:00.000Z",
|
|
60
62
|
"settledAt": "2026-09-10T16:04:10.000Z",
|
|
@@ -65,6 +67,7 @@ Each line in `worklog.jsonl` is one JSON object:
|
|
|
65
67
|
"turns": 9,
|
|
66
68
|
"tools": { "bash": 4, "read": 3, "subagent_run": 1, "ask_user_question": 1 },
|
|
67
69
|
"subagents": [{ "toolCallId": "…", "agent": "sdd-explore", "mode": "task", "taskId": "t1", "ms": 90000 }],
|
|
70
|
+
"segments": { "review": 62000 },
|
|
68
71
|
"usage": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "cost": 0 },
|
|
69
72
|
"status": "completed"
|
|
70
73
|
}
|
|
@@ -122,22 +125,145 @@ Arguments are whitespace-separated and order-insensitive:
|
|
|
122
125
|
session are shown instead.
|
|
123
126
|
- `/kankaku sessions` — one line per session (id, time range, union
|
|
124
127
|
wall/work, cost, task count) for today. Add `all` for every day.
|
|
128
|
+
- `/kankaku client <name>` — set the billing client for the current pi
|
|
129
|
+
session. `/kankaku client` alone shows the effective client and which
|
|
130
|
+
source it came from; `/kankaku client --clear` removes the session-level
|
|
131
|
+
override. See "Billing labels" below.
|
|
132
|
+
- `/kankaku clients` — one line per client (work/waiting/wall time, cost,
|
|
133
|
+
task count) for today. Add `all` for every day. Tasks with no resolved
|
|
134
|
+
client are grouped under `(none)`.
|
|
125
135
|
|
|
126
136
|
Cost figures are the sum of `usage.cost` as priced by pi's model table
|
|
127
137
|
(per-million-token rates in `models.json`, adjustable with `modelOverrides`).
|
|
128
138
|
For subscription-based providers this is an estimate at API list prices, not
|
|
129
139
|
an invoice.
|
|
130
140
|
|
|
131
|
-
While an agent is running, pi's status bar shows a
|
|
141
|
+
While an agent is running, pi's status bar shows a `🕒 mm:ss · <client>` indicator (the client part appears only when one resolves); while idle it shows `💼 <client>`, or nothing when no client resolves. The entry is keyed `zz-kankaku` so it sorts last among extension statuses. The running indicator carries
|
|
132
142
|
the elapsed time for the current run.
|
|
133
143
|
|
|
144
|
+
## Billing labels
|
|
145
|
+
|
|
146
|
+
Every `WorkRecord` can carry a `client` — who the work is billed to — so
|
|
147
|
+
reports and exports can be grouped by client. The effective client is
|
|
148
|
+
resolved from three sources, in decreasing precedence:
|
|
149
|
+
|
|
150
|
+
1. **Session** — set with `/kankaku client <name>` (see above), persisted as
|
|
151
|
+
a `kankaku-client` custom session entry and restored on session reload.
|
|
152
|
+
2. **`KANKAKU_CLIENT`** — the environment variable, a per-process default.
|
|
153
|
+
3. **Project** — `client` in `<KANKAKU_DIR>/config.json` (e.g.
|
|
154
|
+
`{"client": "acme"}`), the project's own default.
|
|
155
|
+
|
|
156
|
+
A client name must match `/^[A-Za-z0-9._-]{1,64}$/`; anything else (empty,
|
|
157
|
+
too long, containing spaces or other characters) is ignored and resolution
|
|
158
|
+
falls through to the next source.
|
|
159
|
+
|
|
160
|
+
A `subagent_run` child process does not resolve its own client — a
|
|
161
|
+
subagent's own `WorkRecord` never carries `client`. Instead, the **task**
|
|
162
|
+
view (see "Task and session views") exposes the client from its
|
|
163
|
+
orchestrator record only, so `/kankaku tasks`, `/kankaku clients`, and the
|
|
164
|
+
export all see subagent work grouped under the task's (i.e. the
|
|
165
|
+
orchestrator's) client.
|
|
166
|
+
|
|
167
|
+
`sessionName` is also attached to every record from `pi.getSessionName()`,
|
|
168
|
+
so reports can show which named session produced a task.
|
|
169
|
+
|
|
170
|
+
## Tagged segments
|
|
171
|
+
|
|
172
|
+
While a run is open, kankaku can also time tool executions that match a
|
|
173
|
+
configured rule and tag the resulting span with a name — for example,
|
|
174
|
+
knowing how much of a task went to gentle-ai's review-with-receipts step,
|
|
175
|
+
which runs as `gentle-ai review ...` commands through the `bash` tool inside
|
|
176
|
+
the prompt's run.
|
|
177
|
+
|
|
178
|
+
The default rule tags `review`: tool `bash` running a command matching
|
|
179
|
+
`/\bgentle-ai review\b/`. Configure rules with `KANKAKU_SEGMENTS`, a
|
|
180
|
+
`;`-separated list of `tag=tool:regex` entries, e.g.:
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
KANKAKU_SEGMENTS="review=bash:gentle-ai review;commit=bash:git commit"
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Setting `KANKAKU_SEGMENTS` replaces the default rule entirely; malformed
|
|
187
|
+
entries (missing tag, tool or regex, or an invalid regex) are skipped.
|
|
188
|
+
When several rules could match the same tool call, only the first one
|
|
189
|
+
applies. A `WorkRecord`'s `segments` field is the **union** of milliseconds
|
|
190
|
+
per tag within that one record, so overlapping matching calls are not
|
|
191
|
+
double-counted. `TaskView.segments` and `SessionView.segments` are instead
|
|
192
|
+
the **sum** of `segments` across the orchestrator and its children (or
|
|
193
|
+
across a session's tasks): segment spans are not persisted to
|
|
194
|
+
`worklog.jsonl`, so once a record settles there is nothing left to union
|
|
195
|
+
across records, only per-record totals to add up.
|
|
196
|
+
|
|
197
|
+
Note that the reviewer's own token cost is not observable here: gentle-pi
|
|
198
|
+
runs it with `--no-extensions`, so kankaku never sees the reviewer's own
|
|
199
|
+
prompt/tool events, only the `bash` call the orchestrator makes to invoke
|
|
200
|
+
it.
|
|
201
|
+
|
|
202
|
+
## Crash recovery
|
|
203
|
+
|
|
204
|
+
While a run is open, each pi process writes a checkpoint of its current
|
|
205
|
+
record to `<KANKAKU_DIR>/inflight/<pid>.json` — first as soon as the run
|
|
206
|
+
starts (`before_agent_start`), so even a crash on the very first turn still
|
|
207
|
+
leaves a checkpoint, and then again after every `turn_end` and
|
|
208
|
+
`tool_execution_end` — and removes it on a normal
|
|
209
|
+
`agent_settled`/`session_shutdown`. If the process is killed outright
|
|
210
|
+
(`kill -9`, power loss) before it can settle, the checkpoint file survives
|
|
211
|
+
it. On the next pi start, `session_start` scans `inflight/` for checkpoints
|
|
212
|
+
whose owning pid is no longer alive, appends each one to `worklog.jsonl` as
|
|
213
|
+
`interrupted`, deletes the checkpoint file, and shows a
|
|
214
|
+
`kankaku: recovered N interrupted record(s)` notice. `settledAt` on a
|
|
215
|
+
recovered record is the time of its last checkpoint, not the actual crash
|
|
216
|
+
time, so `wallMs`/`workMs` are a **lower bound** on the real duration.
|
|
217
|
+
|
|
218
|
+
The same scan also sweeps `inflight/` for orphaned `.tmp` files: `save`
|
|
219
|
+
writes to a temp file before renaming it into place, and a process killed
|
|
220
|
+
between those two steps leaves the temp file behind. A stray `.tmp` file is
|
|
221
|
+
deleted once its writer pid is no longer alive (or its name cannot be
|
|
222
|
+
parsed); one still owned by a live writer — including this very process's
|
|
223
|
+
own in-progress write — is left alone.
|
|
224
|
+
|
|
225
|
+
## Export
|
|
226
|
+
|
|
227
|
+
`/kankaku export [csv|json] [all]` writes one flat row per task (today's
|
|
228
|
+
tasks by default, or every task with `all`) to
|
|
229
|
+
`<KANKAKU_DIR>/export/tasks-<YYYY-MM-DD or all>.<csv|json>`, and confirms
|
|
230
|
+
with the file's path and row count via the durable report card. Format
|
|
231
|
+
defaults to `csv`; each subagent's own time is folded into its task's row
|
|
232
|
+
rather than exported separately (see "Task and session views").
|
|
233
|
+
|
|
234
|
+
Columns (in this order for CSV; the same fields for JSON):
|
|
235
|
+
|
|
236
|
+
| Column | Meaning |
|
|
237
|
+
| --- | --- |
|
|
238
|
+
| `id` | Task id (the orchestrator record's `id`). |
|
|
239
|
+
| `day` | Local calendar day (`YYYY-MM-DD`) the task started on. |
|
|
240
|
+
| `startedAt` / `endedAt` | ISO timestamps of the task's span. |
|
|
241
|
+
| `client` | Billing client, or empty when unresolved. |
|
|
242
|
+
| `sessionName` | pi session display name, or empty. |
|
|
243
|
+
| `sessionId` | pi session id, or empty. |
|
|
244
|
+
| `project` | Project cwd. |
|
|
245
|
+
| `status` | `completed`, `aborted`, or `interrupted`. |
|
|
246
|
+
| `prompt` | First 200 chars of the prompt, newlines collapsed to spaces. |
|
|
247
|
+
| `wallMs` / `waitingMs` / `workMs` | Union-based task timings (see "Task and session views"). |
|
|
248
|
+
| `cost` | Estimated USD cost, orchestrator plus subagents. |
|
|
249
|
+
| `tokensIn` / `tokensOut` / `cacheRead` | Token usage totals. |
|
|
250
|
+
| `subagentCount` | Number of subagent records matched to the task. |
|
|
251
|
+
| `segments` | JSON-encoded per-tag segment totals (see "Tagged segments"). |
|
|
252
|
+
| `model` | The orchestrator record's model, or empty. |
|
|
253
|
+
|
|
134
254
|
## Environment variables
|
|
135
255
|
|
|
136
|
-
- `KANKAKU_DIR`: directory for the work log
|
|
137
|
-
|
|
256
|
+
- `KANKAKU_DIR`: directory for the work log (`worklog.jsonl`) and the
|
|
257
|
+
crash-recovery checkpoints (`inflight/`, see above), relative to the
|
|
258
|
+
project cwd unless given as an absolute path. Defaults to `.kankaku`.
|
|
138
259
|
- `KANKAKU_INTERACTIVE_TOOLS`: comma-separated list of tool names whose
|
|
139
260
|
execution span counts as waiting time. Defaults to
|
|
140
261
|
`ask_user_question,ask_user_choice`.
|
|
262
|
+
- `KANKAKU_SEGMENTS`: `;`-separated `tag=tool:regex` rules for tagged
|
|
263
|
+
segments (see above). Defaults to the single `review` rule.
|
|
264
|
+
- `KANKAKU_CLIENT`: default billing client for this project (see "Billing
|
|
265
|
+
labels" above). Lower precedence than the session-level
|
|
266
|
+
`/kankaku client` override, higher than `<KANKAKU_DIR>/config.json`.
|
|
141
267
|
|
|
142
268
|
## Limitations
|
|
143
269
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { resolveKankakuDir } from "./kankaku-dir.ts";
|
|
4
|
+
|
|
5
|
+
const EXPORT_SUBDIR = "export";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Write `content` to `<dir>/export/<name>` (creating the `export`
|
|
9
|
+
* subdirectory as needed, overwriting any existing file with the same
|
|
10
|
+
* name) and return the absolute path it was written to.
|
|
11
|
+
*/
|
|
12
|
+
export function writeExport(dir: string, name: string, content: string): string {
|
|
13
|
+
const exportDir = join(dir, EXPORT_SUBDIR);
|
|
14
|
+
mkdirSync(exportDir, { recursive: true });
|
|
15
|
+
const filePath = resolve(join(exportDir, name));
|
|
16
|
+
writeFileSync(filePath, content);
|
|
17
|
+
return filePath;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Writes exports under a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
|
|
21
|
+
export class LazyExportWriter {
|
|
22
|
+
private readonly dirOrRelative: string;
|
|
23
|
+
private readonly fallbackCwd: () => string;
|
|
24
|
+
|
|
25
|
+
constructor(dirOrRelative: string, fallbackCwd: () => string = () => process.cwd()) {
|
|
26
|
+
this.dirOrRelative = dirOrRelative;
|
|
27
|
+
this.fallbackCwd = fallbackCwd;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
write(name: string, content: string): string {
|
|
31
|
+
return writeExport(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()), name, content);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isWorkRecord } from "../domain/work-record.ts";
|
|
4
|
+
import type { WorkRecord } from "../domain/work-record.ts";
|
|
5
|
+
import type { InflightStore } from "../ports/inflight-store.ts";
|
|
6
|
+
|
|
7
|
+
const INFLIGHT_DIR_NAME = "inflight";
|
|
8
|
+
const JSON_EXT = ".json";
|
|
9
|
+
const TMP_EXT = ".tmp";
|
|
10
|
+
/** Matches `save`'s tmp filename: `<ownerPid>.json.<writerPid>.<timestamp>.tmp`. */
|
|
11
|
+
const TMP_NAME_PATTERN = /\.json\.(\d+)\.\d+\.tmp$/;
|
|
12
|
+
|
|
13
|
+
function safeUnlink(filePath: string): void {
|
|
14
|
+
try {
|
|
15
|
+
unlinkSync(filePath);
|
|
16
|
+
} catch {
|
|
17
|
+
// Best effort: another process may have already removed it.
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The writer pid embedded in a `save` tmp filename, or `undefined` when it cannot be parsed. */
|
|
22
|
+
function parseTmpWriterPid(entry: string): number | undefined {
|
|
23
|
+
const match = TMP_NAME_PATTERN.exec(entry);
|
|
24
|
+
if (!match) return undefined;
|
|
25
|
+
const pid = Number(match[1]);
|
|
26
|
+
return Number.isFinite(pid) ? pid : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* {@link InflightStore} backed by one checkpoint file per process,
|
|
31
|
+
* `<dir>/inflight/<pid>.json`. `save` writes to a temp file then renames
|
|
32
|
+
* it into place so a reader never observes a partially written checkpoint.
|
|
33
|
+
*/
|
|
34
|
+
export class FileInflightStore implements InflightStore {
|
|
35
|
+
private readonly dir: string;
|
|
36
|
+
private readonly pid: number;
|
|
37
|
+
|
|
38
|
+
constructor(dir: string, pid: number) {
|
|
39
|
+
this.dir = dir;
|
|
40
|
+
this.pid = pid;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private get inflightDir(): string {
|
|
44
|
+
return join(this.dir, INFLIGHT_DIR_NAME);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private filePathFor(pid: number): string {
|
|
48
|
+
return join(this.inflightDir, `${pid}${JSON_EXT}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
save(record: WorkRecord): void {
|
|
52
|
+
mkdirSync(this.inflightDir, { recursive: true });
|
|
53
|
+
const target = this.filePathFor(this.pid);
|
|
54
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
55
|
+
writeFileSync(tmp, JSON.stringify(record));
|
|
56
|
+
renameSync(tmp, target);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
clear(): void {
|
|
60
|
+
safeUnlink(this.filePathFor(this.pid));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
recoverStale(isAlive: (pid: number) => boolean): WorkRecord[] {
|
|
64
|
+
if (!existsSync(this.inflightDir)) return [];
|
|
65
|
+
|
|
66
|
+
const recovered: WorkRecord[] = [];
|
|
67
|
+
const ownFileName = `${this.pid}${JSON_EXT}`;
|
|
68
|
+
|
|
69
|
+
for (const entry of readdirSync(this.inflightDir)) {
|
|
70
|
+
if (entry.endsWith(TMP_EXT)) {
|
|
71
|
+
this.sweepTmpEntry(entry, isAlive);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!entry.endsWith(JSON_EXT) || entry === ownFileName) continue;
|
|
76
|
+
|
|
77
|
+
const filePath = join(this.inflightDir, entry);
|
|
78
|
+
let parsed: unknown;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
81
|
+
} catch {
|
|
82
|
+
safeUnlink(filePath);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!isWorkRecord(parsed)) {
|
|
87
|
+
safeUnlink(filePath);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isAlive(parsed.pid)) continue;
|
|
92
|
+
|
|
93
|
+
recovered.push({ ...parsed, status: "interrupted" });
|
|
94
|
+
safeUnlink(filePath);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return recovered;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Delete a stray `save()` tmp file left behind by a writer that crashed
|
|
102
|
+
* between the write and the rename. Deleted when the writer pid is not
|
|
103
|
+
* alive, or when the filename cannot be parsed at all (nothing to check
|
|
104
|
+
* liveness against). A tmp file written by this very process is always
|
|
105
|
+
* left alone regardless of what `isAlive` reports, since a concurrent
|
|
106
|
+
* `save()` in this process may still be renaming it into place.
|
|
107
|
+
*/
|
|
108
|
+
private sweepTmpEntry(entry: string, isAlive: (pid: number) => boolean): void {
|
|
109
|
+
const writerPid = parseTmpWriterPid(entry);
|
|
110
|
+
if (writerPid === process.pid) return;
|
|
111
|
+
if (writerPid === undefined || !isAlive(writerPid)) {
|
|
112
|
+
safeUnlink(join(this.inflightDir, entry));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { WorkLog } from "../ports/work-log.ts";
|
|
4
4
|
import type { WorkRecord } from "../domain/work-record.ts";
|
|
5
|
+
import { isWorkRecord } from "../domain/work-record.ts";
|
|
5
6
|
|
|
6
7
|
const LOG_FILE_NAME = "worklog.jsonl";
|
|
7
8
|
|
|
@@ -26,6 +27,20 @@ export class JsonlWorkLog implements WorkLog {
|
|
|
26
27
|
appendFileSync(this.filePath, `${JSON.stringify(record)}\n`);
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Cheap change signal: `mtimeMs:size` of the log file, computed with a
|
|
32
|
+
* single `statSync` rather than reading the file. `"0:0"` when the file
|
|
33
|
+
* does not exist yet (before the first `append`).
|
|
34
|
+
*/
|
|
35
|
+
version(): string {
|
|
36
|
+
try {
|
|
37
|
+
const stats = statSync(this.filePath);
|
|
38
|
+
return `${stats.mtimeMs}:${stats.size}`;
|
|
39
|
+
} catch {
|
|
40
|
+
return "0:0";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
29
44
|
readAll(): WorkRecord[] {
|
|
30
45
|
if (!existsSync(this.filePath)) return [];
|
|
31
46
|
const content = readFileSync(this.filePath, "utf8");
|
|
@@ -34,7 +49,12 @@ export class JsonlWorkLog implements WorkLog {
|
|
|
34
49
|
const trimmed = line.trim();
|
|
35
50
|
if (!trimmed) continue;
|
|
36
51
|
try {
|
|
37
|
-
|
|
52
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
53
|
+
if (isWorkRecord(parsed)) {
|
|
54
|
+
records.push(parsed);
|
|
55
|
+
}
|
|
56
|
+
// Tolerate a structurally invalid record (e.g. an incompatible
|
|
57
|
+
// schema or a torn write that still parses as JSON); skip it.
|
|
38
58
|
} catch {
|
|
39
59
|
// Tolerate malformed lines (e.g. a torn write); skip them.
|
|
40
60
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { isValidClient } from "../domain/client-label.ts";
|
|
5
|
+
import { exportRows, toCsv, toJson } from "../domain/export.ts";
|
|
6
|
+
import { buildSessions, buildTasks } from "../domain/task-view.ts";
|
|
7
|
+
import type { WorkLog } from "../ports/work-log.ts";
|
|
8
|
+
import { formatClients, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient } from "./report.ts";
|
|
9
|
+
import type { SessionClient } from "./session-client.ts";
|
|
10
|
+
|
|
11
|
+
const REPORT_ENTRY_TYPE = "kankaku-report";
|
|
12
|
+
|
|
13
|
+
/** Durable report rendered inside the chat transcript; never sent to the LLM. */
|
|
14
|
+
export interface KankakuReportData {
|
|
15
|
+
title: string;
|
|
16
|
+
lines: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Notify the user of an error through the UI, when one is available. */
|
|
20
|
+
export function notifyError(ctx: ExtensionContext, error: unknown): void {
|
|
21
|
+
if (!ctx.hasUI) return;
|
|
22
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
23
|
+
ctx.ui.notify(`kankaku: ${message}`, "error");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const COMMAND_TOKENS = ["all", "tasks", "sessions", "client", "clients", "export"];
|
|
27
|
+
|
|
28
|
+
export interface KankakuCommandDeps {
|
|
29
|
+
log: WorkLog;
|
|
30
|
+
sessionClient: SessionClient;
|
|
31
|
+
/** Refresh the idle status line, e.g. after `/kankaku client` changes the session client. */
|
|
32
|
+
refreshIdleStatus: (ctx: ExtensionContext) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Write an export file (name, content) under the kankaku dir and return
|
|
35
|
+
* its absolute path. `/kankaku export` notifies an error when this is not
|
|
36
|
+
* configured.
|
|
37
|
+
*/
|
|
38
|
+
writeExportFile?: (name: string, content: string) => string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface KankakuCommand {
|
|
42
|
+
/** Drop the cached client-name list so the next completion re-reads the log. */
|
|
43
|
+
invalidateClientNames(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Registers the `/kankaku` command (report/tasks/sessions/clients/export/
|
|
48
|
+
* client), its argument completions, and the durable report entry renderer.
|
|
49
|
+
*/
|
|
50
|
+
export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDeps): KankakuCommand {
|
|
51
|
+
const { log, sessionClient } = deps;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Cached, sorted, de-duplicated client names for `/kankaku client <prefix>`
|
|
55
|
+
* autocomplete, so pressing a key does not re-read the whole worklog.
|
|
56
|
+
* Invalidated whenever this process appends a record, and — when `log`
|
|
57
|
+
* exposes the optional `version()` signal — whenever that signal changes,
|
|
58
|
+
* so a change from another process is picked up too.
|
|
59
|
+
*/
|
|
60
|
+
let clientNamesCache: string[] | undefined;
|
|
61
|
+
let clientNamesCacheVersion: string | number | undefined;
|
|
62
|
+
|
|
63
|
+
function invalidateClientNames(): void {
|
|
64
|
+
clientNamesCache = undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clientNames(): string[] {
|
|
68
|
+
const currentVersion = log.version?.();
|
|
69
|
+
const versionUnchanged = log.version === undefined || currentVersion === clientNamesCacheVersion;
|
|
70
|
+
if (clientNamesCache !== undefined && versionUnchanged) {
|
|
71
|
+
return clientNamesCache;
|
|
72
|
+
}
|
|
73
|
+
const names = Array.from(
|
|
74
|
+
new Set(
|
|
75
|
+
log
|
|
76
|
+
.readAll()
|
|
77
|
+
.map((record) => record.client)
|
|
78
|
+
.filter((client): client is string => typeof client === "string"),
|
|
79
|
+
),
|
|
80
|
+
).sort();
|
|
81
|
+
clientNamesCache = names;
|
|
82
|
+
clientNamesCacheVersion = currentVersion;
|
|
83
|
+
return names;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
pi.registerEntryRenderer<KankakuReportData>(REPORT_ENTRY_TYPE, (entry, _options, theme) => {
|
|
87
|
+
const data = entry.data ?? { title: "kankaku", lines: [] };
|
|
88
|
+
const box = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
|
|
89
|
+
box.addChild(new Text(`${theme.fg("accent", "kankaku")} ${data.title}`, 0, 0));
|
|
90
|
+
for (const line of data.lines) {
|
|
91
|
+
box.addChild(new Text(line, 0, 0));
|
|
92
|
+
}
|
|
93
|
+
return box;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function showReport(ctx: ExtensionContext, report: KankakuReportData): void {
|
|
97
|
+
if (ctx.hasUI) {
|
|
98
|
+
pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Handle `/kankaku client [<name> | --clear]`; `rest` excludes the leading `client` token. */
|
|
105
|
+
function handleClientCommand(rest: string[], ctx: ExtensionContext): void {
|
|
106
|
+
if (rest.length === 1 && rest[0] === "--clear") {
|
|
107
|
+
sessionClient.set(pi, undefined);
|
|
108
|
+
deps.refreshIdleStatus(ctx);
|
|
109
|
+
showReport(ctx, { title: "client", lines: ["client label cleared for this session"] });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (rest.length === 0) {
|
|
114
|
+
const client = sessionClient.effectiveClient();
|
|
115
|
+
const source = sessionClient.effectiveSource();
|
|
116
|
+
const line = client !== undefined ? `client: ${client} (from ${source})` : "client: none";
|
|
117
|
+
showReport(ctx, { title: "client", lines: [line] });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const name = rest.join(" ");
|
|
122
|
+
if (!isValidClient(name)) {
|
|
123
|
+
notifyError(ctx, new Error(`invalid client name: ${name}`));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
sessionClient.set(pi, name);
|
|
127
|
+
deps.refreshIdleStatus(ctx);
|
|
128
|
+
showReport(ctx, { title: "client", lines: [`client set to ${name}`] });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Handle `/kankaku export [csv|json] [all]`; `rest` excludes the leading `export` token. Default format is csv. */
|
|
132
|
+
function handleExportCommand(rest: string[], ctx: ExtensionContext): void {
|
|
133
|
+
if (!deps.writeExportFile) {
|
|
134
|
+
notifyError(ctx, new Error("export is not configured"));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const all = rest.includes("all");
|
|
139
|
+
const format: "csv" | "json" = rest.includes("json") ? "json" : "csv";
|
|
140
|
+
const records = log.readAll();
|
|
141
|
+
const today = localDay(new Date().toISOString());
|
|
142
|
+
const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
|
|
143
|
+
const rows = exportRows(tasks);
|
|
144
|
+
const content = format === "json" ? toJson(rows) : toCsv(rows);
|
|
145
|
+
const name = `tasks-${all ? "all" : today}.${format}`;
|
|
146
|
+
const path = deps.writeExportFile(name, content);
|
|
147
|
+
showReport(ctx, { title: "export", lines: [`wrote ${rows.length} row(s) to ${path}`] });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
pi.registerCommand("kankaku", {
|
|
151
|
+
description:
|
|
152
|
+
"Show kankaku work-time totals for today. Args (any order): 'all' for every record, " +
|
|
153
|
+
"'tasks' for this session's tasks ('tasks all' for every session), 'sessions' for today's sessions, " +
|
|
154
|
+
"'client <name>' to set the session billing client, 'client' to show the effective one and its source, " +
|
|
155
|
+
"'client --clear' to clear it, 'clients' for per-client totals today ('clients all' for every day), " +
|
|
156
|
+
"'export [csv|json] [all]' to write today's (or every) task as a file.",
|
|
157
|
+
getArgumentCompletions: (argumentPrefix: string): AutocompleteItem[] => {
|
|
158
|
+
const clientMatch = /^client\s+(\S*)$/.exec(argumentPrefix);
|
|
159
|
+
if (clientMatch) {
|
|
160
|
+
const prefix = clientMatch[1] ?? "";
|
|
161
|
+
return clientNames()
|
|
162
|
+
.filter((name) => name.startsWith(prefix))
|
|
163
|
+
.map((name) => ({ value: name, label: name }));
|
|
164
|
+
}
|
|
165
|
+
return COMMAND_TOKENS.filter((value) => value.startsWith(argumentPrefix)).map((value) => ({ value, label: value }));
|
|
166
|
+
},
|
|
167
|
+
handler: async (args, ctx) => {
|
|
168
|
+
try {
|
|
169
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
170
|
+
|
|
171
|
+
if (tokens[0] === "client") {
|
|
172
|
+
handleClientCommand(tokens.slice(1), ctx);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (tokens[0] === "export") {
|
|
177
|
+
handleExportCommand(tokens.slice(1), ctx);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const all = tokens.includes("all");
|
|
182
|
+
const records = log.readAll();
|
|
183
|
+
const today = localDay(new Date().toISOString());
|
|
184
|
+
|
|
185
|
+
if (tokens.includes("clients")) {
|
|
186
|
+
const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
|
|
187
|
+
showReport(ctx, {
|
|
188
|
+
title: all ? "clients (all days)" : "clients (today)",
|
|
189
|
+
lines: formatClients(summarizeByClient(tasks)).split("\n"),
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (tokens.includes("tasks")) {
|
|
195
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
196
|
+
const scoped = all || !sessionId;
|
|
197
|
+
const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
|
|
198
|
+
showReport(ctx, {
|
|
199
|
+
title: scoped ? "tasks (every session)" : "tasks (this session)",
|
|
200
|
+
lines: formatTasks(tasks).split("\n"),
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (tokens.includes("sessions")) {
|
|
206
|
+
const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
|
|
207
|
+
showReport(ctx, {
|
|
208
|
+
title: all ? "sessions (all days)" : "sessions (today)",
|
|
209
|
+
lines: formatSessions(buildSessions(tasks)).split("\n"),
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const summary = summarize(records, { all });
|
|
215
|
+
showReport(ctx, {
|
|
216
|
+
title: all ? "summary (all days)" : "summary (today)",
|
|
217
|
+
lines: formatReport(summary).split(" | "),
|
|
218
|
+
});
|
|
219
|
+
} catch (error) {
|
|
220
|
+
notifyError(ctx, error);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return { invalidateClientNames };
|
|
226
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { isAbsolute, join } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the kankaku data directory: an absolute `dirOrRelative` is used
|
|
5
|
+
* as-is, a relative one is joined against `cwd`. Shared by every lazy
|
|
6
|
+
* adapter so the rule lives in exactly one place.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveKankakuDir(dirOrRelative: string, cwd: string): string {
|
|
9
|
+
return isAbsolute(dirOrRelative) ? dirOrRelative : join(cwd, dirOrRelative);
|
|
10
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { WorkRecord } from "../domain/work-record.ts";
|
|
2
|
+
import type { InflightStore } from "../ports/inflight-store.ts";
|
|
3
|
+
import { FileInflightStore } from "./file-inflight-store.ts";
|
|
4
|
+
import { resolveKankakuDir } from "./kankaku-dir.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* {@link InflightStore} that resolves a relative checkpoint directory
|
|
8
|
+
* lazily, mirroring {@link LazyJsonlWorkLog}: against the project of the
|
|
9
|
+
* first saved record, or against `fallbackCwd()` when `clear` or
|
|
10
|
+
* `recoverStale` happens before any record was saved in this process
|
|
11
|
+
* (e.g. `session_start`, which runs before `before_agent_start`).
|
|
12
|
+
*/
|
|
13
|
+
export class LazyFileInflightStore implements InflightStore {
|
|
14
|
+
private readonly dirOrRelative: string;
|
|
15
|
+
private readonly pid: number;
|
|
16
|
+
private readonly fallbackCwd: () => string;
|
|
17
|
+
private resolved: FileInflightStore | undefined;
|
|
18
|
+
|
|
19
|
+
constructor(dirOrRelative: string, pid: number, fallbackCwd: () => string = () => process.cwd()) {
|
|
20
|
+
this.dirOrRelative = dirOrRelative;
|
|
21
|
+
this.pid = pid;
|
|
22
|
+
this.fallbackCwd = fallbackCwd;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private resolveFor(cwd: string): FileInflightStore {
|
|
26
|
+
if (!this.resolved) {
|
|
27
|
+
this.resolved = new FileInflightStore(resolveKankakuDir(this.dirOrRelative, cwd), this.pid);
|
|
28
|
+
}
|
|
29
|
+
return this.resolved;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
save(record: WorkRecord): void {
|
|
33
|
+
this.resolveFor(record.project).save(record);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
clear(): void {
|
|
37
|
+
this.resolveFor(this.fallbackCwd()).clear();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
recoverStale(isAlive: (pid: number) => boolean): WorkRecord[] {
|
|
41
|
+
return this.resolveFor(this.fallbackCwd()).recoverStale(isAlive);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { isAbsolute, join } from "node:path";
|
|
2
1
|
import type { WorkRecord } from "../domain/work-record.ts";
|
|
3
2
|
import type { WorkLog } from "../ports/work-log.ts";
|
|
4
3
|
import { JsonlWorkLog } from "./jsonl-work-log.ts";
|
|
4
|
+
import { resolveKankakuDir } from "./kankaku-dir.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* {@link WorkLog} that resolves a relative log directory lazily: against the
|
|
@@ -20,8 +20,7 @@ export class LazyJsonlWorkLog implements WorkLog {
|
|
|
20
20
|
|
|
21
21
|
private resolveFor(cwd: string): JsonlWorkLog {
|
|
22
22
|
if (!this.resolved) {
|
|
23
|
-
|
|
24
|
-
this.resolved = new JsonlWorkLog(dir);
|
|
23
|
+
this.resolved = new JsonlWorkLog(resolveKankakuDir(this.dirOrRelative, cwd));
|
|
25
24
|
}
|
|
26
25
|
return this.resolved;
|
|
27
26
|
}
|
|
@@ -33,4 +32,8 @@ export class LazyJsonlWorkLog implements WorkLog {
|
|
|
33
32
|
readAll(): WorkRecord[] {
|
|
34
33
|
return this.resolveFor(this.fallbackCwd()).readAll();
|
|
35
34
|
}
|
|
35
|
+
|
|
36
|
+
version(): string {
|
|
37
|
+
return this.resolveFor(this.fallbackCwd()).version();
|
|
38
|
+
}
|
|
36
39
|
}
|