pi-plans 0.1.1 → 0.2.0
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 +47 -15
- package/index.ts +143 -95
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +6 -3
- package/references/state-and-config.md +1 -1
- package/src/autocomplete.ts +163 -0
- package/src/compaction.ts +502 -0
- package/src/exec.ts +1098 -95
- package/src/plan.ts +143 -0
- package/src/query-hook.ts +82 -0
- package/src/refine-ui-helpers.ts +89 -0
- package/src/refine-ui-state.ts +78 -0
- package/src/refine-ui.ts +322 -0
- package/src/state.ts +19 -1
- package/src/subagent.ts +196 -69
- package/tests/autocomplete.test.ts +142 -0
- package/tests/compaction.test.ts +74 -0
- package/tests/exec.test.ts +803 -101
- package/tests/execute-plan.test.ts +65 -0
- package/tests/plan.test.ts +94 -1
- package/tests/plans.test.ts +37 -0
- package/tests/query-hook.test.ts +82 -0
- package/tests/refine-ui.test.ts +127 -0
- package/tests/state.test.ts +57 -0
- package/tests/subagent.test.ts +114 -0
- package/tools/ask-choice.ts +22 -0
- package/tools/execute-plan.ts +13 -5
- package/tools/plans.ts +16 -0
- package/tools/refine.ts +125 -71
- package/src/execution-panel.ts +0 -499
- package/tests/execution-panel.test.ts +0 -205
package/src/exec.ts
CHANGED
|
@@ -3,58 +3,248 @@
|
|
|
3
3
|
*
|
|
4
4
|
* When the user approves the execution handoff, the extension switches into
|
|
5
5
|
* execution mode: every agent turn is injected with the remaining verifier
|
|
6
|
-
* checklist, assistant messages are scanned for [DONE:VC-xxx] markers, and
|
|
7
|
-
*
|
|
6
|
+
* checklist, assistant messages are scanned for [DONE:VC-xxx] markers, and
|
|
7
|
+
* progress is reported through the bottom status bar until every item passes.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
10
11
|
import * as fs from "node:fs";
|
|
11
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
CompactionResult,
|
|
14
|
+
ExtensionAPI,
|
|
15
|
+
ExtensionContext,
|
|
16
|
+
SessionBeforeCompactEvent,
|
|
17
|
+
SessionBeforeCompactResult,
|
|
18
|
+
SessionCompactEvent,
|
|
19
|
+
SessionCompactFailedEvent,
|
|
20
|
+
} from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { AUTOCOMPLETE_ENTRY } from "./autocomplete.ts";
|
|
12
22
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
type
|
|
22
|
-
type
|
|
23
|
-
|
|
24
|
-
} from "./execution-panel.ts";
|
|
23
|
+
compactText as boundedCompactionText,
|
|
24
|
+
compactionCurrentI,
|
|
25
|
+
currentIExceedsTrigger,
|
|
26
|
+
entryCurrentIMarkers,
|
|
27
|
+
extractReadRecords,
|
|
28
|
+
formatReadRecord,
|
|
29
|
+
mergeCompactionDetails,
|
|
30
|
+
planIAwareCompaction,
|
|
31
|
+
type CompactionDetailsLike,
|
|
32
|
+
type CompactionEntryLike,
|
|
33
|
+
} from "./compaction.ts";
|
|
25
34
|
import { getRun, readActive, setRunStatus, utcNow } from "./state.ts";
|
|
26
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
extractCoverage,
|
|
37
|
+
latestPlanVersion,
|
|
38
|
+
resolveImplStatuses,
|
|
39
|
+
scanDoneMarkers,
|
|
40
|
+
scanImplMarkers,
|
|
41
|
+
scanCurrentIMarkers,
|
|
42
|
+
resolveCurrentI,
|
|
43
|
+
inferCurrentI,
|
|
44
|
+
type CheckItem,
|
|
45
|
+
type ImplItem,
|
|
46
|
+
type ImplMarkerState,
|
|
47
|
+
} from "./plan.ts";
|
|
27
48
|
|
|
28
|
-
export interface ExecState
|
|
49
|
+
export interface ExecState {
|
|
50
|
+
planPath: string;
|
|
51
|
+
items: CheckItem[];
|
|
29
52
|
startedAt: string;
|
|
30
|
-
panel?: ExecutionPanelState;
|
|
31
53
|
usage: { inToks: number; outToks: number };
|
|
54
|
+
implItems?: ImplItem[];
|
|
55
|
+
implStatus?: Record<string, ImplMarkerState>;
|
|
56
|
+
currentI?: string;
|
|
32
57
|
}
|
|
33
58
|
|
|
34
59
|
let execution: ExecState | null = null;
|
|
35
60
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
let
|
|
61
|
+
// Execution-loop persistence is deferred until the agent settles so turn_end
|
|
62
|
+
// never causes session writes during a streaming run.
|
|
63
|
+
let pendingExecutionFlush = false;
|
|
39
64
|
|
|
40
|
-
export function
|
|
41
|
-
const pending =
|
|
42
|
-
|
|
65
|
+
export function consumePendingExecutionFlush(): boolean {
|
|
66
|
+
const pending = pendingExecutionFlush;
|
|
67
|
+
pendingExecutionFlush = false;
|
|
43
68
|
return pending;
|
|
44
69
|
}
|
|
45
70
|
|
|
71
|
+
function requestExecutionFlush(_pi: ExtensionAPI, _ctx: ExtensionContext): void {
|
|
72
|
+
// Unconditional defer. turn_end fires mid-run in a gap between agent
|
|
73
|
+
// operations where isIdle() reads true; persistence happens only at the
|
|
74
|
+
// drain points: agent_settled, the next before_agent_start, and stop/complete.
|
|
75
|
+
pendingExecutionFlush = true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function drainExecutionFlush(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
79
|
+
if (!execution || !pendingExecutionFlush) return;
|
|
80
|
+
pendingExecutionFlush = false;
|
|
81
|
+
persist(pi);
|
|
82
|
+
updateStatusWidget(ctx);
|
|
83
|
+
}
|
|
84
|
+
|
|
46
85
|
export function getExecution(): ExecState | null {
|
|
47
86
|
return execution;
|
|
48
87
|
}
|
|
49
88
|
|
|
50
|
-
|
|
51
|
-
|
|
89
|
+
const EXECUTION_COMPACTION_TRIGGER_PERCENT = 20;
|
|
90
|
+
const EXECUTION_COMPACTION_REARM_PERCENT = 80;
|
|
91
|
+
const EXECUTION_COMPACTION_REARM_HIGH_PERCENT = 95;
|
|
92
|
+
const EXECUTION_COMPACTION_RESUME_MESSAGE = "Continue execution.";
|
|
93
|
+
|
|
94
|
+
interface ExecutionCompactionState {
|
|
95
|
+
inFlight: boolean;
|
|
96
|
+
resumeGuard: boolean;
|
|
97
|
+
cooldownActive: boolean;
|
|
98
|
+
lastAttemptReason: string | null;
|
|
99
|
+
lastSuccessfulUsagePercent: number | null;
|
|
100
|
+
lastSuccessfulAt: string | null;
|
|
101
|
+
rearmPending: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type ExecutionCompactionSession = { __executionCompaction?: ExecutionCompactionState };
|
|
105
|
+
type ExecutionCompactionContext = ExtensionContext & { sessionManager?: ExecutionCompactionSession };
|
|
106
|
+
|
|
107
|
+
function getExecutionCompactionSession(ctx: ExtensionContext, create = false): ExecutionCompactionSession | undefined {
|
|
108
|
+
const carrier = ctx as ExecutionCompactionContext;
|
|
109
|
+
if (carrier.sessionManager) return carrier.sessionManager;
|
|
110
|
+
if (!create) return undefined;
|
|
111
|
+
carrier.sessionManager = {};
|
|
112
|
+
return carrier.sessionManager;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function executionCompactionState(ctx: ExtensionContext): ExecutionCompactionState | undefined {
|
|
116
|
+
return getExecutionCompactionSession(ctx)?.__executionCompaction;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ensureExecutionCompactionState(ctx: ExtensionContext): ExecutionCompactionState {
|
|
120
|
+
const session = getExecutionCompactionSession(ctx, true)!;
|
|
121
|
+
return (session.__executionCompaction ??= {
|
|
122
|
+
inFlight: false,
|
|
123
|
+
resumeGuard: false,
|
|
124
|
+
cooldownActive: false,
|
|
125
|
+
lastAttemptReason: null,
|
|
126
|
+
lastSuccessfulUsagePercent: null,
|
|
127
|
+
lastSuccessfulAt: null,
|
|
128
|
+
rearmPending: false,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function resetExecutionCompactionState(ctx: ExtensionContext): void {
|
|
133
|
+
const session = getExecutionCompactionSession(ctx);
|
|
134
|
+
if (!session) return;
|
|
135
|
+
delete session.__executionCompaction;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function consumeExecutionCompactionResumeGuard(ctx: ExtensionContext): boolean {
|
|
139
|
+
const state = executionCompactionState(ctx);
|
|
140
|
+
if (!state?.resumeGuard) return false;
|
|
141
|
+
state.resumeGuard = false;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function refreshExecutionCompactionCooldown(ctx: ExtensionContext): void {
|
|
146
|
+
const state = executionCompactionState(ctx);
|
|
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
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function handleExecutionTurnCompaction(ctx: ExtensionContext): void {
|
|
213
|
+
refreshExecutionCompactionCooldown(ctx);
|
|
214
|
+
if (consumeExecutionCompactionResumeGuard(ctx)) return;
|
|
215
|
+
if (shouldTriggerExecutionCompaction(ctx)) {
|
|
216
|
+
requestExecutionCompaction(ctx);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function computeExecutionProgress(execution: ExecState): { done: number; total: number } {
|
|
221
|
+
const implItems = execution.implItems ?? [];
|
|
222
|
+
if (implItems.length) {
|
|
223
|
+
const statuses = resolveImplStatuses(implItems, execution.items, execution.implStatus);
|
|
224
|
+
const counted = implItems.filter((impl) =>
|
|
225
|
+
execution.items.some((item) => extractCoverage(item.text).includes(impl.id)),
|
|
226
|
+
);
|
|
227
|
+
const total = counted.length > 0 ? counted.length : implItems.length;
|
|
228
|
+
const vcDone = counted.filter((impl) => statuses[impl.id] === "vc-passed").length;
|
|
229
|
+
const currentIndex = execution.currentI
|
|
230
|
+
? implItems.findIndex((impl) => impl.id === execution.currentI)
|
|
231
|
+
: -1;
|
|
232
|
+
return {
|
|
233
|
+
done: Math.min(total, Math.max(vcDone, currentIndex < 0 ? 0 : currentIndex)),
|
|
234
|
+
total,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
52
237
|
return {
|
|
53
238
|
done: execution.items.filter((item) => item.done).length,
|
|
54
239
|
total: execution.items.length,
|
|
55
240
|
};
|
|
56
241
|
}
|
|
57
242
|
|
|
243
|
+
export function executionProgress(): { done: number; total: number } | null {
|
|
244
|
+
if (!execution) return null;
|
|
245
|
+
return computeExecutionProgress(execution);
|
|
246
|
+
}
|
|
247
|
+
|
|
58
248
|
function formatElapsed(startedAt: string): string {
|
|
59
249
|
const total = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
|
|
60
250
|
const h = String(Math.floor(total / 3600)).padStart(2, "0");
|
|
@@ -68,13 +258,14 @@ function formatToks(tokens: number): string {
|
|
|
68
258
|
return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k`;
|
|
69
259
|
}
|
|
70
260
|
|
|
261
|
+
export function formatExecutionStatusLine(execution: ExecState): string {
|
|
262
|
+
const progress = computeExecutionProgress(execution);
|
|
263
|
+
return `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks`;
|
|
264
|
+
}
|
|
265
|
+
|
|
71
266
|
export function updateStatusWidget(ctx: ExtensionContext): void {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// Progress lives in the bottom status bar — the same layer as the ⛔/⌛
|
|
75
|
-
// paused indicator — so both execution states read from one place.
|
|
76
|
-
const tail = execution.panel?.expanded ? "/plans-list hide" : "/plans-list details";
|
|
77
|
-
const line = `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks · ${tail}`;
|
|
267
|
+
if (execution) {
|
|
268
|
+
const line = formatExecutionStatusLine(execution);
|
|
78
269
|
ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", line));
|
|
79
270
|
return;
|
|
80
271
|
}
|
|
@@ -118,27 +309,22 @@ function persist(pi: ExtensionAPI): void {
|
|
|
118
309
|
items: execution.items,
|
|
119
310
|
startedAt: execution.startedAt,
|
|
120
311
|
usage: execution.usage,
|
|
121
|
-
|
|
312
|
+
implItems: execution.implItems,
|
|
313
|
+
implStatus: execution.implStatus,
|
|
314
|
+
currentI: execution.currentI,
|
|
122
315
|
});
|
|
123
316
|
}
|
|
124
317
|
|
|
125
|
-
export function
|
|
126
|
-
if (!execution) {
|
|
127
|
-
clearExecutionPanel(ctx);
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
refreshExecutionPanel(ctx, execution);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function startExecution(
|
|
318
|
+
export async function startExecution(
|
|
134
319
|
pi: ExtensionAPI,
|
|
135
320
|
ctx: ExtensionContext,
|
|
136
321
|
planPath: string,
|
|
137
322
|
items: CheckItem[],
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
323
|
+
implItems?: ImplItem[],
|
|
324
|
+
): Promise<void> {
|
|
325
|
+
execution = { planPath, items, startedAt: utcNow(), usage: { inToks: 0, outToks: 0 }, implItems: implItems ?? [], implStatus: {} };
|
|
326
|
+
pendingExecutionFlush = false; // fresh run: no inherited flush debt
|
|
327
|
+
resetExecutionCompactionState(ctx);
|
|
142
328
|
persist(pi);
|
|
143
329
|
const active = readActive(ctx.cwd);
|
|
144
330
|
if (active) {
|
|
@@ -157,59 +343,834 @@ export function startExecution(
|
|
|
157
343
|
{ triggerTurn: false },
|
|
158
344
|
);
|
|
159
345
|
updateStatusWidget(ctx);
|
|
160
|
-
syncExecutionPanel(ctx);
|
|
161
346
|
}
|
|
162
347
|
|
|
163
|
-
|
|
348
|
+
/** Record one assistant turn: accumulate usage and mark any completed items. */
|
|
349
|
+
export function recordExecutionTurn(
|
|
350
|
+
pi: ExtensionAPI,
|
|
351
|
+
_ctx: ExtensionContext,
|
|
352
|
+
_completedIds: string[],
|
|
353
|
+
usage?: { input: number; output: number },
|
|
354
|
+
): void {
|
|
355
|
+
if (!execution) return;
|
|
356
|
+
if (usage) {
|
|
357
|
+
execution.usage.inToks += usage.input;
|
|
358
|
+
execution.usage.outToks += usage.output;
|
|
359
|
+
}
|
|
360
|
+
requestExecutionFlush(pi, _ctx);
|
|
361
|
+
updateStatusWidget(_ctx);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function registerExecutionTurnHandlers(
|
|
365
|
+
pi: ExtensionAPI,
|
|
366
|
+
onTurnEnd?: (ctx: ExtensionContext) => Promise<void> | void,
|
|
367
|
+
): void {
|
|
368
|
+
// The turn_end projection does not carry usage; message_end delivers the
|
|
369
|
+
// full assistant message, so cache it here and consume it per turn.
|
|
370
|
+
let lastAssistantUsage: { input: number; output: number } | null = null;
|
|
371
|
+
pi.on("message_end", async (event) => {
|
|
372
|
+
const message = event.message as { role?: string; usage?: { input?: number; output?: number } };
|
|
373
|
+
if (message?.role === "assistant" && message.usage) {
|
|
374
|
+
lastAssistantUsage = { input: message.usage.input ?? 0, output: message.usage.output ?? 0 };
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
379
|
+
const message = event.message as { role?: string; content?: Array<{ type: string; text?: string }> };
|
|
380
|
+
if (!message || message.role !== "assistant") {
|
|
381
|
+
updateStatusWidget(ctx);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const text = (message.content ?? [])
|
|
385
|
+
.filter((part) => part.type === "text")
|
|
386
|
+
.map((part) => part.text ?? "")
|
|
387
|
+
.join("\n");
|
|
388
|
+
const changedIds = applyDoneMarkers(text);
|
|
389
|
+
const changedImpls = applyImplMarkers(text);
|
|
390
|
+
const changedCurrentI = applyCurrentIMarker(text);
|
|
391
|
+
const projection = (event.message as { usage?: { input?: number; output?: number } }).usage;
|
|
392
|
+
const raw = projection ?? lastAssistantUsage;
|
|
393
|
+
lastAssistantUsage = null; // consumed: never re-attribute a stale turn
|
|
394
|
+
const usage = raw ? { input: raw.input ?? 0, output: raw.output ?? 0 } : undefined;
|
|
395
|
+
if (usage || changedIds.length > 0 || changedImpls.length > 0 || changedCurrentI) {
|
|
396
|
+
// Attribute this turn's usage now; `[DONE]` markers still only mark completion.
|
|
397
|
+
recordExecutionTurn(pi, ctx, changedIds, usage);
|
|
398
|
+
}
|
|
399
|
+
if (getExecution() && isExecutionComplete()) {
|
|
400
|
+
await completeExecution(pi, ctx);
|
|
401
|
+
}
|
|
402
|
+
await onTurnEnd?.(ctx);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const EXECUTION_RESUME_CUSTOM_TYPE = "pi-plans-exec-resume";
|
|
407
|
+
|
|
408
|
+
type CompactBranchEntry = SessionBeforeCompactEvent["branchEntries"][number];
|
|
409
|
+
type CompactMessage = { role?: string; content?: Array<{ type: string; text?: string }> };
|
|
410
|
+
|
|
411
|
+
function compactText(text: string, limit = 180): string {
|
|
412
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
413
|
+
if (normalized.length <= limit) return normalized;
|
|
414
|
+
return `${normalized.slice(0, Math.max(0, limit - 1))}…`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function messageText(message: CompactMessage | undefined): string {
|
|
418
|
+
if (!message) return "";
|
|
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)}`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function findExecutionCompactionCutEntryId(branchEntries: CompactBranchEntry[], fallback: string): string {
|
|
456
|
+
const completionIds = new Set(execution?.items.filter((item) => item.done).map((item) => item.id) ?? []);
|
|
457
|
+
let lastCompletionIndex = -1;
|
|
458
|
+
for (let i = 0; i < branchEntries.length; i++) {
|
|
459
|
+
const entry = branchEntries[i];
|
|
460
|
+
if (!isSummarizableEntry(entry)) continue;
|
|
461
|
+
const markers = scanDoneMarkers(messageText(entry.message));
|
|
462
|
+
if (markers.some((marker) => completionIds.has(marker))) {
|
|
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;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function buildFinishedItemSections(summaryEntries: CompactBranchEntry[]): string[] {
|
|
479
|
+
if (!execution) return [];
|
|
480
|
+
const completedItems = execution.items.filter((item) => item.done);
|
|
481
|
+
const sections: string[] = [];
|
|
482
|
+
let completedIndex = 0;
|
|
483
|
+
let currentLines: string[] = [];
|
|
484
|
+
for (const entry of summaryEntries) {
|
|
485
|
+
if (!isSummarizableEntry(entry)) continue;
|
|
486
|
+
const line = renderMessageLine(entry);
|
|
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;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return sections;
|
|
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")}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return sections;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function executionSummaryParts(
|
|
556
|
+
event: SessionBeforeCompactEvent,
|
|
557
|
+
ctx: ExtensionContext,
|
|
558
|
+
firstKeptEntryId: string,
|
|
559
|
+
boundaryIndex: number,
|
|
560
|
+
plan: ReturnType<typeof planIAwareCompaction> | null,
|
|
561
|
+
): { parts: string[]; details: CompactionDetailsLike } {
|
|
562
|
+
if (!execution) return { parts: [], details: {} };
|
|
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 },
|
|
592
|
+
reason: event.reason,
|
|
593
|
+
willRetry: event.willRetry,
|
|
594
|
+
finishedItems: execution.items.filter((item) => item.done).map((item) => item.id),
|
|
595
|
+
});
|
|
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
|
+
}
|
|
612
|
+
|
|
613
|
+
export function buildExecutionCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
|
|
164
614
|
if (!execution) return null;
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
615
|
+
const branchEntries = event.branchEntries as unknown as CompactionEntryLike[];
|
|
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
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export function handleExecutionBeforeCompact(
|
|
710
|
+
pi: ExtensionAPI,
|
|
711
|
+
ctx: ExtensionContext,
|
|
712
|
+
event: SessionBeforeCompactEvent,
|
|
713
|
+
): SessionBeforeCompactResult | Promise<SessionBeforeCompactResult | undefined> | undefined {
|
|
714
|
+
if (!execution) return undefined;
|
|
715
|
+
let fallback: CompactionResult | null;
|
|
716
|
+
try {
|
|
717
|
+
fallback = buildExecutionCompactionResult(event, ctx);
|
|
718
|
+
} catch (error) {
|
|
719
|
+
const state = executionCompactionState(ctx);
|
|
720
|
+
if (state) state.inFlight = false;
|
|
721
|
+
ctx.ui.notify(`pi-plans: compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
|
|
722
|
+
return undefined;
|
|
723
|
+
}
|
|
724
|
+
if (!fallback) return undefined;
|
|
725
|
+
notifyHardFloor(ctx, fallback);
|
|
726
|
+
const registry = ctx.modelRegistry as unknown as { complete?: Function };
|
|
727
|
+
if (!ctx.model || typeof registry.complete !== "function") {
|
|
728
|
+
requestExecutionFlush(pi, ctx);
|
|
729
|
+
return { compaction: fallback };
|
|
730
|
+
}
|
|
731
|
+
return buildModelExecutionCompactionResult(event, ctx, fallback).then((compaction) => {
|
|
732
|
+
if (!compaction) return undefined;
|
|
733
|
+
requestExecutionFlush(pi, ctx);
|
|
734
|
+
return { compaction };
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
|
|
739
|
+
if (!execution) return;
|
|
740
|
+
const state = ensureExecutionCompactionState(ctx);
|
|
741
|
+
state.inFlight = false;
|
|
742
|
+
state.lastAttemptReason = event.reason;
|
|
743
|
+
state.cooldownActive = true;
|
|
744
|
+
state.rearmPending = false;
|
|
745
|
+
state.lastSuccessfulAt = utcNow();
|
|
746
|
+
state.lastSuccessfulUsagePercent = ctx.getContextUsage()?.percent ?? state.lastSuccessfulUsagePercent;
|
|
747
|
+
if (!event.willRetry) {
|
|
748
|
+
state.resumeGuard = true;
|
|
749
|
+
pi.sendMessage(
|
|
750
|
+
{
|
|
751
|
+
customType: EXECUTION_RESUME_CUSTOM_TYPE,
|
|
752
|
+
content: EXECUTION_COMPACTION_RESUME_MESSAGE,
|
|
753
|
+
display: false,
|
|
754
|
+
},
|
|
755
|
+
{ triggerTurn: true },
|
|
756
|
+
);
|
|
174
757
|
} else {
|
|
175
|
-
|
|
758
|
+
state.resumeGuard = false;
|
|
176
759
|
}
|
|
177
|
-
|
|
760
|
+
requestExecutionFlush(pi, ctx);
|
|
761
|
+
updateStatusWidget(ctx);
|
|
178
762
|
}
|
|
179
763
|
|
|
180
|
-
export function
|
|
181
|
-
if (!execution
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (!normalized) continue;
|
|
188
|
-
merged.add(normalized);
|
|
764
|
+
export function handleExecutionCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
|
|
765
|
+
if (!execution) return;
|
|
766
|
+
const state = executionCompactionState(ctx);
|
|
767
|
+
const expectedThresholdCancel = event.reason === "threshold" && event.aborted && !state?.inFlight;
|
|
768
|
+
if (expectedThresholdCancel) {
|
|
769
|
+
if (state) state.lastAttemptReason = event.reason;
|
|
770
|
+
return;
|
|
189
771
|
}
|
|
190
|
-
|
|
772
|
+
if (state) {
|
|
773
|
+
state.inFlight = false;
|
|
774
|
+
state.resumeGuard = false;
|
|
775
|
+
state.cooldownActive = false;
|
|
776
|
+
state.rearmPending = false;
|
|
777
|
+
state.lastAttemptReason = event.reason;
|
|
778
|
+
}
|
|
779
|
+
ctx.ui.notify(
|
|
780
|
+
`pi-plans: compaction failed (${event.reason}); execution remains active and will wait for the next eligible turn.`,
|
|
781
|
+
"warning",
|
|
782
|
+
);
|
|
783
|
+
requestExecutionFlush(pi, ctx);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export function filterExecutionResumeMessages<T extends { customType?: string }>(messages: T[]): T[] {
|
|
787
|
+
return messages.filter((message) => message.customType !== EXECUTION_RESUME_CUSTOM_TYPE);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
// Planning-phase auto compaction: same trigger rules and resume pattern as
|
|
792
|
+
// the execution side, but with a different cut-point algorithm and summary
|
|
793
|
+
// shape. The two state machines are kept independent (different memory slot
|
|
794
|
+
// and snapshot key) so execution never bleeds into planning.
|
|
795
|
+
// ---------------------------------------------------------------------------
|
|
796
|
+
|
|
797
|
+
export const PLANNING_RUN_START_CUSTOM_TYPE = "pi-plans-run-start";
|
|
798
|
+
export const PLANNING_PLAN_WRITTEN_CUSTOM_TYPE = "pi-plans-plan-written";
|
|
799
|
+
const PLANNING_QA_SECTION_HEADER = "## Q&A During Planning";
|
|
800
|
+
const PLANNING_RESUME_CUSTOM_TYPE = "pi-plans-plan-resume";
|
|
801
|
+
|
|
802
|
+
interface PlanningCompactionState {
|
|
803
|
+
inFlight: boolean;
|
|
804
|
+
resumeGuard: boolean;
|
|
805
|
+
cooldownActive: boolean;
|
|
806
|
+
lastAttemptReason: "manual" | "threshold" | "overflow" | null;
|
|
807
|
+
lastSuccessfulUsagePercent: number | null;
|
|
808
|
+
lastSuccessfulAt: string | null;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
interface PlanningBranchEntry {
|
|
812
|
+
id?: string;
|
|
813
|
+
type?: string;
|
|
814
|
+
customType?: string;
|
|
815
|
+
data?: { planPath?: string; runId?: string; artifactDir?: string };
|
|
816
|
+
message?: { role?: string; content?: Array<{ type: string; text?: string }> };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function isPlanningInternalCustomType(customType?: string): boolean {
|
|
820
|
+
return (
|
|
821
|
+
customType === "pi-plans-exec"
|
|
822
|
+
|| customType === "pi-plans-exec-cleared"
|
|
823
|
+
|| customType === "pi-plans-exec-start"
|
|
824
|
+
|| customType === "pi-plans-exec-context"
|
|
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
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function summarizePlanningEntryText(entry: PlanningBranchEntry): string {
|
|
834
|
+
return (entry.message?.content ?? [])
|
|
835
|
+
.filter((part) => part.type === "text")
|
|
836
|
+
.map((part) => part.text ?? "")
|
|
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}`;
|
|
191
858
|
}
|
|
192
859
|
|
|
193
|
-
|
|
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 };
|
|
879
|
+
}
|
|
880
|
+
const next = branchEntries
|
|
881
|
+
.slice(anchorIndex + 1)
|
|
882
|
+
.find((entry) => entry.id && !isPlanningInternalCustomType(entry.customType));
|
|
883
|
+
if (!next?.id) {
|
|
884
|
+
return { id: fallback, qaWindowEntries: [], hasMarker: true };
|
|
885
|
+
}
|
|
886
|
+
const qaWindowEntries = branchEntries
|
|
887
|
+
.slice(startIndex >= 0 ? startIndex + 1 : 0, anchorIndex)
|
|
888
|
+
.filter((entry) => entry.type === "message" && entry.message);
|
|
889
|
+
return { id: next.id, qaWindowEntries, hasMarker: true };
|
|
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);
|
|
898
|
+
}
|
|
899
|
+
if (!lines.length) return null;
|
|
900
|
+
return `${PLANNING_QA_SECTION_HEADER}
|
|
901
|
+
${lines.join("\n")}`;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function resolvePlanningCompactionContext(workdir: string): { runId: string; artifactDir: string } | null {
|
|
905
|
+
const active = readActive(workdir);
|
|
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 };
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function planningCurrentIUsage(ctx: ExtensionContext): { tokens: number; contextWindow: number; eligible?: boolean } | null {
|
|
913
|
+
const usage = ctx.getContextUsage();
|
|
914
|
+
if (!usage || typeof usage.contextWindow !== "number" || usage.contextWindow <= 0) return null;
|
|
915
|
+
const manager = ctx.sessionManager as unknown as { getBranch?: () => CompactionEntryLike[] };
|
|
916
|
+
if (typeof manager.getBranch === "function") {
|
|
917
|
+
try {
|
|
918
|
+
const entries = manager.getBranch();
|
|
919
|
+
const currentI = entries.flatMap((entry) => entryCurrentIMarkers(entry)).at(-1)
|
|
920
|
+
?? entries.map((entry) => compactionCurrentI(entry)).filter((id): id is string => !!id).at(-1);
|
|
921
|
+
if (currentI) {
|
|
922
|
+
const plan = planIAwareCompaction({
|
|
923
|
+
entries,
|
|
924
|
+
currentI,
|
|
925
|
+
contextWindow: usage.contextWindow,
|
|
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
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (typeof usage.tokens !== "number") return null;
|
|
939
|
+
return { tokens: usage.tokens, contextWindow: usage.contextWindow };
|
|
940
|
+
}
|
|
941
|
+
export function shouldTriggerPlanningCompaction(ctx: ExtensionContext): boolean {
|
|
942
|
+
if (getExecution()) return false;
|
|
943
|
+
const ctxWorkdir = ctx.cwd;
|
|
944
|
+
if (!resolvePlanningCompactionContext(ctxWorkdir)) return false;
|
|
945
|
+
const currentUsage = planningCurrentIUsage(ctx);
|
|
946
|
+
if (!currentUsage || currentUsage.eligible === false || !currentIExceedsTrigger(currentUsage.tokens, currentUsage.contextWindow)) return false;
|
|
947
|
+
const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
|
|
948
|
+
const state = session.__planningCompaction;
|
|
949
|
+
if (!state) return true;
|
|
950
|
+
if (state.inFlight || state.resumeGuard || state.cooldownActive) return false;
|
|
951
|
+
return true;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export function consumePlanningCompactionResumeGuard(ctx: ExtensionContext): boolean {
|
|
955
|
+
const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
|
|
956
|
+
if (!session.__planningCompaction?.resumeGuard) return false;
|
|
957
|
+
session.__planningCompaction.resumeGuard = false;
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
export function refreshPlanningCompactionCooldown(ctx: ExtensionContext): void {
|
|
962
|
+
const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
|
|
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
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
export function requestPlanningCompaction(ctx: ExtensionContext): void {
|
|
972
|
+
const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
|
|
973
|
+
const state = (session.__planningCompaction ??= {
|
|
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
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export function buildPlanningCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
|
|
993
|
+
const ctxWorkdir = ctx.cwd;
|
|
994
|
+
const planningCtx = resolvePlanningCompactionContext(ctxWorkdir);
|
|
995
|
+
if (!planningCtx) return null;
|
|
996
|
+
const branchEntries = event.branchEntries as unknown as PlanningBranchEntry[];
|
|
997
|
+
const legacyCut = findPlanningCutEntryId(branchEntries, event.preparation.firstKeptEntryId);
|
|
998
|
+
const markerIds = branchEntries.flatMap((entry) => scanCurrentIMarkers(summarizePlanningEntryText(entry)));
|
|
999
|
+
const currentI = markerIds.at(-1)?.id
|
|
1000
|
+
?? branchEntries.map((entry) => compactionCurrentI(entry)).filter((id): id is string => !!id).at(-1)
|
|
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,
|
|
1064
|
+
reason: event.reason,
|
|
1065
|
+
hasMarker: legacyCut.hasMarker,
|
|
1066
|
+
});
|
|
1067
|
+
return {
|
|
1068
|
+
summary: parts.join("\n\n"),
|
|
1069
|
+
firstKeptEntryId,
|
|
1070
|
+
tokensBefore: event.preparation.tokensBefore,
|
|
1071
|
+
estimatedTokensAfter: iPlan?.metrics.estimatedAfterTokens ?? undefined,
|
|
1072
|
+
details,
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
export function handlePlanningBeforeCompact(
|
|
194
1077
|
pi: ExtensionAPI,
|
|
195
1078
|
ctx: ExtensionContext,
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
)
|
|
199
|
-
if (!
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
1079
|
+
event: SessionBeforeCompactEvent,
|
|
1080
|
+
): SessionBeforeCompactResult | undefined {
|
|
1081
|
+
if (getExecution()) return undefined;
|
|
1082
|
+
if (!resolvePlanningCompactionContext(ctx.cwd)) return undefined;
|
|
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 };
|
|
203
1097
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
1098
|
+
state.inFlight = true;
|
|
1099
|
+
state.lastAttemptReason = event.reason;
|
|
1100
|
+
if (percent !== null && percent < 85) {
|
|
1101
|
+
state.cooldownActive = false;
|
|
1102
|
+
}
|
|
1103
|
+
let compaction: CompactionResult | null;
|
|
1104
|
+
try {
|
|
1105
|
+
compaction = buildPlanningCompactionResult(event, ctx);
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
state.inFlight = false;
|
|
1108
|
+
ctx.ui.notify(`pi-plans: planning compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
|
|
1109
|
+
return undefined;
|
|
1110
|
+
}
|
|
1111
|
+
if (!compaction) {
|
|
1112
|
+
state.inFlight = false;
|
|
1113
|
+
return undefined;
|
|
1114
|
+
}
|
|
1115
|
+
notifyHardFloor(ctx, compaction);
|
|
1116
|
+
return { compaction };
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
export function handlePlanningCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
|
|
1120
|
+
if (getExecution()) return;
|
|
1121
|
+
const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
|
|
1122
|
+
const state = session.__planningCompaction;
|
|
1123
|
+
if (!state) return;
|
|
1124
|
+
state.inFlight = false;
|
|
1125
|
+
state.lastAttemptReason = event.reason;
|
|
1126
|
+
state.cooldownActive = true;
|
|
1127
|
+
state.lastSuccessfulAt = utcNow();
|
|
1128
|
+
state.lastSuccessfulUsagePercent = ctx.getContextUsage()?.percent ?? state.lastSuccessfulUsagePercent;
|
|
1129
|
+
if (!event.willRetry) {
|
|
1130
|
+
state.resumeGuard = true;
|
|
1131
|
+
pi.sendMessage(
|
|
1132
|
+
{
|
|
1133
|
+
customType: PLANNING_RESUME_CUSTOM_TYPE,
|
|
1134
|
+
content: "Continue planning.",
|
|
1135
|
+
display: false,
|
|
1136
|
+
},
|
|
1137
|
+
{ triggerTurn: true },
|
|
1138
|
+
);
|
|
1139
|
+
} else {
|
|
1140
|
+
state.resumeGuard = false;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
export function handlePlanningCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
|
|
1145
|
+
if (getExecution()) return;
|
|
1146
|
+
const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
|
|
1147
|
+
const state = session.__planningCompaction;
|
|
1148
|
+
if (!state) return;
|
|
1149
|
+
const expectedThresholdCancel = event.reason === "threshold" && event.aborted && !state.inFlight;
|
|
1150
|
+
if (expectedThresholdCancel) {
|
|
1151
|
+
state.lastAttemptReason = event.reason;
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
state.inFlight = false;
|
|
1155
|
+
state.resumeGuard = false;
|
|
1156
|
+
state.cooldownActive = false;
|
|
1157
|
+
state.lastAttemptReason = event.reason;
|
|
1158
|
+
ctx.ui.notify(
|
|
1159
|
+
`pi-plans: planning compaction failed (${event.reason}); will try again on the next eligible turn.`,
|
|
1160
|
+
"warning",
|
|
1161
|
+
);
|
|
208
1162
|
}
|
|
209
1163
|
|
|
210
|
-
export function
|
|
1164
|
+
export function filterPlanningResumeMessages<T extends { customType?: string }>(messages: T[]): T[] {
|
|
1165
|
+
return messages.filter((message) => message.customType !== PLANNING_RESUME_CUSTOM_TYPE);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): Promise<void> {
|
|
211
1169
|
if (!execution) return;
|
|
212
|
-
|
|
1170
|
+
resetExecutionCompactionState(ctx);
|
|
1171
|
+
// Final synchronous write: drain any deferred flush and land the last snapshot.
|
|
1172
|
+
pendingExecutionFlush = false;
|
|
1173
|
+
persist(pi);
|
|
213
1174
|
execution = null;
|
|
214
1175
|
pi.appendEntry("pi-plans-exec-cleared", { reason });
|
|
215
1176
|
pi.sendMessage(
|
|
@@ -245,15 +1206,47 @@ export function applyDoneMarkers(text: string): string[] {
|
|
|
245
1206
|
return changed;
|
|
246
1207
|
}
|
|
247
1208
|
|
|
1209
|
+
/**
|
|
1210
|
+
* Apply [I-xxx:implemented|validating] markers from an assistant message.
|
|
1211
|
+
* Unknown I-ids are silently ignored; later markers overwrite earlier ones.
|
|
1212
|
+
* Returns the ids whose state actually changed.
|
|
1213
|
+
*/
|
|
1214
|
+
export function applyImplMarkers(text: string): string[] {
|
|
1215
|
+
if (!execution?.implItems?.length) return [];
|
|
1216
|
+
const known = new Set(execution.implItems.map((impl) => impl.id));
|
|
1217
|
+
execution.implStatus ??= {};
|
|
1218
|
+
const changed: string[] = [];
|
|
1219
|
+
for (const marker of scanImplMarkers(text)) {
|
|
1220
|
+
if (!known.has(marker.id)) continue;
|
|
1221
|
+
const previous = execution.implStatus[marker.id];
|
|
1222
|
+
execution.implStatus[marker.id] = marker.state;
|
|
1223
|
+
if (previous !== marker.state) changed.push(marker.id);
|
|
1224
|
+
}
|
|
1225
|
+
return changed;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
export function applyCurrentIMarker(text: string): boolean {
|
|
1229
|
+
if (!execution?.implItems?.length) return false;
|
|
1230
|
+
const markers = scanCurrentIMarkers(text);
|
|
1231
|
+
const resolved = resolveCurrentI(execution.implItems, markers, execution.currentI);
|
|
1232
|
+
if (!resolved || resolved === execution.currentI) return false;
|
|
1233
|
+
execution.currentI = resolved;
|
|
1234
|
+
return true;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
248
1237
|
export function isExecutionComplete(): boolean {
|
|
249
1238
|
return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
|
|
250
1239
|
}
|
|
251
1240
|
|
|
252
|
-
export function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
1241
|
+
export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
253
1242
|
if (!execution) return;
|
|
1243
|
+
resetExecutionCompactionState(ctx);
|
|
1244
|
+
// Final synchronous write: drain any deferred flush and land the last snapshot.
|
|
1245
|
+
pendingExecutionFlush = false;
|
|
1246
|
+
persist(pi);
|
|
1247
|
+
|
|
254
1248
|
const summary = execution.items.map((item) => `- ✅ \`${item.id}\` ${item.text.split(";")[0]}`).join("\n");
|
|
255
1249
|
const planPath = execution.planPath;
|
|
256
|
-
clearExecutionPanel(ctx);
|
|
257
1250
|
execution = null;
|
|
258
1251
|
pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" });
|
|
259
1252
|
pi.sendMessage(
|
|
@@ -281,14 +1274,19 @@ export function executionContextMessage(): string | null {
|
|
|
281
1274
|
const remaining = execution.items.filter((item) => !item.done);
|
|
282
1275
|
const list =
|
|
283
1276
|
remaining.map((item) => `- \`${item.id}\` ${item.text}`).join("\n") || "(none — report completion now)";
|
|
1277
|
+
const implementationItems = execution.implItems?.length
|
|
1278
|
+
? `\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
|
+
: "";
|
|
284
1280
|
return `[PI-PLANS EXECUTION — write access enabled]
|
|
285
1281
|
Implement the accepted plan at ${execution.planPath} (${execution.items.length - remaining.length}/${execution.items.length} verifier items done).
|
|
286
1282
|
|
|
287
1283
|
Remaining verifier items:
|
|
288
|
-
${list}
|
|
1284
|
+
${list}${implementationItems}
|
|
289
1285
|
|
|
290
1286
|
Execution rules:
|
|
291
1287
|
- 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
|
+
- 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.
|
|
1289
|
+
- For subprocess-backed verification, when a step starts a subprocess and needs its result before verifying, use literal \`waiting for\` with backoff \`5s -> 10s -> 20s -> 40s -> 80s\`, then keep polling at 80s; restart at 5s for each new subprocess.
|
|
292
1290
|
- Simplest implementation that fully meets the item: no speculative abstractions, configuration, or indirection; keep components modular with clearly separated concerns.
|
|
293
1291
|
- Architectural decisions are for the long term: no stopgaps. Do not add backward-compatibility layers, fallbacks, or migrations — remove the obsolete paths this change obsoletes.
|
|
294
1292
|
- Prefer established, well-maintained libraries when they reduce complexity or improve reliability; before writing your own implementation or adding a package, check the project's existing dependencies (docs and types) — never reimplement common functionality without a clear reason.
|
|
@@ -309,7 +1307,9 @@ interface SessionEntry {
|
|
|
309
1307
|
* pi-plans-exec snapshot, then re-scans assistant messages after it for
|
|
310
1308
|
* [DONE:VC-xxx] markers so progress survives restarts.
|
|
311
1309
|
*/
|
|
312
|
-
export function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): void {
|
|
1310
|
+
export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): Promise<void> {
|
|
1311
|
+
pendingExecutionFlush = false; // no flush debt survives a restart
|
|
1312
|
+
resetExecutionCompactionState(ctx);
|
|
313
1313
|
let snapshotIndex = -1;
|
|
314
1314
|
let snapshot: ExecState | null = null;
|
|
315
1315
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
@@ -322,35 +1322,34 @@ export function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entr
|
|
|
322
1322
|
if (entry.type === "custom" && entry.customType === "pi-plans-exec-cleared") {
|
|
323
1323
|
// Execution was explicitly stopped or completed after the last snapshot.
|
|
324
1324
|
execution = null;
|
|
325
|
-
syncExecutionPanel(ctx);
|
|
326
1325
|
updateStatusWidget(ctx);
|
|
327
1326
|
return;
|
|
328
1327
|
}
|
|
329
1328
|
}
|
|
330
1329
|
if (!snapshot) {
|
|
331
1330
|
execution = null;
|
|
332
|
-
syncExecutionPanel(ctx);
|
|
333
1331
|
updateStatusWidget(ctx);
|
|
334
1332
|
return;
|
|
335
1333
|
}
|
|
336
1334
|
// Ignore stale plans whose file vanished.
|
|
337
1335
|
if (!fs.existsSync(snapshot.planPath)) {
|
|
338
1336
|
execution = null;
|
|
339
|
-
syncExecutionPanel(ctx);
|
|
340
1337
|
updateStatusWidget(ctx);
|
|
341
1338
|
return;
|
|
342
1339
|
}
|
|
343
1340
|
execution = {
|
|
344
|
-
|
|
1341
|
+
planPath: snapshot.planPath,
|
|
345
1342
|
items: snapshot.items.map((item) => ({ ...item })),
|
|
1343
|
+
startedAt: snapshot.startedAt,
|
|
346
1344
|
usage: snapshot.usage ?? { inToks: 0, outToks: 0 },
|
|
347
|
-
|
|
1345
|
+
implItems: snapshot.implItems ?? [],
|
|
1346
|
+
implStatus: { ...(snapshot.implStatus ?? {}) },
|
|
1347
|
+
currentI: snapshot.currentI ?? inferCurrentI(snapshot.implItems, snapshot.items, snapshot.implStatus),
|
|
348
1348
|
};
|
|
349
1349
|
for (let i = snapshotIndex + 1; i < entries.length; i++) {
|
|
350
1350
|
const entry = entries[i];
|
|
351
1351
|
if (entry.type === "custom" && entry.customType === "pi-plans-exec-cleared") {
|
|
352
1352
|
execution = null;
|
|
353
|
-
syncExecutionPanel(ctx);
|
|
354
1353
|
break;
|
|
355
1354
|
}
|
|
356
1355
|
const message = entry.message;
|
|
@@ -360,12 +1359,16 @@ export function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entr
|
|
|
360
1359
|
.map((part) => part.text ?? "")
|
|
361
1360
|
.join("\n");
|
|
362
1361
|
applyDoneMarkers(text);
|
|
1362
|
+
applyImplMarkers(text);
|
|
1363
|
+
applyCurrentIMarker(text);
|
|
363
1364
|
}
|
|
364
1365
|
}
|
|
365
1366
|
if (execution) {
|
|
366
1367
|
persist(pi); // refresh snapshot so the next resume has less to rescan
|
|
367
|
-
if (isExecutionComplete())
|
|
1368
|
+
if (isExecutionComplete()) {
|
|
1369
|
+
// Completed during the rescan: restore the planning model on the way out.
|
|
1370
|
+
await completeExecution(pi, ctx);
|
|
1371
|
+
}
|
|
368
1372
|
}
|
|
369
|
-
syncExecutionPanel(ctx);
|
|
370
1373
|
updateStatusWidget(ctx);
|
|
371
1374
|
}
|