killeros 2.1.31 → 2.1.32
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 +568 -558
- package/Killeros.ts +53 -53
- package/README.md +112 -112
- package/killeros/activity.ts +110 -110
- package/killeros/auto-compaction.ts +342 -342
- package/killeros/change-receipt.ts +676 -676
- package/killeros/codex-fast-state.ts +47 -47
- package/killeros/codex-fast.ts +36 -36
- package/killeros/commands.ts +259 -259
- package/killeros/display.ts +60 -60
- package/killeros/errors.ts +19 -19
- package/killeros/footer.ts +505 -505
- package/killeros/goal-command.ts +26 -26
- package/killeros/goal-interface.ts +560 -560
- package/killeros/goal-runtime.ts +490 -490
- package/killeros/goal-settlement.ts +348 -348
- package/killeros/goal-state.ts +664 -660
- package/killeros/handoff.ts +326 -326
- package/killeros/hooks.ts +449 -449
- package/killeros/notifications.ts +135 -135
- package/killeros/passive-git-status.ts +346 -346
- package/killeros/personal-instructions.ts +122 -122
- package/killeros/question-ui.ts +590 -590
- package/killeros/question.ts +234 -234
- package/killeros/runtime.ts +150 -150
- package/killeros/secret-detector.ts +19 -19
- package/killeros/settings.ts +141 -141
- package/killeros/shell-ui.ts +418 -418
- package/killeros/variants.ts +13 -13
- package/killeros/worked-for.ts +489 -496
- package/package.json +68 -67
- package/themes/killeros.json +87 -87
|
@@ -1,342 +1,342 @@
|
|
|
1
|
-
import {
|
|
2
|
-
getAgentDir,
|
|
3
|
-
SettingsManager,
|
|
4
|
-
type CompactionSettings,
|
|
5
|
-
type ContextUsage,
|
|
6
|
-
type ExtensionAPI,
|
|
7
|
-
type ExtensionContext,
|
|
8
|
-
} from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { errorMessage } from "./errors.ts";
|
|
10
|
-
import { GOAL_CONTINUATION_TYPE } from "./goal-runtime.ts";
|
|
11
|
-
import { createKillerosSettingsStore, type KillerosSettingsStore } from "./settings.ts";
|
|
12
|
-
|
|
13
|
-
export const DEFAULT_AUTO_COMPACTION_PERCENT_REMAINING = 15;
|
|
14
|
-
export const AUTO_COMPACTION_MESSAGE_TYPE = "killeros-auto-compaction";
|
|
15
|
-
export const AUTO_COMPACTION_MESSAGE = "Continue the interrupted task from the compacted context.";
|
|
16
|
-
/** Pi's exact rejection when a session has no eligible history to summarize; an expected skip, not a failure. */
|
|
17
|
-
export const SESSION_TOO_SMALL_COMPACTION_ERROR = "Nothing to compact (session too small)";
|
|
18
|
-
|
|
19
|
-
export interface AutoCompactionPreference {
|
|
20
|
-
enabled: boolean;
|
|
21
|
-
percentRemaining: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface AutoCompactionGoalHandlers {
|
|
25
|
-
isActive(ctx: ExtensionContext): boolean;
|
|
26
|
-
onRequested(): void;
|
|
27
|
-
onCompleted(ctx: ExtensionContext): void;
|
|
28
|
-
onFailed(ctx: ExtensionContext, error: unknown): void;
|
|
29
|
-
/** Recovers the goal paused for a request Pi rejected as session-too-small, without claiming success. */
|
|
30
|
-
onSkipped(ctx: ExtensionContext): void;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface AutoCompactionDependencies {
|
|
34
|
-
loadPreference?: (ctx: ExtensionContext) => AutoCompactionPreference;
|
|
35
|
-
getCompactionSettings?: (ctx: ExtensionContext) => CompactionSettings;
|
|
36
|
-
settingsStore?: KillerosSettingsStore;
|
|
37
|
-
goal?: AutoCompactionGoalHandlers;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
type AutoCompactionRequest = {
|
|
41
|
-
phase: "compacting";
|
|
42
|
-
goal: boolean;
|
|
43
|
-
token: symbol;
|
|
44
|
-
turnSettled: boolean;
|
|
45
|
-
compactionCompleted: boolean;
|
|
46
|
-
} | {
|
|
47
|
-
phase: "continuation-dispatched";
|
|
48
|
-
goal: false;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
52
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function readAutoCompactionPreference(
|
|
56
|
-
settings: Readonly<Record<string, unknown>>,
|
|
57
|
-
): AutoCompactionPreference {
|
|
58
|
-
const raw = isRecord(settings.autoCompaction) ? settings.autoCompaction : {};
|
|
59
|
-
const percentRemaining = typeof raw.percentRemaining === "number"
|
|
60
|
-
&& Number.isFinite(raw.percentRemaining)
|
|
61
|
-
&& raw.percentRemaining >= 0
|
|
62
|
-
&& raw.percentRemaining <= 100
|
|
63
|
-
? raw.percentRemaining
|
|
64
|
-
: DEFAULT_AUTO_COMPACTION_PERCENT_REMAINING;
|
|
65
|
-
return {
|
|
66
|
-
enabled: typeof raw.enabled === "boolean" ? raw.enabled : true,
|
|
67
|
-
percentRemaining,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function defaultPreference(store: KillerosSettingsStore): AutoCompactionPreference {
|
|
72
|
-
return readAutoCompactionPreference(store.load());
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function defaultCompactionSettings(ctx: ExtensionContext): CompactionSettings {
|
|
76
|
-
return SettingsManager.create(ctx.cwd, getAgentDir(), {
|
|
77
|
-
projectTrusted: ctx.isProjectTrusted(),
|
|
78
|
-
}).getCompactionSettings();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function reserveTokens(settings: Pick<CompactionSettings, "reserveTokens">): number {
|
|
82
|
-
return typeof settings.reserveTokens === "number" && Number.isFinite(settings.reserveTokens)
|
|
83
|
-
? Math.max(0, settings.reserveTokens)
|
|
84
|
-
: 0;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Matches only Pi's exact rejection text on a real Error; near-matches and stringified values stay failures. */
|
|
88
|
-
export function isSessionTooSmallCompactionError(error: unknown): boolean {
|
|
89
|
-
return error instanceof Error && error.message === SESSION_TOO_SMALL_COMPACTION_ERROR;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Triggers at the stricter of the user's percentage and Pi's token reserve. */
|
|
93
|
-
export function shouldTriggerAutoCompaction(
|
|
94
|
-
usage: Pick<ContextUsage, "tokens" | "contextWindow"> | undefined,
|
|
95
|
-
preference: AutoCompactionPreference,
|
|
96
|
-
compactionSettings: Pick<CompactionSettings, "enabled" | "reserveTokens">,
|
|
97
|
-
): boolean {
|
|
98
|
-
if (!preference.enabled || !compactionSettings.enabled || usage?.tokens === undefined || usage.tokens === null) {
|
|
99
|
-
return false;
|
|
100
|
-
}
|
|
101
|
-
if (!Number.isFinite(usage.tokens) || !Number.isFinite(usage.contextWindow) || usage.contextWindow <= 0) {
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
const remainingTokens = usage.contextWindow - usage.tokens;
|
|
105
|
-
const threshold = Math.max(
|
|
106
|
-
usage.contextWindow * preference.percentRemaining / 100,
|
|
107
|
-
reserveTokens(compactionSettings),
|
|
108
|
-
);
|
|
109
|
-
return remainingTokens <= threshold;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function supportedMode(ctx: ExtensionContext): boolean {
|
|
113
|
-
return ctx.mode === "tui" || ctx.mode === "rpc";
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function registerAutoCompaction(
|
|
117
|
-
pi: ExtensionAPI,
|
|
118
|
-
dependencies: AutoCompactionDependencies = {},
|
|
119
|
-
): void {
|
|
120
|
-
const settingsStore = dependencies.settingsStore ?? createKillerosSettingsStore();
|
|
121
|
-
const loadPreference = dependencies.loadPreference ?? (() => defaultPreference(settingsStore));
|
|
122
|
-
const getCompactionSettings = dependencies.getCompactionSettings ?? defaultCompactionSettings;
|
|
123
|
-
let request: AutoCompactionRequest | undefined;
|
|
124
|
-
let armed = true;
|
|
125
|
-
let rearmOnNextLogicalRequest = false;
|
|
126
|
-
let settingsErrorReported = false;
|
|
127
|
-
|
|
128
|
-
const resetForLifecycle = (): void => {
|
|
129
|
-
request = undefined;
|
|
130
|
-
armed = true;
|
|
131
|
-
rearmOnNextLogicalRequest = false;
|
|
132
|
-
settingsErrorReported = false;
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
pi.registerCommand("auto-compact", {
|
|
136
|
-
description: "Inspect or change automatic compaction",
|
|
137
|
-
handler: async (args, ctx) => {
|
|
138
|
-
const argument = args.trim();
|
|
139
|
-
if (!argument || argument === "status") {
|
|
140
|
-
try {
|
|
141
|
-
const preference = readAutoCompactionPreference(settingsStore.load());
|
|
142
|
-
ctx.ui.notify(preference.enabled
|
|
143
|
-
? `Automatic compaction: on at ${preference.percentRemaining}% remaining`
|
|
144
|
-
: `Automatic compaction: off (threshold ${preference.percentRemaining}%)`, "info");
|
|
145
|
-
} catch (error) {
|
|
146
|
-
ctx.ui.notify(`Automatic compaction setting could not be read: ${errorMessage(error)}`, "error");
|
|
147
|
-
}
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const percent = /^\d+$/u.test(argument) ? Number(argument) : undefined;
|
|
152
|
-
if (argument !== "on" && argument !== "off" && (percent === undefined || percent > 100)) {
|
|
153
|
-
ctx.ui.notify("Usage: /auto-compact [status|on|off|<percent 0-100>]", "error");
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
const preference = readAutoCompactionPreference(settingsStore.load());
|
|
159
|
-
const updated = percent === undefined
|
|
160
|
-
? { ...preference, enabled: argument === "on" }
|
|
161
|
-
: { ...preference, percentRemaining: percent };
|
|
162
|
-
settingsStore.update({ autoCompaction: updated });
|
|
163
|
-
ctx.ui.notify(percent === undefined
|
|
164
|
-
? updated.enabled
|
|
165
|
-
? `Automatic compaction: on at ${updated.percentRemaining}% remaining`
|
|
166
|
-
: `Automatic compaction: off (threshold ${updated.percentRemaining}%)`
|
|
167
|
-
: `Automatic compaction threshold: ${percent}% remaining (${updated.enabled ? "on" : "off"})`, "info");
|
|
168
|
-
} catch (error) {
|
|
169
|
-
ctx.ui.notify(`Automatic compaction setting could not be saved: ${errorMessage(error)}`, "error");
|
|
170
|
-
}
|
|
171
|
-
},
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
const notifyFailure = (ctx: ExtensionContext, error: unknown): void => {
|
|
175
|
-
ctx.ui.notify(`Automatic compaction failed: ${errorMessage(error)}`, "error");
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
const finishFailure = (ctx: ExtensionContext, goal: boolean, error: unknown): void => {
|
|
179
|
-
request = undefined;
|
|
180
|
-
if (goal && dependencies.goal) {
|
|
181
|
-
try {
|
|
182
|
-
dependencies.goal.onFailed(ctx, error);
|
|
183
|
-
} catch (callbackError) {
|
|
184
|
-
notifyFailure(ctx, callbackError);
|
|
185
|
-
}
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
notifyFailure(ctx, error);
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
/** Ends a request with Pi's expected eligibility rejection: silent, rearmed, and never a failure. */
|
|
192
|
-
const finishSkip = (ctx: ExtensionContext, goal: boolean): void => {
|
|
193
|
-
request = undefined;
|
|
194
|
-
armed = true;
|
|
195
|
-
if (!goal) return;
|
|
196
|
-
try {
|
|
197
|
-
dependencies.goal?.onSkipped(ctx);
|
|
198
|
-
} catch (callbackError) {
|
|
199
|
-
notifyFailure(ctx, callbackError);
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
/** Single entry point for rejected requests: classifies before any sanitization, stale tokens stay inert. */
|
|
204
|
-
const finishRequestError = (
|
|
205
|
-
ctx: ExtensionContext,
|
|
206
|
-
token: symbol,
|
|
207
|
-
goal: boolean,
|
|
208
|
-
error: unknown,
|
|
209
|
-
): void => {
|
|
210
|
-
if (request?.phase !== "compacting" || request.token !== token) return;
|
|
211
|
-
if (isSessionTooSmallCompactionError(error)) {
|
|
212
|
-
finishSkip(ctx, goal);
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
finishFailure(ctx, goal, error);
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
/** Dispatches one ordinary continuation after compaction and the interrupted run have both finished. */
|
|
219
|
-
const finalizeOrdinaryContinuation = (ctx: ExtensionContext, token: symbol): void => {
|
|
220
|
-
if (request?.phase !== "compacting"
|
|
221
|
-
|| request.token !== token
|
|
222
|
-
|| request.goal
|
|
223
|
-
|| !request.turnSettled
|
|
224
|
-
|| !request.compactionCompleted) return;
|
|
225
|
-
request = { phase: "continuation-dispatched", goal: false };
|
|
226
|
-
try {
|
|
227
|
-
pi.sendMessage({
|
|
228
|
-
customType: AUTO_COMPACTION_MESSAGE_TYPE,
|
|
229
|
-
content: AUTO_COMPACTION_MESSAGE,
|
|
230
|
-
display: false,
|
|
231
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
232
|
-
} catch (error) {
|
|
233
|
-
request = undefined;
|
|
234
|
-
notifyFailure(ctx, error);
|
|
235
|
-
}
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
pi.on("turn_end", (_event, ctx) => {
|
|
239
|
-
if (!supportedMode(ctx) || request) return;
|
|
240
|
-
|
|
241
|
-
let preference: AutoCompactionPreference;
|
|
242
|
-
let compactionSettings: CompactionSettings;
|
|
243
|
-
let usage: ContextUsage | undefined;
|
|
244
|
-
try {
|
|
245
|
-
preference = loadPreference(ctx);
|
|
246
|
-
compactionSettings = getCompactionSettings(ctx);
|
|
247
|
-
} catch (error) {
|
|
248
|
-
if (!settingsErrorReported) {
|
|
249
|
-
settingsErrorReported = true;
|
|
250
|
-
ctx.ui.notify(`Automatic compaction settings could not be read: ${errorMessage(error)}`, "error");
|
|
251
|
-
}
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
try {
|
|
255
|
-
usage = ctx.getContextUsage();
|
|
256
|
-
} catch {
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
if (!usage) return;
|
|
260
|
-
if (usage.tokens !== null && usage.tokens !== undefined && Number.isFinite(usage.tokens)
|
|
261
|
-
&& Number.isFinite(usage.contextWindow) && usage.contextWindow > 0) {
|
|
262
|
-
const remainingTokens = usage.contextWindow - usage.tokens;
|
|
263
|
-
const threshold = Math.max(
|
|
264
|
-
usage.contextWindow * preference.percentRemaining / 100,
|
|
265
|
-
reserveTokens(compactionSettings),
|
|
266
|
-
);
|
|
267
|
-
if (remainingTokens > threshold) {
|
|
268
|
-
armed = true;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
if (!armed) return;
|
|
272
|
-
if (!shouldTriggerAutoCompaction(usage, preference, compactionSettings)) return;
|
|
273
|
-
|
|
274
|
-
armed = false;
|
|
275
|
-
const token = Symbol();
|
|
276
|
-
const goal = dependencies.goal?.isActive(ctx) === true;
|
|
277
|
-
request = {
|
|
278
|
-
phase: "compacting",
|
|
279
|
-
goal,
|
|
280
|
-
token,
|
|
281
|
-
turnSettled: false,
|
|
282
|
-
compactionCompleted: false,
|
|
283
|
-
};
|
|
284
|
-
if (goal && dependencies.goal) {
|
|
285
|
-
try {
|
|
286
|
-
dependencies.goal.onRequested();
|
|
287
|
-
} catch (error) {
|
|
288
|
-
finishFailure(ctx, true, error);
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
try {
|
|
294
|
-
ctx.compact({
|
|
295
|
-
onComplete: () => {
|
|
296
|
-
if (request?.phase !== "compacting" || request.token !== token) return;
|
|
297
|
-
rearmOnNextLogicalRequest = preference.percentRemaining === 100;
|
|
298
|
-
if (goal && dependencies.goal) {
|
|
299
|
-
request = undefined;
|
|
300
|
-
try {
|
|
301
|
-
dependencies.goal.onCompleted(ctx);
|
|
302
|
-
} catch (error) {
|
|
303
|
-
notifyFailure(ctx, error);
|
|
304
|
-
}
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
request.compactionCompleted = true;
|
|
308
|
-
finalizeOrdinaryContinuation(ctx, token);
|
|
309
|
-
},
|
|
310
|
-
onError: (error) => finishRequestError(ctx, token, goal, error),
|
|
311
|
-
});
|
|
312
|
-
} catch (error) {
|
|
313
|
-
finishRequestError(ctx, token, goal, error);
|
|
314
|
-
}
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
pi.on("agent_settled", (_event, ctx) => {
|
|
318
|
-
if (request?.phase === "continuation-dispatched") {
|
|
319
|
-
request = undefined;
|
|
320
|
-
notifyFailure(ctx, new Error("continuation was not accepted"));
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
if (request?.phase !== "compacting" || request.goal) return;
|
|
324
|
-
request.turnSettled = true;
|
|
325
|
-
finalizeOrdinaryContinuation(ctx, request.token);
|
|
326
|
-
});
|
|
327
|
-
pi.on("message_start", (event) => {
|
|
328
|
-
const isOrdinaryContinuation = event.message.role === "custom"
|
|
329
|
-
&& event.message.customType === AUTO_COMPACTION_MESSAGE_TYPE;
|
|
330
|
-
if (request?.phase === "continuation-dispatched" && isOrdinaryContinuation) request = undefined;
|
|
331
|
-
const startsNewLogicalRequest = event.message.role === "user"
|
|
332
|
-
|| (event.message.role === "custom" && event.message.customType === GOAL_CONTINUATION_TYPE);
|
|
333
|
-
if (rearmOnNextLogicalRequest && startsNewLogicalRequest) {
|
|
334
|
-
armed = true;
|
|
335
|
-
rearmOnNextLogicalRequest = false;
|
|
336
|
-
}
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
pi.on("session_start", resetForLifecycle);
|
|
340
|
-
pi.on("session_shutdown", resetForLifecycle);
|
|
341
|
-
pi.on("session_tree", resetForLifecycle);
|
|
342
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
getAgentDir,
|
|
3
|
+
SettingsManager,
|
|
4
|
+
type CompactionSettings,
|
|
5
|
+
type ContextUsage,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
type ExtensionContext,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { errorMessage } from "./errors.ts";
|
|
10
|
+
import { GOAL_CONTINUATION_TYPE } from "./goal-runtime.ts";
|
|
11
|
+
import { createKillerosSettingsStore, type KillerosSettingsStore } from "./settings.ts";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_AUTO_COMPACTION_PERCENT_REMAINING = 15;
|
|
14
|
+
export const AUTO_COMPACTION_MESSAGE_TYPE = "killeros-auto-compaction";
|
|
15
|
+
export const AUTO_COMPACTION_MESSAGE = "Continue the interrupted task from the compacted context.";
|
|
16
|
+
/** Pi's exact rejection when a session has no eligible history to summarize; an expected skip, not a failure. */
|
|
17
|
+
export const SESSION_TOO_SMALL_COMPACTION_ERROR = "Nothing to compact (session too small)";
|
|
18
|
+
|
|
19
|
+
export interface AutoCompactionPreference {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
percentRemaining: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AutoCompactionGoalHandlers {
|
|
25
|
+
isActive(ctx: ExtensionContext): boolean;
|
|
26
|
+
onRequested(): void;
|
|
27
|
+
onCompleted(ctx: ExtensionContext): void;
|
|
28
|
+
onFailed(ctx: ExtensionContext, error: unknown): void;
|
|
29
|
+
/** Recovers the goal paused for a request Pi rejected as session-too-small, without claiming success. */
|
|
30
|
+
onSkipped(ctx: ExtensionContext): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AutoCompactionDependencies {
|
|
34
|
+
loadPreference?: (ctx: ExtensionContext) => AutoCompactionPreference;
|
|
35
|
+
getCompactionSettings?: (ctx: ExtensionContext) => CompactionSettings;
|
|
36
|
+
settingsStore?: KillerosSettingsStore;
|
|
37
|
+
goal?: AutoCompactionGoalHandlers;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type AutoCompactionRequest = {
|
|
41
|
+
phase: "compacting";
|
|
42
|
+
goal: boolean;
|
|
43
|
+
token: symbol;
|
|
44
|
+
turnSettled: boolean;
|
|
45
|
+
compactionCompleted: boolean;
|
|
46
|
+
} | {
|
|
47
|
+
phase: "continuation-dispatched";
|
|
48
|
+
goal: false;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
52
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readAutoCompactionPreference(
|
|
56
|
+
settings: Readonly<Record<string, unknown>>,
|
|
57
|
+
): AutoCompactionPreference {
|
|
58
|
+
const raw = isRecord(settings.autoCompaction) ? settings.autoCompaction : {};
|
|
59
|
+
const percentRemaining = typeof raw.percentRemaining === "number"
|
|
60
|
+
&& Number.isFinite(raw.percentRemaining)
|
|
61
|
+
&& raw.percentRemaining >= 0
|
|
62
|
+
&& raw.percentRemaining <= 100
|
|
63
|
+
? raw.percentRemaining
|
|
64
|
+
: DEFAULT_AUTO_COMPACTION_PERCENT_REMAINING;
|
|
65
|
+
return {
|
|
66
|
+
enabled: typeof raw.enabled === "boolean" ? raw.enabled : true,
|
|
67
|
+
percentRemaining,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function defaultPreference(store: KillerosSettingsStore): AutoCompactionPreference {
|
|
72
|
+
return readAutoCompactionPreference(store.load());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function defaultCompactionSettings(ctx: ExtensionContext): CompactionSettings {
|
|
76
|
+
return SettingsManager.create(ctx.cwd, getAgentDir(), {
|
|
77
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
78
|
+
}).getCompactionSettings();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function reserveTokens(settings: Pick<CompactionSettings, "reserveTokens">): number {
|
|
82
|
+
return typeof settings.reserveTokens === "number" && Number.isFinite(settings.reserveTokens)
|
|
83
|
+
? Math.max(0, settings.reserveTokens)
|
|
84
|
+
: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Matches only Pi's exact rejection text on a real Error; near-matches and stringified values stay failures. */
|
|
88
|
+
export function isSessionTooSmallCompactionError(error: unknown): boolean {
|
|
89
|
+
return error instanceof Error && error.message === SESSION_TOO_SMALL_COMPACTION_ERROR;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Triggers at the stricter of the user's percentage and Pi's token reserve. */
|
|
93
|
+
export function shouldTriggerAutoCompaction(
|
|
94
|
+
usage: Pick<ContextUsage, "tokens" | "contextWindow"> | undefined,
|
|
95
|
+
preference: AutoCompactionPreference,
|
|
96
|
+
compactionSettings: Pick<CompactionSettings, "enabled" | "reserveTokens">,
|
|
97
|
+
): boolean {
|
|
98
|
+
if (!preference.enabled || !compactionSettings.enabled || usage?.tokens === undefined || usage.tokens === null) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
if (!Number.isFinite(usage.tokens) || !Number.isFinite(usage.contextWindow) || usage.contextWindow <= 0) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const remainingTokens = usage.contextWindow - usage.tokens;
|
|
105
|
+
const threshold = Math.max(
|
|
106
|
+
usage.contextWindow * preference.percentRemaining / 100,
|
|
107
|
+
reserveTokens(compactionSettings),
|
|
108
|
+
);
|
|
109
|
+
return remainingTokens <= threshold;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function supportedMode(ctx: ExtensionContext): boolean {
|
|
113
|
+
return ctx.mode === "tui" || ctx.mode === "rpc";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function registerAutoCompaction(
|
|
117
|
+
pi: ExtensionAPI,
|
|
118
|
+
dependencies: AutoCompactionDependencies = {},
|
|
119
|
+
): void {
|
|
120
|
+
const settingsStore = dependencies.settingsStore ?? createKillerosSettingsStore();
|
|
121
|
+
const loadPreference = dependencies.loadPreference ?? (() => defaultPreference(settingsStore));
|
|
122
|
+
const getCompactionSettings = dependencies.getCompactionSettings ?? defaultCompactionSettings;
|
|
123
|
+
let request: AutoCompactionRequest | undefined;
|
|
124
|
+
let armed = true;
|
|
125
|
+
let rearmOnNextLogicalRequest = false;
|
|
126
|
+
let settingsErrorReported = false;
|
|
127
|
+
|
|
128
|
+
const resetForLifecycle = (): void => {
|
|
129
|
+
request = undefined;
|
|
130
|
+
armed = true;
|
|
131
|
+
rearmOnNextLogicalRequest = false;
|
|
132
|
+
settingsErrorReported = false;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
pi.registerCommand("auto-compact", {
|
|
136
|
+
description: "Inspect or change automatic compaction",
|
|
137
|
+
handler: async (args, ctx) => {
|
|
138
|
+
const argument = args.trim();
|
|
139
|
+
if (!argument || argument === "status") {
|
|
140
|
+
try {
|
|
141
|
+
const preference = readAutoCompactionPreference(settingsStore.load());
|
|
142
|
+
ctx.ui.notify(preference.enabled
|
|
143
|
+
? `Automatic compaction: on at ${preference.percentRemaining}% remaining`
|
|
144
|
+
: `Automatic compaction: off (threshold ${preference.percentRemaining}%)`, "info");
|
|
145
|
+
} catch (error) {
|
|
146
|
+
ctx.ui.notify(`Automatic compaction setting could not be read: ${errorMessage(error)}`, "error");
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const percent = /^\d+$/u.test(argument) ? Number(argument) : undefined;
|
|
152
|
+
if (argument !== "on" && argument !== "off" && (percent === undefined || percent > 100)) {
|
|
153
|
+
ctx.ui.notify("Usage: /auto-compact [status|on|off|<percent 0-100>]", "error");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const preference = readAutoCompactionPreference(settingsStore.load());
|
|
159
|
+
const updated = percent === undefined
|
|
160
|
+
? { ...preference, enabled: argument === "on" }
|
|
161
|
+
: { ...preference, percentRemaining: percent };
|
|
162
|
+
settingsStore.update({ autoCompaction: updated });
|
|
163
|
+
ctx.ui.notify(percent === undefined
|
|
164
|
+
? updated.enabled
|
|
165
|
+
? `Automatic compaction: on at ${updated.percentRemaining}% remaining`
|
|
166
|
+
: `Automatic compaction: off (threshold ${updated.percentRemaining}%)`
|
|
167
|
+
: `Automatic compaction threshold: ${percent}% remaining (${updated.enabled ? "on" : "off"})`, "info");
|
|
168
|
+
} catch (error) {
|
|
169
|
+
ctx.ui.notify(`Automatic compaction setting could not be saved: ${errorMessage(error)}`, "error");
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const notifyFailure = (ctx: ExtensionContext, error: unknown): void => {
|
|
175
|
+
ctx.ui.notify(`Automatic compaction failed: ${errorMessage(error)}`, "error");
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const finishFailure = (ctx: ExtensionContext, goal: boolean, error: unknown): void => {
|
|
179
|
+
request = undefined;
|
|
180
|
+
if (goal && dependencies.goal) {
|
|
181
|
+
try {
|
|
182
|
+
dependencies.goal.onFailed(ctx, error);
|
|
183
|
+
} catch (callbackError) {
|
|
184
|
+
notifyFailure(ctx, callbackError);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
notifyFailure(ctx, error);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** Ends a request with Pi's expected eligibility rejection: silent, rearmed, and never a failure. */
|
|
192
|
+
const finishSkip = (ctx: ExtensionContext, goal: boolean): void => {
|
|
193
|
+
request = undefined;
|
|
194
|
+
armed = true;
|
|
195
|
+
if (!goal) return;
|
|
196
|
+
try {
|
|
197
|
+
dependencies.goal?.onSkipped(ctx);
|
|
198
|
+
} catch (callbackError) {
|
|
199
|
+
notifyFailure(ctx, callbackError);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** Single entry point for rejected requests: classifies before any sanitization, stale tokens stay inert. */
|
|
204
|
+
const finishRequestError = (
|
|
205
|
+
ctx: ExtensionContext,
|
|
206
|
+
token: symbol,
|
|
207
|
+
goal: boolean,
|
|
208
|
+
error: unknown,
|
|
209
|
+
): void => {
|
|
210
|
+
if (request?.phase !== "compacting" || request.token !== token) return;
|
|
211
|
+
if (isSessionTooSmallCompactionError(error)) {
|
|
212
|
+
finishSkip(ctx, goal);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
finishFailure(ctx, goal, error);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** Dispatches one ordinary continuation after compaction and the interrupted run have both finished. */
|
|
219
|
+
const finalizeOrdinaryContinuation = (ctx: ExtensionContext, token: symbol): void => {
|
|
220
|
+
if (request?.phase !== "compacting"
|
|
221
|
+
|| request.token !== token
|
|
222
|
+
|| request.goal
|
|
223
|
+
|| !request.turnSettled
|
|
224
|
+
|| !request.compactionCompleted) return;
|
|
225
|
+
request = { phase: "continuation-dispatched", goal: false };
|
|
226
|
+
try {
|
|
227
|
+
pi.sendMessage({
|
|
228
|
+
customType: AUTO_COMPACTION_MESSAGE_TYPE,
|
|
229
|
+
content: AUTO_COMPACTION_MESSAGE,
|
|
230
|
+
display: false,
|
|
231
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
232
|
+
} catch (error) {
|
|
233
|
+
request = undefined;
|
|
234
|
+
notifyFailure(ctx, error);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
239
|
+
if (!supportedMode(ctx) || request) return;
|
|
240
|
+
|
|
241
|
+
let preference: AutoCompactionPreference;
|
|
242
|
+
let compactionSettings: CompactionSettings;
|
|
243
|
+
let usage: ContextUsage | undefined;
|
|
244
|
+
try {
|
|
245
|
+
preference = loadPreference(ctx);
|
|
246
|
+
compactionSettings = getCompactionSettings(ctx);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (!settingsErrorReported) {
|
|
249
|
+
settingsErrorReported = true;
|
|
250
|
+
ctx.ui.notify(`Automatic compaction settings could not be read: ${errorMessage(error)}`, "error");
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
usage = ctx.getContextUsage();
|
|
256
|
+
} catch {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (!usage) return;
|
|
260
|
+
if (usage.tokens !== null && usage.tokens !== undefined && Number.isFinite(usage.tokens)
|
|
261
|
+
&& Number.isFinite(usage.contextWindow) && usage.contextWindow > 0) {
|
|
262
|
+
const remainingTokens = usage.contextWindow - usage.tokens;
|
|
263
|
+
const threshold = Math.max(
|
|
264
|
+
usage.contextWindow * preference.percentRemaining / 100,
|
|
265
|
+
reserveTokens(compactionSettings),
|
|
266
|
+
);
|
|
267
|
+
if (remainingTokens > threshold) {
|
|
268
|
+
armed = true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (!armed) return;
|
|
272
|
+
if (!shouldTriggerAutoCompaction(usage, preference, compactionSettings)) return;
|
|
273
|
+
|
|
274
|
+
armed = false;
|
|
275
|
+
const token = Symbol();
|
|
276
|
+
const goal = dependencies.goal?.isActive(ctx) === true;
|
|
277
|
+
request = {
|
|
278
|
+
phase: "compacting",
|
|
279
|
+
goal,
|
|
280
|
+
token,
|
|
281
|
+
turnSettled: false,
|
|
282
|
+
compactionCompleted: false,
|
|
283
|
+
};
|
|
284
|
+
if (goal && dependencies.goal) {
|
|
285
|
+
try {
|
|
286
|
+
dependencies.goal.onRequested();
|
|
287
|
+
} catch (error) {
|
|
288
|
+
finishFailure(ctx, true, error);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
ctx.compact({
|
|
295
|
+
onComplete: () => {
|
|
296
|
+
if (request?.phase !== "compacting" || request.token !== token) return;
|
|
297
|
+
rearmOnNextLogicalRequest = preference.percentRemaining === 100;
|
|
298
|
+
if (goal && dependencies.goal) {
|
|
299
|
+
request = undefined;
|
|
300
|
+
try {
|
|
301
|
+
dependencies.goal.onCompleted(ctx);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
notifyFailure(ctx, error);
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
request.compactionCompleted = true;
|
|
308
|
+
finalizeOrdinaryContinuation(ctx, token);
|
|
309
|
+
},
|
|
310
|
+
onError: (error) => finishRequestError(ctx, token, goal, error),
|
|
311
|
+
});
|
|
312
|
+
} catch (error) {
|
|
313
|
+
finishRequestError(ctx, token, goal, error);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
318
|
+
if (request?.phase === "continuation-dispatched") {
|
|
319
|
+
request = undefined;
|
|
320
|
+
notifyFailure(ctx, new Error("continuation was not accepted"));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (request?.phase !== "compacting" || request.goal) return;
|
|
324
|
+
request.turnSettled = true;
|
|
325
|
+
finalizeOrdinaryContinuation(ctx, request.token);
|
|
326
|
+
});
|
|
327
|
+
pi.on("message_start", (event) => {
|
|
328
|
+
const isOrdinaryContinuation = event.message.role === "custom"
|
|
329
|
+
&& event.message.customType === AUTO_COMPACTION_MESSAGE_TYPE;
|
|
330
|
+
if (request?.phase === "continuation-dispatched" && isOrdinaryContinuation) request = undefined;
|
|
331
|
+
const startsNewLogicalRequest = event.message.role === "user"
|
|
332
|
+
|| (event.message.role === "custom" && event.message.customType === GOAL_CONTINUATION_TYPE);
|
|
333
|
+
if (rearmOnNextLogicalRequest && startsNewLogicalRequest) {
|
|
334
|
+
armed = true;
|
|
335
|
+
rearmOnNextLogicalRequest = false;
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
pi.on("session_start", resetForLifecycle);
|
|
340
|
+
pi.on("session_shutdown", resetForLifecycle);
|
|
341
|
+
pi.on("session_tree", resetForLifecycle);
|
|
342
|
+
}
|