killeros 2.0.8 → 2.0.10
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 +13 -0
- package/Killeros.ts +11 -4
- package/README.md +147 -134
- package/killeros/auto-compaction.ts +224 -0
- package/killeros/commands.ts +108 -34
- package/killeros/goals.ts +75 -2
- package/killeros/notifications.ts +6 -36
- package/killeros/runtime.ts +2 -0
- package/killeros/settings.ts +47 -0
- package/killeros/shell-ui.ts +109 -5
- package/killeros/workflow-gate.ts +35 -17
- package/package.json +1 -1
package/killeros/commands.ts
CHANGED
|
@@ -30,6 +30,19 @@ interface CommandInfo {
|
|
|
30
30
|
syntaxHint?: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface SlashCommandToken {
|
|
34
|
+
name: string;
|
|
35
|
+
start: number;
|
|
36
|
+
end: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SlashCommandResolver {
|
|
40
|
+
clearFallbackCommands(): void;
|
|
41
|
+
updateFallbackCommands(items: readonly AutocompleteItem[]): void;
|
|
42
|
+
getCommandCatalog(baseSuggestions?: readonly AutocompleteItem[]): ReadonlyMap<string, CommandInfo>;
|
|
43
|
+
isValidCommand(name: string): boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
|
|
34
47
|
{ name: "settings", description: "Open settings menu" },
|
|
35
48
|
{ name: "model", description: "Select model" },
|
|
@@ -70,6 +83,87 @@ interface TaggedAutocompleteItem extends AutocompleteItem {
|
|
|
70
83
|
killerosCommand?: string;
|
|
71
84
|
}
|
|
72
85
|
|
|
86
|
+
const SLASH_COMMAND_PREFIX_PATTERN = /(?:^|[ \t])\/([^\s/]*)$/u;
|
|
87
|
+
const SLASH_COMMAND_TOKEN_PATTERN = /(?:^|[ \t])\/([^\s/]+)(?=$|[ \t])/gu;
|
|
88
|
+
|
|
89
|
+
export function getSlashCommandPrefix(line: string): { prefix: string; slashIndex: number } | undefined {
|
|
90
|
+
const match = SLASH_COMMAND_PREFIX_PATTERN.exec(line);
|
|
91
|
+
if (!match || match.index === undefined) return undefined;
|
|
92
|
+
const prefix = match[1] ?? "";
|
|
93
|
+
const slashIndex = match.index + (match[0].startsWith("/") ? 0 : 1);
|
|
94
|
+
return { prefix, slashIndex };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function findSlashCommandTokens(line: string): SlashCommandToken[] {
|
|
98
|
+
const tokens: SlashCommandToken[] = [];
|
|
99
|
+
for (const match of line.matchAll(SLASH_COMMAND_TOKEN_PATTERN)) {
|
|
100
|
+
const name = match[1];
|
|
101
|
+
if (name === undefined || match.index === undefined) continue;
|
|
102
|
+
const start = match.index + (match[0].startsWith("/") ? 0 : 1);
|
|
103
|
+
tokens.push({ name, start, end: start + name.length + 1 });
|
|
104
|
+
}
|
|
105
|
+
return tokens;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function commandNameFromAutocompleteItem(item: AutocompleteItem): string {
|
|
109
|
+
return (item.value || item.label).replace(/^\//u, "").trim().split(/\s+/u)[0] ?? "";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createSlashCommandResolver(
|
|
113
|
+
pi: Pick<ExtensionAPI, "getCommands">,
|
|
114
|
+
): SlashCommandResolver {
|
|
115
|
+
let fallbackCommands = new Map<string, string | undefined>();
|
|
116
|
+
|
|
117
|
+
const getCommandCatalog = (baseSuggestions: readonly AutocompleteItem[] = []): ReadonlyMap<string, CommandInfo> => {
|
|
118
|
+
const commands = new Map<string, CommandInfo>();
|
|
119
|
+
BUILTIN_COMMANDS.forEach((command) => commands.set(command.name, {
|
|
120
|
+
...command,
|
|
121
|
+
category: "Built-in",
|
|
122
|
+
syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
for (const command of pi.getCommands()) {
|
|
126
|
+
const category: CommandInfo["category"] = command.source === "skill"
|
|
127
|
+
? "Skill"
|
|
128
|
+
: command.source === "prompt"
|
|
129
|
+
? "Prompt"
|
|
130
|
+
: "Extension";
|
|
131
|
+
commands.set(command.name, {
|
|
132
|
+
name: command.name,
|
|
133
|
+
description: command.description,
|
|
134
|
+
category,
|
|
135
|
+
syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const baseCommands = baseSuggestions.length > 0
|
|
140
|
+
? new Map(baseSuggestions.map((item) => [commandNameFromAutocompleteItem(item), item.description] as const))
|
|
141
|
+
: fallbackCommands;
|
|
142
|
+
for (const [name, description] of baseCommands) {
|
|
143
|
+
if (name && !commands.has(name)) {
|
|
144
|
+
commands.set(name, { name, description, category: "Built-in" });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return commands;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
clearFallbackCommands() {
|
|
152
|
+
fallbackCommands = new Map<string, string | undefined>();
|
|
153
|
+
},
|
|
154
|
+
updateFallbackCommands(items) {
|
|
155
|
+
fallbackCommands = new Map(
|
|
156
|
+
items.map((item) => [commandNameFromAutocompleteItem(item), item.description] as const)
|
|
157
|
+
.filter(([name]) => Boolean(name)),
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
getCommandCatalog,
|
|
161
|
+
isValidCommand(name) {
|
|
162
|
+
return getCommandCatalog().has(name);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
73
167
|
function scoreCommandMatch(name: string, prefix: string): number {
|
|
74
168
|
if (!prefix) return 1;
|
|
75
169
|
const normalizedName = name.toLocaleLowerCase();
|
|
@@ -80,47 +174,26 @@ function scoreCommandMatch(name: string, prefix: string): number {
|
|
|
80
174
|
return 0;
|
|
81
175
|
}
|
|
82
176
|
|
|
83
|
-
export function registerSlashAutocomplete(
|
|
177
|
+
export function registerSlashAutocomplete(
|
|
178
|
+
pi: ExtensionAPI,
|
|
179
|
+
resolver: SlashCommandResolver = createSlashCommandResolver(pi),
|
|
180
|
+
): SlashCommandResolver {
|
|
84
181
|
const usage = new Map<string, number>();
|
|
85
182
|
pi.on("session_start", (_event, ctx) => {
|
|
86
183
|
if (ctx.mode !== "tui") return;
|
|
184
|
+
resolver.clearFallbackCommands();
|
|
87
185
|
ctx.ui.addAutocompleteProvider((current) => ({
|
|
88
186
|
triggerCharacters: ["/"],
|
|
89
187
|
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
90
188
|
const line = lines[cursorLine] ?? "";
|
|
91
189
|
const beforeCursor = line.slice(0, cursorCol);
|
|
92
|
-
const
|
|
93
|
-
if (!
|
|
190
|
+
const prefixMatch = getSlashCommandPrefix(beforeCursor);
|
|
191
|
+
if (!prefixMatch) return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
94
192
|
|
|
95
|
-
const prefix =
|
|
193
|
+
const prefix = prefixMatch.prefix.toLocaleLowerCase();
|
|
96
194
|
const baseSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
...command,
|
|
100
|
-
category: "Built-in",
|
|
101
|
-
syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
|
|
102
|
-
}));
|
|
103
|
-
|
|
104
|
-
for (const command of pi.getCommands()) {
|
|
105
|
-
const category: CommandInfo["category"] = command.source === "skill"
|
|
106
|
-
? "Skill"
|
|
107
|
-
: command.source === "prompt"
|
|
108
|
-
? "Prompt"
|
|
109
|
-
: "Extension";
|
|
110
|
-
commands.set(command.name, {
|
|
111
|
-
name: command.name,
|
|
112
|
-
description: command.description,
|
|
113
|
-
category,
|
|
114
|
-
syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
for (const item of baseSuggestions?.items ?? []) {
|
|
119
|
-
const name = (item.value || item.label).replace(/^\//, "").trim().split(/\s+/)[0] ?? "";
|
|
120
|
-
if (name && !commands.has(name)) {
|
|
121
|
-
commands.set(name, { name, description: item.description, category: "Built-in" });
|
|
122
|
-
}
|
|
123
|
-
}
|
|
195
|
+
resolver.updateFallbackCommands(baseSuggestions?.items ?? []);
|
|
196
|
+
const commands = resolver.getCommandCatalog(baseSuggestions?.items ?? []);
|
|
124
197
|
|
|
125
198
|
const ranked = [...commands.values()]
|
|
126
199
|
.map((command) => ({
|
|
@@ -151,9 +224,9 @@ export function registerSlashAutocomplete(pi: ExtensionAPI): void {
|
|
|
151
224
|
const line = lines[cursorLine] ?? "";
|
|
152
225
|
const beforeCursor = line.slice(0, cursorCol);
|
|
153
226
|
const afterCursor = line.slice(cursorCol);
|
|
154
|
-
const
|
|
155
|
-
if (!
|
|
156
|
-
const slashIndex =
|
|
227
|
+
const prefixMatch = getSlashCommandPrefix(beforeCursor);
|
|
228
|
+
if (!prefixMatch) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
229
|
+
const slashIndex = prefixMatch.slashIndex;
|
|
157
230
|
const newBefore = beforeCursor.slice(0, slashIndex) + item.value;
|
|
158
231
|
const nextLines = [...lines];
|
|
159
232
|
nextLines[cursorLine] = newBefore + afterCursor;
|
|
@@ -164,4 +237,5 @@ export function registerSlashAutocomplete(pi: ExtensionAPI): void {
|
|
|
164
237
|
},
|
|
165
238
|
}));
|
|
166
239
|
});
|
|
240
|
+
return resolver;
|
|
167
241
|
}
|
package/killeros/goals.ts
CHANGED
|
@@ -264,7 +264,10 @@ function transitionGoal(
|
|
|
264
264
|
resumeAfterManualCompaction: options.resumeAfterManualCompaction,
|
|
265
265
|
};
|
|
266
266
|
persistGoalState(pi, runtime, event, next);
|
|
267
|
-
if (status !== "active")
|
|
267
|
+
if (status !== "active") {
|
|
268
|
+
runtime.continuationScheduled = false;
|
|
269
|
+
runtime.automaticCompaction = undefined;
|
|
270
|
+
}
|
|
268
271
|
return next;
|
|
269
272
|
}
|
|
270
273
|
|
|
@@ -272,6 +275,7 @@ function clearGoalExecutionFlags(runtime: GoalRuntime): void {
|
|
|
272
275
|
runtime.continuationScheduled = false;
|
|
273
276
|
runtime.goalTurnInFlight = false;
|
|
274
277
|
runtime.agentEndObserved = false;
|
|
278
|
+
runtime.automaticCompaction = undefined;
|
|
275
279
|
runtime.lastStopReason = undefined;
|
|
276
280
|
runtime.lastError = undefined;
|
|
277
281
|
}
|
|
@@ -333,6 +337,7 @@ export function pauseGoalAfterFailure(
|
|
|
333
337
|
syncGoalUpdateTool(pi, runtime);
|
|
334
338
|
runtime.persistenceRetryNeeded = true;
|
|
335
339
|
runtime.continuationScheduled = false;
|
|
340
|
+
runtime.automaticCompaction = undefined;
|
|
336
341
|
runtime.requestRender?.();
|
|
337
342
|
}
|
|
338
343
|
if (notify) ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
|
|
@@ -359,6 +364,7 @@ function pauseGoalForPossibleManualCompaction(
|
|
|
359
364
|
syncGoalUpdateTool(pi, runtime);
|
|
360
365
|
runtime.persistenceRetryNeeded = true;
|
|
361
366
|
runtime.continuationScheduled = false;
|
|
367
|
+
runtime.automaticCompaction = undefined;
|
|
362
368
|
runtime.requestRender?.();
|
|
363
369
|
}
|
|
364
370
|
ctx.ui.notify(
|
|
@@ -384,6 +390,7 @@ function recoverGoalAfterManualCompaction(
|
|
|
384
390
|
return false;
|
|
385
391
|
}
|
|
386
392
|
runtime.continuationScheduled = false;
|
|
393
|
+
runtime.automaticCompaction = undefined;
|
|
387
394
|
ctx.ui.notify("Manual compaction complete. Goal resumed.", "info");
|
|
388
395
|
setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
|
|
389
396
|
return true;
|
|
@@ -450,6 +457,42 @@ function scheduleGoalContinuation(
|
|
|
450
457
|
}
|
|
451
458
|
}
|
|
452
459
|
|
|
460
|
+
function completeAutomaticCompaction(
|
|
461
|
+
pi: ExtensionAPI,
|
|
462
|
+
runtime: GoalRuntime,
|
|
463
|
+
initState: InitRuntime,
|
|
464
|
+
ctx: ExtensionContext,
|
|
465
|
+
): void {
|
|
466
|
+
if (runtime.automaticCompaction === undefined) return;
|
|
467
|
+
if (runtime.state?.status !== "active" || initState.active) {
|
|
468
|
+
runtime.automaticCompaction = undefined;
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
runtime.automaticCompaction = "completed";
|
|
472
|
+
if (runtime.goalTurnInFlight || !ctx.isIdle()) return;
|
|
473
|
+
runtime.automaticCompaction = undefined;
|
|
474
|
+
setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function failAutomaticCompaction(
|
|
478
|
+
pi: ExtensionAPI,
|
|
479
|
+
runtime: GoalRuntime,
|
|
480
|
+
ctx: ExtensionContext,
|
|
481
|
+
error: unknown,
|
|
482
|
+
): void {
|
|
483
|
+
if (runtime.automaticCompaction === undefined) return;
|
|
484
|
+
runtime.automaticCompaction = undefined;
|
|
485
|
+
if (runtime.state?.status !== "active") return;
|
|
486
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
487
|
+
pauseGoalAfterFailure(
|
|
488
|
+
pi,
|
|
489
|
+
runtime,
|
|
490
|
+
ctx,
|
|
491
|
+
`automatic compaction failed: ${reason}`,
|
|
492
|
+
"Automatic continuation is stopped. Run /goal resume after resolving the compaction problem.",
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
453
496
|
function goalInstructions(state: GoalState, heading: string): string {
|
|
454
497
|
return [
|
|
455
498
|
`# ${heading}`,
|
|
@@ -598,6 +641,7 @@ export function registerGoal(
|
|
|
598
641
|
runtime.continuationHeld = false;
|
|
599
642
|
runtime.goalTurnInFlight = false;
|
|
600
643
|
runtime.agentEndObserved = false;
|
|
644
|
+
runtime.automaticCompaction = undefined;
|
|
601
645
|
runtime.persistenceRetryNeeded = false;
|
|
602
646
|
runtime.lastStopReason = undefined;
|
|
603
647
|
runtime.lastError = undefined;
|
|
@@ -630,6 +674,7 @@ export function registerGoal(
|
|
|
630
674
|
runtime.continuationHeld = false;
|
|
631
675
|
runtime.goalTurnInFlight = false;
|
|
632
676
|
runtime.agentEndObserved = false;
|
|
677
|
+
runtime.automaticCompaction = undefined;
|
|
633
678
|
runtime.persistenceRetryNeeded = false;
|
|
634
679
|
runtime.lastStopReason = undefined;
|
|
635
680
|
runtime.lastError = undefined;
|
|
@@ -983,7 +1028,12 @@ export function registerGoalSettlement(
|
|
|
983
1028
|
pi: ExtensionAPI,
|
|
984
1029
|
runtime: GoalRuntime,
|
|
985
1030
|
initState: InitRuntime,
|
|
986
|
-
):
|
|
1031
|
+
): {
|
|
1032
|
+
isActive(ctx: ExtensionContext): boolean;
|
|
1033
|
+
onRequested(): void;
|
|
1034
|
+
onCompleted(ctx: ExtensionContext): void;
|
|
1035
|
+
onFailed(ctx: ExtensionContext, error: unknown): void;
|
|
1036
|
+
} {
|
|
987
1037
|
pi.on("agent_settled", (_event, ctx) => {
|
|
988
1038
|
const wasGoalTurn = runtime.goalTurnInFlight;
|
|
989
1039
|
const continuationWasScheduled = runtime.continuationScheduled;
|
|
@@ -1000,6 +1050,7 @@ export function registerGoalSettlement(
|
|
|
1000
1050
|
return;
|
|
1001
1051
|
}
|
|
1002
1052
|
if (!agentEndObserved) {
|
|
1053
|
+
if (runtime.automaticCompaction !== undefined) return;
|
|
1003
1054
|
pauseGoalAfterFailure(pi, runtime, ctx, "the goal turn ended without an agent result");
|
|
1004
1055
|
return;
|
|
1005
1056
|
}
|
|
@@ -1007,6 +1058,12 @@ export function registerGoalSettlement(
|
|
|
1007
1058
|
const reason = runtime.lastError || "the agent turn was aborted";
|
|
1008
1059
|
runtime.lastStopReason = undefined;
|
|
1009
1060
|
runtime.lastError = undefined;
|
|
1061
|
+
if (runtime.automaticCompaction !== undefined) {
|
|
1062
|
+
if (runtime.automaticCompaction === "completed") {
|
|
1063
|
+
completeAutomaticCompaction(pi, runtime, initState, ctx);
|
|
1064
|
+
}
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1010
1067
|
pauseGoalForPossibleManualCompaction(pi, runtime, ctx, reason);
|
|
1011
1068
|
return;
|
|
1012
1069
|
}
|
|
@@ -1023,7 +1080,23 @@ export function registerGoalSettlement(
|
|
|
1023
1080
|
});
|
|
1024
1081
|
|
|
1025
1082
|
pi.on("session_compact", (event, ctx) => {
|
|
1083
|
+
if (runtime.automaticCompaction !== undefined) {
|
|
1084
|
+
completeAutomaticCompaction(pi, runtime, initState, ctx);
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1026
1087
|
if (event.reason !== "manual") return;
|
|
1027
1088
|
recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
|
|
1028
1089
|
});
|
|
1090
|
+
|
|
1091
|
+
return {
|
|
1092
|
+
isActive: (ctx: ExtensionContext): boolean => isGoalModeSupported(ctx)
|
|
1093
|
+
&& isSavedSession(ctx)
|
|
1094
|
+
&& runtime.state?.status === "active"
|
|
1095
|
+
&& !initState.active,
|
|
1096
|
+
onRequested: (): void => {
|
|
1097
|
+
if (runtime.state?.status === "active") runtime.automaticCompaction = "pending";
|
|
1098
|
+
},
|
|
1099
|
+
onCompleted: (ctx: ExtensionContext): void => completeAutomaticCompaction(pi, runtime, initState, ctx),
|
|
1100
|
+
onFailed: (ctx: ExtensionContext, error: unknown): void => failAutomaticCompaction(pi, runtime, ctx, error),
|
|
1101
|
+
};
|
|
1029
1102
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { basename, dirname, join } from "node:path";
|
|
1
|
+
import { basename } from "node:path";
|
|
4
2
|
import type { StopReason } from "@earendil-works/pi-ai";
|
|
5
3
|
import {
|
|
6
|
-
getAgentDir,
|
|
7
4
|
type ExtensionAPI,
|
|
8
5
|
type ExtensionContext,
|
|
9
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { createKillerosSettingsStore } from "./settings.ts";
|
|
10
8
|
|
|
11
9
|
export interface NotificationPreferenceStore {
|
|
12
10
|
load(): boolean;
|
|
@@ -20,42 +18,14 @@ export interface CompletionNotificationDependencies {
|
|
|
20
18
|
|
|
21
19
|
export const COMPLETION_BELL_GLYPH = "";
|
|
22
20
|
|
|
23
|
-
const defaultSettingsPath = (): string => join(getAgentDir(), "killeros.json");
|
|
24
|
-
|
|
25
|
-
type StoredSettings = Record<string, unknown>;
|
|
26
|
-
|
|
27
|
-
function readStoredSettings(settingsPath: string): StoredSettings {
|
|
28
|
-
try {
|
|
29
|
-
const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
30
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
31
|
-
throw new Error("KillerOS settings must contain a JSON object");
|
|
32
|
-
}
|
|
33
|
-
return parsed as StoredSettings;
|
|
34
|
-
} catch (error) {
|
|
35
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
36
|
-
throw error;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
21
|
export function createNotificationPreferenceStore(
|
|
41
|
-
settingsPath
|
|
22
|
+
settingsPath?: string,
|
|
42
23
|
): NotificationPreferenceStore {
|
|
24
|
+
const settings = createKillerosSettingsStore(settingsPath);
|
|
43
25
|
return {
|
|
44
|
-
load: () =>
|
|
26
|
+
load: () => settings.load().completionSound === true,
|
|
45
27
|
save: (enabled) => {
|
|
46
|
-
|
|
47
|
-
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
48
|
-
const temporaryPath = `${settingsPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
49
|
-
try {
|
|
50
|
-
writeFileSync(
|
|
51
|
-
temporaryPath,
|
|
52
|
-
`${JSON.stringify({ ...current, completionSound: enabled }, null, 2)}\n`,
|
|
53
|
-
{ encoding: "utf8", mode: 0o600 },
|
|
54
|
-
);
|
|
55
|
-
renameSync(temporaryPath, settingsPath);
|
|
56
|
-
} finally {
|
|
57
|
-
rmSync(temporaryPath, { force: true });
|
|
58
|
-
}
|
|
28
|
+
settings.update({ completionSound: enabled });
|
|
59
29
|
},
|
|
60
30
|
};
|
|
61
31
|
}
|
package/killeros/runtime.ts
CHANGED
|
@@ -57,6 +57,7 @@ export interface GoalRuntime {
|
|
|
57
57
|
continuationHeld: boolean;
|
|
58
58
|
goalTurnInFlight: boolean;
|
|
59
59
|
agentEndObserved: boolean;
|
|
60
|
+
automaticCompaction?: "pending" | "completed";
|
|
60
61
|
persistenceRetryNeeded: boolean;
|
|
61
62
|
lastStopReason?: string;
|
|
62
63
|
lastError?: string;
|
|
@@ -73,6 +74,7 @@ export function createGoalRuntime(): GoalRuntime {
|
|
|
73
74
|
continuationHeld: false,
|
|
74
75
|
goalTurnInFlight: false,
|
|
75
76
|
agentEndObserved: false,
|
|
77
|
+
automaticCompaction: undefined,
|
|
76
78
|
persistenceRetryNeeded: false,
|
|
77
79
|
};
|
|
78
80
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export type KillerosSettings = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
export interface KillerosSettingsStore {
|
|
9
|
+
load(): KillerosSettings;
|
|
10
|
+
update(patch: Readonly<Record<string, unknown>>): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readStoredSettings(settingsPath: string): KillerosSettings {
|
|
14
|
+
try {
|
|
15
|
+
const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
16
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
17
|
+
throw new Error("KillerOS settings must contain a JSON object");
|
|
18
|
+
}
|
|
19
|
+
return parsed as KillerosSettings;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createKillerosSettingsStore(
|
|
27
|
+
settingsPath = join(getAgentDir(), "killeros.json"),
|
|
28
|
+
): KillerosSettingsStore {
|
|
29
|
+
return {
|
|
30
|
+
load: () => readStoredSettings(settingsPath),
|
|
31
|
+
update: (patch) => {
|
|
32
|
+
const current = readStoredSettings(settingsPath);
|
|
33
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
34
|
+
const temporaryPath = `${settingsPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
35
|
+
try {
|
|
36
|
+
writeFileSync(
|
|
37
|
+
temporaryPath,
|
|
38
|
+
`${JSON.stringify({ ...current, ...patch }, null, 2)}\n`,
|
|
39
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
40
|
+
);
|
|
41
|
+
renameSync(temporaryPath, settingsPath);
|
|
42
|
+
} finally {
|
|
43
|
+
rmSync(temporaryPath, { force: true });
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
package/killeros/shell-ui.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import {
|
|
12
12
|
CURSOR_MARKER,
|
|
13
|
+
stripTerminalSequences,
|
|
13
14
|
truncateToWidth,
|
|
14
15
|
visibleWidth,
|
|
15
16
|
wrapTextWithAnsi,
|
|
@@ -17,6 +18,11 @@ import {
|
|
|
17
18
|
type TUI,
|
|
18
19
|
} from "@earendil-works/pi-tui";
|
|
19
20
|
import { formatCwd, padRight } from "./display.ts";
|
|
21
|
+
import {
|
|
22
|
+
createSlashCommandResolver,
|
|
23
|
+
findSlashCommandTokens,
|
|
24
|
+
type SlashCommandResolver,
|
|
25
|
+
} from "./commands.ts";
|
|
20
26
|
import { reportError } from "./errors.ts";
|
|
21
27
|
import { formatModel } from "./footer.ts";
|
|
22
28
|
import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
|
|
@@ -166,10 +172,8 @@ class PiStartupHeader {
|
|
|
166
172
|
}
|
|
167
173
|
}
|
|
168
174
|
|
|
169
|
-
const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
170
|
-
|
|
171
175
|
function stripAnsi(text: string): string {
|
|
172
|
-
return text
|
|
176
|
+
return stripTerminalSequences(text).trim();
|
|
173
177
|
}
|
|
174
178
|
|
|
175
179
|
function isBorderLine(line: string): boolean {
|
|
@@ -182,10 +186,98 @@ function isScrolledTopBorder(line: string): boolean {
|
|
|
182
186
|
return unstyled.includes("↑");
|
|
183
187
|
}
|
|
184
188
|
|
|
189
|
+
interface RenderChunk {
|
|
190
|
+
text: string;
|
|
191
|
+
plainStart: number;
|
|
192
|
+
isAnsi: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function extractTerminalSequence(text: string, position: number): { code: string; length: number } | undefined {
|
|
196
|
+
if (text[position] !== "\x1B") return undefined;
|
|
197
|
+
const next = text[position + 1];
|
|
198
|
+
if (next === "[") {
|
|
199
|
+
let end = position + 2;
|
|
200
|
+
while (end < text.length && !/[\x40-\x7E]/u.test(text[end] ?? "")) end += 1;
|
|
201
|
+
if (end < text.length) return { code: text.slice(position, end + 1), length: end + 1 - position };
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
if (next === "]" || next === "_") {
|
|
205
|
+
let end = position + 2;
|
|
206
|
+
while (end < text.length) {
|
|
207
|
+
if (text[end] === "\x07") return { code: text.slice(position, end + 1), length: end + 1 - position };
|
|
208
|
+
if (text[end] === "\x1B" && text[end + 1] === "\\") {
|
|
209
|
+
return { code: text.slice(position, end + 2), length: end + 2 - position };
|
|
210
|
+
}
|
|
211
|
+
end += 1;
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
if (next !== undefined) return { code: text.slice(position, position + 2), length: 2 };
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function highlightSlashCommands(
|
|
220
|
+
line: string,
|
|
221
|
+
isValidCommand: (name: string) => boolean,
|
|
222
|
+
styleCommand: (token: string) => string,
|
|
223
|
+
): string {
|
|
224
|
+
const chunks: RenderChunk[] = [];
|
|
225
|
+
let plain = "";
|
|
226
|
+
let index = 0;
|
|
227
|
+
while (index < line.length) {
|
|
228
|
+
const ansi = extractTerminalSequence(line, index);
|
|
229
|
+
if (ansi) {
|
|
230
|
+
chunks.push({ text: ansi.code, plainStart: plain.length, isAnsi: true });
|
|
231
|
+
index += ansi.length;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const start = index;
|
|
236
|
+
const plainStart = plain.length;
|
|
237
|
+
while (index < line.length && !extractTerminalSequence(line, index)) {
|
|
238
|
+
plain += line[index] ?? "";
|
|
239
|
+
index += 1;
|
|
240
|
+
}
|
|
241
|
+
chunks.push({
|
|
242
|
+
text: line.slice(start, index),
|
|
243
|
+
plainStart,
|
|
244
|
+
isAnsi: false,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const tokens = findSlashCommandTokens(plain).filter((token) => isValidCommand(token.name));
|
|
249
|
+
if (tokens.length === 0) return line;
|
|
250
|
+
|
|
251
|
+
return chunks.map((chunk) => {
|
|
252
|
+
if (chunk.isAnsi) return chunk.text;
|
|
253
|
+
let output = "";
|
|
254
|
+
let offset = 0;
|
|
255
|
+
while (offset < chunk.text.length) {
|
|
256
|
+
const plainIndex = chunk.plainStart + offset;
|
|
257
|
+
const token = tokens.find(({ start, end }) => plainIndex >= start && plainIndex < end);
|
|
258
|
+
if (!token) {
|
|
259
|
+
const nextTokenStart = tokens.find(({ start }) => start > plainIndex)?.start;
|
|
260
|
+
const end = nextTokenStart === undefined
|
|
261
|
+
? chunk.text.length
|
|
262
|
+
: Math.min(chunk.text.length, nextTokenStart - chunk.plainStart);
|
|
263
|
+
output += chunk.text.slice(offset, end);
|
|
264
|
+
offset = end;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const tokenEnd = Math.min(chunk.text.length, token.end - chunk.plainStart);
|
|
269
|
+
output += styleCommand(chunk.text.slice(offset, tokenEnd));
|
|
270
|
+
offset = tokenEnd;
|
|
271
|
+
}
|
|
272
|
+
return output;
|
|
273
|
+
}).join("");
|
|
274
|
+
}
|
|
275
|
+
|
|
185
276
|
class PiCodeEditor extends CustomEditor {
|
|
186
277
|
private readonly appKeybindings: KeybindingsManager;
|
|
187
278
|
private readonly runtimeTheme: Theme;
|
|
188
279
|
private readonly suggestion: string;
|
|
280
|
+
private readonly commandResolver: SlashCommandResolver;
|
|
189
281
|
|
|
190
282
|
constructor(
|
|
191
283
|
tui: TUI,
|
|
@@ -193,11 +285,13 @@ class PiCodeEditor extends CustomEditor {
|
|
|
193
285
|
appKeybindings: KeybindingsManager,
|
|
194
286
|
runtimeTheme: Theme,
|
|
195
287
|
suggestion: string,
|
|
288
|
+
commandResolver: SlashCommandResolver,
|
|
196
289
|
) {
|
|
197
290
|
super(tui, theme, appKeybindings);
|
|
198
291
|
this.appKeybindings = appKeybindings;
|
|
199
292
|
this.runtimeTheme = runtimeTheme;
|
|
200
293
|
this.suggestion = suggestion;
|
|
294
|
+
this.commandResolver = commandResolver;
|
|
201
295
|
}
|
|
202
296
|
|
|
203
297
|
override handleInput(data: string): void {
|
|
@@ -248,6 +342,13 @@ class PiCodeEditor extends CustomEditor {
|
|
|
248
342
|
const cursorMarker = this.focused ? CURSOR_MARKER : "";
|
|
249
343
|
content = `${cursorMarker}\x1B[7m${dim(first)}\x1B[27m${dim(rest)}`;
|
|
250
344
|
}
|
|
345
|
+
if (this.getText() !== "") {
|
|
346
|
+
content = highlightSlashCommands(
|
|
347
|
+
content,
|
|
348
|
+
(name) => this.commandResolver.isValidCommand(name),
|
|
349
|
+
(token) => this.runtimeTheme.fg("mdLink", token),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
251
352
|
rendered.push(`${prefix}${padRight(content, innerWidth)}`);
|
|
252
353
|
}
|
|
253
354
|
|
|
@@ -272,7 +373,10 @@ const ACTIVITY_FRAME_INTERVAL_MS = 120;
|
|
|
272
373
|
|
|
273
374
|
let killerosEditorFactory: ReturnType<ExtensionContext["ui"]["getEditorComponent"]>;
|
|
274
375
|
|
|
275
|
-
export function registerShellUi(
|
|
376
|
+
export function registerShellUi(
|
|
377
|
+
pi: ExtensionAPI,
|
|
378
|
+
commandResolver: SlashCommandResolver = createSlashCommandResolver(pi),
|
|
379
|
+
): void {
|
|
276
380
|
let activeHeader: PiStartupHeader | undefined;
|
|
277
381
|
|
|
278
382
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -294,7 +398,7 @@ export function registerShellUi(pi: ExtensionAPI): void {
|
|
|
294
398
|
if (!existingEditorFactory || existingEditorFactory === killerosEditorFactory) {
|
|
295
399
|
const editorSuggestion = nextEditorSuggestion();
|
|
296
400
|
killerosEditorFactory = (tui, editorTheme, keybindings) =>
|
|
297
|
-
new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, editorSuggestion);
|
|
401
|
+
new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, editorSuggestion, commandResolver);
|
|
298
402
|
ctx.ui.setEditorComponent(killerosEditorFactory);
|
|
299
403
|
}
|
|
300
404
|
} catch (error) {
|