killeros 2.0.21 → 2.1.22
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 +35 -0
- package/Killeros.ts +5 -2
- package/README.md +25 -3
- package/killeros/auto-compaction.ts +56 -15
- package/killeros/change-receipt.ts +727 -0
- package/killeros/commands.ts +1 -1
- package/killeros/footer.ts +157 -32
- package/killeros/goal-command.ts +120 -0
- package/killeros/goal-history.ts +71 -0
- package/killeros/goal-interface.ts +599 -0
- package/killeros/goal-runtime.ts +349 -0
- package/killeros/goal-settlement.ts +261 -0
- package/killeros/goal-state.ts +95 -13
- package/killeros/hooks.ts +144 -20
- package/killeros/personal-instructions.ts +5 -0
- package/killeros/runtime.ts +9 -0
- package/killeros/worked-for.ts +271 -61
- package/package.json +3 -2
- package/killeros/goals.ts +0 -1022
package/killeros/commands.ts
CHANGED
|
@@ -70,7 +70,7 @@ const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
|
|
|
70
70
|
];
|
|
71
71
|
|
|
72
72
|
const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
|
|
73
|
-
goal: "/goal [objective|clear|edit|pause|resume]",
|
|
73
|
+
goal: "/goal [objective|start|check|checks|limit|history|clear|edit|pause|resume]",
|
|
74
74
|
handoff: "/handoff [next-session focus]",
|
|
75
75
|
variants: "/variants [level]",
|
|
76
76
|
model: "/model [provider/model]",
|
package/killeros/footer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { watch } from "node:fs";
|
|
2
3
|
import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
|
|
4
5
|
import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
|
|
@@ -9,18 +10,45 @@ import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
|
9
10
|
import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
|
|
10
11
|
|
|
11
12
|
const GIT_STATUS_REFRESH_INTERVAL_MS = 30_000;
|
|
13
|
+
const GIT_STATUS_TIMEOUT_MS = 5_000;
|
|
14
|
+
const GIT_STATUS_WATCH_DEBOUNCE_MS = 250;
|
|
15
|
+
const GIT_STATUS_WATCH_INTERVAL_MS = 5_000;
|
|
12
16
|
const CODEX_PROVIDER = "openai-codex";
|
|
13
17
|
const colorDirectory = (text: string): string => `\x1B[38;2;240;248;154m${text}\x1B[39m`;
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
export interface GitFileChanges {
|
|
20
|
+
modified: number;
|
|
21
|
+
added: number;
|
|
22
|
+
deleted: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type GitStatusExecutor = (
|
|
26
|
+
file: string,
|
|
27
|
+
args: string[],
|
|
28
|
+
options: {
|
|
29
|
+
encoding: "utf8";
|
|
30
|
+
env: NodeJS.ProcessEnv;
|
|
31
|
+
maxBuffer: number;
|
|
32
|
+
timeout: number;
|
|
33
|
+
windowsHide: true;
|
|
34
|
+
},
|
|
35
|
+
callback: (error: Error | null, stdout: string) => void,
|
|
36
|
+
) => unknown;
|
|
37
|
+
|
|
38
|
+
/** Resolves changed-file counts with a bounded asynchronous Git status process. */
|
|
39
|
+
export function resolveGitFileChanges(
|
|
40
|
+
cwd: string,
|
|
41
|
+
execute: GitStatusExecutor = execFile,
|
|
42
|
+
): Promise<GitFileChanges | undefined> {
|
|
16
43
|
return new Promise((resolve) => {
|
|
17
|
-
|
|
44
|
+
execute(
|
|
18
45
|
"git",
|
|
19
46
|
["-C", cwd, "status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
20
47
|
{
|
|
21
48
|
encoding: "utf8",
|
|
49
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
22
50
|
maxBuffer: 4 * 1024 * 1024,
|
|
23
|
-
timeout:
|
|
51
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
24
52
|
windowsHide: true,
|
|
25
53
|
},
|
|
26
54
|
(error, stdout) => {
|
|
@@ -29,25 +57,32 @@ function resolveUncommittedFileCount(cwd: string): Promise<number | undefined> {
|
|
|
29
57
|
return;
|
|
30
58
|
}
|
|
31
59
|
|
|
60
|
+
const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
|
|
32
61
|
const entries = stdout.split("\0");
|
|
33
|
-
let count = 0;
|
|
34
62
|
for (let index = 0; index < entries.length; index += 1) {
|
|
35
63
|
const entry = entries[index];
|
|
36
64
|
if (!entry) continue;
|
|
37
|
-
|
|
38
|
-
if (
|
|
65
|
+
const status = entry.slice(0, 2);
|
|
66
|
+
if (status.includes("D")) changes.deleted += 1;
|
|
67
|
+
else if (status === "??" || status.includes("A")) changes.added += 1;
|
|
68
|
+
else changes.modified += 1;
|
|
69
|
+
if (status.includes("R") || status.includes("C")) index += 1;
|
|
39
70
|
}
|
|
40
|
-
resolve(
|
|
71
|
+
resolve(changes);
|
|
41
72
|
},
|
|
42
73
|
);
|
|
43
74
|
});
|
|
44
75
|
}
|
|
45
76
|
|
|
46
|
-
|
|
47
|
-
|
|
77
|
+
async function resolveUncommittedFileCount(cwd: string): Promise<number | undefined> {
|
|
78
|
+
const changes = await resolveGitFileChanges(cwd);
|
|
79
|
+
return changes && changes.modified + changes.added + changes.deleted;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function createGitRefresh<T>(
|
|
48
83
|
cwd: string,
|
|
49
|
-
|
|
50
|
-
|
|
84
|
+
onResult: (result: T | undefined) => void,
|
|
85
|
+
resolveResult: (cwd: string) => Promise<T | undefined>,
|
|
51
86
|
): { request: () => void; dispose: () => void } {
|
|
52
87
|
let disposed = false;
|
|
53
88
|
let pending = false;
|
|
@@ -59,9 +94,16 @@ export function createGitStatusRefresh(
|
|
|
59
94
|
return;
|
|
60
95
|
}
|
|
61
96
|
pending = true;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
97
|
+
let result: Promise<T | undefined>;
|
|
98
|
+
try {
|
|
99
|
+
result = resolveResult(cwd);
|
|
100
|
+
} catch {
|
|
101
|
+
result = Promise.resolve(undefined);
|
|
102
|
+
}
|
|
103
|
+
void result.then(
|
|
104
|
+
(value) => { if (!disposed) onResult(value); },
|
|
105
|
+
() => { if (!disposed) onResult(undefined); },
|
|
106
|
+
).finally(() => {
|
|
65
107
|
pending = false;
|
|
66
108
|
if (!disposed && queued) {
|
|
67
109
|
queued = false;
|
|
@@ -78,7 +120,29 @@ export function createGitStatusRefresh(
|
|
|
78
120
|
};
|
|
79
121
|
}
|
|
80
122
|
|
|
123
|
+
/** Coalesces Git status requests to one active scan and one queued follow-up. */
|
|
124
|
+
export function createGitStatusRefresh(
|
|
125
|
+
cwd: string,
|
|
126
|
+
onCount: (count: number | undefined) => void,
|
|
127
|
+
resolveCount: (cwd: string) => Promise<number | undefined> = resolveUncommittedFileCount,
|
|
128
|
+
): { request: () => void; dispose: () => void } {
|
|
129
|
+
return createGitRefresh(cwd, onCount, resolveCount);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Coalesces file-change scans and emits only successful results. */
|
|
133
|
+
export function createGitFileChangesRefresh(
|
|
134
|
+
cwd: string,
|
|
135
|
+
onChanges: (changes: GitFileChanges) => void,
|
|
136
|
+
resolveChanges: (cwd: string) => Promise<GitFileChanges | undefined> = resolveGitFileChanges,
|
|
137
|
+
): { request: () => void; dispose: () => void } {
|
|
138
|
+
return createGitRefresh(cwd, (changes) => {
|
|
139
|
+
if (changes !== undefined) onChanges(changes);
|
|
140
|
+
}, resolveChanges);
|
|
141
|
+
}
|
|
142
|
+
|
|
81
143
|
type ScheduleFallback = (refresh: () => void, intervalMs: number) => () => void;
|
|
144
|
+
type WatchDirectory = (cwd: string, onChange: () => void, onError: () => void) => () => void;
|
|
145
|
+
type ScheduleDelay = (refresh: () => void, delayMs: number) => () => void;
|
|
82
146
|
|
|
83
147
|
const scheduleFallback: ScheduleFallback = (refresh, intervalMs) => {
|
|
84
148
|
const timer = setInterval(refresh, intervalMs);
|
|
@@ -86,6 +150,18 @@ const scheduleFallback: ScheduleFallback = (refresh, intervalMs) => {
|
|
|
86
150
|
return () => clearInterval(timer);
|
|
87
151
|
};
|
|
88
152
|
|
|
153
|
+
const watchDirectory: WatchDirectory = (cwd, onChange, onError) => {
|
|
154
|
+
const watcher = watch(cwd, { recursive: true }, onChange);
|
|
155
|
+
watcher.on("error", onError);
|
|
156
|
+
return () => watcher.close();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const scheduleDelay: ScheduleDelay = (refresh, delayMs) => {
|
|
160
|
+
const timer = setTimeout(refresh, delayMs);
|
|
161
|
+
timer.unref?.();
|
|
162
|
+
return () => clearTimeout(timer);
|
|
163
|
+
};
|
|
164
|
+
|
|
89
165
|
/** Schedules the fallback Git scan independently from footer rendering. */
|
|
90
166
|
export function scheduleGitStatusFallback(
|
|
91
167
|
refresh: () => void,
|
|
@@ -94,6 +170,55 @@ export function scheduleGitStatusFallback(
|
|
|
94
170
|
return schedule(refresh, GIT_STATUS_REFRESH_INTERVAL_MS);
|
|
95
171
|
}
|
|
96
172
|
|
|
173
|
+
/** Throttles filesystem changes and falls back silently when watching is unavailable. */
|
|
174
|
+
export function scheduleGitStatusWatch(
|
|
175
|
+
cwd: string,
|
|
176
|
+
refresh: () => void,
|
|
177
|
+
startWatching: WatchDirectory = watchDirectory,
|
|
178
|
+
schedule: ScheduleDelay = scheduleDelay,
|
|
179
|
+
): () => void {
|
|
180
|
+
let disposed = false;
|
|
181
|
+
let coolingDown = false;
|
|
182
|
+
let queued = false;
|
|
183
|
+
let stopTimer: (() => void) | undefined;
|
|
184
|
+
let stopWatching: (() => void) | undefined;
|
|
185
|
+
const stop = (): void => {
|
|
186
|
+
if (disposed) return;
|
|
187
|
+
disposed = true;
|
|
188
|
+
stopTimer?.();
|
|
189
|
+
stopTimer = undefined;
|
|
190
|
+
stopWatching?.();
|
|
191
|
+
stopWatching = undefined;
|
|
192
|
+
};
|
|
193
|
+
const changed = (): void => {
|
|
194
|
+
if (disposed) return;
|
|
195
|
+
if (stopTimer) {
|
|
196
|
+
if (coolingDown) queued = true;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
stopTimer = schedule(() => {
|
|
200
|
+
stopTimer = undefined;
|
|
201
|
+
if (disposed) return;
|
|
202
|
+
refresh();
|
|
203
|
+
coolingDown = true;
|
|
204
|
+
stopTimer = schedule(() => {
|
|
205
|
+
stopTimer = undefined;
|
|
206
|
+
coolingDown = false;
|
|
207
|
+
if (queued) {
|
|
208
|
+
queued = false;
|
|
209
|
+
changed();
|
|
210
|
+
}
|
|
211
|
+
}, GIT_STATUS_WATCH_INTERVAL_MS);
|
|
212
|
+
}, GIT_STATUS_WATCH_DEBOUNCE_MS);
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
stopWatching = startWatching(cwd, changed, stop);
|
|
216
|
+
} catch {
|
|
217
|
+
disposed = true;
|
|
218
|
+
}
|
|
219
|
+
return stop;
|
|
220
|
+
}
|
|
221
|
+
|
|
97
222
|
export function formatCost(usd: number): string {
|
|
98
223
|
if (!Number.isFinite(usd)) return "$—";
|
|
99
224
|
return `$${usd.toFixed(2)}`;
|
|
@@ -229,13 +354,20 @@ function renderFooter(rows: string[], width: number, theme: Theme): string[] {
|
|
|
229
354
|
function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
|
|
230
355
|
if (!state) return "";
|
|
231
356
|
if (state.status === "active") {
|
|
232
|
-
|
|
357
|
+
const turns = state.maxTurns === undefined ? "" : ` ${state.turns}/${state.maxTurns}`;
|
|
358
|
+
return theme.fg("warning", `/goal is active${turns} (${formatTime(goalElapsedMilliseconds(state, Date.now()))})`);
|
|
233
359
|
}
|
|
234
360
|
if (state.status === "paused") return theme.fg("warning", "/goal is paused");
|
|
235
361
|
if (state.status === "blocked") return theme.fg("error", "/goal is blocked");
|
|
236
362
|
return "";
|
|
237
363
|
}
|
|
238
364
|
|
|
365
|
+
function formatGitFileChanges(changes: GitFileChanges, theme: Theme): string {
|
|
366
|
+
const total = changes.modified + changes.added + changes.deleted;
|
|
367
|
+
if (total === 0) return "";
|
|
368
|
+
return `${theme.fg("dim", `±${total} [`)}${theme.fg("warning", `~${changes.modified}`)} ${theme.fg("success", `+${changes.added}`)} ${theme.fg("error", `−${changes.deleted}`)}${theme.fg("dim", "]")}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
239
371
|
export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void {
|
|
240
372
|
let currentModel: ExtensionContext["model"];
|
|
241
373
|
let thinkingLevel: ThinkingLevel = "off";
|
|
@@ -243,7 +375,6 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
243
375
|
let cachedSessionCost = 0;
|
|
244
376
|
let sessionCostDirty = true;
|
|
245
377
|
let unsubscribeCodexFast: (() => void) | undefined;
|
|
246
|
-
let requestGitStatusRefresh: (() => void) | undefined;
|
|
247
378
|
const resetSessionCost = (): void => {
|
|
248
379
|
cachedSessionCost = 0;
|
|
249
380
|
sessionCostDirty = true;
|
|
@@ -273,25 +404,25 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
273
404
|
|
|
274
405
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
275
406
|
activeTui = tui;
|
|
276
|
-
let
|
|
277
|
-
const gitStatus =
|
|
278
|
-
if (
|
|
279
|
-
|
|
407
|
+
let gitFileChanges: GitFileChanges | undefined;
|
|
408
|
+
const gitStatus = createGitFileChangesRefresh(ctx.cwd, (changes) => {
|
|
409
|
+
if (JSON.stringify(changes) === JSON.stringify(gitFileChanges)) return;
|
|
410
|
+
gitFileChanges = changes;
|
|
280
411
|
tui.requestRender();
|
|
281
412
|
});
|
|
282
|
-
requestGitStatusRefresh = gitStatus.request;
|
|
283
413
|
const unsubscribe = footerData.onBranchChange(() => {
|
|
284
414
|
gitStatus.request();
|
|
285
415
|
tui.requestRender();
|
|
286
416
|
});
|
|
287
417
|
gitStatus.request();
|
|
418
|
+
const stopWatch = scheduleGitStatusWatch(ctx.cwd, gitStatus.request);
|
|
288
419
|
const stopFallback = scheduleGitStatusFallback(gitStatus.request);
|
|
289
420
|
return {
|
|
290
421
|
dispose() {
|
|
291
422
|
unsubscribe();
|
|
423
|
+
stopWatch();
|
|
292
424
|
stopFallback();
|
|
293
425
|
gitStatus.dispose();
|
|
294
|
-
if (requestGitStatusRefresh === gitStatus.request) requestGitStatusRefresh = undefined;
|
|
295
426
|
if (activeTui === tui) activeTui = undefined;
|
|
296
427
|
},
|
|
297
428
|
invalidate() {},
|
|
@@ -332,10 +463,9 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
332
463
|
: footerRowFits(primaryFocused, "", width)
|
|
333
464
|
? renderFooterRow(primaryFocused, "", width)
|
|
334
465
|
: renderFooterRow(essentialModel, context, width);
|
|
466
|
+
const changes = gitFileChanges ? formatGitFileChanges(gitFileChanges, theme) : "";
|
|
335
467
|
const branchLabel = branch
|
|
336
|
-
?
|
|
337
|
-
? `${theme.fg("dim", `${branch} · `)}${theme.fg("warning", `${uncommittedFileCount} changed`)}`
|
|
338
|
-
: theme.fg("dim", branch)
|
|
468
|
+
? `${theme.fg("dim", branch)}${changes ? `${theme.fg("dim", " · ")}${changes}` : ""}`
|
|
339
469
|
: "";
|
|
340
470
|
const workspaceRight = goal || fullDirectory;
|
|
341
471
|
const secondaryRow = footerRowFits(branchLabel, workspaceRight, width)
|
|
@@ -359,12 +489,8 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
359
489
|
thinkingLevel = event.level;
|
|
360
490
|
activeTui?.requestRender();
|
|
361
491
|
});
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
requestGitStatusRefresh?.();
|
|
365
|
-
};
|
|
366
|
-
pi.on("turn_end", refreshAfterActivity);
|
|
367
|
-
pi.on("session_compact", refreshAfterActivity);
|
|
492
|
+
pi.on("turn_end", invalidateSessionCost);
|
|
493
|
+
pi.on("session_compact", invalidateSessionCost);
|
|
368
494
|
pi.on("session_tree", () => {
|
|
369
495
|
resetSessionCost();
|
|
370
496
|
activeTui?.requestRender();
|
|
@@ -374,7 +500,6 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
374
500
|
unsubscribeCodexFast = undefined;
|
|
375
501
|
resetSessionCost();
|
|
376
502
|
activeTui = undefined;
|
|
377
|
-
requestGitStatusRefresh = undefined;
|
|
378
503
|
goalRuntime.requestRender = undefined;
|
|
379
504
|
});
|
|
380
505
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { GOAL_CHECK_NAME_PATTERN, GOAL_MAX_TURNS, validateGoalObjective } from "./goal-state.ts";
|
|
3
|
+
|
|
4
|
+
const GOAL_START_USAGE = "Usage: /goal start [--check <name>] [--turns <count>] -- <objective>";
|
|
5
|
+
const GOAL_CHECK_USAGE = "Usage: /goal check <name|clear>";
|
|
6
|
+
const GOAL_CHECKS_USAGE = "Usage: /goal checks";
|
|
7
|
+
const GOAL_LIMIT_USAGE = "Usage: /goal limit <count|clear>";
|
|
8
|
+
const GOAL_HISTORY_USAGE = "Usage: /goal history [count]";
|
|
9
|
+
const RESERVED_OBJECTIVE_USAGE = "Objective begins with a reserved goal command. Use /goal start -- <objective>.";
|
|
10
|
+
|
|
11
|
+
interface ControlledGoalStart {
|
|
12
|
+
objective: string;
|
|
13
|
+
completionCheckName?: string;
|
|
14
|
+
maxTurns?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type GoalCommand =
|
|
18
|
+
| { kind: "status" }
|
|
19
|
+
| { kind: "objective"; objective: string }
|
|
20
|
+
| { kind: "start"; objective: string; completionCheckName?: string; maxTurns?: number }
|
|
21
|
+
| { kind: "check"; value: { kind: "clear" } | { kind: "named"; name: string } }
|
|
22
|
+
| { kind: "checks" }
|
|
23
|
+
| { kind: "limit"; value: { kind: "clear" } | { kind: "count"; count: number } }
|
|
24
|
+
| { kind: "history"; count: number }
|
|
25
|
+
| { kind: "clear" }
|
|
26
|
+
| { kind: "edit" }
|
|
27
|
+
| { kind: "pause" }
|
|
28
|
+
| { kind: "resume" }
|
|
29
|
+
| { kind: "invalid"; message: string };
|
|
30
|
+
|
|
31
|
+
const RESERVED_GOAL_WORDS = ["start", "check", "checks", "limit", "history", "clear", "edit", "pause", "resume"] as const;
|
|
32
|
+
|
|
33
|
+
function parseBoundedInteger(value: string, maximum: number): number | undefined {
|
|
34
|
+
if (!/^[1-9][0-9]*$/u.test(value)) return undefined;
|
|
35
|
+
const parsed = Number(value);
|
|
36
|
+
return Number.isSafeInteger(parsed) && parsed <= maximum ? parsed : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseControlledGoalStart(input: string): ControlledGoalStart | undefined {
|
|
40
|
+
const separator = input.startsWith("-- ") ? 0 : input.indexOf(" -- ");
|
|
41
|
+
if (separator < 0) return undefined;
|
|
42
|
+
const optionText = input.slice(0, separator).trim();
|
|
43
|
+
const objective = validateGoalObjective(input.slice(separator + (separator === 0 ? 3 : 4)));
|
|
44
|
+
if (!objective) return undefined;
|
|
45
|
+
const tokens = optionText ? optionText.split(/\s+/u) : [];
|
|
46
|
+
if (tokens.filter((token) => token === "--check" || token.startsWith("--check=")).length > 1
|
|
47
|
+
|| tokens.filter((token) => token === "--turns" || token.startsWith("--turns=")).length > 1) return undefined;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = parseArgs({
|
|
50
|
+
args: tokens,
|
|
51
|
+
options: { check: { type: "string" }, turns: { type: "string" } },
|
|
52
|
+
strict: true,
|
|
53
|
+
allowPositionals: false,
|
|
54
|
+
});
|
|
55
|
+
const check = parsed.values.check;
|
|
56
|
+
const turns = parsed.values.turns;
|
|
57
|
+
if (check !== undefined && !GOAL_CHECK_NAME_PATTERN.test(check)) return undefined;
|
|
58
|
+
const maxTurns = turns === undefined ? undefined : parseBoundedInteger(turns, GOAL_MAX_TURNS);
|
|
59
|
+
if (turns !== undefined && maxTurns === undefined) return undefined;
|
|
60
|
+
return {
|
|
61
|
+
objective,
|
|
62
|
+
...(check === undefined ? {} : { completionCheckName: check }),
|
|
63
|
+
...(maxTurns === undefined ? {} : { maxTurns }),
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Parses one raw /goal argument string into a closed command variant. */
|
|
71
|
+
export function parseGoalCommand(args: string): GoalCommand {
|
|
72
|
+
const input = args.trim();
|
|
73
|
+
if (!input) return { kind: "status" };
|
|
74
|
+
const parts = input.split(/\s+/u);
|
|
75
|
+
const rawFirstWord = parts[0] ?? "";
|
|
76
|
+
const firstWord = RESERVED_GOAL_WORDS.find((word) => word === rawFirstWord.toLowerCase());
|
|
77
|
+
if (!firstWord) {
|
|
78
|
+
const objective = validateGoalObjective(input);
|
|
79
|
+
return objective
|
|
80
|
+
? { kind: "objective", objective }
|
|
81
|
+
: { kind: "invalid", message: "A goal objective may not exceed 4,000 characters" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (firstWord === "start") {
|
|
85
|
+
const start = parseControlledGoalStart(input.slice(rawFirstWord.length).trimStart());
|
|
86
|
+
if (start) return { kind: "start", ...start };
|
|
87
|
+
return { kind: "invalid", message: rawFirstWord === firstWord ? GOAL_START_USAGE : RESERVED_OBJECTIVE_USAGE };
|
|
88
|
+
}
|
|
89
|
+
if (firstWord === "check") {
|
|
90
|
+
const value = parts[1];
|
|
91
|
+
if (parts.length === 2 && value?.toLowerCase() === "clear") return { kind: "check", value: { kind: "clear" } };
|
|
92
|
+
if (parts.length === 2 && value && GOAL_CHECK_NAME_PATTERN.test(value)) return { kind: "check", value: { kind: "named", name: value } };
|
|
93
|
+
return { kind: "invalid", message: rawFirstWord !== firstWord && parts.length > 2 ? RESERVED_OBJECTIVE_USAGE : GOAL_CHECK_USAGE };
|
|
94
|
+
}
|
|
95
|
+
if (firstWord === "checks") {
|
|
96
|
+
return parts.length === 1
|
|
97
|
+
? { kind: "checks" }
|
|
98
|
+
: { kind: "invalid", message: rawFirstWord === firstWord ? GOAL_CHECKS_USAGE : RESERVED_OBJECTIVE_USAGE };
|
|
99
|
+
}
|
|
100
|
+
if (firstWord === "limit") {
|
|
101
|
+
const value = parts[1];
|
|
102
|
+
if (parts.length === 2 && value?.toLowerCase() === "clear") return { kind: "limit", value: { kind: "clear" } };
|
|
103
|
+
const count = parts.length === 2 && value ? parseBoundedInteger(value, GOAL_MAX_TURNS) : undefined;
|
|
104
|
+
if (count !== undefined) return { kind: "limit", value: { kind: "count", count } };
|
|
105
|
+
return { kind: "invalid", message: rawFirstWord !== firstWord && parts.length > 2 ? RESERVED_OBJECTIVE_USAGE : GOAL_LIMIT_USAGE };
|
|
106
|
+
}
|
|
107
|
+
if (firstWord === "history") {
|
|
108
|
+
if (parts.length === 1) return { kind: "history", count: 20 };
|
|
109
|
+
const count = parts.length === 2 && parts[1] ? parseBoundedInteger(parts[1], 50) : undefined;
|
|
110
|
+
if (count !== undefined) return { kind: "history", count };
|
|
111
|
+
return { kind: "invalid", message: rawFirstWord !== firstWord && parts.length > 2 ? RESERVED_OBJECTIVE_USAGE : GOAL_HISTORY_USAGE };
|
|
112
|
+
}
|
|
113
|
+
if (parts.length === 1) return { kind: firstWord };
|
|
114
|
+
return {
|
|
115
|
+
kind: "invalid",
|
|
116
|
+
message: rawFirstWord === firstWord
|
|
117
|
+
? `Usage: /goal ${firstWord}`
|
|
118
|
+
: RESERVED_OBJECTIVE_USAGE,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
}
|