pi-plans 0.2.0 → 0.3.1
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/README.md +90 -26
- package/agents/ref-analyst.md +18 -0
- package/index.ts +121 -9
- package/package.json +16 -1
- package/references/pi-planning-workflow.md +21 -6
- package/references/state-and-config.md +52 -5
- package/scripts/validate.ts +5 -0
- package/skills/plan-with-refs/SKILL.md +3 -3
- package/src/code-graph/commands.ts +483 -0
- package/src/code-graph/discovery.ts +118 -0
- package/src/code-graph/git.ts +108 -0
- package/src/code-graph/identity.ts +59 -0
- package/src/code-graph/indexer.ts +281 -0
- package/src/code-graph/materialize.ts +166 -0
- package/src/code-graph/mode.ts +28 -0
- package/src/code-graph/mutations.ts +160 -0
- package/src/code-graph/parser.ts +51 -0
- package/src/code-graph/parsers/javascript.ts +35 -0
- package/src/code-graph/parsers/python.ts +160 -0
- package/src/code-graph/parsers/tree-sitter.ts +316 -0
- package/src/code-graph/paths.ts +85 -0
- package/src/code-graph/prompts.ts +18 -0
- package/src/code-graph/resolver.ts +69 -0
- package/src/code-graph/runtime.ts +158 -0
- package/src/code-graph/schema.ts +135 -0
- package/src/code-graph/screening.ts +82 -0
- package/src/code-graph/store.ts +278 -0
- package/src/code-graph/summary.ts +435 -0
- package/src/code-graph/types.ts +163 -0
- package/src/compaction.ts +1125 -371
- package/src/config-command.ts +361 -0
- package/src/exec.ts +508 -693
- package/src/guard.ts +14 -1
- package/src/refine-prompts.ts +109 -0
- package/src/refine-ui-helpers.ts +71 -18
- package/src/refine-ui-state.ts +88 -22
- package/src/refine-ui.ts +210 -102
- package/src/state.ts +36 -7
- package/src/subagent.ts +164 -61
- package/src/termination-prompt.ts +22 -0
- package/tests/analyze-refs.test.ts +265 -0
- package/tests/ask-choice.test.ts +264 -0
- package/tests/autocomplete.test.ts +6 -1
- package/tests/code-graph-apply-action.test.ts +173 -0
- package/tests/code-graph-apply.test.ts +185 -0
- package/tests/code-graph-commands.test.ts +211 -0
- package/tests/code-graph-db.test.ts +166 -0
- package/tests/code-graph-discovery.test.ts +38 -0
- package/tests/code-graph-git.test.ts +94 -0
- package/tests/code-graph-index.test.ts +175 -0
- package/tests/code-graph-loop.e2e.test.ts +159 -0
- package/tests/code-graph-mutations.test.ts +117 -0
- package/tests/code-graph-parser.test.ts +85 -0
- package/tests/code-graph-rollback.test.ts +100 -0
- package/tests/code-graph-summary-batching.test.ts +518 -0
- package/tests/code-graph-summary.test.ts +148 -0
- package/tests/compaction.test.ts +371 -57
- package/tests/config-command.test.ts +263 -0
- package/tests/exec.test.ts +808 -241
- package/tests/fixtures/code-graph/sample.js +36 -0
- package/tests/fixtures/code-graph/sample.py +20 -0
- package/tests/fixtures/code-graph/sample.ts +15 -0
- package/tests/graph-aware-file-tools.test.ts +411 -0
- package/tests/guard.test.ts +27 -1
- package/tests/plans.test.ts +10 -0
- package/tests/refine-prompts.test.ts +101 -2
- package/tests/refine-ui.test.ts +371 -72
- package/tests/state.test.ts +32 -0
- package/tests/subagent.test.ts +48 -20
- package/tools/analyze-refs.ts +263 -0
- package/tools/ask-choice.ts +159 -11
- package/tools/code-graph.ts +277 -0
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +97 -2
- package/tools/refine.ts +61 -15
package/src/exec.ts
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
* progress is reported through the bottom status bar until every item passes.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { randomUUID } from "node:crypto";
|
|
11
10
|
import * as fs from "node:fs";
|
|
12
11
|
import type {
|
|
13
12
|
CompactionResult,
|
|
@@ -18,20 +17,26 @@ import type {
|
|
|
18
17
|
SessionCompactEvent,
|
|
19
18
|
SessionCompactFailedEvent,
|
|
20
19
|
} from "@earendil-works/pi-coding-agent";
|
|
21
|
-
import {
|
|
20
|
+
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
22
21
|
import {
|
|
23
|
-
|
|
22
|
+
buildPiPlansVccCompaction,
|
|
24
23
|
compactionCurrentI,
|
|
25
|
-
currentIExceedsTrigger,
|
|
26
24
|
entryCurrentIMarkers,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
type CompactionDetailsLike,
|
|
25
|
+
formatVccCompactionStats,
|
|
26
|
+
loadVccSettings,
|
|
27
|
+
scaffoldVccSettings,
|
|
28
|
+
shouldScheduleAutoContinue,
|
|
32
29
|
type CompactionEntryLike,
|
|
30
|
+
type PiPlansCompactionPhase,
|
|
31
|
+
type PiPlansVccPhaseContext,
|
|
32
|
+
type PiPlansVccSettings,
|
|
33
|
+
type VccCompactionBuildResult,
|
|
34
|
+
type VccCompactionStats,
|
|
33
35
|
} from "./compaction.ts";
|
|
34
|
-
import { getRun, readActive, setRunStatus, utcNow } from "./state.ts";
|
|
36
|
+
import { getRun, readActive, resolveStateRootOrNull, setRunStatus, utcNow } from "./state.ts";
|
|
37
|
+
import { graphBlockForExecutor } from "./code-graph/prompts.ts";
|
|
38
|
+
import { resolveGraphMode } from "./code-graph/mode.ts";
|
|
39
|
+
import { TERMINATION_QUESTION, TERMINATION_OPTIONS, renderTerminationOptions } from "./termination-prompt.ts";
|
|
35
40
|
import {
|
|
36
41
|
extractCoverage,
|
|
37
42
|
latestPlanVersion,
|
|
@@ -54,8 +59,21 @@ export interface ExecState {
|
|
|
54
59
|
implItems?: ImplItem[];
|
|
55
60
|
implStatus?: Record<string, ImplMarkerState>;
|
|
56
61
|
currentI?: string;
|
|
62
|
+
goalWait?: GoalWaitState;
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
export interface GoalWaitState {
|
|
66
|
+
noProgressRounds: number;
|
|
67
|
+
waitRounds: number;
|
|
68
|
+
/** Marker/progress snapshot of the last goal-wait round; null = baseline not set. */
|
|
69
|
+
lastMarkers: string | null;
|
|
70
|
+
paused: boolean;
|
|
71
|
+
pausedReason?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const GOAL_WAIT_MAX_NO_PROGRESS = 3;
|
|
75
|
+
const GOAL_WAIT_MAX_WAITING = 6;
|
|
76
|
+
|
|
59
77
|
let execution: ExecState | null = null;
|
|
60
78
|
|
|
61
79
|
// Execution-loop persistence is deferred until the agent settles so turn_end
|
|
@@ -86,9 +104,6 @@ export function getExecution(): ExecState | null {
|
|
|
86
104
|
return execution;
|
|
87
105
|
}
|
|
88
106
|
|
|
89
|
-
const EXECUTION_COMPACTION_TRIGGER_PERCENT = 20;
|
|
90
|
-
const EXECUTION_COMPACTION_REARM_PERCENT = 80;
|
|
91
|
-
const EXECUTION_COMPACTION_REARM_HIGH_PERCENT = 95;
|
|
92
107
|
const EXECUTION_COMPACTION_RESUME_MESSAGE = "Continue execution.";
|
|
93
108
|
|
|
94
109
|
interface ExecutionCompactionState {
|
|
@@ -99,6 +114,11 @@ interface ExecutionCompactionState {
|
|
|
99
114
|
lastSuccessfulUsagePercent: number | null;
|
|
100
115
|
lastSuccessfulAt: string | null;
|
|
101
116
|
rearmPending: boolean;
|
|
117
|
+
/** Terminal failure metadata is retained for diagnostics, not proactive retry. */
|
|
118
|
+
terminalBackoffTokens: number | null;
|
|
119
|
+
pendingStats: VccCompactionStats | null;
|
|
120
|
+
pendingFollowUpPrompt: string | null;
|
|
121
|
+
pendingContinueAfterThresholdCompact: boolean;
|
|
102
122
|
}
|
|
103
123
|
|
|
104
124
|
type ExecutionCompactionSession = { __executionCompaction?: ExecutionCompactionState };
|
|
@@ -126,6 +146,10 @@ function ensureExecutionCompactionState(ctx: ExtensionContext): ExecutionCompact
|
|
|
126
146
|
lastSuccessfulUsagePercent: null,
|
|
127
147
|
lastSuccessfulAt: null,
|
|
128
148
|
rearmPending: false,
|
|
149
|
+
terminalBackoffTokens: null,
|
|
150
|
+
pendingStats: null,
|
|
151
|
+
pendingFollowUpPrompt: null,
|
|
152
|
+
pendingContinueAfterThresholdCompact: false,
|
|
129
153
|
});
|
|
130
154
|
}
|
|
131
155
|
|
|
@@ -142,79 +166,12 @@ function consumeExecutionCompactionResumeGuard(ctx: ExtensionContext): boolean {
|
|
|
142
166
|
return true;
|
|
143
167
|
}
|
|
144
168
|
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
if (!state) return;
|
|
148
|
-
const percent = ctx.getContextUsage()?.percent ?? null;
|
|
149
|
-
if (percent !== null && percent < EXECUTION_COMPACTION_REARM_PERCENT && state.cooldownActive) {
|
|
150
|
-
state.cooldownActive = false;
|
|
151
|
-
state.rearmPending = true;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function executionCurrentIUsage(ctx: ExtensionContext): { tokens: number; contextWindow: number; eligible?: boolean } | null {
|
|
156
|
-
const usage = ctx.getContextUsage();
|
|
157
|
-
if (!usage || typeof usage.contextWindow !== "number" || usage.contextWindow <= 0) return null;
|
|
158
|
-
const manager = ctx.sessionManager as unknown as { getBranch?: () => CompactionEntryLike[] };
|
|
159
|
-
if (execution?.implItems?.length && typeof manager.getBranch === "function") {
|
|
160
|
-
try {
|
|
161
|
-
const entries = manager.getBranch();
|
|
162
|
-
if (entries.length) {
|
|
163
|
-
const plan = planIAwareCompaction({
|
|
164
|
-
entries,
|
|
165
|
-
currentI: execution.currentI,
|
|
166
|
-
knownIIds: execution.implItems.map((item) => item.id),
|
|
167
|
-
contextWindow: usage.contextWindow,
|
|
168
|
-
tokensBefore: usage.tokens ?? undefined,
|
|
169
|
-
});
|
|
170
|
-
if (plan.currentI || execution.currentI) {
|
|
171
|
-
return {
|
|
172
|
-
tokens: plan.currentITokens,
|
|
173
|
-
contextWindow: usage.contextWindow,
|
|
174
|
-
eligible: plan.firstKeptEntryIndex !== null && plan.firstKeptEntryIndex > 0,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
} catch {
|
|
179
|
-
// A read-only session projection is optional in test and startup contexts.
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
if (typeof usage.tokens !== "number") return null;
|
|
183
|
-
return { tokens: usage.tokens, contextWindow: usage.contextWindow };
|
|
184
|
-
}
|
|
185
|
-
export function shouldTriggerExecutionCompaction(ctx: ExtensionContext): boolean {
|
|
186
|
-
const currentUsage = executionCurrentIUsage(ctx);
|
|
187
|
-
if (!currentUsage || currentUsage.eligible === false || !currentIExceedsTrigger(currentUsage.tokens, currentUsage.contextWindow)) return false;
|
|
188
|
-
const percent = (currentUsage.tokens / currentUsage.contextWindow) * 100;
|
|
189
|
-
const state = executionCompactionState(ctx);
|
|
190
|
-
if (!state) return true;
|
|
191
|
-
if (state.inFlight || state.resumeGuard || state.cooldownActive) return false;
|
|
192
|
-
if (state.rearmPending) {
|
|
193
|
-
if (percent < EXECUTION_COMPACTION_REARM_HIGH_PERCENT) return false;
|
|
194
|
-
state.rearmPending = false;
|
|
195
|
-
}
|
|
196
|
-
return true;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function requestExecutionCompaction(ctx: ExtensionContext): void {
|
|
200
|
-
const state = ensureExecutionCompactionState(ctx);
|
|
201
|
-
if (state.inFlight || state.resumeGuard) return;
|
|
202
|
-
state.inFlight = true;
|
|
203
|
-
state.lastAttemptReason = "threshold";
|
|
204
|
-
try {
|
|
205
|
-
ctx.compact({ customInstructions: "pi-plans execution auto compact" });
|
|
206
|
-
} catch (error) {
|
|
207
|
-
state.inFlight = false;
|
|
208
|
-
ctx.ui.notify(`pi-plans: could not request execution compaction (${String(error)}).`, "warning");
|
|
209
|
-
}
|
|
169
|
+
export function shouldTriggerExecutionCompaction(_ctx: ExtensionContext): boolean {
|
|
170
|
+
return false;
|
|
210
171
|
}
|
|
211
172
|
|
|
212
173
|
export function handleExecutionTurnCompaction(ctx: ExtensionContext): void {
|
|
213
|
-
|
|
214
|
-
if (consumeExecutionCompactionResumeGuard(ctx)) return;
|
|
215
|
-
if (shouldTriggerExecutionCompaction(ctx)) {
|
|
216
|
-
requestExecutionCompaction(ctx);
|
|
217
|
-
}
|
|
174
|
+
consumeExecutionCompactionResumeGuard(ctx);
|
|
218
175
|
}
|
|
219
176
|
|
|
220
177
|
export function computeExecutionProgress(execution: ExecState): { done: number; total: number } {
|
|
@@ -240,11 +197,6 @@ export function computeExecutionProgress(execution: ExecState): { done: number;
|
|
|
240
197
|
};
|
|
241
198
|
}
|
|
242
199
|
|
|
243
|
-
export function executionProgress(): { done: number; total: number } | null {
|
|
244
|
-
if (!execution) return null;
|
|
245
|
-
return computeExecutionProgress(execution);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
200
|
function formatElapsed(startedAt: string): string {
|
|
249
201
|
const total = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
|
|
250
202
|
const h = String(Math.floor(total / 3600)).padStart(2, "0");
|
|
@@ -260,7 +212,14 @@ function formatToks(tokens: number): string {
|
|
|
260
212
|
|
|
261
213
|
export function formatExecutionStatusLine(execution: ExecState): string {
|
|
262
214
|
const progress = computeExecutionProgress(execution);
|
|
263
|
-
|
|
215
|
+
let line = `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks`;
|
|
216
|
+
const goalWait = execution.goalWait;
|
|
217
|
+
if (goalWait?.paused) {
|
|
218
|
+
line += ` · ⏸ goal-wait paused (${goalWait.pausedReason ?? "paused"})`;
|
|
219
|
+
} else if (goalWait && (goalWait.noProgressRounds > 0 || goalWait.waitRounds > 0)) {
|
|
220
|
+
line += ` · 🔁 goal-wait · 无进展 ${goalWait.noProgressRounds}/3 · 等待 ${goalWait.waitRounds}/6`;
|
|
221
|
+
}
|
|
222
|
+
return line;
|
|
264
223
|
}
|
|
265
224
|
|
|
266
225
|
export function updateStatusWidget(ctx: ExtensionContext): void {
|
|
@@ -312,6 +271,7 @@ function persist(pi: ExtensionAPI): void {
|
|
|
312
271
|
implItems: execution.implItems,
|
|
313
272
|
implStatus: execution.implStatus,
|
|
314
273
|
currentI: execution.currentI,
|
|
274
|
+
goalWait: execution.goalWait,
|
|
315
275
|
});
|
|
316
276
|
}
|
|
317
277
|
|
|
@@ -322,7 +282,10 @@ export async function startExecution(
|
|
|
322
282
|
items: CheckItem[],
|
|
323
283
|
implItems?: ImplItem[],
|
|
324
284
|
): Promise<void> {
|
|
325
|
-
execution = { planPath, items, startedAt: utcNow(), usage: { inToks: 0, outToks: 0 }, implItems: implItems ?? [], implStatus: {} };
|
|
285
|
+
execution = { planPath, items, startedAt: utcNow(), usage: { inToks: 0, outToks: 0 }, implItems: implItems ?? [], implStatus: {}, goalWait: { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false } };
|
|
286
|
+
// Seed the marker baseline so the first quiet round is counted against a
|
|
287
|
+
// real snapshot instead of counting unconditionally (F-006).
|
|
288
|
+
if (execution.goalWait) execution.goalWait.lastMarkers = goalWaitSnapshot();
|
|
326
289
|
pendingExecutionFlush = false; // fresh run: no inherited flush debt
|
|
327
290
|
resetExecutionCompactionState(ctx);
|
|
328
291
|
persist(pi);
|
|
@@ -398,6 +361,8 @@ export function registerExecutionTurnHandlers(
|
|
|
398
361
|
}
|
|
399
362
|
if (getExecution() && isExecutionComplete()) {
|
|
400
363
|
await completeExecution(pi, ctx);
|
|
364
|
+
} else if (getExecution()) {
|
|
365
|
+
maybeGoalWaitFollowUp(pi, ctx, text);
|
|
401
366
|
}
|
|
402
367
|
await onTurnEnd?.(ctx);
|
|
403
368
|
});
|
|
@@ -405,368 +370,171 @@ export function registerExecutionTurnHandlers(
|
|
|
405
370
|
|
|
406
371
|
const EXECUTION_RESUME_CUSTOM_TYPE = "pi-plans-exec-resume";
|
|
407
372
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
return (message.content ?? [])
|
|
420
|
-
.filter((part) => part.type === "text")
|
|
421
|
-
.map((part) => part.text ?? "")
|
|
422
|
-
.join("\n")
|
|
423
|
-
.trim();
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function messageRoleLabel(role?: string): string {
|
|
427
|
-
switch (role) {
|
|
428
|
-
case "assistant": return "Assistant";
|
|
429
|
-
case "user": return "User";
|
|
430
|
-
case "toolResult": return "Tool";
|
|
431
|
-
case "custom": return "Custom";
|
|
432
|
-
default: return role ? role : "Message";
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
function isInternalExecutionCustomType(customType?: string): boolean {
|
|
437
|
-
return customType === "pi-plans-exec"
|
|
438
|
-
|| customType === "pi-plans-exec-cleared"
|
|
439
|
-
|| customType === "pi-plans-exec-start"
|
|
440
|
-
|| customType === "pi-plans-exec-context"
|
|
441
|
-
|| customType === EXECUTION_RESUME_CUSTOM_TYPE;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
function isSummarizableEntry(entry: CompactBranchEntry): boolean {
|
|
445
|
-
return entry.type === "message" && !!entry.message;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function renderMessageLine(entry: CompactBranchEntry): string {
|
|
449
|
-
if (!isSummarizableEntry(entry)) return "";
|
|
450
|
-
const text = messageText(entry.message);
|
|
451
|
-
if (!text) return "";
|
|
452
|
-
return `- [${messageRoleLabel(entry.message?.role)}] ${compactText(text)}`;
|
|
373
|
+
function activeVccSettings(ctx: ExtensionContext, phase: PiPlansCompactionPhase): { settings: PiPlansVccSettings; runId: string; artifactDir: string } | null {
|
|
374
|
+
const stateRoot = resolveStateRootOrNull(ctx.cwd);
|
|
375
|
+
if (!stateRoot) return null;
|
|
376
|
+
const active = readActive(ctx.cwd);
|
|
377
|
+
if (!active) return null;
|
|
378
|
+
const run = getRun(ctx.cwd, active.run_id);
|
|
379
|
+
if (!run) return null;
|
|
380
|
+
if (phase === "planning" && run.status !== "planning") return null;
|
|
381
|
+
if (phase === "execution" && run.status !== "executing") return null;
|
|
382
|
+
scaffoldVccSettings(stateRoot);
|
|
383
|
+
return { settings: loadVccSettings(stateRoot), runId: run.run_id, artifactDir: run.artifact_dir };
|
|
453
384
|
}
|
|
454
385
|
|
|
455
|
-
function
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
lastCompletionIndex = i;
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
if (lastCompletionIndex >= 0) {
|
|
467
|
-
const next = branchEntries.slice(lastCompletionIndex + 1).find((entry) => entry.id && !isInternalExecutionCustomType(entry.customType));
|
|
468
|
-
if (next?.id) return next.id;
|
|
469
|
-
}
|
|
470
|
-
const startIndex = branchEntries.findIndex((entry) => entry.type === "custom" && entry.customType === "pi-plans-exec-start");
|
|
471
|
-
if (startIndex >= 0) {
|
|
472
|
-
const next = branchEntries.slice(startIndex + 1).find((entry) => entry.id && !isInternalExecutionCustomType(entry.customType));
|
|
473
|
-
if (next?.id) return next.id;
|
|
474
|
-
}
|
|
475
|
-
return fallback;
|
|
386
|
+
function executionVccContext(): PiPlansVccPhaseContext {
|
|
387
|
+
return {
|
|
388
|
+
phase: "execution",
|
|
389
|
+
planPath: execution?.planPath ?? null,
|
|
390
|
+
currentI: execution?.currentI ?? null,
|
|
391
|
+
remainingVerifierIds: execution?.items.filter((item) => !item.done).map((item) => item.id) ?? [],
|
|
392
|
+
implementationIds: execution?.implItems?.map((item) => item.id) ?? [],
|
|
393
|
+
};
|
|
476
394
|
}
|
|
477
395
|
|
|
478
|
-
function
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
let
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if (line) currentLines.push(line);
|
|
488
|
-
for (const marker of scanDoneMarkers(messageText(entry.message))) {
|
|
489
|
-
const itemIndex = completedItems.findIndex((item, index) => index >= completedIndex && item.id === marker);
|
|
490
|
-
if (itemIndex < 0) continue;
|
|
491
|
-
const item = completedItems[itemIndex];
|
|
492
|
-
sections.push(`### \`${item.id}\` ${item.text.split(";")[0]}
|
|
493
|
-
${currentLines.length ? currentLines.join("\n") : "- (no transcript captured)"}`);
|
|
494
|
-
currentLines = [];
|
|
495
|
-
completedIndex = itemIndex + 1;
|
|
396
|
+
function planningVccContext(branchEntries: CompactionEntryLike[], fallback: { runId?: string; artifactDir?: string }): PiPlansVccPhaseContext {
|
|
397
|
+
let runId: string | null = fallback.runId ?? null;
|
|
398
|
+
let artifactDir: string | null = fallback.artifactDir ?? null;
|
|
399
|
+
let planPath: string | null = null;
|
|
400
|
+
let currentI: string | null = null;
|
|
401
|
+
for (const entry of branchEntries) {
|
|
402
|
+
if (entry.type === "custom" && entry.customType === PLANNING_RUN_START_CUSTOM_TYPE) {
|
|
403
|
+
runId = typeof entry.data?.runId === "string" ? entry.data.runId : runId;
|
|
404
|
+
artifactDir = typeof entry.data?.artifactDir === "string" ? entry.data.artifactDir : artifactDir;
|
|
496
405
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function splitTurnBoundaryIndex(event: SessionBeforeCompactEvent): number | undefined {
|
|
502
|
-
if (!event.preparation.isSplitTurn) return undefined;
|
|
503
|
-
const index = event.branchEntries.findIndex((entry) => entry.id === event.preparation.firstKeptEntryId);
|
|
504
|
-
return index >= 0 ? index : undefined;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function buildExecutionIPlan(event: SessionBeforeCompactEvent, ctx?: ExtensionContext): ReturnType<typeof planIAwareCompaction> {
|
|
508
|
-
if (!execution) throw new Error("execution state is unavailable");
|
|
509
|
-
const usage = eventPreparationUsage(event, ctx);
|
|
510
|
-
return planIAwareCompaction({
|
|
511
|
-
entries: event.branchEntries as unknown as CompactionEntryLike[],
|
|
512
|
-
currentI: execution.currentI,
|
|
513
|
-
knownIIds: execution.implItems?.map((item) => item.id),
|
|
514
|
-
contextWindow: usage.contextWindow,
|
|
515
|
-
tokensBefore: event.preparation.tokensBefore,
|
|
516
|
-
fallbackFirstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
517
|
-
maxFirstKeptEntryIndex: splitTurnBoundaryIndex(event),
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function eventPreparationUsage(event: SessionBeforeCompactEvent, ctx?: ExtensionContext): { contextWindow: number | null } {
|
|
522
|
-
const fromContext = ctx?.getContextUsage()?.contextWindow;
|
|
523
|
-
const contextWindow = (event as SessionBeforeCompactEvent & { contextWindow?: number }).contextWindow ?? fromContext;
|
|
524
|
-
return { contextWindow: typeof contextWindow === "number" && contextWindow > 0 ? contextWindow : null };
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function previousCompactionDetails(entries: CompactionEntryLike[]): CompactionDetailsLike | undefined {
|
|
528
|
-
let merged: CompactionDetailsLike | undefined;
|
|
529
|
-
for (const entry of entries) {
|
|
530
|
-
if (entry.type !== "compaction") continue;
|
|
531
|
-
const raw = entry.details ?? entry.data;
|
|
532
|
-
if (!raw || typeof raw !== "object") continue;
|
|
533
|
-
const details = raw as CompactionDetailsLike;
|
|
534
|
-
if (!details.readRecords && !details.metrics && !details.iSections) continue;
|
|
535
|
-
merged = mergeCompactionDetails(merged, details);
|
|
536
|
-
}
|
|
537
|
-
return merged;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
function buildImplementationSections(plan: ReturnType<typeof planIAwareCompaction>): string[] {
|
|
541
|
-
const sections: string[] = [];
|
|
542
|
-
for (const slice of plan.slices) {
|
|
543
|
-
if (slice.id === null) continue;
|
|
544
|
-
const lines = slice.entries
|
|
545
|
-
.filter((entry) => plan.summaryEntries.includes(entry))
|
|
546
|
-
.map((entry) => renderMessageLine(entry as CompactBranchEntry))
|
|
547
|
-
.filter(Boolean);
|
|
548
|
-
if (lines.length) {
|
|
549
|
-
sections.push(`### ${slice.current ? "Current I" : "Implementation I"} \`${slice.id}\`\n${lines.join("\\n")}`);
|
|
406
|
+
if (entry.type === "custom" && entry.customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE) {
|
|
407
|
+
planPath = typeof entry.data?.planPath === "string" ? entry.data.planPath : planPath;
|
|
550
408
|
}
|
|
409
|
+
for (const id of entryCurrentIMarkers(entry)) currentI = id;
|
|
410
|
+
currentI = compactionCurrentI(entry) ?? currentI;
|
|
551
411
|
}
|
|
552
|
-
return
|
|
412
|
+
return { phase: "planning", runId, artifactDir, planPath, currentI };
|
|
553
413
|
}
|
|
554
414
|
|
|
555
|
-
function
|
|
556
|
-
|
|
557
|
-
ctx
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const active = readActive(ctx.cwd);
|
|
564
|
-
const run = active ? getRun(ctx.cwd, active.run_id) : null;
|
|
565
|
-
const branchEntries = event.branchEntries as unknown as CompactionEntryLike[];
|
|
566
|
-
const summaryEntries = boundaryIndex >= 0
|
|
567
|
-
? (branchEntries.slice(0, boundaryIndex) as CompactBranchEntry[])
|
|
568
|
-
: (branchEntries as CompactBranchEntry[]);
|
|
569
|
-
const sections = buildFinishedItemSections(summaryEntries);
|
|
570
|
-
const readRecords = plan?.readRecords ?? extractReadRecords(summaryEntries as unknown as CompactionEntryLike[]);
|
|
571
|
-
const priorDetails = previousCompactionDetails(branchEntries);
|
|
572
|
-
const metrics = plan?.metrics ?? {
|
|
573
|
-
contextWindow: ctx.getContextUsage()?.contextWindow ?? null,
|
|
574
|
-
tokensBefore: event.preparation.tokensBefore,
|
|
575
|
-
currentITokens: 0,
|
|
576
|
-
summaryTokens: 0,
|
|
577
|
-
keptSuffixTokens: 0,
|
|
578
|
-
estimatedAfterTokens: null,
|
|
579
|
-
targetRatio: 0.1,
|
|
580
|
-
currentI: execution.currentI ?? null,
|
|
581
|
-
firstKeptEntryId,
|
|
582
|
-
targetMet: false,
|
|
583
|
-
hardFloorReason: "I-aware budget unavailable for this legacy execution snapshot",
|
|
584
|
-
};
|
|
585
|
-
const details = mergeCompactionDetails(priorDetails, {
|
|
586
|
-
kind: "pi-plans-execution-compaction",
|
|
587
|
-
version: 1,
|
|
588
|
-
currentI: execution.currentI ?? plan?.currentI ?? null,
|
|
589
|
-
iSections: plan?.slices.map((slice) => ({ id: slice.id, entryIds: slice.entries.map((entry) => entry.id).filter((id): id is string => !!id) })) ?? [],
|
|
590
|
-
readRecords,
|
|
591
|
-
metrics: { ...metrics, firstKeptEntryId },
|
|
415
|
+
function buildExecutionVccResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): VccCompactionBuildResult | null {
|
|
416
|
+
if (!execution) return null;
|
|
417
|
+
const active = activeVccSettings(ctx, "execution");
|
|
418
|
+
if (!active) return null;
|
|
419
|
+
return buildPiPlansVccCompaction({
|
|
420
|
+
branchEntries: event.branchEntries as unknown as CompactionEntryLike[],
|
|
421
|
+
preparation: event.preparation,
|
|
422
|
+
customInstructions: event.customInstructions,
|
|
592
423
|
reason: event.reason,
|
|
593
424
|
willRetry: event.willRetry,
|
|
594
|
-
|
|
425
|
+
settings: active.settings,
|
|
426
|
+
phaseContext: executionVccContext(),
|
|
595
427
|
});
|
|
596
|
-
const parts: string[] = [];
|
|
597
|
-
if (event.customInstructions?.trim()) {
|
|
598
|
-
parts.push(`## Compact Instructions\n${compactText(event.customInstructions, 1000)}`);
|
|
599
|
-
}
|
|
600
|
-
parts.push(`## Plan Before This Run\n- Request: ${compactText(run?.request_text ?? execution.planPath, 280)}\n- Plan file: \`${execution.planPath}\``);
|
|
601
|
-
if (event.preparation.previousSummary?.trim()) {
|
|
602
|
-
parts.push(`## Previous Compact Summary\n${event.preparation.previousSummary.trim()}`);
|
|
603
|
-
}
|
|
604
|
-
parts.push(`## Implementation Items\n${plan?.slices.length ? (buildImplementationSections(plan).join("\\n\\n") || "- (no I transcript captured)") : "- Legacy snapshot: current I is inferred from the execution frontier."}`);
|
|
605
|
-
parts.push(`## Finished VC Items\n${sections.length ? sections.join("\\n\\n") : "- (none yet)"}`);
|
|
606
|
-
parts.push(`## Current I\n- \`${execution.currentI ?? plan?.currentI ?? "unknown"}\`\n- Recent legal suffix begins at \`${firstKeptEntryId}\`.`);
|
|
607
|
-
parts.push(`## Read Records\n${readRecords.length ? readRecords.map((record) => formatReadRecord(record)).join("\\n") : "- (none)"}`);
|
|
608
|
-
parts.push(`## Compaction Boundary\n- firstKeptEntryId: \`${firstKeptEntryId}\`\n- currentI: \`${execution.currentI ?? plan?.currentI ?? "unknown"}\`\n- tokensBefore: ${metrics.tokensBefore}\n- estimatedAfterTokens: ${metrics.estimatedAfterTokens ?? "unknown"}\n- targetRatio: ${metrics.targetRatio}\n- targetMet: ${metrics.targetMet}\n- hardFloorReason: ${metrics.hardFloorReason ?? "none"}`);
|
|
609
|
-
parts.push(`## Current Work\n- Raw tail preserved from \`${firstKeptEntryId}\` onward.${event.preparation.isSplitTurn ? "\\n- Split-turn prefix remains in the kept tail." : ""}`);
|
|
610
|
-
return { parts, details };
|
|
611
428
|
}
|
|
612
429
|
|
|
613
430
|
export function buildExecutionCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const hasIState = (execution.implItems?.length ?? 0) > 0;
|
|
617
|
-
const plan = hasIState ? buildExecutionIPlan(event, ctx) : null;
|
|
618
|
-
const firstKeptEntryId = plan?.firstKeptEntryId
|
|
619
|
-
?? findExecutionCompactionCutEntryId(event.branchEntries as CompactBranchEntry[], event.preparation.firstKeptEntryId);
|
|
620
|
-
const boundaryIndex = event.branchEntries.findIndex((entry) => entry.id === firstKeptEntryId);
|
|
621
|
-
const { parts, details } = executionSummaryParts(event, ctx, firstKeptEntryId, boundaryIndex, plan);
|
|
622
|
-
return {
|
|
623
|
-
summary: parts.join("\\n\\n"),
|
|
624
|
-
firstKeptEntryId,
|
|
625
|
-
tokensBefore: event.preparation.tokensBefore,
|
|
626
|
-
estimatedTokensAfter: plan?.metrics.estimatedAfterTokens ?? undefined,
|
|
627
|
-
details,
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
function assistantResponseText(response: { content?: Array<{ type?: string; text?: string }> }): string {
|
|
632
|
-
return (response.content ?? [])
|
|
633
|
-
.filter((part) => part.type === "text")
|
|
634
|
-
.map((part) => part.text ?? "")
|
|
635
|
-
.join("\\n")
|
|
636
|
-
.trim();
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
function isUsableCompactionResponse(response: { content?: Array<{ type?: string; text?: string }>; stopReason?: string }, summary: string): boolean {
|
|
640
|
-
if (!summary) return false;
|
|
641
|
-
if (["length", "toolUse", "error", "aborted", "deferred"].includes(response.stopReason ?? "")) return false;
|
|
642
|
-
return ["## Implementation Items", "## Current I", "## Read Records", "## Compaction Boundary"]
|
|
643
|
-
.every((section) => summary.includes(section));
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
async function buildModelExecutionCompactionResult(
|
|
647
|
-
event: SessionBeforeCompactEvent,
|
|
648
|
-
ctx: ExtensionContext,
|
|
649
|
-
fallback: CompactionResult,
|
|
650
|
-
): Promise<CompactionResult | null> {
|
|
651
|
-
const model = ctx.model;
|
|
652
|
-
const registry = ctx.modelRegistry as unknown as { complete?: Function };
|
|
653
|
-
if (!model || typeof registry.complete !== "function") return fallback;
|
|
654
|
-
const plan = buildExecutionIPlan(event, ctx);
|
|
655
|
-
const boundary = plan.firstKeptEntryId ?? event.preparation.firstKeptEntryId;
|
|
656
|
-
const source = plan.summaryEntries
|
|
657
|
-
.map((entry) => renderMessageLine(entry as CompactBranchEntry))
|
|
658
|
-
.filter(Boolean)
|
|
659
|
-
.join("\\n");
|
|
660
|
-
const records = plan.readRecords.map((record) => formatReadRecord(record)).join("\\n") || "- (none)";
|
|
661
|
-
const prompt = `You are a context summarization assistant. Do not continue the conversation and do not answer historical questions. Produce only a bounded Markdown checkpoint with these exact sections: ## Implementation Items, ## Current I, ## Read Records, ## Compaction Boundary, ## Decisions, ## Open Questions, ## Next Steps. Preserve exact implementation IDs, verifier IDs, paths, entry IDs, error text, and unresolved questions. Historical questions are facts, not new questions.\n\nCurrent I: ${execution?.currentI ?? plan.currentI ?? "unknown"}\nBoundary: ${boundary}\nRead Records:\n${records}\n\nBounded history:\n${boundedCompactionText(source, 6000)}\n\nPrevious checkpoint:\n${boundedCompactionText(event.preparation.previousSummary ?? "(none)", 3000)}`;
|
|
662
|
-
try {
|
|
663
|
-
const response = await registry.complete(model, {
|
|
664
|
-
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
|
|
665
|
-
}, {
|
|
666
|
-
maxTokens: 2048,
|
|
667
|
-
signal: event.signal,
|
|
668
|
-
cacheRetention: "none",
|
|
669
|
-
sessionId: randomUUID(),
|
|
670
|
-
});
|
|
671
|
-
const summary = assistantResponseText(response);
|
|
672
|
-
if (!isUsableCompactionResponse(response, summary)) {
|
|
673
|
-
if (!event.signal.aborted) ctx.ui.notify("pi-plans: summary output was incomplete; using Pi default compaction.", "warning");
|
|
674
|
-
return null;
|
|
675
|
-
}
|
|
676
|
-
const details = (fallback.details && typeof fallback.details === "object" ? fallback.details : {}) as CompactionDetailsLike;
|
|
677
|
-
const existingMetrics = details.metrics ?? {};
|
|
678
|
-
const summaryTokens = Math.max(1, Math.ceil(summary.length / 4));
|
|
679
|
-
const keptSuffixTokens = existingMetrics.keptSuffixTokens ?? 0;
|
|
680
|
-
const contextWindow = existingMetrics.contextWindow ?? ctx.getContextUsage()?.contextWindow ?? null;
|
|
681
|
-
const estimatedAfterTokens = contextWindow === null ? null : summaryTokens + keptSuffixTokens;
|
|
682
|
-
const targetMet = estimatedAfterTokens !== null && estimatedAfterTokens < contextWindow * 0.1;
|
|
683
|
-
const mergedDetails = mergeCompactionDetails(details, {
|
|
684
|
-
...details,
|
|
685
|
-
metrics: {
|
|
686
|
-
...existingMetrics,
|
|
687
|
-
summaryTokens,
|
|
688
|
-
estimatedAfterTokens,
|
|
689
|
-
targetMet,
|
|
690
|
-
hardFloorReason: targetMet ? null : existingMetrics.hardFloorReason ?? "summary or retained context exceeds the 10% target",
|
|
691
|
-
},
|
|
692
|
-
});
|
|
693
|
-
return { ...fallback, summary, estimatedTokensAfter: estimatedAfterTokens ?? undefined, usage: response.usage, details: mergedDetails };
|
|
694
|
-
} catch (error) {
|
|
695
|
-
if (!event.signal.aborted) ctx.ui.notify(`pi-plans: summary model failed; using Pi default compaction (${String(error)}).`, "warning");
|
|
696
|
-
return null;
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
function notifyHardFloor(ctx: ExtensionContext, compaction: CompactionResult): void {
|
|
701
|
-
const metrics = (compaction.details as CompactionDetailsLike | undefined)?.metrics;
|
|
702
|
-
if (!metrics || metrics.targetMet !== false) return;
|
|
703
|
-
ctx.ui.notify(
|
|
704
|
-
`pi-plans: compaction target <10% is unreachable; retaining the legal floor (${metrics.hardFloorReason ?? "unknown reason"}).`,
|
|
705
|
-
"warning",
|
|
706
|
-
);
|
|
431
|
+
const built = buildExecutionVccResult(event, ctx);
|
|
432
|
+
return built?.kind === "compaction" ? built.compaction : null;
|
|
707
433
|
}
|
|
708
434
|
|
|
709
435
|
export function handleExecutionBeforeCompact(
|
|
710
436
|
pi: ExtensionAPI,
|
|
711
437
|
ctx: ExtensionContext,
|
|
712
438
|
event: SessionBeforeCompactEvent,
|
|
713
|
-
): SessionBeforeCompactResult |
|
|
439
|
+
): SessionBeforeCompactResult | undefined {
|
|
714
440
|
if (!execution) return undefined;
|
|
715
|
-
|
|
441
|
+
const state = ensureExecutionCompactionState(ctx);
|
|
442
|
+
state.inFlight = true;
|
|
443
|
+
state.lastAttemptReason = event.reason;
|
|
444
|
+
state.pendingStats = null;
|
|
445
|
+
state.pendingFollowUpPrompt = null;
|
|
446
|
+
state.pendingContinueAfterThresholdCompact = false;
|
|
447
|
+
let built: VccCompactionBuildResult | null;
|
|
716
448
|
try {
|
|
717
|
-
|
|
449
|
+
built = buildExecutionVccResult(event, ctx);
|
|
718
450
|
} catch (error) {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
ctx.ui.notify(`pi-plans: compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
|
|
451
|
+
state.inFlight = false;
|
|
452
|
+
ctx.ui.notify(`pi-plans: VCC compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
|
|
722
453
|
return undefined;
|
|
723
454
|
}
|
|
724
|
-
if (!fallback)
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
if (!ctx.model || typeof registry.complete !== "function") {
|
|
728
|
-
requestExecutionFlush(pi, ctx);
|
|
729
|
-
return { compaction: fallback };
|
|
455
|
+
if (!built || built.kind === "fallback") {
|
|
456
|
+
state.inFlight = false;
|
|
457
|
+
return undefined;
|
|
730
458
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
return {
|
|
735
|
-
}
|
|
459
|
+
if (built.kind === "cancel") {
|
|
460
|
+
state.inFlight = false;
|
|
461
|
+
ctx.ui.notify(built.message, "warning");
|
|
462
|
+
return { cancel: true };
|
|
463
|
+
}
|
|
464
|
+
state.pendingStats = built.stats;
|
|
465
|
+
state.pendingFollowUpPrompt = built.followUpPrompt;
|
|
466
|
+
state.pendingContinueAfterThresholdCompact = built.settings.continueAfterThresholdCompact;
|
|
467
|
+
requestExecutionFlush(pi, ctx);
|
|
468
|
+
return { compaction: built.compaction };
|
|
736
469
|
}
|
|
737
470
|
|
|
738
|
-
|
|
471
|
+
function runtimePiVersion(ctx: ExtensionContext): unknown {
|
|
472
|
+
return (ctx as ExtensionContext & { piVersion?: unknown }).piVersion ?? VERSION;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export async function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): Promise<void> {
|
|
739
476
|
if (!execution) return;
|
|
740
477
|
const state = ensureExecutionCompactionState(ctx);
|
|
478
|
+
const stats = state.pendingStats;
|
|
479
|
+
const followUpPrompt = state.pendingFollowUpPrompt;
|
|
480
|
+
const continueAfterThresholdCompact = state.pendingContinueAfterThresholdCompact;
|
|
481
|
+
state.pendingStats = null;
|
|
482
|
+
state.pendingFollowUpPrompt = null;
|
|
483
|
+
state.pendingContinueAfterThresholdCompact = false;
|
|
741
484
|
state.inFlight = false;
|
|
742
485
|
state.lastAttemptReason = event.reason;
|
|
743
486
|
state.cooldownActive = true;
|
|
744
487
|
state.rearmPending = false;
|
|
488
|
+
state.terminalBackoffTokens = null;
|
|
745
489
|
state.lastSuccessfulAt = utcNow();
|
|
746
490
|
state.lastSuccessfulUsagePercent = ctx.getContextUsage()?.percent ?? state.lastSuccessfulUsagePercent;
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
491
|
+
state.resumeGuard = false;
|
|
492
|
+
if (!event.willRetry && stats) {
|
|
493
|
+
ctx.ui.notify(formatVccCompactionStats(stats), "info");
|
|
494
|
+
if (followUpPrompt) {
|
|
495
|
+
compactionFollowUpSentThisTurn = true;
|
|
496
|
+
await pi.sendUserMessage?.(followUpPrompt);
|
|
497
|
+
} else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) {
|
|
498
|
+
state.resumeGuard = true;
|
|
499
|
+
pi.sendMessage(
|
|
500
|
+
{
|
|
501
|
+
customType: EXECUTION_RESUME_CUSTOM_TYPE,
|
|
502
|
+
content: EXECUTION_COMPACTION_RESUME_MESSAGE,
|
|
503
|
+
display: false,
|
|
504
|
+
},
|
|
505
|
+
{ triggerTurn: true },
|
|
506
|
+
);
|
|
507
|
+
}
|
|
759
508
|
}
|
|
760
509
|
requestExecutionFlush(pi, ctx);
|
|
761
510
|
updateStatusWidget(ctx);
|
|
762
511
|
}
|
|
763
512
|
|
|
513
|
+
|
|
764
514
|
export function handleExecutionCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
|
|
765
515
|
if (!execution) return;
|
|
766
516
|
const state = executionCompactionState(ctx);
|
|
767
|
-
const
|
|
768
|
-
if (
|
|
769
|
-
|
|
517
|
+
const terminal = isTerminalCompactionFailure(event);
|
|
518
|
+
if (terminal) {
|
|
519
|
+
// Pi refused or aborted the compaction. Hold the cooldown and re-arm
|
|
520
|
+
// only after real growth or high-watermark pressure so the loop stops.
|
|
521
|
+
if (state) {
|
|
522
|
+
state.inFlight = false;
|
|
523
|
+
state.resumeGuard = false;
|
|
524
|
+
state.cooldownActive = true;
|
|
525
|
+
state.rearmPending = false;
|
|
526
|
+
state.lastAttemptReason = event.reason;
|
|
527
|
+
const tokens = ctx.getContextUsage()?.tokens;
|
|
528
|
+
state.terminalBackoffTokens = typeof tokens === "number" ? tokens : Number.POSITIVE_INFINITY;
|
|
529
|
+
state.pendingStats = null;
|
|
530
|
+
state.pendingFollowUpPrompt = null;
|
|
531
|
+
state.pendingContinueAfterThresholdCompact = false;
|
|
532
|
+
}
|
|
533
|
+
const message = terminal.kind === "content"
|
|
534
|
+
? "pi-plans: compaction found nothing to summarize; backing off until the session grows past the keep-recent window."
|
|
535
|
+
: "pi-plans: compaction was aborted (provider interruption, user cancel, or a competing manual compact); backing off until the session grows or usage nears the window.";
|
|
536
|
+
ctx.ui.notify(message, "info");
|
|
537
|
+
requestExecutionFlush(pi, ctx);
|
|
770
538
|
return;
|
|
771
539
|
}
|
|
772
540
|
if (state) {
|
|
@@ -775,6 +543,9 @@ export function handleExecutionCompactFailed(pi: ExtensionAPI, ctx: ExtensionCon
|
|
|
775
543
|
state.cooldownActive = false;
|
|
776
544
|
state.rearmPending = false;
|
|
777
545
|
state.lastAttemptReason = event.reason;
|
|
546
|
+
state.pendingStats = null;
|
|
547
|
+
state.pendingFollowUpPrompt = null;
|
|
548
|
+
state.pendingContinueAfterThresholdCompact = false;
|
|
778
549
|
}
|
|
779
550
|
ctx.ui.notify(
|
|
780
551
|
`pi-plans: compaction failed (${event.reason}); execution remains active and will wait for the next eligible turn.`,
|
|
@@ -788,15 +559,14 @@ export function filterExecutionResumeMessages<T extends { customType?: string }>
|
|
|
788
559
|
}
|
|
789
560
|
|
|
790
561
|
// ---------------------------------------------------------------------------
|
|
791
|
-
// Planning-phase
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
//
|
|
562
|
+
// Planning-phase compaction: Pi core owns scheduling; this hook customizes
|
|
563
|
+
// active planning compact events with the same VCC builder used by execution.
|
|
564
|
+
// The two state machines are kept independent (different memory slot and
|
|
565
|
+
// snapshot key) so execution never bleeds into planning.
|
|
795
566
|
// ---------------------------------------------------------------------------
|
|
796
567
|
|
|
797
568
|
export const PLANNING_RUN_START_CUSTOM_TYPE = "pi-plans-run-start";
|
|
798
569
|
export const PLANNING_PLAN_WRITTEN_CUSTOM_TYPE = "pi-plans-plan-written";
|
|
799
|
-
const PLANNING_QA_SECTION_HEADER = "## Q&A During Planning";
|
|
800
570
|
const PLANNING_RESUME_CUSTOM_TYPE = "pi-plans-plan-resume";
|
|
801
571
|
|
|
802
572
|
interface PlanningCompactionState {
|
|
@@ -806,149 +576,114 @@ interface PlanningCompactionState {
|
|
|
806
576
|
lastAttemptReason: "manual" | "threshold" | "overflow" | null;
|
|
807
577
|
lastSuccessfulUsagePercent: number | null;
|
|
808
578
|
lastSuccessfulAt: string | null;
|
|
579
|
+
/** Terminal "nothing to compact" backoff: tokens observed when Pi refused. */
|
|
580
|
+
terminalBackoffTokens: number | null;
|
|
581
|
+
pendingStats: VccCompactionStats | null;
|
|
582
|
+
pendingFollowUpPrompt: string | null;
|
|
583
|
+
pendingContinueAfterThresholdCompact: boolean;
|
|
809
584
|
}
|
|
810
585
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|| customType === EXECUTION_RESUME_CUSTOM_TYPE
|
|
826
|
-
|| customType === PLANNING_RUN_START_CUSTOM_TYPE
|
|
827
|
-
|| customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE
|
|
828
|
-
|| customType === PLANNING_RESUME_CUSTOM_TYPE
|
|
829
|
-
|| customType === AUTOCOMPLETE_ENTRY
|
|
830
|
-
);
|
|
586
|
+
function ensurePlanningCompactionState(ctx: ExtensionContext): PlanningCompactionState {
|
|
587
|
+
const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
|
|
588
|
+
return (session.__planningCompaction ??= {
|
|
589
|
+
inFlight: false,
|
|
590
|
+
resumeGuard: false,
|
|
591
|
+
cooldownActive: false,
|
|
592
|
+
lastAttemptReason: null,
|
|
593
|
+
lastSuccessfulUsagePercent: null,
|
|
594
|
+
lastSuccessfulAt: null,
|
|
595
|
+
terminalBackoffTokens: null,
|
|
596
|
+
pendingStats: null,
|
|
597
|
+
pendingFollowUpPrompt: null,
|
|
598
|
+
pendingContinueAfterThresholdCompact: false,
|
|
599
|
+
});
|
|
831
600
|
}
|
|
832
601
|
|
|
833
|
-
function
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
.join("\n")
|
|
838
|
-
.trim();
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
function summarizePlanningEntryLine(entry: PlanningBranchEntry): string | null {
|
|
842
|
-
return summarizePlanningMessageLine(entry);
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
function summarizePlanningMessageLine(entry: PlanningBranchEntry): string | null {
|
|
846
|
-
if (!entry.message) return null;
|
|
847
|
-
const text = (entry.message.content ?? [])
|
|
848
|
-
.filter((part) => part.type === "text")
|
|
849
|
-
.map((part) => part.text ?? "")
|
|
850
|
-
.join("\n")
|
|
851
|
-
.trim();
|
|
852
|
-
if (!text) return null;
|
|
853
|
-
const role = entry.message.role ?? "message";
|
|
854
|
-
const normalized = text.replace(/\s+/g, " ").trim();
|
|
855
|
-
const limit = 180;
|
|
856
|
-
const clipped = normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized;
|
|
857
|
-
return `- [${role}] ${clipped}`;
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
function findPlanningCutEntryId(
|
|
861
|
-
branchEntries: PlanningBranchEntry[],
|
|
862
|
-
fallback: string,
|
|
863
|
-
): { id: string; qaWindowEntries: PlanningBranchEntry[]; hasMarker: boolean } {
|
|
864
|
-
const planWrittenIndexes: number[] = [];
|
|
865
|
-
const runStartIndexes: number[] = [];
|
|
866
|
-
for (let i = 0; i < branchEntries.length; i++) {
|
|
867
|
-
const entry = branchEntries[i];
|
|
868
|
-
if (entry.type === "custom" && entry.customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE) {
|
|
869
|
-
planWrittenIndexes.push(i);
|
|
870
|
-
} else if (entry.type === "custom" && entry.customType === PLANNING_RUN_START_CUSTOM_TYPE) {
|
|
871
|
-
runStartIndexes.push(i);
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
const planIndex = planWrittenIndexes[planWrittenIndexes.length - 1] ?? -1;
|
|
875
|
-
const startIndex = runStartIndexes[runStartIndexes.length - 1] ?? -1;
|
|
876
|
-
const anchorIndex = planIndex >= 0 ? planIndex : startIndex;
|
|
877
|
-
if (anchorIndex < 0) {
|
|
878
|
-
return { id: fallback, qaWindowEntries: [], hasMarker: false };
|
|
602
|
+
function isTerminalCompactionFailure(event: { errorMessage?: string; aborted?: boolean }): { kind: "content" | "abort-stream" } | null {
|
|
603
|
+
const message = (event.errorMessage ?? "").toLowerCase();
|
|
604
|
+
if (message.includes("nothing to compact") || message.includes("already compacted") || message.includes("session too small")) {
|
|
605
|
+
return { kind: "content" };
|
|
879
606
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
607
|
+
// abort/stream class: explicit event names only, so that provider blips
|
|
608
|
+
// (network down, etc.) stay retryable.
|
|
609
|
+
const abortPatterns = [
|
|
610
|
+
"this operation was aborted",
|
|
611
|
+
"aborted",
|
|
612
|
+
"stream ended before a terminal response event",
|
|
613
|
+
"turn prefix summarization failed",
|
|
614
|
+
"auto-compaction failed",
|
|
615
|
+
"context overflow recovery failed",
|
|
616
|
+
];
|
|
617
|
+
if (abortPatterns.some((pattern) => message.includes(pattern))) {
|
|
618
|
+
return { kind: "abort-stream" };
|
|
885
619
|
}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
function buildPlanningQASection(qaWindowEntries: PlanningBranchEntry[]): string | null {
|
|
893
|
-
if (!qaWindowEntries.length) return null;
|
|
894
|
-
const lines: string[] = [];
|
|
895
|
-
for (const entry of qaWindowEntries) {
|
|
896
|
-
const line = summarizePlanningMessageLine(entry);
|
|
897
|
-
if (line) lines.push(line);
|
|
620
|
+
// Aborted with no recognized message: still an abort-class terminal so the
|
|
621
|
+
// next eligible turn does not immediately retry the same operation.
|
|
622
|
+
if (event.aborted === true) {
|
|
623
|
+
return { kind: "abort-stream" };
|
|
898
624
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Session-scoped phase-local "compaction in flight" guard.
|
|
629
|
+
* - Set on `session_before_compact` for the phase attributed by the custom
|
|
630
|
+
* instructions hint; auto-compaction (no hint) marks both phases defensively.
|
|
631
|
+
* - Cleared on `session_compact` and `session_compact_failed`.
|
|
632
|
+
* - Retained so lifecycle events expose the same phase-local state to tests
|
|
633
|
+
* and future Pi core schema additions. */
|
|
634
|
+
type CompactionPhase = "planning" | "execution";
|
|
635
|
+
|
|
636
|
+
function compactionLifecycleStore(ctx: ExtensionContext): {
|
|
637
|
+
planning: boolean;
|
|
638
|
+
execution: boolean;
|
|
639
|
+
} {
|
|
640
|
+
const carrier = ctx.sessionManager as unknown as {
|
|
641
|
+
__piPlansCompactionInFlight?: { planning: boolean; execution: boolean };
|
|
642
|
+
};
|
|
643
|
+
carrier.__piPlansCompactionInFlight ??= { planning: false, execution: false };
|
|
644
|
+
return carrier.__piPlansCompactionInFlight;
|
|
902
645
|
}
|
|
903
646
|
|
|
904
|
-
function
|
|
905
|
-
|
|
906
|
-
if (!active) return null;
|
|
907
|
-
const run = getRun(workdir, active.run_id);
|
|
908
|
-
if (!run || run.status !== "planning") return null;
|
|
909
|
-
return { runId: run.run_id, artifactDir: run.artifact_dir };
|
|
647
|
+
function isPlanningCustomInstructions(hint: unknown): boolean {
|
|
648
|
+
return typeof hint === "string" && hint.startsWith("pi-plans planning");
|
|
910
649
|
}
|
|
911
650
|
|
|
912
|
-
function
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
tokensBefore: usage.tokens ?? undefined,
|
|
927
|
-
});
|
|
928
|
-
return {
|
|
929
|
-
tokens: plan.currentITokens,
|
|
930
|
-
contextWindow: usage.contextWindow,
|
|
931
|
-
eligible: plan.firstKeptEntryIndex !== null && plan.firstKeptEntryIndex > 0,
|
|
932
|
-
};
|
|
933
|
-
}
|
|
934
|
-
} catch {
|
|
935
|
-
// Session projection is unavailable during startup in some hosts.
|
|
936
|
-
}
|
|
651
|
+
function isExecutionCustomInstructions(hint: unknown): boolean {
|
|
652
|
+
return typeof hint === "string" && hint.startsWith("pi-plans execution");
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export function noteCompactionStarted(ctx: ExtensionContext, customInstructions: unknown): void {
|
|
656
|
+
const store = compactionLifecycleStore(ctx);
|
|
657
|
+
if (isPlanningCustomInstructions(customInstructions)) {
|
|
658
|
+
store.planning = true;
|
|
659
|
+
} else if (isExecutionCustomInstructions(customInstructions)) {
|
|
660
|
+
store.execution = true;
|
|
661
|
+
} else {
|
|
662
|
+
// Auto-compaction (threshold/overflow/manual without our hint) marks both.
|
|
663
|
+
store.planning = true;
|
|
664
|
+
store.execution = true;
|
|
937
665
|
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
const
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Pi core's `SessionCompactEvent` / `SessionCompactFailedEvent` do not carry
|
|
669
|
+
* `customInstructions` in any emission site, so the END side has no way to
|
|
670
|
+
* know which phase the compaction belonged to. Clearing both phases is the
|
|
671
|
+
* safe default — the per-phase start side (above) already encodes the hint
|
|
672
|
+
* attribution. The hint parameter is retained for API symmetry and future
|
|
673
|
+
* Pi core schema additions. */
|
|
674
|
+
export function noteCompactionEnded(ctx: ExtensionContext, _customInstructions: unknown): void {
|
|
675
|
+
const store = compactionLifecycleStore(ctx);
|
|
676
|
+
store.planning = false;
|
|
677
|
+
store.execution = false;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export function compactionInFlight(ctx: ExtensionContext, phase: CompactionPhase): boolean {
|
|
681
|
+
const store = compactionLifecycleStore(ctx);
|
|
682
|
+
return store[phase];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function shouldTriggerPlanningCompaction(_ctx: ExtensionContext): boolean {
|
|
686
|
+
return false;
|
|
952
687
|
}
|
|
953
688
|
|
|
954
689
|
export function consumePlanningCompactionResumeGuard(ctx: ExtensionContext): boolean {
|
|
@@ -958,119 +693,34 @@ export function consumePlanningCompactionResumeGuard(ctx: ExtensionContext): boo
|
|
|
958
693
|
return true;
|
|
959
694
|
}
|
|
960
695
|
|
|
961
|
-
export function refreshPlanningCompactionCooldown(
|
|
962
|
-
|
|
963
|
-
const state = session.__planningCompaction;
|
|
964
|
-
if (!state) return;
|
|
965
|
-
const percent = ctx.getContextUsage()?.percent ?? null;
|
|
966
|
-
if (percent !== null && percent < 85 && state.cooldownActive) {
|
|
967
|
-
state.cooldownActive = false;
|
|
968
|
-
}
|
|
696
|
+
export function refreshPlanningCompactionCooldown(_ctx: ExtensionContext): void {
|
|
697
|
+
// Pi core owns scheduling; retained for lifecycle compatibility only.
|
|
969
698
|
}
|
|
970
699
|
|
|
971
|
-
export function requestPlanningCompaction(
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
inFlight: false,
|
|
975
|
-
resumeGuard: false,
|
|
976
|
-
cooldownActive: false,
|
|
977
|
-
lastAttemptReason: null,
|
|
978
|
-
lastSuccessfulUsagePercent: null,
|
|
979
|
-
lastSuccessfulAt: null,
|
|
980
|
-
} satisfies PlanningCompactionState);
|
|
981
|
-
if (state.inFlight || state.resumeGuard) return;
|
|
982
|
-
state.inFlight = true;
|
|
983
|
-
state.lastAttemptReason = "threshold";
|
|
984
|
-
try {
|
|
985
|
-
ctx.compact({ customInstructions: "pi-plans planning auto compact" });
|
|
986
|
-
} catch (error) {
|
|
987
|
-
state.inFlight = false;
|
|
988
|
-
ctx.ui.notify(`pi-plans: could not request planning compaction (${String(error)}).`, "warning");
|
|
989
|
-
}
|
|
700
|
+
export function requestPlanningCompaction(_ctx: ExtensionContext): void {
|
|
701
|
+
// Proactive pi-plans compaction is intentionally disabled. Manual,
|
|
702
|
+
// threshold, and overflow compactions are handled by session_before_compact.
|
|
990
703
|
}
|
|
991
704
|
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
const
|
|
995
|
-
if (!
|
|
996
|
-
const branchEntries = event.branchEntries as unknown as
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
?? null;
|
|
1002
|
-
const iPlan = currentI
|
|
1003
|
-
? planIAwareCompaction({
|
|
1004
|
-
entries: branchEntries as unknown as CompactionEntryLike[],
|
|
1005
|
-
currentI,
|
|
1006
|
-
contextWindow: eventPreparationUsage(event, ctx).contextWindow,
|
|
1007
|
-
tokensBefore: event.preparation.tokensBefore,
|
|
1008
|
-
fallbackFirstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
1009
|
-
maxFirstKeptEntryIndex: splitTurnBoundaryIndex(event),
|
|
1010
|
-
})
|
|
1011
|
-
: null;
|
|
1012
|
-
const firstKeptEntryId = iPlan?.firstKeptEntryId ?? legacyCut.id;
|
|
1013
|
-
const qaSection = legacyCut.hasMarker ? buildPlanningQASection(legacyCut.qaWindowEntries) : null;
|
|
1014
|
-
const boundaryIndex = event.branchEntries.findIndex((entry) => entry.id === firstKeptEntryId);
|
|
1015
|
-
const summaryEntries = boundaryIndex >= 0 ? branchEntries.slice(0, boundaryIndex) : branchEntries;
|
|
1016
|
-
const readRecords = iPlan?.readRecords ?? extractReadRecords(summaryEntries as unknown as CompactionEntryLike[]);
|
|
1017
|
-
const parts: string[] = [];
|
|
1018
|
-
if (event.customInstructions?.trim()) {
|
|
1019
|
-
parts.push(`## Compact Instructions
|
|
1020
|
-
${compactText(event.customInstructions, 1000)}`);
|
|
1021
|
-
}
|
|
1022
|
-
if (qaSection) {
|
|
1023
|
-
parts.push(qaSection);
|
|
1024
|
-
}
|
|
1025
|
-
const previousSummary = event.preparation.previousSummary?.trim();
|
|
1026
|
-
parts.push(`## Goal
|
|
1027
|
-
${compactText(planningCtx.runId, 80)} — keep current planning progress.`);
|
|
1028
|
-
parts.push(`## Constraints & Preferences
|
|
1029
|
-
- Stay in the active planning run (\`${planningCtx.runId}\`).
|
|
1030
|
-
- Plan files live under \`${planningCtx.artifactDir}\`.`);
|
|
1031
|
-
parts.push(`## Progress
|
|
1032
|
-
### Done
|
|
1033
|
-
- Pre-plan history compressed below.
|
|
1034
|
-
|
|
1035
|
-
### In Progress
|
|
1036
|
-
- Current planning question or open decision.
|
|
1037
|
-
|
|
1038
|
-
### Blocked
|
|
1039
|
-
- ${legacyCut.hasMarker ? "None" : "Planning cut-point marker missing; falling back to default."}`);
|
|
1040
|
-
if (iPlan) {
|
|
1041
|
-
const iSections = iPlan.slices
|
|
1042
|
-
.map((slice) => {
|
|
1043
|
-
const lines = slice.entries.filter((entry) => iPlan.summaryEntries.includes(entry)).map((entry) => summarizePlanningEntryLine(entry)).filter(Boolean);
|
|
1044
|
-
return slice.id && lines.length ? `### ${slice.current ? "Current I" : "Implementation I"} \`${slice.id}\`\n${lines.join("\\n")}` : "";
|
|
1045
|
-
})
|
|
1046
|
-
.filter(Boolean);
|
|
1047
|
-
parts.push(`## Current I\n- \`${currentI}\`\n${iSections.join("\\n\\n") || "- Current I transcript is in the retained suffix."}`);
|
|
1048
|
-
parts.push(`## Read Records\n${readRecords.length ? readRecords.map((record) => formatReadRecord(record)).join("\\n") : "- (none)"}`);
|
|
1049
|
-
parts.push(`## Compaction Boundary\n- firstKeptEntryId: \`${firstKeptEntryId}\`\n- currentI: \`${currentI}\`\n- targetMet: ${iPlan.metrics.targetMet}\n- hardFloorReason: ${iPlan.metrics.hardFloorReason ?? "none"}`);
|
|
1050
|
-
}
|
|
1051
|
-
if (previousSummary) {
|
|
1052
|
-
parts.push(`## Previous Compact Summary\n${previousSummary}`);
|
|
1053
|
-
}
|
|
1054
|
-
parts.push(`## Next Steps
|
|
1055
|
-
- Resume the active planning turn from the raw tail.`);
|
|
1056
|
-
const priorDetails = previousCompactionDetails(branchEntries as unknown as CompactionEntryLike[]);
|
|
1057
|
-
const details = mergeCompactionDetails(priorDetails, {
|
|
1058
|
-
kind: "pi-plans-planning-compaction",
|
|
1059
|
-
version: 1,
|
|
1060
|
-
currentI,
|
|
1061
|
-
iSections: iPlan?.slices.map((slice) => ({ id: slice.id, entryIds: slice.entries.map((entry) => entry.id).filter((id): id is string => !!id) })),
|
|
1062
|
-
readRecords,
|
|
1063
|
-
metrics: iPlan?.metrics,
|
|
705
|
+
function buildPlanningVccResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): VccCompactionBuildResult | null {
|
|
706
|
+
if (getExecution()) return null;
|
|
707
|
+
const active = activeVccSettings(ctx, "planning");
|
|
708
|
+
if (!active) return null;
|
|
709
|
+
const branchEntries = event.branchEntries as unknown as CompactionEntryLike[];
|
|
710
|
+
return buildPiPlansVccCompaction({
|
|
711
|
+
branchEntries,
|
|
712
|
+
preparation: event.preparation,
|
|
713
|
+
customInstructions: event.customInstructions,
|
|
1064
714
|
reason: event.reason,
|
|
1065
|
-
|
|
715
|
+
willRetry: event.willRetry,
|
|
716
|
+
settings: active.settings,
|
|
717
|
+
phaseContext: planningVccContext(branchEntries, active),
|
|
1066
718
|
});
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
details,
|
|
1073
|
-
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function buildPlanningCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
|
|
722
|
+
const built = buildPlanningVccResult(event, ctx);
|
|
723
|
+
return built?.kind === "compaction" ? built.compaction : null;
|
|
1074
724
|
}
|
|
1075
725
|
|
|
1076
726
|
export function handlePlanningBeforeCompact(
|
|
@@ -1079,82 +729,103 @@ export function handlePlanningBeforeCompact(
|
|
|
1079
729
|
event: SessionBeforeCompactEvent,
|
|
1080
730
|
): SessionBeforeCompactResult | undefined {
|
|
1081
731
|
if (getExecution()) return undefined;
|
|
1082
|
-
|
|
1083
|
-
const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
|
|
1084
|
-
const state = (session.__planningCompaction ??= {
|
|
1085
|
-
inFlight: false,
|
|
1086
|
-
resumeGuard: false,
|
|
1087
|
-
cooldownActive: false,
|
|
1088
|
-
lastAttemptReason: null,
|
|
1089
|
-
lastSuccessfulUsagePercent: null,
|
|
1090
|
-
lastSuccessfulAt: null,
|
|
1091
|
-
} satisfies PlanningCompactionState);
|
|
1092
|
-
const percent = ctx.getContextUsage()?.percent ?? null;
|
|
1093
|
-
if (event.reason === "threshold" && (percent === null || percent < 100)) {
|
|
1094
|
-
state.inFlight = false;
|
|
1095
|
-
state.lastAttemptReason = event.reason;
|
|
1096
|
-
return { cancel: true };
|
|
1097
|
-
}
|
|
732
|
+
const state = ensurePlanningCompactionState(ctx);
|
|
1098
733
|
state.inFlight = true;
|
|
1099
734
|
state.lastAttemptReason = event.reason;
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
let
|
|
735
|
+
state.pendingStats = null;
|
|
736
|
+
state.pendingFollowUpPrompt = null;
|
|
737
|
+
state.pendingContinueAfterThresholdCompact = false;
|
|
738
|
+
let built: VccCompactionBuildResult | null;
|
|
1104
739
|
try {
|
|
1105
|
-
|
|
740
|
+
built = buildPlanningVccResult(event, ctx);
|
|
1106
741
|
} catch (error) {
|
|
1107
742
|
state.inFlight = false;
|
|
1108
|
-
ctx.ui.notify(`pi-plans: planning compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
|
|
743
|
+
ctx.ui.notify(`pi-plans: VCC planning compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
|
|
1109
744
|
return undefined;
|
|
1110
745
|
}
|
|
1111
|
-
if (!
|
|
746
|
+
if (!built || built.kind === "fallback") {
|
|
1112
747
|
state.inFlight = false;
|
|
1113
748
|
return undefined;
|
|
1114
749
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
750
|
+
if (built.kind === "cancel") {
|
|
751
|
+
state.inFlight = false;
|
|
752
|
+
ctx.ui.notify(built.message, "warning");
|
|
753
|
+
return { cancel: true };
|
|
754
|
+
}
|
|
755
|
+
state.pendingStats = built.stats;
|
|
756
|
+
state.pendingFollowUpPrompt = built.followUpPrompt;
|
|
757
|
+
state.pendingContinueAfterThresholdCompact = built.settings.continueAfterThresholdCompact;
|
|
758
|
+
return { compaction: built.compaction };
|
|
1117
759
|
}
|
|
1118
760
|
|
|
1119
|
-
export function handlePlanningCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
|
|
761
|
+
export async function handlePlanningCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): Promise<void> {
|
|
1120
762
|
if (getExecution()) return;
|
|
1121
763
|
const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
|
|
1122
764
|
const state = session.__planningCompaction;
|
|
1123
765
|
if (!state) return;
|
|
766
|
+
const stats = state.pendingStats;
|
|
767
|
+
const followUpPrompt = state.pendingFollowUpPrompt;
|
|
768
|
+
const continueAfterThresholdCompact = state.pendingContinueAfterThresholdCompact;
|
|
769
|
+
state.pendingStats = null;
|
|
770
|
+
state.pendingFollowUpPrompt = null;
|
|
771
|
+
state.pendingContinueAfterThresholdCompact = false;
|
|
1124
772
|
state.inFlight = false;
|
|
1125
773
|
state.lastAttemptReason = event.reason;
|
|
774
|
+
state.terminalBackoffTokens = null;
|
|
1126
775
|
state.cooldownActive = true;
|
|
1127
776
|
state.lastSuccessfulAt = utcNow();
|
|
1128
777
|
state.lastSuccessfulUsagePercent = ctx.getContextUsage()?.percent ?? state.lastSuccessfulUsagePercent;
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
778
|
+
state.resumeGuard = false;
|
|
779
|
+
if (!event.willRetry && stats) {
|
|
780
|
+
ctx.ui.notify(formatVccCompactionStats(stats), "info");
|
|
781
|
+
if (followUpPrompt) {
|
|
782
|
+
await pi.sendUserMessage?.(followUpPrompt);
|
|
783
|
+
} else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) {
|
|
784
|
+
state.resumeGuard = true;
|
|
785
|
+
pi.sendMessage(
|
|
786
|
+
{
|
|
787
|
+
customType: PLANNING_RESUME_CUSTOM_TYPE,
|
|
788
|
+
content: "Continue planning.",
|
|
789
|
+
display: false,
|
|
790
|
+
},
|
|
791
|
+
{ triggerTurn: true },
|
|
792
|
+
);
|
|
793
|
+
}
|
|
1141
794
|
}
|
|
1142
795
|
}
|
|
1143
796
|
|
|
797
|
+
|
|
1144
798
|
export function handlePlanningCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
|
|
1145
799
|
if (getExecution()) return;
|
|
1146
800
|
const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
|
|
1147
801
|
const state = session.__planningCompaction;
|
|
1148
802
|
if (!state) return;
|
|
1149
|
-
const
|
|
1150
|
-
if (
|
|
803
|
+
const terminal = isTerminalCompactionFailure(event);
|
|
804
|
+
if (terminal) {
|
|
805
|
+
// Pi refused or aborted the compaction. Hold the cooldown and re-arm
|
|
806
|
+
// only after real growth or high-watermark pressure so the loop stops.
|
|
807
|
+
state.inFlight = false;
|
|
808
|
+
state.resumeGuard = false;
|
|
809
|
+
state.cooldownActive = true;
|
|
1151
810
|
state.lastAttemptReason = event.reason;
|
|
811
|
+
const tokens = ctx.getContextUsage()?.tokens;
|
|
812
|
+
state.terminalBackoffTokens = typeof tokens === "number" ? tokens : Number.POSITIVE_INFINITY;
|
|
813
|
+
state.pendingStats = null;
|
|
814
|
+
state.pendingFollowUpPrompt = null;
|
|
815
|
+
state.pendingContinueAfterThresholdCompact = false;
|
|
816
|
+
const message = terminal.kind === "content"
|
|
817
|
+
? "pi-plans: compaction found nothing to summarize; backing off until the session grows past the keep-recent window."
|
|
818
|
+
: "pi-plans: compaction was aborted (provider interruption, user cancel, or a competing manual compact); backing off until the session grows or usage nears the window.";
|
|
819
|
+
ctx.ui.notify(message, "info");
|
|
1152
820
|
return;
|
|
1153
821
|
}
|
|
1154
822
|
state.inFlight = false;
|
|
1155
823
|
state.resumeGuard = false;
|
|
1156
824
|
state.cooldownActive = false;
|
|
1157
825
|
state.lastAttemptReason = event.reason;
|
|
826
|
+
state.pendingStats = null;
|
|
827
|
+
state.pendingFollowUpPrompt = null;
|
|
828
|
+
state.pendingContinueAfterThresholdCompact = false;
|
|
1158
829
|
ctx.ui.notify(
|
|
1159
830
|
`pi-plans: planning compaction failed (${event.reason}); will try again on the next eligible turn.`,
|
|
1160
831
|
"warning",
|
|
@@ -1238,6 +909,109 @@ export function isExecutionComplete(): boolean {
|
|
|
1238
909
|
return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
|
|
1239
910
|
}
|
|
1240
911
|
|
|
912
|
+
let compactionFollowUpSentThisTurn = false;
|
|
913
|
+
|
|
914
|
+
/** Reset per-turn continuation flags at the start of a new agent turn. */
|
|
915
|
+
export function resetGoalWaitTurnFlags(): void {
|
|
916
|
+
compactionFollowUpSentThisTurn = false;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function goalWaitSnapshot(): string {
|
|
920
|
+
if (!execution) return "";
|
|
921
|
+
return JSON.stringify({
|
|
922
|
+
done: execution.items
|
|
923
|
+
.filter((item) => item.done)
|
|
924
|
+
.map((item) => item.id)
|
|
925
|
+
.sort()
|
|
926
|
+
.join("|"),
|
|
927
|
+
implStatus: execution.implStatus ?? {},
|
|
928
|
+
currentI: execution.currentI ?? null,
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function pauseGoalWait(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): void {
|
|
933
|
+
const ex = getExecution();
|
|
934
|
+
if (!ex?.goalWait) return;
|
|
935
|
+
ex.goalWait.paused = true;
|
|
936
|
+
ex.goalWait.pausedReason = reason;
|
|
937
|
+
persist(pi);
|
|
938
|
+
ctx.ui.notify?.(
|
|
939
|
+
`pi-plans: goal-wait paused (${reason}). Send any message or run /plans-execute to resume.`,
|
|
940
|
+
"warning",
|
|
941
|
+
);
|
|
942
|
+
updateStatusWidget(ctx);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Goal-wait continuation: a turn that ends with unpassed VCs gets one light
|
|
947
|
+
* followUp so the worker keeps going (working, or polling an external event
|
|
948
|
+
* per the taught backoff rules). Skipped when the compaction machinery owns
|
|
949
|
+
* continuation for this turn, and paused entirely by the no-progress guard.
|
|
950
|
+
* Note: the tri-flag check here deliberately runs BEFORE index.ts's
|
|
951
|
+
* handleExecutionTurnCompaction consumes resumeGuard — checking after the
|
|
952
|
+
* one-shot consumption would never observe it.
|
|
953
|
+
*/
|
|
954
|
+
function maybeGoalWaitFollowUp(pi: ExtensionAPI, ctx: ExtensionContext, assistantText: string): void {
|
|
955
|
+
const ex = getExecution();
|
|
956
|
+
if (!ex) return;
|
|
957
|
+
ex.goalWait ??= { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false };
|
|
958
|
+
const goalWait = ex.goalWait;
|
|
959
|
+
if (goalWait.paused) {
|
|
960
|
+
updateStatusWidget(ctx);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const compaction = executionCompactionState(ctx);
|
|
964
|
+
if (compaction && (compaction.inFlight || compaction.pendingFollowUpPrompt != null || compaction.resumeGuard)) {
|
|
965
|
+
updateStatusWidget(ctx);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (compactionFollowUpSentThisTurn) {
|
|
969
|
+
compactionFollowUpSentThisTurn = false; // the compaction path already queued a continuation
|
|
970
|
+
updateStatusWidget(ctx);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
const snapshot = goalWaitSnapshot();
|
|
974
|
+
const changed = goalWait.lastMarkers !== null && snapshot !== goalWait.lastMarkers;
|
|
975
|
+
goalWait.lastMarkers = snapshot;
|
|
976
|
+
if (!changed) {
|
|
977
|
+
if (/waiting for/i.test(assistantText)) {
|
|
978
|
+
goalWait.waitRounds += 1;
|
|
979
|
+
} else {
|
|
980
|
+
goalWait.noProgressRounds += 1;
|
|
981
|
+
}
|
|
982
|
+
} else {
|
|
983
|
+
goalWait.noProgressRounds = 0;
|
|
984
|
+
goalWait.waitRounds = 0;
|
|
985
|
+
}
|
|
986
|
+
if (goalWait.noProgressRounds >= GOAL_WAIT_MAX_NO_PROGRESS) {
|
|
987
|
+
pauseGoalWait(pi, ctx, `no progress in ${goalWait.noProgressRounds} rounds`);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (goalWait.waitRounds >= GOAL_WAIT_MAX_WAITING) {
|
|
991
|
+
pauseGoalWait(pi, ctx, `waiting without progress for ${goalWait.waitRounds} rounds`);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const remaining = ex.items.filter((item) => !item.done);
|
|
995
|
+
const remainingIds = remaining.map((item) => `\`${item.id}\``).join(", ");
|
|
996
|
+
pi.sendUserMessage?.(
|
|
997
|
+
`Goal wait: ${remaining.length}/${ex.items.length} verifier items still open (${remainingIds}). Continue the plan — if blocked on an external event, keep waiting per the backoff rules; otherwise resolve the remaining items.`,
|
|
998
|
+
{ deliverAs: "followUp" },
|
|
999
|
+
);
|
|
1000
|
+
updateStatusWidget(ctx);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/** Any external input re-kicks a paused goal-wait (clears pause and counters). */
|
|
1004
|
+
export function resumeGoalWaitIfPaused(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
1005
|
+
const ex = getExecution();
|
|
1006
|
+
if (!ex?.goalWait?.paused) return;
|
|
1007
|
+
ex.goalWait.paused = false;
|
|
1008
|
+
ex.goalWait.pausedReason = undefined;
|
|
1009
|
+
ex.goalWait.noProgressRounds = 0;
|
|
1010
|
+
ex.goalWait.waitRounds = 0;
|
|
1011
|
+
persist(pi);
|
|
1012
|
+
updateStatusWidget(ctx);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1241
1015
|
export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
1242
1016
|
if (!execution) return;
|
|
1243
1017
|
resetExecutionCompactionState(ctx);
|
|
@@ -1249,14 +1023,26 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
|
|
|
1249
1023
|
const planPath = execution.planPath;
|
|
1250
1024
|
execution = null;
|
|
1251
1025
|
pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" });
|
|
1026
|
+
// Post-execution goal-running continuation: in interactive sessions, attach
|
|
1027
|
+
// the continuation block and trigger a new turn so the agent immediately
|
|
1028
|
+
// enters the implementation-review loop. Headless sessions keep the silent
|
|
1029
|
+
// completion behavior. Both completeExecution call sites (turn_end and the
|
|
1030
|
+
// restoreFromSession recovery path) share this behavior.
|
|
1031
|
+
const interactive = ctx.hasUI === true;
|
|
1032
|
+
const content = interactive
|
|
1033
|
+
? `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}\n\n${AMELIORATION_PROMPT_TEXT}`
|
|
1034
|
+
: `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}`;
|
|
1252
1035
|
pi.sendMessage(
|
|
1253
1036
|
{
|
|
1254
1037
|
customType: "pi-plans-complete",
|
|
1255
|
-
content
|
|
1038
|
+
content,
|
|
1256
1039
|
display: true,
|
|
1257
1040
|
},
|
|
1258
|
-
{ triggerTurn:
|
|
1041
|
+
{ triggerTurn: interactive },
|
|
1259
1042
|
);
|
|
1043
|
+
if (interactive) {
|
|
1044
|
+
pi.appendEntry("pi-plans-ameliorate", { planPath, phase: "goal-started", rounds: null, currentRound: 0 });
|
|
1045
|
+
}
|
|
1260
1046
|
const active = readActive(ctx.cwd);
|
|
1261
1047
|
if (active) {
|
|
1262
1048
|
try {
|
|
@@ -1268,12 +1054,26 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
|
|
|
1268
1054
|
updateStatusWidget(ctx);
|
|
1269
1055
|
}
|
|
1270
1056
|
|
|
1057
|
+
/** Instructions appended to the post-execution completion message in
|
|
1058
|
+
* interactive sessions, telling the agent to enter the goal-running
|
|
1059
|
+
* implementation-review loop. Termination options are single-sourced from
|
|
1060
|
+
* src/termination-prompt.ts (shared with the ask_choice trailing branch). */
|
|
1061
|
+
export const AMELIORATION_PROMPT_TEXT = `---
|
|
1062
|
+
Goal-running continuation: immediately ask the user now via ask_choice (autoComplete: false, in the session language) the termination question: "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. Then keep running the implementation-review loop without asking whether to continue; the goal-wait option keeps the loop running until no unpassed VCs remain.`;
|
|
1063
|
+
|
|
1271
1064
|
/** Injection text for before_agent_start while executing. */
|
|
1272
|
-
export function executionContextMessage(): string | null {
|
|
1065
|
+
export function executionContextMessage(ctx: ExtensionContext): string | null {
|
|
1273
1066
|
if (!execution) return null;
|
|
1274
1067
|
const remaining = execution.items.filter((item) => !item.done);
|
|
1275
1068
|
const list =
|
|
1276
1069
|
remaining.map((item) => `- \`${item.id}\` ${item.text}`).join("\n") || "(none — report completion now)";
|
|
1070
|
+
// Live read: the injected guidance and the tool wrappers share the same
|
|
1071
|
+
// tri-state, so they can never contradict each other mid-run.
|
|
1072
|
+
const mode = resolveGraphMode(ctx?.cwd ?? process.cwd());
|
|
1073
|
+
const graphLine =
|
|
1074
|
+
mode === "config-unavailable"
|
|
1075
|
+
? `${graphBlockForExecutor(false)}\n[pi-plans: config unreadable this turn; graph features are off until .git/pi_plans/config.json is repaired]`
|
|
1076
|
+
: graphBlockForExecutor(mode === "enabled");
|
|
1277
1077
|
const implementationItems = execution.implItems?.length
|
|
1278
1078
|
? `\nImplementation items: ${execution.implItems.map((item) => item.id).join(", ")}${execution.currentI ? `\nCurrent implementation item: \`${execution.currentI}\`` : ""}\nWhen beginning an implementation item, emit its current anchor exactly once as \`[I-###:current]\`; then use \`[I-###:implemented]\` or \`[I-###:validating]\` for progress.`
|
|
1279
1079
|
: "";
|
|
@@ -1283,6 +1083,8 @@ Implement the accepted plan at ${execution.planPath} (${execution.items.length -
|
|
|
1283
1083
|
Remaining verifier items:
|
|
1284
1084
|
${list}${implementationItems}
|
|
1285
1085
|
|
|
1086
|
+
${graphLine}
|
|
1087
|
+
|
|
1286
1088
|
Execution rules:
|
|
1287
1089
|
- Implement implementation items in dependency order; grow the change in layers — smallest end-to-end slice first, then stack each new capability on top of what already works.
|
|
1288
1090
|
- Report implementation-item progress with lightweight markers in your reply: write \`[I-001:implemented]\` when an item's code is done, \`[I-001:validating]\` when you start verifying it. The execution status bar tracks these states.
|
|
@@ -1345,6 +1147,9 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
|
|
|
1345
1147
|
implItems: snapshot.implItems ?? [],
|
|
1346
1148
|
implStatus: { ...(snapshot.implStatus ?? {}) },
|
|
1347
1149
|
currentI: snapshot.currentI ?? inferCurrentI(snapshot.implItems, snapshot.items, snapshot.implStatus),
|
|
1150
|
+
goalWait: snapshot.goalWait
|
|
1151
|
+
? { ...snapshot.goalWait }
|
|
1152
|
+
: { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false },
|
|
1348
1153
|
};
|
|
1349
1154
|
for (let i = snapshotIndex + 1; i < entries.length; i++) {
|
|
1350
1155
|
const entry = entries[i];
|
|
@@ -1364,6 +1169,16 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
|
|
|
1364
1169
|
}
|
|
1365
1170
|
}
|
|
1366
1171
|
if (execution) {
|
|
1172
|
+
// D-010: replay may have advanced progress past the persisted baseline.
|
|
1173
|
+
// Recompute the goal-wait markers; new progress resets the guard counters.
|
|
1174
|
+
if (execution.goalWait) {
|
|
1175
|
+
const markerSnapshot = goalWaitSnapshot();
|
|
1176
|
+
if (markerSnapshot !== execution.goalWait.lastMarkers) {
|
|
1177
|
+
execution.goalWait.lastMarkers = markerSnapshot;
|
|
1178
|
+
execution.goalWait.noProgressRounds = 0;
|
|
1179
|
+
execution.goalWait.waitRounds = 0;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1367
1182
|
persist(pi); // refresh snapshot so the next resume has less to rescan
|
|
1368
1183
|
if (isExecutionComplete()) {
|
|
1369
1184
|
// Completed during the rescan: restore the planning model on the way out.
|