killeros 1.5.2 → 1.5.4

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.
@@ -0,0 +1,566 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ SessionBeforeCompactEvent,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { pauseGoalAfterFailure } from "./goals.ts";
7
+ import type { CompactionRuntime, GoalRuntime } from "./runtime.ts";
8
+
9
+ type CompactionPreparation = SessionBeforeCompactEvent["preparation"];
10
+ type CompactionMessage = CompactionPreparation["messagesToSummarize"][number];
11
+ type MessageRecord = Record<string, unknown>;
12
+
13
+ const warnedRuntimes = new WeakSet<CompactionRuntime>();
14
+ const inFlightTimers = new WeakMap<CompactionRuntime, NodeJS.Timeout>();
15
+ const abortCleanups = new WeakMap<CompactionRuntime, () => void>();
16
+ const compactionFailureHandlers = new WeakMap<CompactionRuntime, () => void>();
17
+ const FILE_PATH_PATTERN = /(?:[A-Za-z]:[\\/]|[\\/]|\.{1,2}[\\/])?[A-Za-z0-9_@.-]+(?:[\\/][A-Za-z0-9_@.-]+)*\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|css|html|py|rs|go|java|kt|rb|php|vue|svelte|yaml|yml|toml|sh|sql)\b/giu;
18
+ const PATH_KEYS = new Set(["path", "file", "filepath", "file_path", "filename", "target"]);
19
+ const MAX_MESSAGE_TEXT = 1_000;
20
+ const MAX_CONTEXT_ITEMS = 80;
21
+ const MAX_FILES = 40;
22
+ const MAX_PREVIOUS_SUMMARY = 6_000;
23
+ const MAX_CUSTOM_INSTRUCTIONS = 4_000;
24
+ const COMPACTION_STATE_TIMEOUT_MS = 60_000;
25
+
26
+ type NotificationLevel = Parameters<ExtensionContext["ui"]["notify"]>[1];
27
+
28
+ function asRecord(value: unknown): MessageRecord | undefined {
29
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
30
+ return value as MessageRecord;
31
+ }
32
+
33
+ function compactText(value: string, limit = MAX_MESSAGE_TEXT): string {
34
+ return value.replace(/\s+/gu, " ").trim().slice(0, limit);
35
+ }
36
+
37
+ function textFromContent(content: unknown): string[] {
38
+ if (typeof content === "string") return [content];
39
+ if (!Array.isArray(content)) return [];
40
+
41
+ const text: string[] = [];
42
+ for (const part of content) {
43
+ const record = asRecord(part);
44
+ if (!record) continue;
45
+ if (typeof record.text === "string") text.push(record.text);
46
+ if (record.type === "toolCall" && typeof record.name === "string") {
47
+ text.push(`Called ${record.name}`);
48
+ }
49
+ }
50
+ return text;
51
+ }
52
+
53
+ function messageText(message: CompactionMessage): string {
54
+ const record = asRecord(message);
55
+ if (!record) return "";
56
+
57
+ const text = textFromContent(record.content);
58
+ if (typeof record.summary === "string") text.unshift(record.summary);
59
+ return compactText(text.join(" "));
60
+ }
61
+
62
+ function messageRole(message: CompactionMessage): string {
63
+ const role = asRecord(message)?.role;
64
+ return typeof role === "string" && role.trim() ? role : "context";
65
+ }
66
+
67
+ function extractMessages(preparation: CompactionPreparation): Array<{ role: string; text: string }> {
68
+ const messages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
69
+ const extracted: Array<{ role: string; text: string }> = [];
70
+ for (const message of messages) {
71
+ const text = messageText(message);
72
+ if (text) extracted.push({ role: messageRole(message), text });
73
+ if (extracted.length >= MAX_CONTEXT_ITEMS) break;
74
+ }
75
+ return extracted;
76
+ }
77
+
78
+ function addPath(paths: Set<string>, value: string): void {
79
+ const path = value.trim().replace(/^['"`([{<]+|['"`.,;:!?)}\]>]+$/gu, "");
80
+ if (!path || path.includes("://") || path.length > 512 || paths.size >= MAX_FILES) return;
81
+ paths.add(path);
82
+ }
83
+
84
+ function extractFilePaths(text: string, paths: Set<string>): void {
85
+ FILE_PATH_PATTERN.lastIndex = 0;
86
+ let match: RegExpExecArray | null;
87
+ while ((match = FILE_PATH_PATTERN.exec(text)) !== null) {
88
+ addPath(paths, match[0]);
89
+ }
90
+ }
91
+
92
+ function collectPaths(value: unknown, paths: Set<string>, depth = 0): void {
93
+ if (paths.size >= MAX_FILES || depth > 4) return;
94
+ if (typeof value === "string") {
95
+ extractFilePaths(value, paths);
96
+ return;
97
+ }
98
+ if (Array.isArray(value)) {
99
+ for (const item of value) collectPaths(item, paths, depth + 1);
100
+ return;
101
+ }
102
+
103
+ const record = asRecord(value);
104
+ if (!record) return;
105
+ for (const [key, item] of Object.entries(record)) {
106
+ const normalizedKey = key.toLocaleLowerCase();
107
+ if (PATH_KEYS.has(normalizedKey) || normalizedKey === "content" || normalizedKey === "text" || normalizedKey === "summary" || normalizedKey === "arguments" || normalizedKey === "input") {
108
+ collectPaths(item, paths, depth + 1);
109
+ }
110
+ }
111
+ }
112
+
113
+ function collectMessagePaths(preparation: CompactionPreparation): Set<string> {
114
+ const paths = new Set<string>();
115
+ const messages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
116
+ for (const message of messages) collectPaths(message, paths);
117
+ return paths;
118
+ }
119
+
120
+ function collectFileOperationPaths(preparation: CompactionPreparation): Set<string> {
121
+ const paths = new Set<string>();
122
+ const fileOps = asRecord(preparation.fileOps);
123
+ if (!fileOps) return paths;
124
+
125
+ for (const key of ["written", "edited"]) {
126
+ const values = fileOps[key];
127
+ if (values instanceof Set) {
128
+ for (const value of values) {
129
+ if (typeof value === "string") addPath(paths, value);
130
+ }
131
+ } else if (Array.isArray(values)) {
132
+ for (const value of values) {
133
+ if (typeof value === "string") addPath(paths, value);
134
+ }
135
+ }
136
+ }
137
+ return paths;
138
+ }
139
+
140
+ function quoteText(value: string, limit: number): string {
141
+ const text = value.trim().slice(0, limit);
142
+ return text
143
+ .split(/\r?\n/gu)
144
+ .map((line) => `> ${line}`)
145
+ .join("\n");
146
+ }
147
+
148
+ function retainSummaryEdges(value: string, limit: number): string {
149
+ if (value.length <= limit) return value;
150
+ const marker = "\n...[previous summary truncated]...\n";
151
+ const available = Math.max(0, limit - marker.length);
152
+ const headLength = Math.ceil(available * 0.6);
153
+ return `${value.slice(0, headLength)}${marker}${value.slice(-(available - headLength))}`;
154
+ }
155
+
156
+ function evidenceLine(item: { role: string; text: string }): string {
157
+ return `- [${item.role}] ${compactText(item.text, 320)}`;
158
+ }
159
+
160
+ function recentMatches(
161
+ messages: Array<{ role: string; text: string }>,
162
+ predicate: (item: { role: string; text: string }) => boolean,
163
+ limit: number,
164
+ ): string[] {
165
+ return messages.filter(predicate).slice(-limit).map(evidenceLine);
166
+ }
167
+
168
+ function buildProgress(messages: Array<{ role: string; text: string }>): string[] {
169
+ const done = recentMatches(
170
+ messages,
171
+ (item) => item.role === "assistant" && /\b(?:added|built|changed|completed|created|done|fixed|implemented|modified|removed|resolved|updated|verified|passed|finished)\b/iu.test(item.text),
172
+ 4,
173
+ );
174
+ const inProgress = recentMatches(
175
+ messages,
176
+ (item) => !done.includes(evidenceLine(item)),
177
+ 3,
178
+ );
179
+
180
+ const lines = ["### Done"];
181
+ lines.push(...(done.length ? done : ["- No completed work was clearly reported in the retained context."]));
182
+ lines.push("", "### In Progress", ...(inProgress.length ? inProgress : ["- The retained context does not state the current work clearly."]));
183
+ return lines;
184
+ }
185
+
186
+ function buildKeyDecisions(messages: Array<{ role: string; text: string }>): string[] {
187
+ const decisions = recentMatches(
188
+ messages,
189
+ (item) => /\b(?:decid(?:e|ed)|chose|choose|instead|must|should|will|avoid|required|prefer|keep)\b/iu.test(item.text),
190
+ 4,
191
+ );
192
+ return ["- Extracted from conversation context.", ...(decisions.length ? decisions : ["- No explicit decisions were found in the retained context."])];
193
+ }
194
+
195
+ function buildModifiedFiles(preparation: CompactionPreparation, messages: Array<{ role: string; text: string }>): string[] {
196
+ const exact = collectFileOperationPaths(preparation);
197
+ if (exact.size) return [...exact].map((path) => `- ${path}`);
198
+
199
+ const mentioned = collectMessagePaths(preparation);
200
+ if (!mentioned.size) return ["- No modified files were recorded in the retained context."];
201
+
202
+ const evidence = messages.some((item) => /\b(?:add(?:ed)?|chang(?:ed|ing)|creat(?:ed|ing)|edit(?:ed|ing)|fix(?:ed|ing)|modif(?:ied|y|ying)|updat(?:ed|ing)|writ(?:e|ten|ing)|remov(?:e|ed|ing))\b/iu.test(item.text));
203
+ return [
204
+ evidence
205
+ ? "- Files mentioned with a change action (no structured file-operation record was retained):"
206
+ : "- Files mentioned in the retained context (no structured file-operation record was retained):",
207
+ ...[...mentioned].map((path) => `- ${path}`),
208
+ ];
209
+ }
210
+
211
+ function buildStructuredSummary(
212
+ preparation: CompactionPreparation,
213
+ goalObjective: string | undefined,
214
+ customInstructions: string | undefined,
215
+ ): string {
216
+ const messages = extractMessages(preparation);
217
+ const firstUserMessage = messages.find((item) => item.role === "user");
218
+ const goal = goalObjective?.trim()
219
+ ? compactText(goalObjective, 4_000)
220
+ : firstUserMessage
221
+ ? compactText(firstUserMessage.text, 4_000)
222
+ : "No explicit goal was retained; continue from the latest context.";
223
+
224
+ const nextSteps = [
225
+ "- Continue the task from where it was interrupted by compaction.",
226
+ "- Re-read any files that were being edited to verify current state.",
227
+ ];
228
+ if (goalObjective?.trim()) nextSteps.push("- Keep the active goal moving until it is complete or clearly blocked.");
229
+ if (customInstructions?.trim()) {
230
+ nextSteps.push("", "Custom Instructions:", quoteText(customInstructions, MAX_CUSTOM_INSTRUCTIONS));
231
+ }
232
+
233
+ return [
234
+ "# KillerOS Compaction Summary",
235
+ "",
236
+ ...(preparation.previousSummary?.trim()
237
+ ? ["## Previous Summary", quoteText(retainSummaryEdges(preparation.previousSummary, MAX_PREVIOUS_SUMMARY), MAX_PREVIOUS_SUMMARY), ""]
238
+ : []),
239
+ "## Goal",
240
+ goal,
241
+ "",
242
+ "## Progress",
243
+ ...buildProgress(messages),
244
+ "",
245
+ "## Key Decisions",
246
+ ...buildKeyDecisions(messages),
247
+ "",
248
+ "## Next Steps",
249
+ ...nextSteps,
250
+ "",
251
+ "## Modified Files",
252
+ ...buildModifiedFiles(preparation, messages),
253
+ ...(customInstructions?.trim()
254
+ ? ["", "## Custom Instructions", quoteText(customInstructions, MAX_CUSTOM_INSTRUCTIONS)]
255
+ : []),
256
+ ].join("\n");
257
+ }
258
+
259
+ function exactPercentRemaining(ctx: ExtensionContext): number | null {
260
+ let usage: ReturnType<ExtensionContext["getContextUsage"]>;
261
+ try {
262
+ usage = ctx.getContextUsage();
263
+ } catch {
264
+ return null;
265
+ }
266
+ if (!usage || !Number.isFinite(usage.contextWindow) || usage.contextWindow <= 0) return null;
267
+ if (usage.tokens === null || !Number.isFinite(usage.tokens)) return null;
268
+
269
+ const percentRemaining = ((usage.contextWindow - usage.tokens) / usage.contextWindow) * 100;
270
+ return Math.max(0, Math.min(100, percentRemaining));
271
+ }
272
+
273
+ export function contextPercentRemaining(ctx: ExtensionContext): number | null {
274
+ const percentRemaining = exactPercentRemaining(ctx);
275
+ return percentRemaining === null ? null : Math.round(percentRemaining);
276
+ }
277
+
278
+ function resetCompactionState(runtime: CompactionRuntime, clearTimestamp = false): void {
279
+ runtime.compactionInFlight = false;
280
+ runtime.automaticCompactionAwaitingHook = false;
281
+ runtime.automaticCompactionPending = false;
282
+ warnedRuntimes.delete(runtime);
283
+ const timer = inFlightTimers.get(runtime);
284
+ if (timer) clearTimeout(timer);
285
+ inFlightTimers.delete(runtime);
286
+ abortCleanups.get(runtime)?.();
287
+ abortCleanups.delete(runtime);
288
+ if (clearTimestamp) runtime.lastCompactionAt = undefined;
289
+ }
290
+
291
+ function isCurrentCompaction(runtime: CompactionRuntime, operationId: number): boolean {
292
+ return runtime.compactionInFlight && runtime.compactionOperationId === operationId;
293
+ }
294
+
295
+ function armCompactionTimeout(runtime: CompactionRuntime, operationId: number): void {
296
+ // ponytail: recover stale state after 60s because Pi exposes no failed-compaction extension event; replace with that event if Pi adds one.
297
+ const timer = setTimeout(() => {
298
+ if (!isCurrentCompaction(runtime, operationId)) return;
299
+ const onFailure = compactionFailureHandlers.get(runtime);
300
+ resetCompactionState(runtime);
301
+ compactionFailureHandlers.delete(runtime);
302
+ onFailure?.();
303
+ }, COMPACTION_STATE_TIMEOUT_MS);
304
+ timer.unref();
305
+ inFlightTimers.set(runtime, timer);
306
+ }
307
+
308
+ function markCompactionInFlight(
309
+ runtime: CompactionRuntime,
310
+ signal: AbortSignal,
311
+ onFailure?: () => void,
312
+ ): number | null {
313
+ const expectedAutomaticHook = runtime.automaticCompactionAwaitingHook;
314
+ if (runtime.compactionInFlight && !expectedAutomaticHook) return null;
315
+ const operationId = expectedAutomaticHook
316
+ ? runtime.compactionOperationId
317
+ : runtime.compactionOperationId + 1;
318
+ resetCompactionState(runtime);
319
+ if (!expectedAutomaticHook) {
320
+ compactionFailureHandlers.delete(runtime);
321
+ runtime.compactionOperationId = operationId;
322
+ if (onFailure) compactionFailureHandlers.set(runtime, onFailure);
323
+ }
324
+ runtime.compactionInFlight = true;
325
+ const onAbort = (): void => {
326
+ if (!isCurrentCompaction(runtime, operationId)) return;
327
+ const failure = compactionFailureHandlers.get(runtime);
328
+ resetCompactionState(runtime);
329
+ compactionFailureHandlers.delete(runtime);
330
+ failure?.();
331
+ };
332
+ signal.addEventListener("abort", onAbort, { once: true });
333
+ abortCleanups.set(runtime, () => signal.removeEventListener("abort", onAbort));
334
+ armCompactionTimeout(runtime, operationId);
335
+ if (signal.aborted) {
336
+ onAbort();
337
+ return null;
338
+ }
339
+ return operationId;
340
+ }
341
+
342
+ function markAutomaticCompactionInFlight(runtime: CompactionRuntime): number {
343
+ resetCompactionState(runtime);
344
+ compactionFailureHandlers.delete(runtime);
345
+ const operationId = runtime.compactionOperationId + 1;
346
+ runtime.compactionOperationId = operationId;
347
+ runtime.compactionInFlight = true;
348
+ runtime.automaticCompactionAwaitingHook = true;
349
+ armCompactionTimeout(runtime, operationId);
350
+ return operationId;
351
+ }
352
+
353
+ function errorMessage(error: unknown): string {
354
+ return error instanceof Error ? error.message : String(error);
355
+ }
356
+
357
+ function notify(ctx: ExtensionContext, message: string, level: NotificationLevel): void {
358
+ try {
359
+ ctx.ui.notify(message, level);
360
+ } catch {
361
+ // The compaction callback can run after session replacement; stale UI must not escape the detached task.
362
+ }
363
+ }
364
+
365
+ function notifyCompactionFailure(
366
+ pi: ExtensionAPI,
367
+ ctx: ExtensionContext,
368
+ compactionRuntime: CompactionRuntime,
369
+ goalRuntime: GoalRuntime,
370
+ error: unknown,
371
+ ): void {
372
+ resetCompactionState(compactionRuntime);
373
+ compactionRuntime.automaticCompactionArmed = true;
374
+ compactionFailureHandlers.delete(compactionRuntime);
375
+ let goalWasPaused = false;
376
+ if (goalRuntime.continuationHeldForCompaction) {
377
+ goalRuntime.continuationHeldForCompaction = false;
378
+ goalRuntime.continuationHeld = false;
379
+ pauseGoalAfterFailure(
380
+ pi,
381
+ goalRuntime,
382
+ ctx,
383
+ `automatic context compaction failed: ${errorMessage(error)}`,
384
+ "Run /compact to retry context compaction, then /goal resume.",
385
+ );
386
+ goalWasPaused = true;
387
+ goalRuntime.requestRender?.();
388
+ }
389
+ if (!goalWasPaused) {
390
+ notify(ctx, `Automatic context compaction failed: ${errorMessage(error)}. Run /compact to try again.`, "error");
391
+ }
392
+ }
393
+
394
+ function notifyPiCompactionFailure(
395
+ pi: ExtensionAPI,
396
+ ctx: ExtensionContext,
397
+ compactionRuntime: CompactionRuntime,
398
+ goalRuntime: GoalRuntime,
399
+ error: unknown,
400
+ ): void {
401
+ resetCompactionState(compactionRuntime);
402
+ compactionRuntime.automaticCompactionArmed = true;
403
+ compactionFailureHandlers.delete(compactionRuntime);
404
+ if (goalRuntime.continuationHeldForCompaction) {
405
+ goalRuntime.continuationHeldForCompaction = false;
406
+ goalRuntime.continuationHeld = false;
407
+ pauseGoalAfterFailure(
408
+ pi,
409
+ goalRuntime,
410
+ ctx,
411
+ `context compaction failed: ${errorMessage(error)}`,
412
+ "Run /compact to retry context compaction, then /goal resume.",
413
+ );
414
+ goalRuntime.requestRender?.();
415
+ }
416
+ }
417
+
418
+ function requestAutomaticCompaction(
419
+ pi: ExtensionAPI,
420
+ ctx: ExtensionContext,
421
+ compactionRuntime: CompactionRuntime,
422
+ goalRuntime: GoalRuntime,
423
+ percentRemaining: number | null,
424
+ ): void {
425
+ const sessionGeneration = compactionRuntime.sessionGeneration;
426
+ compactionRuntime.automaticCompactionArmed = false;
427
+ const operationId = markAutomaticCompactionInFlight(compactionRuntime);
428
+ const handleFailure = (error: unknown, fromCleanup = false): void => {
429
+ if (sessionGeneration !== compactionRuntime.sessionGeneration
430
+ || compactionRuntime.compactionOperationId !== operationId
431
+ || (!fromCleanup && !compactionRuntime.compactionInFlight)) return;
432
+ try {
433
+ notifyCompactionFailure(pi, ctx, compactionRuntime, goalRuntime, error);
434
+ } catch {
435
+ resetCompactionState(compactionRuntime);
436
+ compactionFailureHandlers.delete(compactionRuntime);
437
+ }
438
+ };
439
+ compactionFailureHandlers.set(compactionRuntime, () => handleFailure(new Error("compaction timed out or was cancelled"), true));
440
+ try {
441
+ ctx.compact({
442
+ onError: handleFailure,
443
+ });
444
+ } catch (error) {
445
+ handleFailure(error);
446
+ return;
447
+ }
448
+
449
+ const percent = percentRemaining === null ? "the threshold" : `${percentRemaining}% remaining`;
450
+ notify(ctx, `Context ${percent}. Compacting automatically.`, "info");
451
+ }
452
+
453
+ function resetForSessionBoundary(compactionRuntime: CompactionRuntime): void {
454
+ compactionRuntime.sessionGeneration += 1;
455
+ resetCompactionState(compactionRuntime, true);
456
+ compactionRuntime.automaticCompactionArmed = true;
457
+ compactionFailureHandlers.delete(compactionRuntime);
458
+ }
459
+
460
+ export function registerContextCompaction(
461
+ pi: ExtensionAPI,
462
+ compactionRuntime: CompactionRuntime,
463
+ goalRuntime: GoalRuntime,
464
+ ): void {
465
+ pi.on("session_start", () => {
466
+ resetForSessionBoundary(compactionRuntime);
467
+ });
468
+
469
+ pi.on("session_tree", () => {
470
+ resetForSessionBoundary(compactionRuntime);
471
+ });
472
+
473
+ pi.on("session_shutdown", () => {
474
+ resetForSessionBoundary(compactionRuntime);
475
+ });
476
+
477
+ pi.on("turn_end", (_event, ctx) => {
478
+ const exactRemaining = exactPercentRemaining(ctx);
479
+ if (exactRemaining === null) return;
480
+ const percentRemaining = Math.round(exactRemaining);
481
+ if (exactRemaining > compactionRuntime.thresholdPercent) {
482
+ compactionRuntime.automaticCompactionArmed = true;
483
+ compactionRuntime.automaticCompactionPending = false;
484
+ warnedRuntimes.delete(compactionRuntime);
485
+ return;
486
+ }
487
+ if (compactionRuntime.compactionInFlight) return;
488
+ if (!compactionRuntime.automaticCompactionArmed) return;
489
+
490
+ compactionRuntime.automaticCompactionPending = true;
491
+ if (warnedRuntimes.has(compactionRuntime)) return;
492
+ warnedRuntimes.add(compactionRuntime);
493
+ notify(
494
+ ctx,
495
+ `Context ${percentRemaining}% remaining. Automatic compaction will start when this run settles.`,
496
+ "warning",
497
+ );
498
+ });
499
+
500
+ pi.on("agent_settled", (_event, ctx) => {
501
+ if (compactionRuntime.compactionInFlight) return;
502
+ if (goalRuntime.goalTurnInFlight
503
+ && (goalRuntime.lastStopReason === "error" || goalRuntime.lastStopReason === "aborted")) return;
504
+ const exactRemaining = exactPercentRemaining(ctx);
505
+ const percentRemaining = exactRemaining === null ? null : Math.round(exactRemaining);
506
+ if (exactRemaining !== null && exactRemaining > compactionRuntime.thresholdPercent) {
507
+ compactionRuntime.automaticCompactionArmed = true;
508
+ compactionRuntime.automaticCompactionPending = false;
509
+ warnedRuntimes.delete(compactionRuntime);
510
+ return;
511
+ }
512
+ if (percentRemaining === null && !compactionRuntime.automaticCompactionPending) return;
513
+ if (!compactionRuntime.automaticCompactionArmed) return;
514
+ if (exactRemaining !== null && exactRemaining <= compactionRuntime.thresholdPercent) {
515
+ compactionRuntime.automaticCompactionPending = true;
516
+ }
517
+ if (!compactionRuntime.automaticCompactionPending) return;
518
+
519
+ requestAutomaticCompaction(pi, ctx, compactionRuntime, goalRuntime, percentRemaining);
520
+ });
521
+
522
+ pi.on("session_before_compact", (event, ctx) => {
523
+ const expectedAutomaticHook = compactionRuntime.automaticCompactionAwaitingHook;
524
+ const sessionGeneration = compactionRuntime.sessionGeneration;
525
+ const operationId = markCompactionInFlight(
526
+ compactionRuntime,
527
+ event.signal,
528
+ expectedAutomaticHook
529
+ ? undefined
530
+ : () => {
531
+ if (sessionGeneration !== compactionRuntime.sessionGeneration) return;
532
+ try {
533
+ notifyPiCompactionFailure(
534
+ pi,
535
+ ctx,
536
+ compactionRuntime,
537
+ goalRuntime,
538
+ new Error("compaction timed out or was cancelled"),
539
+ );
540
+ } catch {
541
+ resetCompactionState(compactionRuntime);
542
+ compactionFailureHandlers.delete(compactionRuntime);
543
+ }
544
+ },
545
+ );
546
+ if (operationId === null) return { cancel: true };
547
+ const goalObjective = goalRuntime.state?.status === "active"
548
+ ? goalRuntime.state.objective
549
+ : undefined;
550
+ const summary = buildStructuredSummary(event.preparation, goalObjective, event.customInstructions);
551
+
552
+ return {
553
+ compaction: {
554
+ summary,
555
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
556
+ tokensBefore: event.preparation.tokensBefore,
557
+ },
558
+ };
559
+ });
560
+
561
+ pi.on("session_compact", () => {
562
+ resetCompactionState(compactionRuntime);
563
+ compactionFailureHandlers.delete(compactionRuntime);
564
+ compactionRuntime.lastCompactionAt = Date.now();
565
+ });
566
+ }
package/killeros/goals.ts CHANGED
@@ -6,7 +6,7 @@ import { CONCISE_SYSTEM_PROMPT } from "./concise.ts";
6
6
  import { formatTime, formatTokens } from "./display.ts";
7
7
  import { reportError } from "./errors.ts";
8
8
  import { resolvePersonalInstructions } from "./personal-instructions.ts";
9
- import type { GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
9
+ import type { CompactionRuntime, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
10
10
 
11
11
  const GOAL_ENTRY_TYPE = "killeros-goal";
12
12
  const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
@@ -185,7 +185,7 @@ function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
185
185
  return lines.join("\n");
186
186
  }
187
187
 
188
- function pauseGoalAfterFailure(
188
+ export function pauseGoalAfterFailure(
189
189
  pi: ExtensionAPI,
190
190
  runtime: GoalRuntime,
191
191
  ctx: ExtensionContext,
@@ -339,6 +339,7 @@ export function registerGoal(
339
339
  runtime.state = restoreGoalState(ctx);
340
340
  runtime.continuationScheduled = false;
341
341
  runtime.continuationHeld = false;
342
+ runtime.continuationHeldForCompaction = false;
342
343
  runtime.goalTurnInFlight = false;
343
344
  runtime.agentEndObserved = false;
344
345
  runtime.persistenceRetryNeeded = false;
@@ -354,6 +355,7 @@ export function registerGoal(
354
355
  runtime.state = restoreGoalState(ctx);
355
356
  runtime.continuationScheduled = false;
356
357
  runtime.continuationHeld = false;
358
+ runtime.continuationHeldForCompaction = false;
357
359
  runtime.goalTurnInFlight = false;
358
360
  runtime.agentEndObserved = false;
359
361
  runtime.persistenceRetryNeeded = false;
@@ -382,6 +384,7 @@ export function registerGoal(
382
384
  runtime.state = undefined;
383
385
  runtime.continuationScheduled = false;
384
386
  runtime.continuationHeld = false;
387
+ runtime.continuationHeldForCompaction = false;
385
388
  runtime.goalTurnInFlight = false;
386
389
  runtime.agentEndObserved = false;
387
390
  runtime.persistenceRetryNeeded = false;
@@ -688,6 +691,7 @@ export function registerGoalSettlement(
688
691
  pi: ExtensionAPI,
689
692
  runtime: GoalRuntime,
690
693
  initState: InitRuntime,
694
+ compactionRuntime?: CompactionRuntime,
691
695
  ): void {
692
696
  pi.on("agent_settled", (_event, ctx) => {
693
697
  const wasGoalTurn = runtime.goalTurnInFlight;
@@ -713,8 +717,27 @@ export function registerGoalSettlement(
713
717
  pauseGoalAfterFailure(pi, runtime, ctx, reason);
714
718
  return;
715
719
  }
720
+ if (compactionRuntime?.compactionInFlight) {
721
+ runtime.continuationHeld = true;
722
+ runtime.continuationHeldForCompaction = true;
723
+ runtime.requestRender?.();
724
+ return;
725
+ }
716
726
  runtime.lastStopReason = undefined;
717
727
  runtime.lastError = undefined;
718
728
  scheduleGoalContinuation(pi, runtime, initState, ctx);
719
729
  });
730
+
731
+ if (compactionRuntime) {
732
+ pi.on("session_compact", (_event, ctx) => {
733
+ if (!runtime.continuationHeldForCompaction) return;
734
+ runtime.continuationHeldForCompaction = false;
735
+ if (!runtime.continuationHeld || runtime.state?.status !== "active" || initState.active) {
736
+ runtime.continuationHeld = false;
737
+ return;
738
+ }
739
+ runtime.continuationHeld = false;
740
+ setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
741
+ });
742
+ }
720
743
  }
@@ -29,6 +29,7 @@ export interface GoalRuntime {
29
29
  state?: GoalState;
30
30
  continuationScheduled: boolean;
31
31
  continuationHeld: boolean;
32
+ continuationHeldForCompaction: boolean;
32
33
  goalTurnInFlight: boolean;
33
34
  agentEndObserved: boolean;
34
35
  persistenceRetryNeeded: boolean;
@@ -45,6 +46,7 @@ export function createGoalRuntime(): GoalRuntime {
45
46
  return {
46
47
  continuationScheduled: false,
47
48
  continuationHeld: false,
49
+ continuationHeldForCompaction: false,
48
50
  goalTurnInFlight: false,
49
51
  agentEndObserved: false,
50
52
  persistenceRetryNeeded: false,
@@ -59,3 +61,26 @@ export function resetInitRuntime(state: InitRuntime): void {
59
61
  state.projectRoot = undefined;
60
62
  state.activeTools = undefined;
61
63
  }
64
+
65
+ export interface CompactionRuntime {
66
+ compactionInFlight: boolean;
67
+ automaticCompactionArmed: boolean;
68
+ automaticCompactionAwaitingHook: boolean;
69
+ automaticCompactionPending: boolean;
70
+ compactionOperationId: number;
71
+ sessionGeneration: number;
72
+ lastCompactionAt?: number;
73
+ thresholdPercent: number;
74
+ }
75
+
76
+ export function createCompactionRuntime(): CompactionRuntime {
77
+ return {
78
+ compactionInFlight: false,
79
+ automaticCompactionArmed: true,
80
+ automaticCompactionAwaitingHook: false,
81
+ automaticCompactionPending: false,
82
+ compactionOperationId: 0,
83
+ sessionGeneration: 0,
84
+ thresholdPercent: 30,
85
+ };
86
+ }