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,84 @@
|
|
|
1
|
+
import type { RegistryEntry } from "../ports/process-registry.ts";
|
|
2
|
+
import type { OrchestratorRef } from "./work-record.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Max allowed drift (ms) between a live process's freshly re-derived start
|
|
6
|
+
* identity and the one recorded in its registry entry. Absorbs
|
|
7
|
+
* second-granularity rounding/reading noise from the underlying OS
|
|
8
|
+
* start-time source (`adapters/ancestry.ts`) — not a measure of real clock
|
|
9
|
+
* error, since both readings of the *same* process instance stay tightly
|
|
10
|
+
* consistent regardless of that source's own precision (see that module's
|
|
11
|
+
* docs). A genuine pid-reuse produces a gap far larger than this.
|
|
12
|
+
*/
|
|
13
|
+
export const START_ID_TOLERANCE_MS = 2000;
|
|
14
|
+
|
|
15
|
+
/** Whether two start identities are close enough to be the same process instance. `undefined` on either side is always "no", never a coincidental match. */
|
|
16
|
+
function sameProcessInstance(recorded: number | undefined, live: number | undefined): boolean {
|
|
17
|
+
if (recorded === undefined || live === undefined) return false;
|
|
18
|
+
return Math.abs(recorded - live) <= START_ID_TOLERANCE_MS;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The nearest tracked ancestor whose identity can actually be *proven*: the
|
|
23
|
+
* first `ancestryPids` entry (ordered nearest-parent first, see
|
|
24
|
+
* `adapters/ancestry.ts#walkAncestry`) that has a matching
|
|
25
|
+
* {@link RegistryEntry} AND whose registry-recorded `processStartId`
|
|
26
|
+
* agrees, within {@link START_ID_TOLERANCE_MS}, with `liveStartId(pid)` —
|
|
27
|
+
* a fresh re-derivation of that same pid's actual OS start time, taken
|
|
28
|
+
* from the same ancestry snapshot the caller already has. Matching by pid
|
|
29
|
+
* number alone is not safe: the OS reuses pids, so a stale entry left
|
|
30
|
+
* behind by a dead, never-cleaned-up process can otherwise be
|
|
31
|
+
* misattributed to whatever unrelated live process the kernel later hands
|
|
32
|
+
* that same pid to (a genuine top-level session silently misclassified as
|
|
33
|
+
* someone's subagent forever). A candidate whose identity cannot be
|
|
34
|
+
* verified — no `processStartId` on the entry (legacy/malformed), or no
|
|
35
|
+
* live start id available for that pid (platform without ancestor-chain
|
|
36
|
+
* support, or a snapshot gap) — is never matched; the walk continues past
|
|
37
|
+
* it exactly like an untracked hop, so a further genuine ancestor can still
|
|
38
|
+
* be found. Returns `undefined` when no ancestor pid is trackable at all —
|
|
39
|
+
* callers must treat that exactly like "no registry available" (safe
|
|
40
|
+
* fallback to the pre-registry behaviour), never invent a match.
|
|
41
|
+
*/
|
|
42
|
+
export function findAncestorEntry(ancestryPids: number[], entries: RegistryEntry[], liveStartId: (pid: number) => number | undefined): RegistryEntry | undefined {
|
|
43
|
+
if (ancestryPids.length === 0 || entries.length === 0) return undefined;
|
|
44
|
+
|
|
45
|
+
const byPid = new Map<number, RegistryEntry>();
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
// First entry for a given pid wins; a pid should only ever have one
|
|
48
|
+
// live registry file, but a torn write racing a sweep could in theory
|
|
49
|
+
// leave two candidates behind — deterministic tie-break, not correctness-critical.
|
|
50
|
+
if (!byPid.has(entry.pid)) byPid.set(entry.pid, entry);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const pid of ancestryPids) {
|
|
54
|
+
const entry = byPid.get(pid);
|
|
55
|
+
if (!entry) continue;
|
|
56
|
+
if (sameProcessInstance(entry.processStartId, liveStartId(pid))) return entry;
|
|
57
|
+
// Either unprovable, or this pid has genuinely been reused by a
|
|
58
|
+
// different process instance since the entry was written: not our
|
|
59
|
+
// ancestor. Keep walking — a further, verifiable ancestor may still exist.
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the ultimate, top-level {@link OrchestratorRef} for `ancestorEntry`
|
|
66
|
+
* (the nearest verified ancestor {@link findAncestorEntry} returned) — F4's
|
|
67
|
+
* nested-subagent fix. When that ancestor is itself a `subagent`-role entry
|
|
68
|
+
* that already resolved its own verified `orchestratorRef` (a
|
|
69
|
+
* subagent-of-subagent chain: this process's parent is itself someone's
|
|
70
|
+
* child), that inherited ref is returned instead of one built from the
|
|
71
|
+
* ancestor's own identity — so a grandchild's `orchestratorRef` (and, via
|
|
72
|
+
* its `dir`, `extension.ts`'s F1 write-routing target) always points at
|
|
73
|
+
* the real top-level orchestrator, never a middle hop. Falls back to an
|
|
74
|
+
* ancestor's own identity when it is an orchestrator, or a subagent that
|
|
75
|
+
* never resolved a ref of its own (e.g. it discovered no tracked ancestor
|
|
76
|
+
* at its own startup) — never invents one. `undefined` in, `undefined` out.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveOrchestratorRef(ancestorEntry: RegistryEntry | undefined): OrchestratorRef | undefined {
|
|
79
|
+
if (ancestorEntry === undefined) return undefined;
|
|
80
|
+
if (ancestorEntry.role === "subagent" && ancestorEntry.orchestratorRef !== undefined) {
|
|
81
|
+
return ancestorEntry.orchestratorRef;
|
|
82
|
+
}
|
|
83
|
+
return { pid: ancestorEntry.pid, project: ancestorEntry.project, startedAt: ancestorEntry.startedAt, dir: ancestorEntry.dir };
|
|
84
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client (billing target) label resolution: pure, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* A client name identifies who a piece of work is billed to. It can come
|
|
5
|
+
* from three sources, in decreasing precedence: the pi session (set with
|
|
6
|
+
* `/kankaku client <name>`), the `KANKAKU_CLIENT` environment variable, or
|
|
7
|
+
* the project's `.kankaku/config.json`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Safe client name: letters, digits, `.`, `_`, `-`, 1-64 chars. */
|
|
11
|
+
const CLIENT_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
12
|
+
|
|
13
|
+
/** Property names that behave specially on a plain object; never usable as a client name. */
|
|
14
|
+
const RESERVED_NAMES = new Set(["__proto__", "constructor", "prototype"]);
|
|
15
|
+
|
|
16
|
+
export interface ClientSources {
|
|
17
|
+
/** Set for the current pi session via `/kankaku client <name>`. Highest precedence. */
|
|
18
|
+
session?: string;
|
|
19
|
+
/** From the `KANKAKU_CLIENT` environment variable. */
|
|
20
|
+
env?: string;
|
|
21
|
+
/** From the project's `.kankaku/config.json`. Lowest precedence. */
|
|
22
|
+
project?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ClientSourceName = "session" | "env" | "project";
|
|
26
|
+
|
|
27
|
+
/** `true` when `value` is a non-empty, safe client name. */
|
|
28
|
+
export function isValidClient(value: string): boolean {
|
|
29
|
+
return CLIENT_PATTERN.test(value) && !RESERVED_NAMES.has(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Trim and validate a candidate client name; `undefined` when absent or invalid. */
|
|
33
|
+
function normalize(value: string | undefined): string | undefined {
|
|
34
|
+
if (value === undefined) return undefined;
|
|
35
|
+
const trimmed = value.trim();
|
|
36
|
+
if (trimmed.length === 0) return undefined;
|
|
37
|
+
return isValidClient(trimmed) ? trimmed : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the effective client name from `session`, `env` and `project`
|
|
42
|
+
* sources, in that precedence order. Each candidate is trimmed; an empty
|
|
43
|
+
* string or a value that does not match the safe client-name pattern is
|
|
44
|
+
* treated as absent and resolution falls through to the next source.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveClient(sources: ClientSources): string | undefined {
|
|
47
|
+
return normalize(sources.session) ?? normalize(sources.env) ?? normalize(sources.project);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Which source produced {@link resolveClient}'s result, or `undefined` when none applies. */
|
|
51
|
+
export function resolveClientSource(sources: ClientSources): ClientSourceName | undefined {
|
|
52
|
+
if (normalize(sources.session) !== undefined) return "session";
|
|
53
|
+
if (normalize(sources.env) !== undefined) return "env";
|
|
54
|
+
if (normalize(sources.project) !== undefined) return "project";
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Local (not UTC) calendar day of an ISO timestamp, as `YYYY-MM-DD`. */
|
|
2
|
+
export function localDay(iso: string): string {
|
|
3
|
+
const date = new Date(iso);
|
|
4
|
+
const year = date.getFullYear();
|
|
5
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
6
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
7
|
+
return `${year}-${month}-${day}`;
|
|
8
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { localDay } from "./day.ts";
|
|
2
|
+
import { finiteOrZero } from "./work-record.ts";
|
|
3
|
+
import type { WorkStatus } from "./work-record.ts";
|
|
4
|
+
import type { TaskView } from "./task-view.ts";
|
|
5
|
+
|
|
6
|
+
/** One flat, spreadsheet-friendly row per {@link TaskView}. */
|
|
7
|
+
export interface ExportRow {
|
|
8
|
+
id: string;
|
|
9
|
+
/** Local calendar day (`YYYY-MM-DD`) the task started on. */
|
|
10
|
+
day: string;
|
|
11
|
+
startedAt: string;
|
|
12
|
+
endedAt: string;
|
|
13
|
+
/** Empty string when the task has no resolved client. */
|
|
14
|
+
client: string;
|
|
15
|
+
/** Empty string when the orchestrator record has no session name. */
|
|
16
|
+
sessionName: string;
|
|
17
|
+
/** Empty string when the orchestrator record has no session id. */
|
|
18
|
+
sessionId: string;
|
|
19
|
+
project: string;
|
|
20
|
+
status: WorkStatus;
|
|
21
|
+
/** First 200 chars of the task's prompt, with newlines collapsed to spaces. */
|
|
22
|
+
prompt: string;
|
|
23
|
+
wallMs: number;
|
|
24
|
+
waitingMs: number;
|
|
25
|
+
workMs: number;
|
|
26
|
+
cost: number;
|
|
27
|
+
tokensIn: number;
|
|
28
|
+
tokensOut: number;
|
|
29
|
+
cacheRead: number;
|
|
30
|
+
subagentCount: number;
|
|
31
|
+
/** JSON-encoded `TaskView.segments` map. */
|
|
32
|
+
segments: string;
|
|
33
|
+
/** Empty string when the orchestrator record has no model. */
|
|
34
|
+
model: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** First 200 chars of `prompt`, with `\n`/`\r` collapsed to a single space. */
|
|
38
|
+
function truncatePrompt(prompt: string): string {
|
|
39
|
+
return prompt.slice(0, 200).replace(/\r\n|\r|\n/g, " ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Build one flat {@link ExportRow} per task, in the same order as `tasks`. */
|
|
43
|
+
export function exportRows(tasks: TaskView[]): ExportRow[] {
|
|
44
|
+
return tasks.map((task) => ({
|
|
45
|
+
id: task.id,
|
|
46
|
+
day: localDay(task.startedAt),
|
|
47
|
+
startedAt: task.startedAt,
|
|
48
|
+
endedAt: task.endedAt,
|
|
49
|
+
client: task.client ?? "",
|
|
50
|
+
sessionName: task.sessionName ?? "",
|
|
51
|
+
sessionId: task.sessionId ?? "",
|
|
52
|
+
project: task.project,
|
|
53
|
+
status: task.status,
|
|
54
|
+
prompt: truncatePrompt(task.prompt),
|
|
55
|
+
wallMs: task.wallMs,
|
|
56
|
+
waitingMs: task.waitingMs,
|
|
57
|
+
workMs: task.workMs,
|
|
58
|
+
cost: finiteOrZero(task.usage.cost),
|
|
59
|
+
tokensIn: finiteOrZero(task.usage.input),
|
|
60
|
+
tokensOut: finiteOrZero(task.usage.output),
|
|
61
|
+
cacheRead: finiteOrZero(task.usage.cacheRead),
|
|
62
|
+
subagentCount: task.subagents.length,
|
|
63
|
+
segments: JSON.stringify(task.segments),
|
|
64
|
+
model: task.orchestrator.model ?? "",
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Column order for {@link toCsv}'s header row, matching {@link ExportRow}'s field order. */
|
|
69
|
+
const COLUMNS: Array<keyof ExportRow> = [
|
|
70
|
+
"id",
|
|
71
|
+
"day",
|
|
72
|
+
"startedAt",
|
|
73
|
+
"endedAt",
|
|
74
|
+
"client",
|
|
75
|
+
"sessionName",
|
|
76
|
+
"sessionId",
|
|
77
|
+
"project",
|
|
78
|
+
"status",
|
|
79
|
+
"prompt",
|
|
80
|
+
"wallMs",
|
|
81
|
+
"waitingMs",
|
|
82
|
+
"workMs",
|
|
83
|
+
"cost",
|
|
84
|
+
"tokensIn",
|
|
85
|
+
"tokensOut",
|
|
86
|
+
"cacheRead",
|
|
87
|
+
"subagentCount",
|
|
88
|
+
"segments",
|
|
89
|
+
"model",
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/** RFC 4180 field quoting: quote a field containing `,`, `"`, or a newline; double any embedded quote. */
|
|
93
|
+
function csvField(value: string | number): string {
|
|
94
|
+
const text = String(value);
|
|
95
|
+
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Render `rows` as RFC 4180 CSV: a header row, one row per record, `\n` line endings. */
|
|
99
|
+
export function toCsv(rows: ExportRow[]): string {
|
|
100
|
+
const lines = [COLUMNS.join(","), ...rows.map((row) => COLUMNS.map((column) => csvField(row[column])).join(","))];
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Render `rows` as pretty-printed (2-space indent) JSON. */
|
|
105
|
+
export function toJson(rows: ExportRow[]): string {
|
|
106
|
+
return JSON.stringify(rows, null, 2);
|
|
107
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map a {@link TaskView} (and its `WorkRecord`s) to the hub's `task_entries`
|
|
3
|
+
* / `work_records` payload shapes. Pure, no I/O — see `contract.md` in
|
|
4
|
+
* kankaku-hub for the exact field names/types this mirrors.
|
|
5
|
+
*
|
|
6
|
+
* The CRITICAL rule this file encodes: assignment (`client`/`project`/
|
|
7
|
+
* `task`/`legacy_client_label`) is create-only. The owner reassigns rows in
|
|
8
|
+
* the web; a later re-sync of the same task must never undo that, so
|
|
9
|
+
* `buildTaskEntryUpdatePayload` never includes those fields. See
|
|
10
|
+
* `buildTaskEntryCreatePayload` vs `buildTaskEntryUpdatePayload`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { TaskView } from "./task-view.ts";
|
|
14
|
+
import type { WorkRecord, WorkRole, WorkStatus } from "./work-record.ts";
|
|
15
|
+
import { finiteOrZero } from "./work-record.ts";
|
|
16
|
+
import type { Client, HubTask, Project } from "./work-target.ts";
|
|
17
|
+
|
|
18
|
+
export type PromptPrivacyMode = "none" | "truncated" | "full";
|
|
19
|
+
|
|
20
|
+
export interface HubEntryContext {
|
|
21
|
+
clients: Client[];
|
|
22
|
+
projects: Project[];
|
|
23
|
+
/** The hub's known tasks, to validate a task's linked `hubTaskId` against. See {@link resolveTaskAssignment}. */
|
|
24
|
+
tasks: HubTask[];
|
|
25
|
+
/** This machine's hostname or `KANKAKU_MACHINE`. */
|
|
26
|
+
machine: string;
|
|
27
|
+
/** `KANKAKU_SYNC_PROMPT`; see {@link applyPromptPrivacy}. */
|
|
28
|
+
promptMode: PromptPrivacyMode;
|
|
29
|
+
/** Coding agent that produced this row, lowercase slug. Always `"pi"` for this package. See kankaku-hub `docs/contract.md` "Agent and measurement quality". */
|
|
30
|
+
agent: string;
|
|
31
|
+
/** The `pi` package's own version, when it could be determined without a hot-path cost. Never guessed — omitted rather than sent wrong. */
|
|
32
|
+
agentVersion?: string;
|
|
33
|
+
/** The integration that wrote this row, lowercase slug. Always `"kankaku"` for this package. */
|
|
34
|
+
plugin: string;
|
|
35
|
+
/** This kankaku package's own version, from its `package.json`, read once. */
|
|
36
|
+
pluginVersion?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TaskAssignment {
|
|
40
|
+
/** `clients` relation id, or `""` when no usable client could be resolved. */
|
|
41
|
+
clientId: string;
|
|
42
|
+
/** `projects` relation id, or `""`. */
|
|
43
|
+
projectId: string;
|
|
44
|
+
/** `tasks` relation id, or `""` when no usable hub task could be resolved. See {@link resolveTaskAssignment}. */
|
|
45
|
+
hubTaskId: string;
|
|
46
|
+
/** Only set (non-empty) for a task routed to the unassigned client. */
|
|
47
|
+
legacyClientLabel: string;
|
|
48
|
+
/** `true` when this task was routed to the catalog's unassigned ("Sin determinar") client. */
|
|
49
|
+
routedToUnassigned: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve which client/project/hub-task a task's `task_entries` row should
|
|
54
|
+
* link to.
|
|
55
|
+
*
|
|
56
|
+
* - A task whose `clientId` still exists in `clients` links to that client
|
|
57
|
+
* (regardless of its `active` flag — this is a historical fact, not a
|
|
58
|
+
* future selection), and to `projectId` too when it still exists and
|
|
59
|
+
* belongs to that client; otherwise the project relation is empty.
|
|
60
|
+
* - A task with no `clientId`, or whose `clientId` no longer resolves,
|
|
61
|
+
* routes to the catalog's unassigned client (the row with
|
|
62
|
+
* `unassigned: true`), carrying forward the record's free-text `client`
|
|
63
|
+
* label (or its `clientName` when the label itself is absent) as
|
|
64
|
+
* `legacyClientLabel` — the historical backfill rule (proposal §5.3).
|
|
65
|
+
* - `hubTaskId` is kept only when it still exists in `tasks` AND belongs to
|
|
66
|
+
* the resolved `projectId` (non-empty); otherwise it is `""` — including
|
|
67
|
+
* whenever the client fell back to unassigned, since there is then no
|
|
68
|
+
* resolved project for a task to belong to.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveTaskAssignment(task: TaskView, clients: Client[], projects: Project[], tasks: HubTask[]): TaskAssignment {
|
|
71
|
+
const client = task.clientId !== undefined ? clients.find((candidate) => candidate.id === task.clientId) : undefined;
|
|
72
|
+
|
|
73
|
+
if (client) {
|
|
74
|
+
const project =
|
|
75
|
+
task.projectId !== undefined ? projects.find((candidate) => candidate.id === task.projectId && candidate.clientId === client.id) : undefined;
|
|
76
|
+
const hubTask =
|
|
77
|
+
project !== undefined && task.hubTaskId !== undefined
|
|
78
|
+
? tasks.find((candidate) => candidate.id === task.hubTaskId && candidate.projectId === project.id)
|
|
79
|
+
: undefined;
|
|
80
|
+
return {
|
|
81
|
+
clientId: client.id,
|
|
82
|
+
projectId: project ? project.id : "",
|
|
83
|
+
hubTaskId: hubTask ? hubTask.id : "",
|
|
84
|
+
legacyClientLabel: "",
|
|
85
|
+
routedToUnassigned: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const unassigned = clients.find((candidate) => candidate.unassigned === true);
|
|
90
|
+
return {
|
|
91
|
+
clientId: unassigned ? unassigned.id : "",
|
|
92
|
+
projectId: "",
|
|
93
|
+
hubTaskId: "",
|
|
94
|
+
legacyClientLabel: task.client ?? task.clientName ?? "",
|
|
95
|
+
routedToUnassigned: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Privacy transform for a prompt about to leave the machine
|
|
101
|
+
* (`KANKAKU_SYNC_PROMPT`, default `none`): `none` omits it entirely,
|
|
102
|
+
* `truncated` keeps the first 120 chars plus an ellipsis marker when
|
|
103
|
+
* anything was cut, `full` sends it verbatim.
|
|
104
|
+
*/
|
|
105
|
+
export function applyPromptPrivacy(prompt: string, mode: PromptPrivacyMode): string {
|
|
106
|
+
if (mode === "full") return prompt;
|
|
107
|
+
if (mode === "truncated") return prompt.length > 120 ? `${prompt.slice(0, 120)}…` : prompt;
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* PocketBase `date` fields read back as `"YYYY-MM-DD HH:MM:SS.mmmZ"` (space,
|
|
113
|
+
* not `T`) though both forms are accepted on write; kankaku always sends
|
|
114
|
+
* the space form so a round trip through the contract's documented shape
|
|
115
|
+
* never depends on PocketBase's own normalization.
|
|
116
|
+
*/
|
|
117
|
+
function toPbDate(iso: string): string {
|
|
118
|
+
return iso.replace("T", " ");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type WaitingQuality = "measured" | "unavailable";
|
|
122
|
+
export type CostQuality = "measured" | "estimated" | "unknown";
|
|
123
|
+
export type SubagentLinkage = "linked" | "unlinked" | "not_applicable";
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether waiting time (time blocked on the human) was actually observed
|
|
127
|
+
* for this task, as opposed to `work_ms` being an unmeasured upper bound
|
|
128
|
+
* equal to `wall_ms`. pi always instruments `ui_prompt_start`/`_end` and
|
|
129
|
+
* interactive-tool spans (`domain/work-tracker.ts`), so this is always
|
|
130
|
+
* `"measured"` for kankaku/pi — a constant here, not computed from a
|
|
131
|
+
* record, kept as its own function (rather than a literal in the payload)
|
|
132
|
+
* so the reasoning has one documented home.
|
|
133
|
+
*/
|
|
134
|
+
export function computeWaitingQuality(): WaitingQuality {
|
|
135
|
+
return "measured";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether this task's cost figure came from a real provider-reported cost
|
|
140
|
+
* (`"measured"`) or not (`"unknown"`) — never `"estimated"`: kankaku has no
|
|
141
|
+
* token-based price-table estimator today, only a real provider figure or
|
|
142
|
+
* nothing. A task counts as measured when its own orchestrator record OR
|
|
143
|
+
* any of its joined subagent records observed one (see
|
|
144
|
+
* `WorkRecord.costObserved`, set by `domain/work-tracker.ts`).
|
|
145
|
+
*/
|
|
146
|
+
export function computeCostQuality(task: TaskView): CostQuality {
|
|
147
|
+
const observed = task.orchestrator.costObserved === true || task.subagents.some((child) => child.costObserved === true);
|
|
148
|
+
return observed ? "measured" : "unknown";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether this task's subagent tool spans (`orchestrator.subagents`,
|
|
153
|
+
* `SubagentSpan[]` — the orchestrator's own tool-call bookkeeping) are
|
|
154
|
+
* accounted for by joined child `WorkRecord`s (`task.subagents`, from
|
|
155
|
+
* `domain/task-view.ts#matchChildren`). There is no explicit per-span
|
|
156
|
+
* correlation id today (README "Subagents" > "Limitations": upstream
|
|
157
|
+
* gentle-pi does not hand a child its own task id), so this is a
|
|
158
|
+
* task-level approximation, not a per-span one:
|
|
159
|
+
*
|
|
160
|
+
* - `not_applicable`: the orchestrator opened no subagent spans at all.
|
|
161
|
+
* - `linked`: at least as many child records were joined as spans were
|
|
162
|
+
* opened — plausibly every span is accounted for (a gentle-pi subagent
|
|
163
|
+
* spawns exactly one child process per span, so counts normally match
|
|
164
|
+
* 1:1).
|
|
165
|
+
* - `unlinked`: fewer joined children than spans (including zero) — at
|
|
166
|
+
* least one span's time is only visible inside the orchestrator's own
|
|
167
|
+
* tool-call span, with no corroborating child record. This also covers
|
|
168
|
+
* a fully in-process subagent mechanism (no separate OS process at all,
|
|
169
|
+
* invisible to the registry/ancestry machinery) and a genuine join miss
|
|
170
|
+
* alike — kankaku cannot tell those apart from here, so it reports the
|
|
171
|
+
* conservative, visible-gap answer rather than guessing "linked".
|
|
172
|
+
*/
|
|
173
|
+
export function computeSubagentLinkage(task: TaskView): SubagentLinkage {
|
|
174
|
+
const spanCount = task.orchestrator.subagents.length;
|
|
175
|
+
if (spanCount === 0) return "not_applicable";
|
|
176
|
+
return task.subagents.length >= spanCount ? "linked" : "unlinked";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The `task_entries` fields present on every write. */
|
|
180
|
+
export interface TaskEntryPayload {
|
|
181
|
+
task_id: string;
|
|
182
|
+
client: string;
|
|
183
|
+
project: string;
|
|
184
|
+
task: string;
|
|
185
|
+
started_at: string;
|
|
186
|
+
ended_at: string;
|
|
187
|
+
wall_ms: number;
|
|
188
|
+
waiting_ms: number;
|
|
189
|
+
work_ms: number;
|
|
190
|
+
input: number;
|
|
191
|
+
output: number;
|
|
192
|
+
cache_read: number;
|
|
193
|
+
cache_write: number;
|
|
194
|
+
cost: number;
|
|
195
|
+
segments: Record<string, number>;
|
|
196
|
+
subagent_count: number;
|
|
197
|
+
runs: number;
|
|
198
|
+
turns: number;
|
|
199
|
+
status: WorkStatus;
|
|
200
|
+
session_id: string;
|
|
201
|
+
session_name: string;
|
|
202
|
+
machine: string;
|
|
203
|
+
model: string;
|
|
204
|
+
/** The model's reasoning effort; omitted when the record does not know it. */
|
|
205
|
+
thinking_level?: string;
|
|
206
|
+
prompt: string;
|
|
207
|
+
legacy_client_label: string;
|
|
208
|
+
repo_project: string;
|
|
209
|
+
schema: number;
|
|
210
|
+
/** Coding agent that MEASURED this task, lowercase slug — the orchestrator record's own {@link WorkRecordMetadata.agent} when it carries one (who measured, not who syncs), else `ctx.agent` (the syncing process's own identity) as a legacy fallback. See {@link WorkRecordMetadata.agent}. */
|
|
211
|
+
agent: string;
|
|
212
|
+
agent_version?: string;
|
|
213
|
+
/** Non-default session directory, see `TaskView.sessionDir`. A measurement field, not assignment: sent on both create and update. */
|
|
214
|
+
session_dir?: string;
|
|
215
|
+
/** The integration that wrote this task's records, lowercase slug — same record-wins-over-ctx rule as {@link agent}. */
|
|
216
|
+
plugin: string;
|
|
217
|
+
plugin_version?: string;
|
|
218
|
+
waiting_quality: WaitingQuality;
|
|
219
|
+
cost_quality: CostQuality;
|
|
220
|
+
subagent_linkage: SubagentLinkage;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* `TaskEntryPayload` minus the assignment fields — what an update sends. See
|
|
225
|
+
* the module docs' CRITICAL rule.
|
|
226
|
+
*
|
|
227
|
+
* `agent`/`agent_version`/`plugin`/`plugin_version` are optional here (unlike
|
|
228
|
+
* on `TaskEntryPayload`, where they are always sent on create): an update for
|
|
229
|
+
* a task whose orchestrator record carries no who-measured identity (a
|
|
230
|
+
* legacy record, written before this feature existed) omits them entirely,
|
|
231
|
+
* so a re-sync by a DIFFERENT process never overwrites a row's original
|
|
232
|
+
* identity with its own. See {@link buildTaskEntryUpdatePayload}.
|
|
233
|
+
*/
|
|
234
|
+
export type TaskEntryUpdatePayload = Omit<
|
|
235
|
+
TaskEntryPayload,
|
|
236
|
+
"client" | "project" | "task" | "legacy_client_label" | "agent" | "agent_version" | "plugin" | "plugin_version"
|
|
237
|
+
> & {
|
|
238
|
+
agent?: string;
|
|
239
|
+
agent_version?: string;
|
|
240
|
+
plugin?: string;
|
|
241
|
+
plugin_version?: string;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The who-measured identity (`agent`/`agentVersion`/`plugin`/`pluginVersion`)
|
|
246
|
+
* for a task's payload: the orchestrator record's own fields, taken as a
|
|
247
|
+
* unit, when it carries an `agent` — a missing version on the record is
|
|
248
|
+
* omitted, never backfilled from `ctx` — otherwise `ctx`'s identity (the
|
|
249
|
+
* syncing process's own), exactly as before this feature existed. See
|
|
250
|
+
* `domain/work-record.ts#WorkRecordMetadata.agent`'s doc comment.
|
|
251
|
+
*/
|
|
252
|
+
function resolveTaskIdentity(
|
|
253
|
+
task: TaskView,
|
|
254
|
+
ctx: HubEntryContext,
|
|
255
|
+
): { agent: string; agentVersion?: string; plugin: string; pluginVersion?: string } {
|
|
256
|
+
const orchestrator = task.orchestrator;
|
|
257
|
+
if (orchestrator.agent !== undefined) {
|
|
258
|
+
// The record's four fields are taken as a unit. A record written by an
|
|
259
|
+
// integration that stamped `agent` but not `plugin` gets the syncing
|
|
260
|
+
// context's `plugin` ONLY on create, so the row is never blank; the
|
|
261
|
+
// update payload below never resends a plugin the record does not own.
|
|
262
|
+
return {
|
|
263
|
+
agent: orchestrator.agent,
|
|
264
|
+
agentVersion: orchestrator.agentVersion,
|
|
265
|
+
plugin: orchestrator.plugin ?? ctx.plugin,
|
|
266
|
+
pluginVersion: orchestrator.plugin !== undefined ? orchestrator.pluginVersion : undefined,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return { agent: ctx.agent, agentVersion: ctx.agentVersion, plugin: ctx.plugin, pluginVersion: ctx.pluginVersion };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
|
|
273
|
+
export function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryPayload {
|
|
274
|
+
const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects, ctx.tasks);
|
|
275
|
+
const identity = resolveTaskIdentity(task, ctx);
|
|
276
|
+
return {
|
|
277
|
+
task_id: task.id,
|
|
278
|
+
client: assignment.clientId,
|
|
279
|
+
project: assignment.projectId,
|
|
280
|
+
task: assignment.hubTaskId,
|
|
281
|
+
started_at: toPbDate(task.startedAt),
|
|
282
|
+
ended_at: toPbDate(task.endedAt),
|
|
283
|
+
wall_ms: task.wallMs,
|
|
284
|
+
waiting_ms: task.waitingMs,
|
|
285
|
+
work_ms: task.workMs,
|
|
286
|
+
input: finiteOrZero(task.usage.input),
|
|
287
|
+
output: finiteOrZero(task.usage.output),
|
|
288
|
+
cache_read: finiteOrZero(task.usage.cacheRead),
|
|
289
|
+
cache_write: finiteOrZero(task.usage.cacheWrite),
|
|
290
|
+
cost: finiteOrZero(task.usage.cost),
|
|
291
|
+
segments: task.segments,
|
|
292
|
+
subagent_count: task.subagents.length,
|
|
293
|
+
runs: task.orchestrator.runs,
|
|
294
|
+
turns: task.orchestrator.turns,
|
|
295
|
+
status: task.status,
|
|
296
|
+
session_id: task.sessionId ?? "",
|
|
297
|
+
session_name: task.sessionName ?? "",
|
|
298
|
+
machine: ctx.machine,
|
|
299
|
+
model: task.orchestrator.model ?? "",
|
|
300
|
+
...(task.orchestrator.thinkingLevel !== undefined ? { thinking_level: task.orchestrator.thinkingLevel } : {}),
|
|
301
|
+
prompt: applyPromptPrivacy(task.prompt, ctx.promptMode),
|
|
302
|
+
legacy_client_label: assignment.legacyClientLabel,
|
|
303
|
+
repo_project: task.project,
|
|
304
|
+
schema: task.orchestrator.schema,
|
|
305
|
+
agent: identity.agent,
|
|
306
|
+
...(identity.agentVersion !== undefined ? { agent_version: identity.agentVersion } : {}),
|
|
307
|
+
...(task.sessionDir !== undefined ? { session_dir: task.sessionDir } : {}),
|
|
308
|
+
plugin: identity.plugin,
|
|
309
|
+
...(identity.pluginVersion !== undefined ? { plugin_version: identity.pluginVersion } : {}),
|
|
310
|
+
waiting_quality: computeWaitingQuality(),
|
|
311
|
+
cost_quality: computeCostQuality(task),
|
|
312
|
+
subagent_linkage: computeSubagentLinkage(task),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Build the `task_entries` payload for an **update** request: measurement
|
|
318
|
+
* fields only — never `client`, `project`, `task` or `legacy_client_label`,
|
|
319
|
+
* so a re-sync can never undo a reassignment made in the web. See the
|
|
320
|
+
* module docs' CRITICAL rule.
|
|
321
|
+
*
|
|
322
|
+
* `agent`/`agent_version`/`plugin`/`plugin_version` are included ONLY when
|
|
323
|
+
* the orchestrator record itself carries a who-measured `agent` — otherwise
|
|
324
|
+
* they are OMITTED entirely (never sent as `ctx`'s own identity), so a
|
|
325
|
+
* re-sync of a legacy record by a different process never overwrites the
|
|
326
|
+
* row's original identity in the hub. See `domain/work-record.ts`'s
|
|
327
|
+
* "Measurement rules" and {@link resolveTaskIdentity}.
|
|
328
|
+
*/
|
|
329
|
+
export function buildTaskEntryUpdatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryUpdatePayload {
|
|
330
|
+
const {
|
|
331
|
+
client: _client,
|
|
332
|
+
project: _project,
|
|
333
|
+
task: _task,
|
|
334
|
+
legacy_client_label: _legacy,
|
|
335
|
+
agent,
|
|
336
|
+
agent_version,
|
|
337
|
+
plugin,
|
|
338
|
+
plugin_version,
|
|
339
|
+
...rest
|
|
340
|
+
} = buildTaskEntryCreatePayload(task, ctx);
|
|
341
|
+
|
|
342
|
+
if (task.orchestrator.agent === undefined) return rest;
|
|
343
|
+
|
|
344
|
+
// `plugin`/`plugin_version` are resent only when the record itself carries
|
|
345
|
+
// them; a record with `agent` but no `plugin` must never have the syncing
|
|
346
|
+
// process's plugin written over the row's original one.
|
|
347
|
+
const recordHasPlugin = task.orchestrator.plugin !== undefined;
|
|
348
|
+
return {
|
|
349
|
+
...rest,
|
|
350
|
+
agent,
|
|
351
|
+
...(agent_version !== undefined ? { agent_version } : {}),
|
|
352
|
+
...(recordHasPlugin ? { plugin } : {}),
|
|
353
|
+
...(recordHasPlugin && plugin_version !== undefined ? { plugin_version } : {}),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** One `work_records` row — raw per-`WorkRecord` detail, always `rollup: false`. */
|
|
358
|
+
export interface WorkRecordPayload {
|
|
359
|
+
kankaku_id: string;
|
|
360
|
+
task_entry: string;
|
|
361
|
+
rollup: false;
|
|
362
|
+
role: WorkRole;
|
|
363
|
+
pid: number;
|
|
364
|
+
parent_pid: number;
|
|
365
|
+
started_at: string;
|
|
366
|
+
settled_at: string;
|
|
367
|
+
wall_ms: number;
|
|
368
|
+
waiting_ms: number;
|
|
369
|
+
work_ms: number;
|
|
370
|
+
runs: number;
|
|
371
|
+
turns: number;
|
|
372
|
+
status: WorkStatus;
|
|
373
|
+
model: string;
|
|
374
|
+
thinking_level?: string;
|
|
375
|
+
input: number;
|
|
376
|
+
output: number;
|
|
377
|
+
cache_read: number;
|
|
378
|
+
cache_write: number;
|
|
379
|
+
cost: number;
|
|
380
|
+
segments: Record<string, number>;
|
|
381
|
+
tools: Record<string, number>;
|
|
382
|
+
session_id: string;
|
|
383
|
+
prompt: string;
|
|
384
|
+
machine: string;
|
|
385
|
+
schema: number;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Build one `work_records` payload from a raw {@link WorkRecord}
|
|
390
|
+
* (orchestrator or subagent), linked to its parent `task_entries` row by
|
|
391
|
+
* PocketBase id. `work_records` carries no assignment fields, so there is
|
|
392
|
+
* no create/update distinction here — this payload is sent as-is either way.
|
|
393
|
+
*/
|
|
394
|
+
export function buildWorkRecordPayload(
|
|
395
|
+
record: WorkRecord,
|
|
396
|
+
taskEntryRecordId: string,
|
|
397
|
+
ctx: { machine: string; promptMode: PromptPrivacyMode },
|
|
398
|
+
): WorkRecordPayload {
|
|
399
|
+
return {
|
|
400
|
+
kankaku_id: record.id,
|
|
401
|
+
task_entry: taskEntryRecordId,
|
|
402
|
+
rollup: false,
|
|
403
|
+
role: record.role,
|
|
404
|
+
pid: record.pid,
|
|
405
|
+
parent_pid: record.parentPid,
|
|
406
|
+
started_at: toPbDate(record.startedAt),
|
|
407
|
+
settled_at: toPbDate(record.settledAt),
|
|
408
|
+
wall_ms: record.wallMs,
|
|
409
|
+
waiting_ms: record.waitingMs,
|
|
410
|
+
work_ms: record.workMs,
|
|
411
|
+
runs: record.runs,
|
|
412
|
+
turns: record.turns,
|
|
413
|
+
status: record.status,
|
|
414
|
+
model: record.model ?? "",
|
|
415
|
+
...(record.thinkingLevel !== undefined ? { thinking_level: record.thinkingLevel } : {}),
|
|
416
|
+
input: finiteOrZero(record.usage.input),
|
|
417
|
+
output: finiteOrZero(record.usage.output),
|
|
418
|
+
cache_read: finiteOrZero(record.usage.cacheRead),
|
|
419
|
+
cache_write: finiteOrZero(record.usage.cacheWrite),
|
|
420
|
+
cost: finiteOrZero(record.usage.cost),
|
|
421
|
+
segments: record.segments ?? {},
|
|
422
|
+
tools: record.tools,
|
|
423
|
+
session_id: record.sessionId ?? "",
|
|
424
|
+
prompt: applyPromptPrivacy(record.prompt, ctx.promptMode),
|
|
425
|
+
machine: ctx.machine,
|
|
426
|
+
schema: record.schema,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Every `WorkRecord` folded into a task: the orchestrator plus its subagents, in that order. */
|
|
431
|
+
export function taskWorkRecords(task: TaskView): WorkRecord[] {
|
|
432
|
+
return [task.orchestrator, ...task.subagents];
|
|
433
|
+
}
|