killeros 2.1.22 → 2.1.24
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/CHANGELOG.md +23 -0
- package/README.md +18 -23
- package/killeros/change-receipt.ts +1 -1
- package/killeros/commands.ts +1 -1
- package/killeros/display.ts +16 -7
- package/killeros/footer.ts +2 -7
- package/killeros/goal-command.ts +10 -107
- package/killeros/goal-interface.ts +48 -227
- package/killeros/goal-runtime.ts +1 -3
- package/killeros/goal-state.ts +29 -83
- package/killeros/hooks.ts +2 -82
- package/killeros/runtime.ts +0 -7
- package/killeros/worked-for.ts +59 -8
- package/package.json +8 -7
- package/killeros/goal-history.ts +0 -71
package/killeros/hooks.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import {
|
|
3
2
|
spawn,
|
|
4
3
|
type SpawnOptionsWithStdioTuple,
|
|
@@ -9,8 +8,6 @@ import path from "node:path";
|
|
|
9
8
|
import { StringDecoder } from "node:string_decoder";
|
|
10
9
|
import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
10
|
import { errorMessage, reportError } from "./errors.ts";
|
|
12
|
-
import { GOAL_CHECK_NAME_PATTERN } from "./goal-state.ts";
|
|
13
|
-
import type { GoalCompletionCheck } from "./runtime.ts";
|
|
14
11
|
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
15
12
|
|
|
16
13
|
type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
|
|
@@ -21,14 +18,8 @@ interface KillerosHook {
|
|
|
21
18
|
timeoutMs?: number;
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
interface KillerosGoalCheck {
|
|
25
|
-
command: string;
|
|
26
|
-
timeoutMs?: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
21
|
interface KillerosHookConfig {
|
|
30
22
|
hooks?: Partial<Record<KillerosHookEvent, KillerosHook[]>>;
|
|
31
|
-
goalChecks?: Record<string, KillerosGoalCheck>;
|
|
32
23
|
}
|
|
33
24
|
|
|
34
25
|
interface HookExecutionResult {
|
|
@@ -70,8 +61,6 @@ const HOOK_OUTPUT_LIMIT = 16 * 1024;
|
|
|
70
61
|
const HOOK_PAYLOAD_LIMIT = 8_000;
|
|
71
62
|
const HOOK_TIMEOUT_DEFAULT_MS = 30_000;
|
|
72
63
|
const HOOK_TIMEOUT_MAX_MS = 300_000;
|
|
73
|
-
const GOAL_CHECK_LIMIT = 32;
|
|
74
|
-
const GOAL_CHECK_COMMAND_LIMIT = 8_000;
|
|
75
64
|
|
|
76
65
|
// Reads executable project configuration through a bounded, project-local file descriptor.
|
|
77
66
|
function readHookConfig(configPath: string, projectRoot: string): string {
|
|
@@ -122,12 +111,11 @@ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
|
|
122
111
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
123
112
|
}
|
|
124
113
|
|
|
125
|
-
function loadKillerosConfig(ctx: ExtensionContext
|
|
114
|
+
function loadKillerosConfig(ctx: ExtensionContext): KillerosHookConfig {
|
|
126
115
|
const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
|
|
127
116
|
if (!existsSync(configPath)) return {};
|
|
128
117
|
const displayPath = safeTerminalText(configPath).replaceAll("\n", "");
|
|
129
118
|
if (!ctx.isProjectTrusted()) {
|
|
130
|
-
if (strictGoalChecks) throw new Error("Goal completion checks require a trusted project");
|
|
131
119
|
ctx.ui.notify(`Ignored untrusted project hooks in ${displayPath}`, "warning");
|
|
132
120
|
return {};
|
|
133
121
|
}
|
|
@@ -181,34 +169,8 @@ function loadKillerosConfig(ctx: ExtensionContext, strictGoalChecks = false): Ki
|
|
|
181
169
|
hooks[event] = accepted;
|
|
182
170
|
}
|
|
183
171
|
|
|
184
|
-
|
|
185
|
-
try {
|
|
186
|
-
const candidates = parsed.goalChecks;
|
|
187
|
-
if (candidates !== undefined) {
|
|
188
|
-
if (!isUnknownRecord(candidates)) throw new Error("goalChecks must contain a JSON object");
|
|
189
|
-
const entries = Object.entries(candidates);
|
|
190
|
-
if (entries.length > GOAL_CHECK_LIMIT) throw new Error(`goalChecks may contain at most ${GOAL_CHECK_LIMIT} checks`);
|
|
191
|
-
goalChecks = {};
|
|
192
|
-
for (const [name, candidate] of entries) {
|
|
193
|
-
if (!GOAL_CHECK_NAME_PATTERN.test(name)) throw new Error(`Invalid goal check name: ${JSON.stringify(name)}`);
|
|
194
|
-
if (!isUnknownRecord(candidate)) throw new Error(`Goal check ${name} must contain a JSON object`);
|
|
195
|
-
const { command, timeoutMs } = candidate;
|
|
196
|
-
if (typeof command !== "string" || command.trim().length < 1 || command.trim().length > GOAL_CHECK_COMMAND_LIMIT) {
|
|
197
|
-
throw new Error(`Goal check ${name} command must contain 1 to ${GOAL_CHECK_COMMAND_LIMIT} characters`);
|
|
198
|
-
}
|
|
199
|
-
if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
|
|
200
|
-
throw new Error(`Goal check ${name} timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`);
|
|
201
|
-
}
|
|
202
|
-
goalChecks[name] = { command: command.trim(), ...(timeoutMs === undefined ? {} : { timeoutMs }) };
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
} catch (error) {
|
|
206
|
-
if (strictGoalChecks) throw error;
|
|
207
|
-
reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json goalChecks`, error);
|
|
208
|
-
}
|
|
209
|
-
return { hooks, ...(goalChecks === undefined ? {} : { goalChecks }) };
|
|
172
|
+
return { hooks };
|
|
210
173
|
} catch (error) {
|
|
211
|
-
if (strictGoalChecks) throw error;
|
|
212
174
|
reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json`, error);
|
|
213
175
|
return {};
|
|
214
176
|
}
|
|
@@ -427,48 +389,6 @@ function hookFailureMessage(result: HookExecutionResult): string {
|
|
|
427
389
|
return safeTerminalText(`Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}\n${detail}`);
|
|
428
390
|
}
|
|
429
391
|
|
|
430
|
-
function goalCheckHash(check: KillerosGoalCheck): string {
|
|
431
|
-
return createHash("sha256")
|
|
432
|
-
.update(JSON.stringify({ command: check.command, timeoutMs: check.timeoutMs ?? HOOK_TIMEOUT_DEFAULT_MS }))
|
|
433
|
-
.digest("hex");
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/** Lists validated completion-check names without exposing their definitions. */
|
|
437
|
-
export function listGoalCompletionChecks(ctx: ExtensionContext): readonly string[] {
|
|
438
|
-
if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
|
|
439
|
-
return Object.keys(loadKillerosConfig(ctx, true).goalChecks ?? {}).sort();
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
export function resolveGoalCompletionCheck(ctx: ExtensionContext, name: string): GoalCompletionCheck {
|
|
443
|
-
if (!GOAL_CHECK_NAME_PATTERN.test(name)) throw new Error("Invalid goal completion check name");
|
|
444
|
-
if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
|
|
445
|
-
const check = loadKillerosConfig(ctx, true).goalChecks?.[name];
|
|
446
|
-
if (!check) throw new Error(`Unknown goal completion check: ${safeTerminalText(name)}`);
|
|
447
|
-
return { kind: "named-command", name, configHash: goalCheckHash(check) };
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
export async function runGoalCompletionCheck(
|
|
451
|
-
ctx: ExtensionContext,
|
|
452
|
-
bound: GoalCompletionCheck,
|
|
453
|
-
signal?: AbortSignal,
|
|
454
|
-
): Promise<void> {
|
|
455
|
-
if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
|
|
456
|
-
const check = loadKillerosConfig(ctx, true).goalChecks?.[bound.name];
|
|
457
|
-
if (!check) throw new Error(`Unknown goal completion check: ${safeTerminalText(bound.name)}`);
|
|
458
|
-
if (goalCheckHash(check) !== bound.configHash) {
|
|
459
|
-
throw new Error(`Goal completion check ${safeTerminalText(bound.name)} changed; run /goal check ${safeTerminalText(bound.name)} to approve it`);
|
|
460
|
-
}
|
|
461
|
-
const result = await executeHook({
|
|
462
|
-
command: check.command,
|
|
463
|
-
cwd: ctx.cwd,
|
|
464
|
-
environment: { KILLEROS_EVENT: "goal_check", KILLEROS_GOAL_CHECK: bound.name },
|
|
465
|
-
timeoutMs: check.timeoutMs,
|
|
466
|
-
signal,
|
|
467
|
-
});
|
|
468
|
-
if (result.cancelled) throw new Error(`Goal completion check ${safeTerminalText(bound.name)} was cancelled`);
|
|
469
|
-
if (result.code !== 0) throw new Error(hookFailureMessage(result).replace(/^Hook failed/u, `Goal completion check ${safeTerminalText(bound.name)} failed`));
|
|
470
|
-
}
|
|
471
|
-
|
|
472
392
|
export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
473
393
|
let config: KillerosHookConfig = {};
|
|
474
394
|
pi.on("session_start", (_event, ctx) => { config = loadKillerosConfig(ctx); });
|
package/killeros/runtime.ts
CHANGED
|
@@ -39,12 +39,6 @@ export interface GoalFileVerification {
|
|
|
39
39
|
baseline: GoalFileBaseline;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export interface GoalCompletionCheck {
|
|
43
|
-
kind: "named-command";
|
|
44
|
-
name: string;
|
|
45
|
-
configHash: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
42
|
export interface GoalStateCommon {
|
|
49
43
|
version: 1;
|
|
50
44
|
revision: number;
|
|
@@ -56,7 +50,6 @@ export interface GoalStateCommon {
|
|
|
56
50
|
blockedAuditStartTurn: number;
|
|
57
51
|
baselineTokens: number;
|
|
58
52
|
verification?: GoalFileVerification;
|
|
59
|
-
completionCheck?: GoalCompletionCheck;
|
|
60
53
|
maxTurns?: number;
|
|
61
54
|
}
|
|
62
55
|
|
package/killeros/worked-for.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type ChangedFile,
|
|
13
13
|
type CheckAttempt,
|
|
14
14
|
} from "./change-receipt.ts";
|
|
15
|
-
import { formatTokens } from "./display.ts";
|
|
15
|
+
import { formatTokens, modelDisplayName } from "./display.ts";
|
|
16
16
|
import { errorMessage } from "./errors.ts";
|
|
17
17
|
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
18
18
|
|
|
@@ -47,15 +47,16 @@ export interface WorkedForEntryDataV4 {
|
|
|
47
47
|
changes: ChangeSummary;
|
|
48
48
|
checks: CheckAttempt[];
|
|
49
49
|
omittedChecks: { passed: number; failed: number };
|
|
50
|
+
model?: string;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
type WorkedForEntryData = WorkedForEntryDataV1 | WorkedForEntryDataV2 | WorkedForEntryDataV3 | WorkedForEntryDataV4;
|
|
53
54
|
|
|
54
55
|
const OUTCOMES = {
|
|
55
|
-
done: {
|
|
56
|
-
stopped: {
|
|
57
|
-
failed: {
|
|
58
|
-
} as const satisfies Record<WorkedForOutcome, {
|
|
56
|
+
done: { label: "Done", color: "success" },
|
|
57
|
+
stopped: { label: "■ Stopped", color: "warning" },
|
|
58
|
+
failed: { label: "× Failed", color: "error" },
|
|
59
|
+
} as const satisfies Record<WorkedForOutcome, { label: string; color: string }>;
|
|
59
60
|
|
|
60
61
|
function isWorkedForOutcome(value: unknown): value is WorkedForOutcome {
|
|
61
62
|
return value === "done" || value === "stopped" || value === "failed";
|
|
@@ -121,6 +122,12 @@ function parseChanges(value: unknown): ChangeSummary | undefined {
|
|
|
121
122
|
};
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
function parseModelName(value: unknown): string | undefined {
|
|
126
|
+
if (typeof value !== "string" || value.length > 200) return undefined;
|
|
127
|
+
const sanitized = safeTerminalText(value).replaceAll("\n", "").trim();
|
|
128
|
+
return sanitized || undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
124
131
|
function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefined {
|
|
125
132
|
try {
|
|
126
133
|
if (Buffer.byteLength(JSON.stringify(data), "utf8") > MAX_PAYLOAD_BYTES) return undefined;
|
|
@@ -139,6 +146,7 @@ function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefine
|
|
|
139
146
|
if (!label) return undefined;
|
|
140
147
|
checks.push({ label, outcome: check.outcome });
|
|
141
148
|
}
|
|
149
|
+
const model = parseModelName(data.model);
|
|
142
150
|
return {
|
|
143
151
|
version: 4,
|
|
144
152
|
milliseconds: data.milliseconds,
|
|
@@ -147,6 +155,7 @@ function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefine
|
|
|
147
155
|
changes,
|
|
148
156
|
checks,
|
|
149
157
|
omittedChecks: { passed: data.omittedChecks.passed, failed: data.omittedChecks.failed },
|
|
158
|
+
...(model ? { model } : {}),
|
|
150
159
|
};
|
|
151
160
|
}
|
|
152
161
|
|
|
@@ -210,8 +219,10 @@ class WorkedForV4Component implements Component {
|
|
|
210
219
|
if (width <= 0) return [];
|
|
211
220
|
const { data, theme } = this;
|
|
212
221
|
const outcome = OUTCOMES[data.outcome];
|
|
222
|
+
const headline = theme.fg(outcome.color, outcome.label);
|
|
223
|
+
const modelSuffix = data.model ? ` · ${data.model}` : "";
|
|
213
224
|
const lines = [
|
|
214
|
-
`${
|
|
225
|
+
`${headline}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)} · ↑ ${formatTokens(data.tokens)} tokens${modelSuffix}`)}`,
|
|
215
226
|
];
|
|
216
227
|
if (data.changes.state === "unavailable") lines.push(theme.fg("dim", " Changes unavailable"));
|
|
217
228
|
else if (data.changes.totalFiles === 0) lines.push(theme.fg("dim", " No files changed"));
|
|
@@ -272,8 +283,22 @@ type ActiveReceipt = {
|
|
|
272
283
|
collection: Promise<ChangeReceiptCollection>;
|
|
273
284
|
checks: CheckAttempt[];
|
|
274
285
|
omittedChecks: { passed: number; failed: number };
|
|
286
|
+
modelProvider: string | undefined;
|
|
287
|
+
modelId: string | undefined;
|
|
288
|
+
modelMismatch: boolean;
|
|
275
289
|
};
|
|
276
290
|
|
|
291
|
+
function receiptModelName(
|
|
292
|
+
settled: ActiveReceipt,
|
|
293
|
+
model: ExtensionContext["model"],
|
|
294
|
+
): string | undefined {
|
|
295
|
+
if (settled.modelMismatch || settled.modelProvider === undefined || settled.modelId === undefined) return undefined;
|
|
296
|
+
if (!model || model.provider !== settled.modelProvider || model.id !== settled.modelId) return undefined;
|
|
297
|
+
const resolved = modelDisplayName(model);
|
|
298
|
+
if (!resolved || resolved.length > 200) return undefined;
|
|
299
|
+
return resolved;
|
|
300
|
+
}
|
|
301
|
+
|
|
277
302
|
function fitPayload(data: WorkedForEntryDataV4): WorkedForEntryDataV4 {
|
|
278
303
|
if (data.changes.state === "unavailable") return data;
|
|
279
304
|
const changes = { ...data.changes, files: [...data.changes.files] };
|
|
@@ -300,7 +325,8 @@ export function registerWorkedFor(
|
|
|
300
325
|
if (data.version === 4) return new WorkedForV4Component(data, options.expanded, theme);
|
|
301
326
|
const outcome = OUTCOMES[data.outcome];
|
|
302
327
|
const tokens = data.version === 3 ? ` · ↑ ${formatTokens(data.tokens)} tokens` : "";
|
|
303
|
-
|
|
328
|
+
const headline = theme.fg(outcome.color, outcome.label);
|
|
329
|
+
return new Text(`${headline}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}${tokens}`)}`, 0, 0);
|
|
304
330
|
});
|
|
305
331
|
|
|
306
332
|
pi.on("session_start", async () => {
|
|
@@ -319,12 +345,35 @@ export function registerWorkedFor(
|
|
|
319
345
|
collection: collect(ctx.cwd),
|
|
320
346
|
checks: [],
|
|
321
347
|
omittedChecks: { passed: 0, failed: 0 },
|
|
348
|
+
modelProvider: undefined,
|
|
349
|
+
modelId: undefined,
|
|
350
|
+
modelMismatch: false,
|
|
322
351
|
};
|
|
323
352
|
active = state;
|
|
324
353
|
const collection = await state.collection;
|
|
325
354
|
if (active !== state) await collection.dispose();
|
|
326
355
|
});
|
|
327
356
|
|
|
357
|
+
pi.on("message_end", (event, ctx) => {
|
|
358
|
+
if (ctx.mode !== "tui" || !active) return;
|
|
359
|
+
if (event.message.role !== "assistant") return;
|
|
360
|
+
const provider: unknown = event.message.provider;
|
|
361
|
+
const modelId: unknown = event.message.model;
|
|
362
|
+
if (typeof provider !== "string" || typeof modelId !== "string") {
|
|
363
|
+
active.modelMismatch = true;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (active.modelProvider === undefined || active.modelId === undefined) {
|
|
367
|
+
active.modelProvider = provider;
|
|
368
|
+
active.modelId = modelId;
|
|
369
|
+
} else if (active.modelProvider !== provider || active.modelId !== modelId) {
|
|
370
|
+
active.modelMismatch = true;
|
|
371
|
+
}
|
|
372
|
+
if (event.message.responseModel !== undefined && event.message.responseModel !== modelId) {
|
|
373
|
+
active.modelMismatch = true;
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
328
377
|
pi.on("tool_result", (event: ToolResultEvent, ctx) => {
|
|
329
378
|
if (ctx.mode !== "tui" || !active || event.toolName !== "bash" && event.toolName !== "powershell") return;
|
|
330
379
|
const check = recognizedCheck(event.input.command, event.isError);
|
|
@@ -347,8 +396,9 @@ export function registerWorkedFor(
|
|
|
347
396
|
if (ctx.mode !== "tui" || !active) return;
|
|
348
397
|
const settled = active;
|
|
349
398
|
active = undefined;
|
|
399
|
+
const model = receiptModelName(settled, ctx.model);
|
|
350
400
|
const changes = await (await settled.collection).finish();
|
|
351
|
-
if (changes.state === "unavailable" && changes.reason !== "not-git" && !collectionNoticeShown) {
|
|
401
|
+
if (changes.state === "unavailable" && changes.reason !== "not-git" && changes.reason !== "timeout" && !collectionNoticeShown) {
|
|
352
402
|
collectionNoticeShown = true;
|
|
353
403
|
ctx.ui.notify(`Change receipt unavailable: ${changes.reason}`, "warning");
|
|
354
404
|
}
|
|
@@ -361,6 +411,7 @@ export function registerWorkedFor(
|
|
|
361
411
|
changes,
|
|
362
412
|
checks: settled.checks,
|
|
363
413
|
omittedChecks: settled.omittedChecks,
|
|
414
|
+
...(model ? { model } : {}),
|
|
364
415
|
});
|
|
365
416
|
try {
|
|
366
417
|
pi.appendEntry<WorkedForEntryDataV4>(WORKED_FOR_ENTRY_TYPE, data);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "killeros",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.24",
|
|
4
4
|
"description": "TUI, goals, and workflow automation for the Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -44,15 +44,16 @@
|
|
|
44
44
|
]
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"@earendil-works/pi-ai": ">=0.
|
|
48
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
49
|
-
"@earendil-works/pi-tui": ">=0.
|
|
47
|
+
"@earendil-works/pi-ai": ">=0.85.0 <1",
|
|
48
|
+
"@earendil-works/pi-coding-agent": ">=0.85.0 <1",
|
|
49
|
+
"@earendil-works/pi-tui": ">=0.85.0 <1",
|
|
50
50
|
"typebox": ">=1.1.38 <2"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@earendil-works/pi-ai": "0.
|
|
54
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
55
|
-
"@earendil-works/pi-
|
|
53
|
+
"@earendil-works/pi-ai": "0.85.0",
|
|
54
|
+
"@earendil-works/pi-coding-agent": "0.85.0",
|
|
55
|
+
"@earendil-works/pi-server": "0.85.0",
|
|
56
|
+
"@earendil-works/pi-tui": "0.85.0",
|
|
56
57
|
"@types/node": "24.12.4",
|
|
57
58
|
"eslint": "^10.9.1",
|
|
58
59
|
"typebox": "1.3.20",
|
package/killeros/goal-history.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { formatTime, formatTokens } from "./display.ts";
|
|
2
|
-
import { parseGoalState } from "./goal-state.ts";
|
|
3
|
-
import type { GoalState } from "./runtime.ts";
|
|
4
|
-
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
5
|
-
|
|
6
|
-
const GOAL_ENTRY_TYPE = "killeros-goal";
|
|
7
|
-
const VALID_GOAL_EVENTS: ReadonlySet<string> = new Set([
|
|
8
|
-
"set", "replace", "edit", "check", "limit", "turn", "pause", "resume",
|
|
9
|
-
"blocker-audit", "blocked", "complete", "error", "clear", "checkpoint",
|
|
10
|
-
]);
|
|
11
|
-
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
|
12
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function tokenUsage(entry: Record<string, unknown>): number | undefined {
|
|
16
|
-
if (entry.type === "message" && isUnknownRecord(entry.message)
|
|
17
|
-
&& (entry.message.role === "assistant" || entry.message.role === "toolResult")
|
|
18
|
-
&& isUnknownRecord(entry.message.usage)
|
|
19
|
-
&& typeof entry.message.usage.totalTokens === "number"
|
|
20
|
-
&& Number.isFinite(entry.message.usage.totalTokens)
|
|
21
|
-
&& entry.message.usage.totalTokens >= 0) {
|
|
22
|
-
return entry.message.usage.totalTokens;
|
|
23
|
-
}
|
|
24
|
-
if ((entry.type === "compaction" || entry.type === "branch_summary")
|
|
25
|
-
&& isUnknownRecord(entry.usage)
|
|
26
|
-
&& typeof entry.usage.totalTokens === "number"
|
|
27
|
-
&& Number.isFinite(entry.usage.totalTokens)
|
|
28
|
-
&& entry.usage.totalTokens >= 0) {
|
|
29
|
-
return entry.usage.totalTokens;
|
|
30
|
-
}
|
|
31
|
-
return undefined;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function preview(value: string): string {
|
|
35
|
-
const safe = safeTerminalText(value).replaceAll("\n", " ").trim();
|
|
36
|
-
const characters = [...safe];
|
|
37
|
-
return characters.length <= 160 ? safe : `${characters.slice(0, 159).join("")}…`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function eventDetail(event: string, state: GoalState): string {
|
|
41
|
-
if (event === "check") return state.completionCheck ? `check ${state.completionCheck.name}` : "check cleared";
|
|
42
|
-
if (event === "limit") return state.maxTurns === undefined ? "limit cleared" : `limit ${state.maxTurns}`;
|
|
43
|
-
if (event === "blocker-audit" && state.blockerAudit) {
|
|
44
|
-
return `Blocker ${state.blockerAudit.streak}/3: ${state.blockerAudit.evidence ?? state.blockerAudit.key}`;
|
|
45
|
-
}
|
|
46
|
-
return state.result || state.objective;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Projects branch entries into the latest bounded goal-history rows. */
|
|
50
|
-
export function formatGoalHistory(entries: readonly unknown[], count: number): string | undefined {
|
|
51
|
-
const lines: string[] = [];
|
|
52
|
-
let tokens = 0;
|
|
53
|
-
let previousState: GoalState | undefined;
|
|
54
|
-
for (const value of entries) {
|
|
55
|
-
if (!isUnknownRecord(value)) continue;
|
|
56
|
-
const usage = tokenUsage(value);
|
|
57
|
-
if (usage !== undefined) {
|
|
58
|
-
tokens += usage;
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
if (value.type !== "custom" || value.customType !== GOAL_ENTRY_TYPE || !isUnknownRecord(value.data)) continue;
|
|
62
|
-
const event = value.data.event;
|
|
63
|
-
if (typeof event !== "string" || !VALID_GOAL_EVENTS.has(event)) continue;
|
|
64
|
-
const state = value.data.state === null ? previousState : parseGoalState(value.data.state);
|
|
65
|
-
if (!state) continue;
|
|
66
|
-
if (value.data.state !== null) previousState = state;
|
|
67
|
-
if (event === "turn" || event === "checkpoint") continue;
|
|
68
|
-
lines.push(`+${formatTime(Math.max(0, state.updatedAt - state.createdAt))} ${event} turn ${state.turns} ${formatTokens(Math.max(0, tokens - state.baselineTokens))} tokens ${preview(eventDetail(event, state))}`);
|
|
69
|
-
}
|
|
70
|
-
return lines.length ? lines.slice(-count).join("\n") : undefined;
|
|
71
|
-
}
|