kankaku 0.5.0 → 0.6.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/README.md +67 -19
- package/dist/adapters/cached-catalog.d.ts +36 -0
- package/dist/adapters/cached-catalog.js +111 -0
- package/dist/adapters/file-modes.d.ts +20 -0
- package/dist/adapters/file-modes.js +34 -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 +14 -0
- package/dist/adapters/pocketbase-catalog.js +39 -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 +52 -0
- package/dist/adapters/pocketbase-sink.js +180 -0
- package/dist/adapters/sync-runner.d.ts +99 -0
- package/dist/adapters/sync-runner.js +242 -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 +201 -0
- package/dist/domain/hub-entry.js +211 -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 +150 -0
- package/dist/domain/sync-plan.js +182 -0
- package/dist/domain/task-view.d.ts +113 -0
- package/dist/domain/task-view.js +426 -0
- package/dist/domain/work-record.d.ts +201 -0
- package/dist/domain/work-record.js +69 -0
- package/dist/domain/work-target.d.ts +72 -0
- package/dist/domain/work-target.js +127 -0
- package/dist/domain/work-tracker.d.ts +90 -0
- package/dist/domain/work-tracker.js +405 -0
- package/dist/hub/index.d.ts +19 -0
- package/dist/hub/index.js +19 -0
- package/dist/ports/catalog.d.ts +29 -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 +20 -2
- package/src/adapters/session-target.ts +86 -24
- package/src/domain/index.ts +19 -0
- package/src/hub/index.ts +19 -0
- package/src/ports/index.ts +11 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { localDay } from "./day.js";
|
|
2
|
+
import { finiteOrZero } from "./work-record.js";
|
|
3
|
+
/** First 200 chars of `prompt`, with `\n`/`\r` collapsed to a single space. */
|
|
4
|
+
function truncatePrompt(prompt) {
|
|
5
|
+
return prompt.slice(0, 200).replace(/\r\n|\r|\n/g, " ");
|
|
6
|
+
}
|
|
7
|
+
/** Build one flat {@link ExportRow} per task, in the same order as `tasks`. */
|
|
8
|
+
export function exportRows(tasks) {
|
|
9
|
+
return tasks.map((task) => ({
|
|
10
|
+
id: task.id,
|
|
11
|
+
day: localDay(task.startedAt),
|
|
12
|
+
startedAt: task.startedAt,
|
|
13
|
+
endedAt: task.endedAt,
|
|
14
|
+
client: task.client ?? "",
|
|
15
|
+
sessionName: task.sessionName ?? "",
|
|
16
|
+
sessionId: task.sessionId ?? "",
|
|
17
|
+
project: task.project,
|
|
18
|
+
status: task.status,
|
|
19
|
+
prompt: truncatePrompt(task.prompt),
|
|
20
|
+
wallMs: task.wallMs,
|
|
21
|
+
waitingMs: task.waitingMs,
|
|
22
|
+
workMs: task.workMs,
|
|
23
|
+
cost: finiteOrZero(task.usage.cost),
|
|
24
|
+
tokensIn: finiteOrZero(task.usage.input),
|
|
25
|
+
tokensOut: finiteOrZero(task.usage.output),
|
|
26
|
+
cacheRead: finiteOrZero(task.usage.cacheRead),
|
|
27
|
+
subagentCount: task.subagents.length,
|
|
28
|
+
segments: JSON.stringify(task.segments),
|
|
29
|
+
model: task.orchestrator.model ?? "",
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
/** Column order for {@link toCsv}'s header row, matching {@link ExportRow}'s field order. */
|
|
33
|
+
const COLUMNS = [
|
|
34
|
+
"id",
|
|
35
|
+
"day",
|
|
36
|
+
"startedAt",
|
|
37
|
+
"endedAt",
|
|
38
|
+
"client",
|
|
39
|
+
"sessionName",
|
|
40
|
+
"sessionId",
|
|
41
|
+
"project",
|
|
42
|
+
"status",
|
|
43
|
+
"prompt",
|
|
44
|
+
"wallMs",
|
|
45
|
+
"waitingMs",
|
|
46
|
+
"workMs",
|
|
47
|
+
"cost",
|
|
48
|
+
"tokensIn",
|
|
49
|
+
"tokensOut",
|
|
50
|
+
"cacheRead",
|
|
51
|
+
"subagentCount",
|
|
52
|
+
"segments",
|
|
53
|
+
"model",
|
|
54
|
+
];
|
|
55
|
+
/** RFC 4180 field quoting: quote a field containing `,`, `"`, or a newline; double any embedded quote. */
|
|
56
|
+
function csvField(value) {
|
|
57
|
+
const text = String(value);
|
|
58
|
+
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
59
|
+
}
|
|
60
|
+
/** Render `rows` as RFC 4180 CSV: a header row, one row per record, `\n` line endings. */
|
|
61
|
+
export function toCsv(rows) {
|
|
62
|
+
const lines = [COLUMNS.join(","), ...rows.map((row) => COLUMNS.map((column) => csvField(row[column])).join(","))];
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
/** Render `rows` as pretty-printed (2-space indent) JSON. */
|
|
66
|
+
export function toJson(rows) {
|
|
67
|
+
return JSON.stringify(rows, null, 2);
|
|
68
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
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
|
+
import type { TaskView } from "./task-view.ts";
|
|
13
|
+
import type { WorkRecord, WorkRole, WorkStatus } from "./work-record.ts";
|
|
14
|
+
import type { Client, Project } from "./work-target.ts";
|
|
15
|
+
export type PromptPrivacyMode = "none" | "truncated" | "full";
|
|
16
|
+
export interface HubEntryContext {
|
|
17
|
+
clients: Client[];
|
|
18
|
+
projects: Project[];
|
|
19
|
+
/** This machine's hostname or `KANKAKU_MACHINE`. */
|
|
20
|
+
machine: string;
|
|
21
|
+
/** `KANKAKU_SYNC_PROMPT`; see {@link applyPromptPrivacy}. */
|
|
22
|
+
promptMode: PromptPrivacyMode;
|
|
23
|
+
/** Coding agent that produced this row, lowercase slug. Always `"pi"` for this package. See kankaku-hub `docs/contract.md` "Agent and measurement quality". */
|
|
24
|
+
agent: string;
|
|
25
|
+
/** The `pi` package's own version, when it could be determined without a hot-path cost. Never guessed — omitted rather than sent wrong. */
|
|
26
|
+
agentVersion?: string;
|
|
27
|
+
/** The integration that wrote this row, lowercase slug. Always `"kankaku"` for this package. */
|
|
28
|
+
plugin: string;
|
|
29
|
+
/** This kankaku package's own version, from its `package.json`, read once. */
|
|
30
|
+
pluginVersion?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface TaskAssignment {
|
|
33
|
+
/** `clients` relation id, or `""` when no usable client could be resolved. */
|
|
34
|
+
clientId: string;
|
|
35
|
+
/** `projects` relation id, or `""`. */
|
|
36
|
+
projectId: string;
|
|
37
|
+
/** Only set (non-empty) for a task routed to the unassigned client. */
|
|
38
|
+
legacyClientLabel: string;
|
|
39
|
+
/** `true` when this task was routed to the catalog's unassigned ("Sin determinar") client. */
|
|
40
|
+
routedToUnassigned: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve which client/project a task's `task_entries` row should link to.
|
|
44
|
+
*
|
|
45
|
+
* - A task whose `clientId` still exists in `clients` links to that client
|
|
46
|
+
* (regardless of its `active` flag — this is a historical fact, not a
|
|
47
|
+
* future selection), and to `projectId` too when it still exists and
|
|
48
|
+
* belongs to that client; otherwise the project relation is empty.
|
|
49
|
+
* - A task with no `clientId`, or whose `clientId` no longer resolves,
|
|
50
|
+
* routes to the catalog's unassigned client (the row with
|
|
51
|
+
* `unassigned: true`), carrying forward the record's free-text `client`
|
|
52
|
+
* label (or its `clientName` when the label itself is absent) as
|
|
53
|
+
* `legacyClientLabel` — the historical backfill rule (proposal §5.3).
|
|
54
|
+
*/
|
|
55
|
+
export declare function resolveTaskAssignment(task: TaskView, clients: Client[], projects: Project[]): TaskAssignment;
|
|
56
|
+
/**
|
|
57
|
+
* Privacy transform for a prompt about to leave the machine
|
|
58
|
+
* (`KANKAKU_SYNC_PROMPT`, default `none`): `none` omits it entirely,
|
|
59
|
+
* `truncated` keeps the first 120 chars plus an ellipsis marker when
|
|
60
|
+
* anything was cut, `full` sends it verbatim.
|
|
61
|
+
*/
|
|
62
|
+
export declare function applyPromptPrivacy(prompt: string, mode: PromptPrivacyMode): string;
|
|
63
|
+
export type WaitingQuality = "measured" | "unavailable";
|
|
64
|
+
export type CostQuality = "measured" | "estimated" | "unknown";
|
|
65
|
+
export type SubagentLinkage = "linked" | "unlinked" | "not_applicable";
|
|
66
|
+
/**
|
|
67
|
+
* Whether waiting time (time blocked on the human) was actually observed
|
|
68
|
+
* for this task, as opposed to `work_ms` being an unmeasured upper bound
|
|
69
|
+
* equal to `wall_ms`. pi always instruments `ui_prompt_start`/`_end` and
|
|
70
|
+
* interactive-tool spans (`domain/work-tracker.ts`), so this is always
|
|
71
|
+
* `"measured"` for kankaku/pi — a constant here, not computed from a
|
|
72
|
+
* record, kept as its own function (rather than a literal in the payload)
|
|
73
|
+
* so the reasoning has one documented home.
|
|
74
|
+
*/
|
|
75
|
+
export declare function computeWaitingQuality(): WaitingQuality;
|
|
76
|
+
/**
|
|
77
|
+
* Whether this task's cost figure came from a real provider-reported cost
|
|
78
|
+
* (`"measured"`) or not (`"unknown"`) — never `"estimated"`: kankaku has no
|
|
79
|
+
* token-based price-table estimator today, only a real provider figure or
|
|
80
|
+
* nothing. A task counts as measured when its own orchestrator record OR
|
|
81
|
+
* any of its joined subagent records observed one (see
|
|
82
|
+
* `WorkRecord.costObserved`, set by `domain/work-tracker.ts`).
|
|
83
|
+
*/
|
|
84
|
+
export declare function computeCostQuality(task: TaskView): CostQuality;
|
|
85
|
+
/**
|
|
86
|
+
* Whether this task's subagent tool spans (`orchestrator.subagents`,
|
|
87
|
+
* `SubagentSpan[]` — the orchestrator's own tool-call bookkeeping) are
|
|
88
|
+
* accounted for by joined child `WorkRecord`s (`task.subagents`, from
|
|
89
|
+
* `domain/task-view.ts#matchChildren`). There is no explicit per-span
|
|
90
|
+
* correlation id today (README "Subagents" > "Limitations": upstream
|
|
91
|
+
* gentle-pi does not hand a child its own task id), so this is a
|
|
92
|
+
* task-level approximation, not a per-span one:
|
|
93
|
+
*
|
|
94
|
+
* - `not_applicable`: the orchestrator opened no subagent spans at all.
|
|
95
|
+
* - `linked`: at least as many child records were joined as spans were
|
|
96
|
+
* opened — plausibly every span is accounted for (a gentle-pi subagent
|
|
97
|
+
* spawns exactly one child process per span, so counts normally match
|
|
98
|
+
* 1:1).
|
|
99
|
+
* - `unlinked`: fewer joined children than spans (including zero) — at
|
|
100
|
+
* least one span's time is only visible inside the orchestrator's own
|
|
101
|
+
* tool-call span, with no corroborating child record. This also covers
|
|
102
|
+
* a fully in-process subagent mechanism (no separate OS process at all,
|
|
103
|
+
* invisible to the registry/ancestry machinery) and a genuine join miss
|
|
104
|
+
* alike — kankaku cannot tell those apart from here, so it reports the
|
|
105
|
+
* conservative, visible-gap answer rather than guessing "linked".
|
|
106
|
+
*/
|
|
107
|
+
export declare function computeSubagentLinkage(task: TaskView): SubagentLinkage;
|
|
108
|
+
/** The `task_entries` fields present on every write. */
|
|
109
|
+
export interface TaskEntryPayload {
|
|
110
|
+
task_id: string;
|
|
111
|
+
client: string;
|
|
112
|
+
project: string;
|
|
113
|
+
task: string;
|
|
114
|
+
started_at: string;
|
|
115
|
+
ended_at: string;
|
|
116
|
+
wall_ms: number;
|
|
117
|
+
waiting_ms: number;
|
|
118
|
+
work_ms: number;
|
|
119
|
+
input: number;
|
|
120
|
+
output: number;
|
|
121
|
+
cache_read: number;
|
|
122
|
+
cache_write: number;
|
|
123
|
+
cost: number;
|
|
124
|
+
segments: Record<string, number>;
|
|
125
|
+
subagent_count: number;
|
|
126
|
+
runs: number;
|
|
127
|
+
turns: number;
|
|
128
|
+
status: WorkStatus;
|
|
129
|
+
session_id: string;
|
|
130
|
+
session_name: string;
|
|
131
|
+
machine: string;
|
|
132
|
+
model: string;
|
|
133
|
+
/** The model's reasoning effort; omitted when the record does not know it. */
|
|
134
|
+
thinking_level?: string;
|
|
135
|
+
prompt: string;
|
|
136
|
+
legacy_client_label: string;
|
|
137
|
+
repo_project: string;
|
|
138
|
+
schema: number;
|
|
139
|
+
agent: string;
|
|
140
|
+
agent_version?: string;
|
|
141
|
+
/** Non-default session directory, see `TaskView.sessionDir`. A measurement field, not assignment: sent on both create and update. */
|
|
142
|
+
session_dir?: string;
|
|
143
|
+
plugin: string;
|
|
144
|
+
plugin_version?: string;
|
|
145
|
+
waiting_quality: WaitingQuality;
|
|
146
|
+
cost_quality: CostQuality;
|
|
147
|
+
subagent_linkage: SubagentLinkage;
|
|
148
|
+
}
|
|
149
|
+
/** `TaskEntryPayload` minus the assignment fields — what an update sends. See the module docs' CRITICAL rule. */
|
|
150
|
+
export type TaskEntryUpdatePayload = Omit<TaskEntryPayload, "client" | "project" | "task" | "legacy_client_label">;
|
|
151
|
+
/** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
|
|
152
|
+
export declare function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryPayload;
|
|
153
|
+
/**
|
|
154
|
+
* Build the `task_entries` payload for an **update** request: measurement
|
|
155
|
+
* fields only — never `client`, `project`, `task` or `legacy_client_label`,
|
|
156
|
+
* so a re-sync can never undo a reassignment made in the web. See the
|
|
157
|
+
* module docs' CRITICAL rule.
|
|
158
|
+
*/
|
|
159
|
+
export declare function buildTaskEntryUpdatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryUpdatePayload;
|
|
160
|
+
/** One `work_records` row — raw per-`WorkRecord` detail, always `rollup: false`. */
|
|
161
|
+
export interface WorkRecordPayload {
|
|
162
|
+
kankaku_id: string;
|
|
163
|
+
task_entry: string;
|
|
164
|
+
rollup: false;
|
|
165
|
+
role: WorkRole;
|
|
166
|
+
pid: number;
|
|
167
|
+
parent_pid: number;
|
|
168
|
+
started_at: string;
|
|
169
|
+
settled_at: string;
|
|
170
|
+
wall_ms: number;
|
|
171
|
+
waiting_ms: number;
|
|
172
|
+
work_ms: number;
|
|
173
|
+
runs: number;
|
|
174
|
+
turns: number;
|
|
175
|
+
status: WorkStatus;
|
|
176
|
+
model: string;
|
|
177
|
+
thinking_level?: string;
|
|
178
|
+
input: number;
|
|
179
|
+
output: number;
|
|
180
|
+
cache_read: number;
|
|
181
|
+
cache_write: number;
|
|
182
|
+
cost: number;
|
|
183
|
+
segments: Record<string, number>;
|
|
184
|
+
tools: Record<string, number>;
|
|
185
|
+
session_id: string;
|
|
186
|
+
prompt: string;
|
|
187
|
+
machine: string;
|
|
188
|
+
schema: number;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Build one `work_records` payload from a raw {@link WorkRecord}
|
|
192
|
+
* (orchestrator or subagent), linked to its parent `task_entries` row by
|
|
193
|
+
* PocketBase id. `work_records` carries no assignment fields, so there is
|
|
194
|
+
* no create/update distinction here — this payload is sent as-is either way.
|
|
195
|
+
*/
|
|
196
|
+
export declare function buildWorkRecordPayload(record: WorkRecord, taskEntryRecordId: string, ctx: {
|
|
197
|
+
machine: string;
|
|
198
|
+
promptMode: PromptPrivacyMode;
|
|
199
|
+
}): WorkRecordPayload;
|
|
200
|
+
/** Every `WorkRecord` folded into a task: the orchestrator plus its subagents, in that order. */
|
|
201
|
+
export declare function taskWorkRecords(task: TaskView): WorkRecord[];
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
import { finiteOrZero } from "./work-record.js";
|
|
13
|
+
/**
|
|
14
|
+
* Resolve which client/project a task's `task_entries` row should link to.
|
|
15
|
+
*
|
|
16
|
+
* - A task whose `clientId` still exists in `clients` links to that client
|
|
17
|
+
* (regardless of its `active` flag — this is a historical fact, not a
|
|
18
|
+
* future selection), and to `projectId` too when it still exists and
|
|
19
|
+
* belongs to that client; otherwise the project relation is empty.
|
|
20
|
+
* - A task with no `clientId`, or whose `clientId` no longer resolves,
|
|
21
|
+
* routes to the catalog's unassigned client (the row with
|
|
22
|
+
* `unassigned: true`), carrying forward the record's free-text `client`
|
|
23
|
+
* label (or its `clientName` when the label itself is absent) as
|
|
24
|
+
* `legacyClientLabel` — the historical backfill rule (proposal §5.3).
|
|
25
|
+
*/
|
|
26
|
+
export function resolveTaskAssignment(task, clients, projects) {
|
|
27
|
+
const client = task.clientId !== undefined ? clients.find((candidate) => candidate.id === task.clientId) : undefined;
|
|
28
|
+
if (client) {
|
|
29
|
+
const project = task.projectId !== undefined ? projects.find((candidate) => candidate.id === task.projectId && candidate.clientId === client.id) : undefined;
|
|
30
|
+
return {
|
|
31
|
+
clientId: client.id,
|
|
32
|
+
projectId: project ? project.id : "",
|
|
33
|
+
legacyClientLabel: "",
|
|
34
|
+
routedToUnassigned: false,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const unassigned = clients.find((candidate) => candidate.unassigned === true);
|
|
38
|
+
return {
|
|
39
|
+
clientId: unassigned ? unassigned.id : "",
|
|
40
|
+
projectId: "",
|
|
41
|
+
legacyClientLabel: task.client ?? task.clientName ?? "",
|
|
42
|
+
routedToUnassigned: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Privacy transform for a prompt about to leave the machine
|
|
47
|
+
* (`KANKAKU_SYNC_PROMPT`, default `none`): `none` omits it entirely,
|
|
48
|
+
* `truncated` keeps the first 120 chars plus an ellipsis marker when
|
|
49
|
+
* anything was cut, `full` sends it verbatim.
|
|
50
|
+
*/
|
|
51
|
+
export function applyPromptPrivacy(prompt, mode) {
|
|
52
|
+
if (mode === "full")
|
|
53
|
+
return prompt;
|
|
54
|
+
if (mode === "truncated")
|
|
55
|
+
return prompt.length > 120 ? `${prompt.slice(0, 120)}…` : prompt;
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* PocketBase `date` fields read back as `"YYYY-MM-DD HH:MM:SS.mmmZ"` (space,
|
|
60
|
+
* not `T`) though both forms are accepted on write; kankaku always sends
|
|
61
|
+
* the space form so a round trip through the contract's documented shape
|
|
62
|
+
* never depends on PocketBase's own normalization.
|
|
63
|
+
*/
|
|
64
|
+
function toPbDate(iso) {
|
|
65
|
+
return iso.replace("T", " ");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Whether waiting time (time blocked on the human) was actually observed
|
|
69
|
+
* for this task, as opposed to `work_ms` being an unmeasured upper bound
|
|
70
|
+
* equal to `wall_ms`. pi always instruments `ui_prompt_start`/`_end` and
|
|
71
|
+
* interactive-tool spans (`domain/work-tracker.ts`), so this is always
|
|
72
|
+
* `"measured"` for kankaku/pi — a constant here, not computed from a
|
|
73
|
+
* record, kept as its own function (rather than a literal in the payload)
|
|
74
|
+
* so the reasoning has one documented home.
|
|
75
|
+
*/
|
|
76
|
+
export function computeWaitingQuality() {
|
|
77
|
+
return "measured";
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether this task's cost figure came from a real provider-reported cost
|
|
81
|
+
* (`"measured"`) or not (`"unknown"`) — never `"estimated"`: kankaku has no
|
|
82
|
+
* token-based price-table estimator today, only a real provider figure or
|
|
83
|
+
* nothing. A task counts as measured when its own orchestrator record OR
|
|
84
|
+
* any of its joined subagent records observed one (see
|
|
85
|
+
* `WorkRecord.costObserved`, set by `domain/work-tracker.ts`).
|
|
86
|
+
*/
|
|
87
|
+
export function computeCostQuality(task) {
|
|
88
|
+
const observed = task.orchestrator.costObserved === true || task.subagents.some((child) => child.costObserved === true);
|
|
89
|
+
return observed ? "measured" : "unknown";
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Whether this task's subagent tool spans (`orchestrator.subagents`,
|
|
93
|
+
* `SubagentSpan[]` — the orchestrator's own tool-call bookkeeping) are
|
|
94
|
+
* accounted for by joined child `WorkRecord`s (`task.subagents`, from
|
|
95
|
+
* `domain/task-view.ts#matchChildren`). There is no explicit per-span
|
|
96
|
+
* correlation id today (README "Subagents" > "Limitations": upstream
|
|
97
|
+
* gentle-pi does not hand a child its own task id), so this is a
|
|
98
|
+
* task-level approximation, not a per-span one:
|
|
99
|
+
*
|
|
100
|
+
* - `not_applicable`: the orchestrator opened no subagent spans at all.
|
|
101
|
+
* - `linked`: at least as many child records were joined as spans were
|
|
102
|
+
* opened — plausibly every span is accounted for (a gentle-pi subagent
|
|
103
|
+
* spawns exactly one child process per span, so counts normally match
|
|
104
|
+
* 1:1).
|
|
105
|
+
* - `unlinked`: fewer joined children than spans (including zero) — at
|
|
106
|
+
* least one span's time is only visible inside the orchestrator's own
|
|
107
|
+
* tool-call span, with no corroborating child record. This also covers
|
|
108
|
+
* a fully in-process subagent mechanism (no separate OS process at all,
|
|
109
|
+
* invisible to the registry/ancestry machinery) and a genuine join miss
|
|
110
|
+
* alike — kankaku cannot tell those apart from here, so it reports the
|
|
111
|
+
* conservative, visible-gap answer rather than guessing "linked".
|
|
112
|
+
*/
|
|
113
|
+
export function computeSubagentLinkage(task) {
|
|
114
|
+
const spanCount = task.orchestrator.subagents.length;
|
|
115
|
+
if (spanCount === 0)
|
|
116
|
+
return "not_applicable";
|
|
117
|
+
return task.subagents.length >= spanCount ? "linked" : "unlinked";
|
|
118
|
+
}
|
|
119
|
+
/** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
|
|
120
|
+
export function buildTaskEntryCreatePayload(task, ctx) {
|
|
121
|
+
const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects);
|
|
122
|
+
return {
|
|
123
|
+
task_id: task.id,
|
|
124
|
+
client: assignment.clientId,
|
|
125
|
+
project: assignment.projectId,
|
|
126
|
+
task: "",
|
|
127
|
+
started_at: toPbDate(task.startedAt),
|
|
128
|
+
ended_at: toPbDate(task.endedAt),
|
|
129
|
+
wall_ms: task.wallMs,
|
|
130
|
+
waiting_ms: task.waitingMs,
|
|
131
|
+
work_ms: task.workMs,
|
|
132
|
+
input: finiteOrZero(task.usage.input),
|
|
133
|
+
output: finiteOrZero(task.usage.output),
|
|
134
|
+
cache_read: finiteOrZero(task.usage.cacheRead),
|
|
135
|
+
cache_write: finiteOrZero(task.usage.cacheWrite),
|
|
136
|
+
cost: finiteOrZero(task.usage.cost),
|
|
137
|
+
segments: task.segments,
|
|
138
|
+
subagent_count: task.subagents.length,
|
|
139
|
+
runs: task.orchestrator.runs,
|
|
140
|
+
turns: task.orchestrator.turns,
|
|
141
|
+
status: task.status,
|
|
142
|
+
session_id: task.sessionId ?? "",
|
|
143
|
+
session_name: task.sessionName ?? "",
|
|
144
|
+
machine: ctx.machine,
|
|
145
|
+
model: task.orchestrator.model ?? "",
|
|
146
|
+
...(task.orchestrator.thinkingLevel !== undefined ? { thinking_level: task.orchestrator.thinkingLevel } : {}),
|
|
147
|
+
prompt: applyPromptPrivacy(task.prompt, ctx.promptMode),
|
|
148
|
+
legacy_client_label: assignment.legacyClientLabel,
|
|
149
|
+
repo_project: task.project,
|
|
150
|
+
schema: task.orchestrator.schema,
|
|
151
|
+
agent: ctx.agent,
|
|
152
|
+
...(ctx.agentVersion !== undefined ? { agent_version: ctx.agentVersion } : {}),
|
|
153
|
+
...(task.sessionDir !== undefined ? { session_dir: task.sessionDir } : {}),
|
|
154
|
+
plugin: ctx.plugin,
|
|
155
|
+
...(ctx.pluginVersion !== undefined ? { plugin_version: ctx.pluginVersion } : {}),
|
|
156
|
+
waiting_quality: computeWaitingQuality(),
|
|
157
|
+
cost_quality: computeCostQuality(task),
|
|
158
|
+
subagent_linkage: computeSubagentLinkage(task),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Build the `task_entries` payload for an **update** request: measurement
|
|
163
|
+
* fields only — never `client`, `project`, `task` or `legacy_client_label`,
|
|
164
|
+
* so a re-sync can never undo a reassignment made in the web. See the
|
|
165
|
+
* module docs' CRITICAL rule.
|
|
166
|
+
*/
|
|
167
|
+
export function buildTaskEntryUpdatePayload(task, ctx) {
|
|
168
|
+
const { client: _client, project: _project, task: _task, legacy_client_label: _legacy, ...rest } = buildTaskEntryCreatePayload(task, ctx);
|
|
169
|
+
return rest;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Build one `work_records` payload from a raw {@link WorkRecord}
|
|
173
|
+
* (orchestrator or subagent), linked to its parent `task_entries` row by
|
|
174
|
+
* PocketBase id. `work_records` carries no assignment fields, so there is
|
|
175
|
+
* no create/update distinction here — this payload is sent as-is either way.
|
|
176
|
+
*/
|
|
177
|
+
export function buildWorkRecordPayload(record, taskEntryRecordId, ctx) {
|
|
178
|
+
return {
|
|
179
|
+
kankaku_id: record.id,
|
|
180
|
+
task_entry: taskEntryRecordId,
|
|
181
|
+
rollup: false,
|
|
182
|
+
role: record.role,
|
|
183
|
+
pid: record.pid,
|
|
184
|
+
parent_pid: record.parentPid,
|
|
185
|
+
started_at: toPbDate(record.startedAt),
|
|
186
|
+
settled_at: toPbDate(record.settledAt),
|
|
187
|
+
wall_ms: record.wallMs,
|
|
188
|
+
waiting_ms: record.waitingMs,
|
|
189
|
+
work_ms: record.workMs,
|
|
190
|
+
runs: record.runs,
|
|
191
|
+
turns: record.turns,
|
|
192
|
+
status: record.status,
|
|
193
|
+
model: record.model ?? "",
|
|
194
|
+
...(record.thinkingLevel !== undefined ? { thinking_level: record.thinkingLevel } : {}),
|
|
195
|
+
input: finiteOrZero(record.usage.input),
|
|
196
|
+
output: finiteOrZero(record.usage.output),
|
|
197
|
+
cache_read: finiteOrZero(record.usage.cacheRead),
|
|
198
|
+
cache_write: finiteOrZero(record.usage.cacheWrite),
|
|
199
|
+
cost: finiteOrZero(record.usage.cost),
|
|
200
|
+
segments: record.segments ?? {},
|
|
201
|
+
tools: record.tools,
|
|
202
|
+
session_id: record.sessionId ?? "",
|
|
203
|
+
prompt: applyPromptPrivacy(record.prompt, ctx.promptMode),
|
|
204
|
+
machine: ctx.machine,
|
|
205
|
+
schema: record.schema,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** Every `WorkRecord` folded into a task: the orchestrator plus its subagents, in that order. */
|
|
209
|
+
export function taskWorkRecords(task) {
|
|
210
|
+
return [task.orchestrator, ...task.subagents];
|
|
211
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public library entrypoint (`kankaku/domain`): the pure domain layer, with
|
|
3
|
+
* no I/O and no pi imports. See AGENTS.md "Architecture (hexagonal)" and
|
|
4
|
+
* odd/tasks/library-exports.md.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./ancestry-match.ts";
|
|
7
|
+
export * from "./client-label.ts";
|
|
8
|
+
export * from "./day.ts";
|
|
9
|
+
export * from "./export.ts";
|
|
10
|
+
export * from "./hub-entry.ts";
|
|
11
|
+
export * from "./intervals.ts";
|
|
12
|
+
export * from "./registry-health.ts";
|
|
13
|
+
export * from "./segment-rule.ts";
|
|
14
|
+
export * from "./subagent-profile.ts";
|
|
15
|
+
export * from "./sync-plan.ts";
|
|
16
|
+
export * from "./task-view.ts";
|
|
17
|
+
export * from "./work-record.ts";
|
|
18
|
+
export * from "./work-target.ts";
|
|
19
|
+
export * from "./work-tracker.ts";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public library entrypoint (`kankaku/domain`): the pure domain layer, with
|
|
3
|
+
* no I/O and no pi imports. See AGENTS.md "Architecture (hexagonal)" and
|
|
4
|
+
* odd/tasks/library-exports.md.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./ancestry-match.js";
|
|
7
|
+
export * from "./client-label.js";
|
|
8
|
+
export * from "./day.js";
|
|
9
|
+
export * from "./export.js";
|
|
10
|
+
export * from "./hub-entry.js";
|
|
11
|
+
export * from "./intervals.js";
|
|
12
|
+
export * from "./registry-health.js";
|
|
13
|
+
export * from "./segment-rule.js";
|
|
14
|
+
export * from "./subagent-profile.js";
|
|
15
|
+
export * from "./sync-plan.js";
|
|
16
|
+
export * from "./task-view.js";
|
|
17
|
+
export * from "./work-record.js";
|
|
18
|
+
export * from "./work-target.js";
|
|
19
|
+
export * from "./work-tracker.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface Interval {
|
|
2
|
+
start: number;
|
|
3
|
+
end: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Restrict every interval to `[windowStart, windowEnd]` and drop any that
|
|
7
|
+
* become empty (or invalid) after clamping.
|
|
8
|
+
*/
|
|
9
|
+
export declare function clampIntervals(intervals: Interval[], windowStart: number, windowEnd: number): Interval[];
|
|
10
|
+
/**
|
|
11
|
+
* Total duration covered by the union of the given intervals — never the
|
|
12
|
+
* sum, so overlapping (e.g. parallel subagent) spans are not double-counted.
|
|
13
|
+
*/
|
|
14
|
+
export declare function unionMs(intervals: Array<{
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
}>): number;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restrict every interval to `[windowStart, windowEnd]` and drop any that
|
|
3
|
+
* become empty (or invalid) after clamping.
|
|
4
|
+
*/
|
|
5
|
+
export function clampIntervals(intervals, windowStart, windowEnd) {
|
|
6
|
+
return intervals
|
|
7
|
+
.map((interval) => ({
|
|
8
|
+
start: Math.max(interval.start, windowStart),
|
|
9
|
+
end: Math.min(interval.end, windowEnd),
|
|
10
|
+
}))
|
|
11
|
+
.filter((interval) => interval.end > interval.start);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Total duration covered by the union of the given intervals — never the
|
|
15
|
+
* sum, so overlapping (e.g. parallel subagent) spans are not double-counted.
|
|
16
|
+
*/
|
|
17
|
+
export function unionMs(intervals) {
|
|
18
|
+
const sorted = [...intervals].sort((a, b) => a.start - b.start);
|
|
19
|
+
let total = 0;
|
|
20
|
+
let currentStart;
|
|
21
|
+
let currentEnd;
|
|
22
|
+
for (const interval of sorted) {
|
|
23
|
+
if (interval.end <= interval.start)
|
|
24
|
+
continue;
|
|
25
|
+
if (currentStart === undefined || currentEnd === undefined) {
|
|
26
|
+
currentStart = interval.start;
|
|
27
|
+
currentEnd = interval.end;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (interval.start <= currentEnd) {
|
|
31
|
+
currentEnd = Math.max(currentEnd, interval.end);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
total += currentEnd - currentStart;
|
|
35
|
+
currentStart = interval.start;
|
|
36
|
+
currentEnd = interval.end;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (currentStart !== undefined && currentEnd !== undefined) {
|
|
40
|
+
total += currentEnd - currentStart;
|
|
41
|
+
}
|
|
42
|
+
return total;
|
|
43
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide which machine-wide {@link RegistryEntry} rows a sweep should keep
|
|
3
|
+
* vs discard, and why. Pure, no I/O — `adapters/machine-process-registry.ts`
|
|
4
|
+
* drives this with real `isAlive`/`liveStartId`/`now`, and
|
|
5
|
+
* `adapters/kankaku-command.ts`'s `/kankaku doctor` reuses it (cheap mode,
|
|
6
|
+
* no fresh identity re-verification) to report registry health.
|
|
7
|
+
*/
|
|
8
|
+
import type { RegistryEntry } from "../ports/process-registry.ts";
|
|
9
|
+
/** Sane last-resort ceiling on an entry's age, regardless of aliveness/identity: 7 days. */
|
|
10
|
+
export declare const DEFAULT_MAX_ENTRY_AGE_MS: number;
|
|
11
|
+
export type DiscardReason = "dead"
|
|
12
|
+
/** The pid is alive, but its live start identity no longer matches what this entry recorded: the OS has reused this pid for a different process instance. */
|
|
13
|
+
| "stale-reuse" | "over-age";
|
|
14
|
+
export interface RegistryClassifyDeps {
|
|
15
|
+
isAlive: (pid: number) => boolean;
|
|
16
|
+
/** Live start identity for a pid, from the same ancestry snapshot the caller already took. `undefined` means "unknown" — never treated as evidence of reuse. */
|
|
17
|
+
liveStartId: (pid: number) => number | undefined;
|
|
18
|
+
now: number;
|
|
19
|
+
maxAgeMs: number;
|
|
20
|
+
}
|
|
21
|
+
export interface RegistryClassification {
|
|
22
|
+
keep: RegistryEntry[];
|
|
23
|
+
discard: Array<{
|
|
24
|
+
entry: RegistryEntry;
|
|
25
|
+
reason: DiscardReason;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Classify every entry except `ownPid`'s (the caller's own, just-written
|
|
30
|
+
* entry — always kept, never re-evaluated against its own freshly-recorded
|
|
31
|
+
* data). An entry is discarded the first reason that applies, in this
|
|
32
|
+
* order: dead pid; alive but identity mismatched beyond
|
|
33
|
+
* {@link START_ID_TOLERANCE_MS} (pid reuse) — only ever checked when the
|
|
34
|
+
* entry actually carries a `processStartId`; older than `maxAgeMs`.
|
|
35
|
+
* Anything else is kept.
|
|
36
|
+
*
|
|
37
|
+
* An entry with no verifiable `processStartId` at all (written by a build
|
|
38
|
+
* predating this field, or a torn/partial write) is **never used for
|
|
39
|
+
* identity matching** (`domain/ancestry-match.ts#findAncestorEntry` already
|
|
40
|
+
* requires both sides to carry a start id) — but that alone is no longer
|
|
41
|
+
* grounds for deletion here (F4): a live, in-age entry that merely cannot be
|
|
42
|
+
* verified is kept, exactly like a verified one, so a sweep run by an
|
|
43
|
+
* unrelated sibling process can never un-register a genuinely live
|
|
44
|
+
* orchestrator whose own start-time read happened to fail. It still gets
|
|
45
|
+
* cleaned up the ordinary way once its pid dies or it ages out — dead and
|
|
46
|
+
* over-age entries are discarded regardless of whether they carry a
|
|
47
|
+
* `processStartId`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function classifyRegistryEntries(entries: RegistryEntry[], ownPid: number, deps: RegistryClassifyDeps): RegistryClassification;
|