pi-plans 0.1.2 → 0.3.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.
Files changed (76) hide show
  1. package/README.md +98 -19
  2. package/index.ts +147 -66
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +21 -3
  5. package/references/state-and-config.md +35 -3
  6. package/scripts/validate.ts +4 -0
  7. package/src/autocomplete.ts +163 -0
  8. package/src/code-graph/commands.ts +437 -0
  9. package/src/code-graph/discovery.ts +118 -0
  10. package/src/code-graph/git.ts +108 -0
  11. package/src/code-graph/identity.ts +59 -0
  12. package/src/code-graph/indexer.ts +281 -0
  13. package/src/code-graph/materialize.ts +166 -0
  14. package/src/code-graph/mode.ts +28 -0
  15. package/src/code-graph/mutations.ts +160 -0
  16. package/src/code-graph/parser.ts +51 -0
  17. package/src/code-graph/parsers/javascript.ts +35 -0
  18. package/src/code-graph/parsers/python.ts +160 -0
  19. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  20. package/src/code-graph/paths.ts +85 -0
  21. package/src/code-graph/prompts.ts +18 -0
  22. package/src/code-graph/resolver.ts +69 -0
  23. package/src/code-graph/runtime.ts +158 -0
  24. package/src/code-graph/schema.ts +135 -0
  25. package/src/code-graph/screening.ts +82 -0
  26. package/src/code-graph/store.ts +278 -0
  27. package/src/code-graph/summary.ts +435 -0
  28. package/src/code-graph/types.ts +163 -0
  29. package/src/compaction.ts +1256 -0
  30. package/src/config-command.ts +326 -0
  31. package/src/exec.ts +519 -625
  32. package/src/plan.ts +37 -0
  33. package/src/query-hook.ts +82 -0
  34. package/src/refine-prompts.ts +50 -0
  35. package/src/refine-ui-helpers.ts +142 -0
  36. package/src/refine-ui-state.ts +144 -0
  37. package/src/refine-ui.ts +430 -0
  38. package/src/state.ts +24 -29
  39. package/src/subagent.ts +299 -70
  40. package/tests/ask-choice.test.ts +263 -0
  41. package/tests/autocomplete.test.ts +147 -0
  42. package/tests/code-graph-apply.test.ts +185 -0
  43. package/tests/code-graph-commands.test.ts +211 -0
  44. package/tests/code-graph-db.test.ts +166 -0
  45. package/tests/code-graph-discovery.test.ts +38 -0
  46. package/tests/code-graph-git.test.ts +94 -0
  47. package/tests/code-graph-index.test.ts +175 -0
  48. package/tests/code-graph-loop.e2e.test.ts +159 -0
  49. package/tests/code-graph-mutations.test.ts +117 -0
  50. package/tests/code-graph-parser.test.ts +85 -0
  51. package/tests/code-graph-rollback.test.ts +100 -0
  52. package/tests/code-graph-summary-batching.test.ts +518 -0
  53. package/tests/code-graph-summary.test.ts +148 -0
  54. package/tests/compaction.test.ts +388 -0
  55. package/tests/config-command.test.ts +255 -0
  56. package/tests/exec.test.ts +751 -422
  57. package/tests/execute-plan.test.ts +65 -0
  58. package/tests/fixtures/code-graph/sample.js +36 -0
  59. package/tests/fixtures/code-graph/sample.py +20 -0
  60. package/tests/fixtures/code-graph/sample.ts +15 -0
  61. package/tests/graph-aware-file-tools.test.ts +411 -0
  62. package/tests/plan.test.ts +11 -1
  63. package/tests/plans.test.ts +6 -5
  64. package/tests/query-hook.test.ts +82 -0
  65. package/tests/refine-prompts.test.ts +67 -2
  66. package/tests/refine-ui.test.ts +392 -0
  67. package/tests/state.test.ts +12 -15
  68. package/tests/subagent.test.ts +120 -0
  69. package/tools/ask-choice.ts +180 -11
  70. package/tools/code-graph.ts +254 -0
  71. package/tools/execute-plan.ts +7 -39
  72. package/tools/graph-aware-file-tools.ts +392 -0
  73. package/tools/plans.ts +84 -18
  74. package/tools/refine.ts +180 -80
  75. package/src/execution-panel.ts +0 -633
  76. package/tests/execution-panel.test.ts +0 -234
@@ -0,0 +1,1256 @@
1
+ /**
2
+ * Deterministic VCC-style compaction helpers for planning and execution.
3
+ * SessionManager remains the owner of session persistence; this module only
4
+ * builds summaries, cut points, stats, and repo-private VCC settings.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
8
+ import * as path from "node:path";
9
+
10
+ export type CompactionReason = "manual" | "threshold" | "overflow";
11
+ export type PiPlansCompactionPhase = "planning" | "execution";
12
+
13
+ export interface PiPlansVccSettings {
14
+ overrideDefaultCompaction: boolean;
15
+ smartKeepTail: boolean;
16
+ continueAfterThresholdCompact: boolean;
17
+ debug: boolean;
18
+ }
19
+
20
+ export const DEFAULT_VCC_SETTINGS: PiPlansVccSettings = {
21
+ overrideDefaultCompaction: true,
22
+ smartKeepTail: true,
23
+ continueAfterThresholdCompact: true,
24
+ debug: false,
25
+ };
26
+
27
+ export const VCC_SETTINGS_FILENAME = "pi-vcc-config.json";
28
+ export const MIN_SMART_TAIL_TOKENS = 5_000;
29
+ export const MAX_SMART_TAIL_TOKENS = 25_000;
30
+ export const OVERSIZED_TAIL_FACTOR = 2.5;
31
+ export const DEFAULT_CHARS_PER_TOKEN = 4;
32
+ export const MIN_CHARS_PER_TOKEN = 2;
33
+ export const MAX_CHARS_PER_TOKEN = 6;
34
+ export const PI_SELF_RESUME_VERSION: readonly [number, number, number] = [0, 84, 4];
35
+ export const PI_VCC_COMPACT_INSTRUCTION = "__pi_vcc__";
36
+
37
+ const INTERNAL_COMPACT_INSTRUCTIONS = new Set([
38
+ "pi-plans execution auto compact",
39
+ "pi-plans planning auto compact",
40
+ ]);
41
+
42
+ export function vccSettingsPath(stateRoot: string): string {
43
+ return path.join(stateRoot, VCC_SETTINGS_FILENAME);
44
+ }
45
+
46
+ function readJsonObject(filePath: string): Record<string, unknown> | null {
47
+ try {
48
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
49
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function atomicWriteJson(filePath: string, data: unknown): void {
56
+ mkdirSync(path.dirname(filePath), { recursive: true });
57
+ const tmp = `${filePath}.tmp`;
58
+ writeFileSync(tmp, `${JSON.stringify(data, null, "\t")}\n`, "utf8");
59
+ renameSync(tmp, filePath);
60
+ }
61
+
62
+ /**
63
+ * Repo-private pi-vcc config scaffold.
64
+ * Missing file -> create defaults; valid file -> fill missing keys; invalid
65
+ * JSON -> no-op so a user file is never clobbered.
66
+ */
67
+ export function scaffoldVccSettings(stateRoot: string): void {
68
+ const filePath = vccSettingsPath(stateRoot);
69
+ try {
70
+ mkdirSync(path.dirname(filePath), { recursive: true });
71
+ if (!existsSync(filePath)) {
72
+ atomicWriteJson(filePath, DEFAULT_VCC_SETTINGS);
73
+ return;
74
+ }
75
+ const parsed = readJsonObject(filePath);
76
+ if (!parsed) return;
77
+ let changed = false;
78
+ const next = { ...parsed };
79
+ for (const [key, value] of Object.entries(DEFAULT_VCC_SETTINGS)) {
80
+ if (!(key in next)) {
81
+ next[key] = value;
82
+ changed = true;
83
+ }
84
+ }
85
+ if (changed) atomicWriteJson(filePath, next);
86
+ } catch {
87
+ // Settings are best-effort; compaction can still use defaults.
88
+ }
89
+ }
90
+
91
+ export function loadVccSettings(stateRoot: string): PiPlansVccSettings {
92
+ const parsed = readJsonObject(vccSettingsPath(stateRoot));
93
+ if (!parsed) return { ...DEFAULT_VCC_SETTINGS };
94
+ return {
95
+ overrideDefaultCompaction: typeof parsed.overrideDefaultCompaction === "boolean" ? parsed.overrideDefaultCompaction : DEFAULT_VCC_SETTINGS.overrideDefaultCompaction,
96
+ smartKeepTail: typeof parsed.smartKeepTail === "boolean" ? parsed.smartKeepTail : DEFAULT_VCC_SETTINGS.smartKeepTail,
97
+ continueAfterThresholdCompact: typeof parsed.continueAfterThresholdCompact === "boolean" ? parsed.continueAfterThresholdCompact : DEFAULT_VCC_SETTINGS.continueAfterThresholdCompact,
98
+ debug: typeof parsed.debug === "boolean" ? parsed.debug : DEFAULT_VCC_SETTINGS.debug,
99
+ };
100
+ }
101
+
102
+ export interface CompactionContentPart {
103
+ type?: string;
104
+ text?: string;
105
+ thinking?: string;
106
+ id?: string;
107
+ name?: string;
108
+ arguments?: Record<string, unknown> | string;
109
+ input?: Record<string, unknown> | string;
110
+ content?: unknown;
111
+ mimeType?: string;
112
+ }
113
+
114
+ export interface CompactionMessage {
115
+ role?: string;
116
+ content?: string | CompactionContentPart[];
117
+ toolCallId?: string;
118
+ toolName?: string;
119
+ details?: Record<string, unknown>;
120
+ isError?: boolean;
121
+ [key: string]: unknown;
122
+ }
123
+
124
+ export interface CompactionEntryLike {
125
+ id?: string;
126
+ type?: string;
127
+ customType?: string;
128
+ message?: CompactionMessage;
129
+ content?: string | CompactionContentPart[];
130
+ data?: Record<string, unknown>;
131
+ details?: Record<string, unknown>;
132
+ firstKeptEntryId?: string;
133
+ tokens?: number;
134
+ timestamp?: number | string;
135
+ [key: string]: unknown;
136
+ }
137
+
138
+ export interface FileOpsLike {
139
+ read?: string[];
140
+ written?: string[];
141
+ edited?: string[];
142
+ readFiles?: string[];
143
+ modifiedFiles?: string[];
144
+ createdFiles?: string[];
145
+ }
146
+
147
+ export interface PiPlansVccPhaseContext {
148
+ phase: PiPlansCompactionPhase;
149
+ runId?: string | null;
150
+ artifactDir?: string | null;
151
+ planPath?: string | null;
152
+ currentI?: string | null;
153
+ remainingVerifierIds?: string[];
154
+ implementationIds?: string[];
155
+ }
156
+
157
+ export interface PiPlansCompactionDetails {
158
+ compactor: "pi-vcc";
159
+ version: number;
160
+ sections: string[];
161
+ sourceMessageCount: number;
162
+ previousSummaryUsed: boolean;
163
+ reason?: CompactionReason;
164
+ willRetry?: boolean;
165
+ phase: PiPlansCompactionPhase;
166
+ stats: VccCompactionStats;
167
+ }
168
+
169
+ export interface VccCompactionStats {
170
+ summarized: number;
171
+ kept: number;
172
+ keptUserTurns: number;
173
+ totalUserTurns: number;
174
+ requestedKeepUserTurns: number;
175
+ keepUserTurnsExplicit: boolean;
176
+ keepFallbackToCompactAll: boolean;
177
+ budgetCut?: BudgetCutKind;
178
+ keptTokensEst: number;
179
+ estimatedSummaryTokens: number;
180
+ estimatedTokensAfter: number;
181
+ smartKeepAdjusted?: boolean;
182
+ smartFromKeep?: number;
183
+ reason?: CompactionReason;
184
+ willRetry?: boolean;
185
+ }
186
+
187
+ export type BudgetCutKind = "no_anchor" | "oversized_tail";
188
+
189
+ interface EntryWithMessage {
190
+ entry: CompactionEntryLike;
191
+ message: CompactionMessage;
192
+ }
193
+
194
+ export type OwnCutCancelReason = "no_live_messages" | "too_few_live_messages";
195
+
196
+ export type OwnCutResult =
197
+ | {
198
+ ok: true;
199
+ messages: CompactionMessage[];
200
+ firstKeptEntryId: string;
201
+ compactAll: boolean;
202
+ keptUserTurns: number;
203
+ totalUserTurns: number;
204
+ requestedKeepUserTurns: number;
205
+ keepFallbackToCompactAll: boolean;
206
+ budgetCut?: BudgetCutKind;
207
+ }
208
+ | { ok: false; reason: OwnCutCancelReason };
209
+
210
+ export type VccCompactionBuildResult =
211
+ | {
212
+ kind: "compaction";
213
+ compaction: {
214
+ summary: string;
215
+ firstKeptEntryId: string;
216
+ tokensBefore: number;
217
+ estimatedTokensAfter?: number;
218
+ details: PiPlansCompactionDetails;
219
+ };
220
+ stats: VccCompactionStats;
221
+ followUpPrompt: string | null;
222
+ settings: PiPlansVccSettings;
223
+ }
224
+ | { kind: "cancel"; message: string; reason: OwnCutCancelReason }
225
+ | { kind: "fallback"; reason: string };
226
+
227
+ function contentParts(message: CompactionMessage | undefined): CompactionContentPart[] {
228
+ if (!message) return [];
229
+ if (typeof message.content === "string") return [{ type: "text", text: message.content }];
230
+ return Array.isArray(message.content) ? message.content : [];
231
+ }
232
+
233
+ export function compactText(text: string, limit = 180): string {
234
+ const normalized = text.replace(/\s+/g, " ").trim();
235
+ if (!normalized) return "";
236
+ if (normalized.length <= limit) return normalized;
237
+ return `${normalized.slice(0, Math.max(0, limit - 1))}...`;
238
+ }
239
+
240
+ export function messageText(message: CompactionMessage | undefined): string {
241
+ return contentParts(message)
242
+ .filter((part) => part.type === "text")
243
+ .map((part) => part.text ?? "")
244
+ .join("\n")
245
+ .trim();
246
+ }
247
+
248
+ const CURRENT_I_RE = /\[(I-\d+):current\]/g;
249
+
250
+ export function scanCurrentIMarkers(text: string): string[] {
251
+ return [...text.matchAll(CURRENT_I_RE)].map((match) => match[1]);
252
+ }
253
+
254
+ export function entryCurrentIMarkers(entry: CompactionEntryLike): string[] {
255
+ return scanCurrentIMarkers(messageText(entry.message));
256
+ }
257
+
258
+ export function compactionCurrentI(entry: CompactionEntryLike): string | undefined {
259
+ if (entry.type !== "compaction") return undefined;
260
+ const raw = entry.details ?? entry.data;
261
+ if (!raw || typeof raw !== "object") return undefined;
262
+ const details = raw as { currentI?: unknown; stats?: { currentI?: unknown }; metrics?: { currentI?: unknown } };
263
+ const currentI = details.currentI ?? details.stats?.currentI ?? details.metrics?.currentI;
264
+ return typeof currentI === "string" && currentI ? currentI : undefined;
265
+ }
266
+
267
+ export function estimateMessageContentChars(content: unknown): number {
268
+ if (typeof content === "string") return content.length;
269
+ if (!Array.isArray(content)) return 0;
270
+ return content.reduce((sum: number, part: CompactionContentPart) => {
271
+ if (!part || typeof part !== "object") return sum;
272
+ switch (part.type) {
273
+ case "text":
274
+ return sum + (typeof part.text === "string" ? part.text.length : 0);
275
+ case "thinking":
276
+ return sum + (typeof part.thinking === "string" ? part.thinking.length : 0);
277
+ case "toolCall": {
278
+ const args = part.arguments ?? part.input;
279
+ return sum + (part.name?.length ?? 0) + safeStringify(args).length;
280
+ }
281
+ case "toolResult":
282
+ return sum + safeStringify(part.content).length;
283
+ case "image":
284
+ return sum + 4_800;
285
+ default:
286
+ return sum + (typeof part.text === "string" ? part.text.length : 0);
287
+ }
288
+ }, 0);
289
+ }
290
+
291
+ function safeStringify(value: unknown): string {
292
+ if (typeof value === "string") return value;
293
+ try {
294
+ return JSON.stringify(value ?? "") ?? "";
295
+ } catch {
296
+ return "";
297
+ }
298
+ }
299
+
300
+ export function estimateTokensFromChars(chars: number, charsPerToken = DEFAULT_CHARS_PER_TOKEN): number {
301
+ return Math.ceil(Math.max(0, chars) / charsPerToken);
302
+ }
303
+
304
+ export function estimateMessageContentTokens(content: unknown, charsPerToken = DEFAULT_CHARS_PER_TOKEN): number {
305
+ return estimateTokensFromChars(estimateMessageContentChars(content), charsPerToken);
306
+ }
307
+
308
+ export function estimateEntryTokens(entry: CompactionEntryLike, charsPerToken = DEFAULT_CHARS_PER_TOKEN): number {
309
+ if (typeof entry.tokens === "number" && Number.isFinite(entry.tokens)) return Math.max(0, entry.tokens);
310
+ return Math.max(1, estimateMessageContentTokens(entry.message?.content, charsPerToken));
311
+ }
312
+
313
+ function clamp(value: number, min: number, max: number): number {
314
+ return Math.min(max, Math.max(min, value));
315
+ }
316
+
317
+ export function calibrateCharsPerToken(sourceChars: number, sourceTokens: number | undefined): number {
318
+ if (!sourceTokens || sourceTokens <= 0 || sourceChars <= 0) return DEFAULT_CHARS_PER_TOKEN;
319
+ const raw = sourceChars / sourceTokens;
320
+ if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_CHARS_PER_TOKEN;
321
+ return clamp(raw, MIN_CHARS_PER_TOKEN, MAX_CHARS_PER_TOKEN);
322
+ }
323
+
324
+ function toLiveMessage(entry: CompactionEntryLike): CompactionMessage | null {
325
+ if (entry.type === "message" && entry.message) return entry.message;
326
+ if (entry.type === "custom_message") {
327
+ return { role: "custom", customType: entry.customType, content: entry.content, display: entry.display };
328
+ }
329
+ if (entry.type === "branch_summary") {
330
+ return { role: "branchSummary", summary: entry.summary, content: undefined };
331
+ }
332
+ return null;
333
+ }
334
+
335
+ function previousFirstKeptId(entry: CompactionEntryLike): string | undefined {
336
+ if (typeof entry.firstKeptEntryId === "string") return entry.firstKeptEntryId;
337
+ const raw = entry.details ?? entry.data;
338
+ if (!raw || typeof raw !== "object") return undefined;
339
+ const details = raw as { firstKeptEntryId?: unknown; metrics?: { firstKeptEntryId?: unknown } };
340
+ const id = details.firstKeptEntryId ?? details.metrics?.firstKeptEntryId;
341
+ return typeof id === "string" ? id : undefined;
342
+ }
343
+
344
+ export function collectLiveMessages(branchEntries: CompactionEntryLike[]): EntryWithMessage[] {
345
+ let lastCompactionIdx = -1;
346
+ let lastKeptId: string | undefined;
347
+ for (let i = branchEntries.length - 1; i >= 0; i--) {
348
+ if (branchEntries[i].type === "compaction") {
349
+ lastCompactionIdx = i;
350
+ lastKeptId = previousFirstKeptId(branchEntries[i]);
351
+ break;
352
+ }
353
+ }
354
+ const hasPriorCompaction = lastCompactionIdx >= 0;
355
+ const hasValidKeptId = !!lastKeptId && branchEntries.some((entry) => entry.id === lastKeptId);
356
+ const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
357
+ const live: EntryWithMessage[] = [];
358
+ if (orphanRecovery) {
359
+ for (let i = lastCompactionIdx + 1; i < branchEntries.length; i++) {
360
+ const message = toLiveMessage(branchEntries[i]);
361
+ if (message) live.push({ entry: branchEntries[i], message });
362
+ }
363
+ return live;
364
+ }
365
+ let foundKept = !lastKeptId;
366
+ for (const entry of branchEntries) {
367
+ if (!foundKept && entry.id === lastKeptId) foundKept = true;
368
+ if (!foundKept || entry.type === "compaction") continue;
369
+ const message = toLiveMessage(entry);
370
+ if (message) live.push({ entry, message });
371
+ }
372
+ return live;
373
+ }
374
+
375
+ function normalizeKeepUserTurns(keepUserTurns: number): number {
376
+ if (!Number.isFinite(keepUserTurns)) return 0;
377
+ return Math.max(0, Math.floor(keepUserTurns));
378
+ }
379
+
380
+ export function buildOwnCut(branchEntries: CompactionEntryLike[], keepUserTurns = 1): OwnCutResult {
381
+ const normalizedKeepUserTurns = normalizeKeepUserTurns(keepUserTurns);
382
+ const liveMessages = collectLiveMessages(branchEntries);
383
+ if (liveMessages.length === 0) return { ok: false, reason: "no_live_messages" };
384
+ if (liveMessages.length <= 2) return { ok: false, reason: "too_few_live_messages" };
385
+ const userIndices = liveMessages.reduce<number[]>((acc, item, index) => {
386
+ if (item.message.role === "user") acc.push(index);
387
+ return acc;
388
+ }, []);
389
+ const compactAll = (keepFallbackToCompactAll: boolean): OwnCutResult => ({
390
+ ok: true,
391
+ messages: liveMessages.map((item) => item.message),
392
+ firstKeptEntryId: "",
393
+ compactAll: true,
394
+ keptUserTurns: 0,
395
+ totalUserTurns: userIndices.length,
396
+ requestedKeepUserTurns: normalizedKeepUserTurns,
397
+ keepFallbackToCompactAll,
398
+ });
399
+ if (normalizedKeepUserTurns <= 0) return compactAll(false);
400
+ const targetUserIdx = userIndices.length - normalizedKeepUserTurns;
401
+ const cutIdx = targetUserIdx >= 0 ? userIndices[targetUserIdx] : -1;
402
+ if (cutIdx <= 0) return compactAll(true);
403
+ return {
404
+ ok: true,
405
+ messages: liveMessages.slice(0, cutIdx).map((item) => item.message),
406
+ firstKeptEntryId: liveMessages[cutIdx].entry.id ?? "",
407
+ compactAll: false,
408
+ keptUserTurns: userIndices.length - targetUserIdx,
409
+ totalUserTurns: userIndices.length,
410
+ requestedKeepUserTurns: normalizedKeepUserTurns,
411
+ keepFallbackToCompactAll: false,
412
+ };
413
+ }
414
+
415
+ function isToolResultRole(role?: string): boolean {
416
+ return role === "toolResult" || role === "tool_result";
417
+ }
418
+
419
+ export function findBudgetCutIndex(live: EntryWithMessage[], maxTokens: number, charsPerToken?: number): number {
420
+ let acc = 0;
421
+ let crossed = -1;
422
+ for (let i = live.length - 1; i >= 0; i--) {
423
+ acc += estimateMessageContentTokens(live[i].message.content, charsPerToken);
424
+ if (acc >= maxTokens) {
425
+ crossed = i;
426
+ break;
427
+ }
428
+ }
429
+ if (crossed < 0) return -1;
430
+ for (let j = Math.max(crossed, 1); j < live.length; j++) {
431
+ if (!isToolResultRole(live[j].message.role)) return j;
432
+ }
433
+ return -1;
434
+ }
435
+
436
+ export function applyTailBudget(
437
+ branchEntries: CompactionEntryLike[],
438
+ cut: OwnCutResult,
439
+ opts: { maxTokens?: number; oversizedFactor?: number; charsPerToken?: number } = {},
440
+ ): OwnCutResult {
441
+ if (!cut.ok) return cut;
442
+ const maxTokens = opts.maxTokens ?? MAX_SMART_TAIL_TOKENS;
443
+ const factor = opts.oversizedFactor ?? OVERSIZED_TAIL_FACTOR;
444
+ const live = collectLiveMessages(branchEntries);
445
+ const budgetResult = (idx: number, budgetCut: BudgetCutKind): OwnCutResult => ({
446
+ ok: true,
447
+ messages: live.slice(0, idx).map((item) => item.message),
448
+ firstKeptEntryId: live[idx].entry.id ?? "",
449
+ compactAll: false,
450
+ keptUserTurns: live.slice(idx).filter((item) => item.message.role === "user").length,
451
+ totalUserTurns: live.filter((item) => item.message.role === "user").length,
452
+ requestedKeepUserTurns: cut.requestedKeepUserTurns,
453
+ keepFallbackToCompactAll: false,
454
+ budgetCut,
455
+ });
456
+ if (cut.compactAll) {
457
+ if (!cut.keepFallbackToCompactAll) return cut;
458
+ const idx = findBudgetCutIndex(live, maxTokens, opts.charsPerToken);
459
+ return idx < 0 ? cut : budgetResult(idx, "no_anchor");
460
+ }
461
+ const tailStart = cut.messages.length;
462
+ let tailTokens = 0;
463
+ for (let i = tailStart; i < live.length; i++) {
464
+ tailTokens += estimateMessageContentTokens(live[i].message.content, opts.charsPerToken);
465
+ }
466
+ if (tailTokens <= maxTokens * factor) return cut;
467
+ const idx = findBudgetCutIndex(live, maxTokens, opts.charsPerToken);
468
+ if (idx <= tailStart) return cut;
469
+ return budgetResult(idx, "oversized_tail");
470
+ }
471
+
472
+ function tailTokensForKeep(branchEntries: CompactionEntryLike[], keepUserTurns: number, charsPerToken?: number): number | null {
473
+ const cut = buildOwnCut(branchEntries, keepUserTurns);
474
+ if (!cut.ok || cut.compactAll) return null;
475
+ const idx = branchEntries.findIndex((entry) => entry.id === cut.firstKeptEntryId);
476
+ if (idx < 0) return null;
477
+ const chars = branchEntries.slice(idx)
478
+ .map((entry) => toLiveMessage(entry))
479
+ .filter((message): message is CompactionMessage => !!message)
480
+ .reduce((sum, message) => sum + estimateMessageContentChars(message.content), 0);
481
+ return estimateTokensFromChars(chars, charsPerToken);
482
+ }
483
+
484
+ export function resolveSmartKeepUserTurns(opts: {
485
+ branchEntries: CompactionEntryLike[];
486
+ requestedKeepUserTurns: number | null;
487
+ explicit: boolean;
488
+ smartKeepTail: boolean;
489
+ minTokens?: number;
490
+ maxTokens?: number;
491
+ charsPerToken?: number;
492
+ }): { keepUserTurns: number; smartAdjusted: boolean; fromKeep: number } {
493
+ const minTokens = opts.minTokens ?? MIN_SMART_TAIL_TOKENS;
494
+ const maxTokens = opts.maxTokens ?? MAX_SMART_TAIL_TOKENS;
495
+ const baseKeep = opts.requestedKeepUserTurns ?? 1;
496
+ if (opts.explicit || !opts.smartKeepTail) return { keepUserTurns: baseKeep, smartAdjusted: false, fromKeep: baseKeep };
497
+ const baseTokens = tailTokensForKeep(opts.branchEntries, baseKeep, opts.charsPerToken);
498
+ if (baseTokens == null || baseTokens > minTokens) return { keepUserTurns: baseKeep, smartAdjusted: false, fromKeep: baseKeep };
499
+ const baseCut = buildOwnCut(opts.branchEntries, baseKeep);
500
+ const totalUserTurns = baseCut.ok ? baseCut.totalUserTurns : 0;
501
+ let selected = baseKeep;
502
+ for (let keep = baseKeep + 1; keep <= totalUserTurns; keep++) {
503
+ const tokens = tailTokensForKeep(opts.branchEntries, keep, opts.charsPerToken);
504
+ if (tokens == null || tokens > maxTokens) break;
505
+ selected = keep;
506
+ }
507
+ return { keepUserTurns: selected, smartAdjusted: selected !== baseKeep, fromKeep: baseKeep };
508
+ }
509
+
510
+ const KEEP_TOKEN_RE = /^keep:(\d+)$/;
511
+
512
+ function parseKeepUserTurns(raw: string): number {
513
+ const value = Number(raw);
514
+ return Number.isSafeInteger(value) ? value : Number.MAX_SAFE_INTEGER;
515
+ }
516
+
517
+ export function parseKeepAndPrompt(args?: string): { followUpPrompt: string; keepUserTurns: number | null; keepUserTurnsExplicit: boolean } {
518
+ const trimmed = args?.trim() ?? "";
519
+ if (!trimmed) return { followUpPrompt: "", keepUserTurns: null, keepUserTurnsExplicit: false };
520
+ const startMatch = trimmed.match(/^keep:(\d+)(?:\s+|$)([\s\S]*)$/);
521
+ if (startMatch) {
522
+ return { followUpPrompt: startMatch[2].trim(), keepUserTurns: parseKeepUserTurns(startMatch[1]), keepUserTurnsExplicit: true };
523
+ }
524
+ const parts = trimmed.split(/\s+/);
525
+ const endMatch = parts.at(-1)?.match(KEEP_TOKEN_RE);
526
+ if (endMatch) {
527
+ return {
528
+ followUpPrompt: trimmed.slice(0, trimmed.length - parts[parts.length - 1].length).trim(),
529
+ keepUserTurns: parseKeepUserTurns(endMatch[1]),
530
+ keepUserTurnsExplicit: true,
531
+ };
532
+ }
533
+ return { followUpPrompt: trimmed, keepUserTurns: null, keepUserTurnsExplicit: false };
534
+ }
535
+
536
+ export function parseCompactionInstructions(customInstructions?: string): {
537
+ isPiVcc: boolean;
538
+ isInternalPiPlans: boolean;
539
+ keepUserTurns: number;
540
+ keepUserTurnsExplicit: boolean;
541
+ followUpPrompt: string | null;
542
+ } {
543
+ const trimmed = customInstructions?.trim();
544
+ if (trimmed && INTERNAL_COMPACT_INSTRUCTIONS.has(trimmed)) {
545
+ return { isPiVcc: false, isInternalPiPlans: true, keepUserTurns: 1, keepUserTurnsExplicit: false, followUpPrompt: null };
546
+ }
547
+ if (trimmed === PI_VCC_COMPACT_INSTRUCTION) {
548
+ return { isPiVcc: true, isInternalPiPlans: false, keepUserTurns: 1, keepUserTurnsExplicit: false, followUpPrompt: null };
549
+ }
550
+ const keepPrefix = `${PI_VCC_COMPACT_INSTRUCTION} `;
551
+ if (trimmed?.startsWith(keepPrefix)) {
552
+ const parsed = parseKeepAndPrompt(trimmed.slice(keepPrefix.length));
553
+ return {
554
+ isPiVcc: true,
555
+ isInternalPiPlans: false,
556
+ keepUserTurns: parsed.keepUserTurns ?? 1,
557
+ keepUserTurnsExplicit: parsed.keepUserTurnsExplicit,
558
+ followUpPrompt: null,
559
+ };
560
+ }
561
+ const parsed = parseKeepAndPrompt(customInstructions);
562
+ return {
563
+ isPiVcc: false,
564
+ isInternalPiPlans: false,
565
+ keepUserTurns: parsed.keepUserTurns ?? 1,
566
+ keepUserTurnsExplicit: parsed.keepUserTurnsExplicit,
567
+ followUpPrompt: parsed.followUpPrompt || null,
568
+ };
569
+ }
570
+
571
+ type NormalizedBlock =
572
+ | { kind: "user"; text: string; sourceIndex?: number }
573
+ | { kind: "assistant"; text: string; sourceIndex?: number }
574
+ | { kind: "tool_call"; name: string; args: Record<string, unknown>; sourceIndex?: number }
575
+ | { kind: "tool_result"; name: string; text: string; sourceIndex?: number }
576
+ | { kind: "bash"; command: string; output: string; exitCode?: number; sourceIndex?: number };
577
+
578
+ const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
579
+ const CTRL_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
580
+
581
+ function sanitize(text: string): string {
582
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(ANSI_RE, "").replace(CTRL_RE, "");
583
+ }
584
+
585
+ function textOf(content: unknown): string {
586
+ if (typeof content === "string") return content;
587
+ if (!Array.isArray(content)) return "";
588
+ return content
589
+ .filter((part: CompactionContentPart) => part.type === "text")
590
+ .map((part: CompactionContentPart) => part.text ?? "")
591
+ .join("\n");
592
+ }
593
+
594
+ function normalizeOne(message: CompactionMessage, index: number): NormalizedBlock[] {
595
+ if (message.role === "user") {
596
+ const blocks: NormalizedBlock[] = [];
597
+ const text = sanitize(textOf(message.content));
598
+ if (text) blocks.push({ kind: "user", text, sourceIndex: index });
599
+ for (const part of contentParts(message)) {
600
+ if (part.type === "image") blocks.push({ kind: "user", text: `[image: ${part.mimeType ?? "unknown"}]`, sourceIndex: index });
601
+ }
602
+ return blocks.length ? blocks : [{ kind: "user", text: "", sourceIndex: index }];
603
+ }
604
+ if (message.role === "bashExecution") {
605
+ return [{ kind: "bash", command: String(message.command ?? ""), output: String(message.output ?? ""), exitCode: typeof message.exitCode === "number" ? message.exitCode : undefined, sourceIndex: index }];
606
+ }
607
+ if (isToolResultRole(message.role)) {
608
+ return [{ kind: "tool_result", name: String(message.toolName ?? "tool"), text: sanitize(textOf(message.content)), sourceIndex: index }];
609
+ }
610
+ if (message.role === "assistant") {
611
+ if (!message.content) return [];
612
+ if (typeof message.content === "string") return [{ kind: "assistant", text: sanitize(message.content), sourceIndex: index }];
613
+ const blocks: NormalizedBlock[] = [];
614
+ for (const part of message.content) {
615
+ if (part.type === "text") blocks.push({ kind: "assistant", text: sanitize(part.text ?? ""), sourceIndex: index });
616
+ else if (part.type === "toolCall") {
617
+ const args = typeof part.arguments === "object" && part.arguments !== null ? part.arguments : {};
618
+ blocks.push({ kind: "tool_call", name: part.name ?? "tool", args: args as Record<string, unknown>, sourceIndex: index });
619
+ }
620
+ }
621
+ return blocks;
622
+ }
623
+ return [];
624
+ }
625
+
626
+ function normalize(messages: CompactionMessage[]): NormalizedBlock[] {
627
+ return messages.flatMap((message, index) => normalizeOne(message, index));
628
+ }
629
+
630
+ function nonEmptyLines(text: string): string[] {
631
+ return text.split("\n").map((line) => line.trim()).filter(Boolean);
632
+ }
633
+
634
+ function clip(text: string, max = 200): string {
635
+ if (text.length <= max) return text;
636
+ const cut = text.lastIndexOf(" ", max);
637
+ const end = cut > max * 0.6 ? cut : max;
638
+ return text.slice(0, end).trimEnd();
639
+ }
640
+
641
+ function clipSentence(text: string, max = 200): string {
642
+ if (text.length <= max) return text;
643
+ const window = text.slice(0, max);
644
+ const matches = [...window.matchAll(/[.!?](?:\s|$)/g)];
645
+ if (matches.length) {
646
+ const end = (matches.at(-1)?.index ?? 0) + 1;
647
+ if (end >= max * 0.5) return text.slice(0, end);
648
+ }
649
+ return clip(text, max);
650
+ }
651
+
652
+ const TASK_RE = /\b(fix|implement|add|create|build|refactor|debug|investigate|update|remove|delete|migrate|deploy|test|write|set up|plan|execute)\b/i;
653
+ const SCOPE_CHANGE_RE = /\b(instead|actually|change of plan|forget that|new task|switch to|now I want|pivot|let'?s do|stop .* and)\b/i;
654
+ const NOISE_SHORT_RE = /^(ok|yes|no|sure|yeah|yep|go|hi|hey|thx|thanks|y|n|k)\s*[.!?]*$/i;
655
+ const NON_GOAL_RE = /^\s*[\[│├└─╭╰]|```|^\s*(function |const |let |var |import |export |class )|^(https?:|file:|\/[A-Za-z])/;
656
+
657
+ function extractGoals(blocks: NormalizedBlock[]): string[] {
658
+ const goals: string[] = [];
659
+ let latestScopeChange: string[] | null = null;
660
+ for (const block of blocks) {
661
+ if (block.kind !== "user") continue;
662
+ const lines = nonEmptyLines(block.text)
663
+ .map((line) => line.replace(/^\s*(?:[-*+]|\d+\.)\s+/, "").trim())
664
+ .filter((line) => line.length > 5 && line.length <= 200 && !NOISE_SHORT_RE.test(line) && !NON_GOAL_RE.test(line));
665
+ if (!lines.length) continue;
666
+ if (!goals.length) {
667
+ goals.push(...lines.slice(0, 6));
668
+ continue;
669
+ }
670
+ const leading = block.text.slice(0, 200);
671
+ if (SCOPE_CHANGE_RE.test(leading) || (TASK_RE.test(leading) && lines[0].length > 15)) {
672
+ latestScopeChange = lines.slice(0, 2).map((line) => clip(line, 200));
673
+ }
674
+ }
675
+ if (latestScopeChange?.length) goals.push("[Scope change]", ...latestScopeChange);
676
+ return goals.slice(0, 8);
677
+ }
678
+
679
+ const PREF_PATTERNS = [
680
+ /\bprefer(?:s|red|ring)?\s+\w/i,
681
+ /\bdon'?t want\b/i,
682
+ /\balways (?:use|do|run|prefer|keep|make|format|write|add|set|put|prefix|start|include|append)\b/i,
683
+ /\bnever (?:use|do|run|push|commit|write|ignore|add|set|put|remove|delete|include|deploy)\b/i,
684
+ /\bplease (?:use|avoid|keep|make|don'?t|do not|format|write)\b/i,
685
+ /\b(?:style|format|language|naming)\s*[:=]\s*\S/i,
686
+ ];
687
+
688
+ function extractPreferences(blocks: NormalizedBlock[], goals: string[]): string[] {
689
+ const seen = new Set(goals.map((goal) => goal.trim().toLowerCase()));
690
+ const prefs: string[] = [];
691
+ for (const block of blocks) {
692
+ if (block.kind !== "user") continue;
693
+ for (const line of nonEmptyLines(block.text)) {
694
+ if (line.length < 5 || line.length > 200 || line.endsWith("?")) continue;
695
+ if (!PREF_PATTERNS.some((pattern) => pattern.test(line))) continue;
696
+ const clipped = clip(line, 200);
697
+ const key = clipped.trim().toLowerCase();
698
+ if (seen.has(key)) continue;
699
+ seen.add(key);
700
+ prefs.push(clipped);
701
+ break;
702
+ }
703
+ }
704
+ return prefs.slice(0, 10);
705
+ }
706
+
707
+ const BLOCKER_RE = /\b(fail(ed|s|ure|ing)?|broken|cannot|can't|won't work|does not work|doesn't work|still (broken|failing|wrong)|blocked|blocker|not (fixed|resolved|working)|crash(es|ed|ing)?)\b/i;
708
+
709
+ function extractOutstandingContext(blocks: NormalizedBlock[]): string[] {
710
+ const items: string[] = [];
711
+ for (const block of blocks.slice(-20)) {
712
+ if (block.kind !== "assistant" && block.kind !== "user") continue;
713
+ for (const line of nonEmptyLines(block.text)) {
714
+ if (!BLOCKER_RE.test(line) || line.length < 15) continue;
715
+ const clipped = block.kind === "user" ? `[user] ${clipSentence(line, 150)}` : clipSentence(line, 150);
716
+ if (!items.includes(clipped)) items.push(clipped);
717
+ break;
718
+ }
719
+ }
720
+ return items.slice(0, 5);
721
+ }
722
+
723
+ const PATH_KEYS = ["path", "file", "filePath", "file_path", "filename", "url"];
724
+
725
+ function extractPath(args: Record<string, unknown>): string | null {
726
+ for (const key of PATH_KEYS) {
727
+ const value = args[key];
728
+ if (typeof value === "string" && value.trim()) return value.trim();
729
+ }
730
+ return null;
731
+ }
732
+
733
+ function formatFileActivity(blocks: NormalizedBlock[], fileOps?: FileOpsLike): string[] {
734
+ const read = new Set(fileOps?.readFiles ?? fileOps?.read ?? []);
735
+ const modified = new Set(fileOps?.modifiedFiles ?? [...(fileOps?.written ?? []), ...(fileOps?.edited ?? [])]);
736
+ const created = new Set(fileOps?.createdFiles ?? []);
737
+ for (const block of blocks) {
738
+ if (block.kind !== "tool_call") continue;
739
+ const name = block.name.toLowerCase();
740
+ const file = extractPath(block.args);
741
+ if (!file) continue;
742
+ if (["read", "read_file", "view"].includes(name)) read.add(file);
743
+ if (["edit", "write", "edit_file", "write_file", "multiedit", "quick_edit", "target_edit", "apply_patch"].includes(name)) modified.add(file);
744
+ if (["write", "write_file"].includes(name)) created.add(file);
745
+ }
746
+ for (const file of modified) created.delete(file);
747
+ const cap = (set: Set<string>, limit: number) => {
748
+ const values = [...set].filter(Boolean);
749
+ return values.length <= limit ? values.join(", ") : `${values.slice(0, limit).join(", ")} (+${values.length - limit} more)`;
750
+ };
751
+ const lines: string[] = [];
752
+ if (modified.size) lines.push(`Modified: ${cap(modified, 10)}`);
753
+ if (created.size) lines.push(`Created: ${cap(created, 10)}`);
754
+ if (read.size) lines.push(`Read: ${cap(read, 10)}`);
755
+ return lines;
756
+ }
757
+
758
+ const COMMIT_MSG_RE = /git\s+commit[^\n]*?-m\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')/;
759
+ const HASH_RE = /\b([0-9a-f]{7,12})\b/;
760
+
761
+ function extractCommits(blocks: NormalizedBlock[]): string[] {
762
+ const commits: string[] = [];
763
+ for (let index = 0; index < blocks.length; index++) {
764
+ const block = blocks[index];
765
+ if (block.kind !== "tool_call" || block.name !== "bash") continue;
766
+ const command = typeof block.args.command === "string" ? block.args.command : "";
767
+ if (!/\bgit\s+commit\b/.test(command)) continue;
768
+ const match = command.match(COMMIT_MSG_RE);
769
+ const message = (match?.[1] ?? match?.[2] ?? "").replace(/\\"/g, '"').replace(/\\'/g, "'").trim().split(/\\n|\n/)[0];
770
+ if (!message) continue;
771
+ let hash = "";
772
+ for (let j = index + 1; j < Math.min(blocks.length, index + 3); j++) {
773
+ if (blocks[j].kind !== "tool_result") continue;
774
+ hash = blocks[j].text.match(/\[\S+\s+([0-9a-f]{7,12})\]/)?.[1] ?? blocks[j].text.match(HASH_RE)?.[1] ?? "";
775
+ if (hash) break;
776
+ }
777
+ const line = hash ? `${hash}: ${message}` : message;
778
+ if (!commits.includes(line)) commits.push(line);
779
+ }
780
+ return commits.slice(-8);
781
+ }
782
+
783
+ function compressBash(raw: string): string {
784
+ const lines = raw.split("\n").map((line) => line.trim()).filter(Boolean);
785
+ const meaningful = lines
786
+ .filter((line) => !/^(?:set\s+[-+]|cd\s+\S+$|export\s+\w+=|(?:source|\.)\s+\S+|pwd$|true$|:$|#)/.test(line))
787
+ .map((line) => line.replace(/^cd\s+\S+\s*&&\s*/, "").trim())
788
+ .filter(Boolean);
789
+ const command = (meaningful.length ? meaningful : lines).join("; ");
790
+ return command.length > 240 ? `${command.slice(0, 237)}...` : command;
791
+ }
792
+
793
+ function toolOneLiner(name: string, args: Record<string, unknown>): string {
794
+ const file = extractPath(args);
795
+ if (file) return `* ${name} "${file}"`;
796
+ if (/^bash$/i.test(name)) return `* ${name} "${compressBash(String(args.command ?? args.description ?? ""))}"`;
797
+ if (typeof args.query === "string") return `* ${name} "${clip(args.query, 60)}"`;
798
+ return `* ${name}`;
799
+ }
800
+
801
+ function briefBlockText(block: NormalizedBlock): string {
802
+ switch (block.kind) {
803
+ case "user":
804
+ return `[user]\n${clip(block.text.replace(/\s+/g, " ").trim(), 256)}${block.sourceIndex != null ? ` (#${block.sourceIndex})` : ""}`;
805
+ case "assistant":
806
+ return `[assistant]\n${clip(block.text.replace(/^\s*(?:hmm|wait|actually|oh|okay|ok|well|so)[,.!\s-]+/i, "").trim(), 600)}${block.sourceIndex != null ? ` (#${block.sourceIndex})` : ""}`;
807
+ case "tool_call":
808
+ return `[assistant]\n${toolOneLiner(block.name, block.args)}${block.sourceIndex != null ? ` (#${block.sourceIndex})` : ""}`;
809
+ case "bash":
810
+ return `[user]\n$ ${compressBash(block.command)}${block.sourceIndex != null ? ` (#${block.sourceIndex})` : ""}`;
811
+ case "tool_result":
812
+ return "";
813
+ }
814
+ }
815
+
816
+ const EDIT_TOOL_RE = /^(edit|write|multiedit|quick_edit|target_edit|apply_patch)$/i;
817
+ const READ_TOOL_RE = /^(read|glob|grep|ls|find|semantic_query|semantic_grep|semantic_show)$/i;
818
+ const TEST_COMMAND_RE = /\b(?:bun|npm|pnpm|yarn|node|pytest|cargo|go|mvn|gradle)\b[^\n]*(?:test|spec|check|lint|build|tsc)/i;
819
+
820
+ function scoreBlock(block: NormalizedBlock, index: number, total: number, fileOps?: FileOpsLike): number {
821
+ let score = total <= 1 ? 0 : Math.round((index / (total - 1)) * 12);
822
+ if (block.kind === "user") score += 18;
823
+ if (block.kind === "assistant") score += 10;
824
+ if (block.kind === "tool_call") {
825
+ if (EDIT_TOOL_RE.test(block.name)) score += 34;
826
+ else if (/^bash$/i.test(block.name) && TEST_COMMAND_RE.test(String(block.args.command ?? ""))) score += 26;
827
+ else if (READ_TOOL_RE.test(block.name)) score += 6;
828
+ else score += 12;
829
+ const file = extractPath(block.args);
830
+ if (file && [...(fileOps?.written ?? []), ...(fileOps?.edited ?? []), ...(fileOps?.modifiedFiles ?? [])].includes(file)) score += 18;
831
+ if (file && [...(fileOps?.read ?? []), ...(fileOps?.readFiles ?? [])].includes(file)) score += 6;
832
+ }
833
+ if (block.kind === "bash") {
834
+ score += 8;
835
+ if (block.exitCode != null && block.exitCode !== 0) score += 24;
836
+ if (TEST_COMMAND_RE.test(block.command)) score += 22;
837
+ }
838
+ return score;
839
+ }
840
+
841
+ function selectRankedBriefBlocks(blocks: NormalizedBlock[], fileOps?: FileOpsLike, maxChars = 4_400, maxCharsCeiling = 8_000, charsPerBlock = 60): NormalizedBlock[] {
842
+ const effectiveMaxChars = Math.round(Math.min(maxCharsCeiling, Math.max(maxChars, charsPerBlock * blocks.length)));
843
+ const selected = new Set<number>();
844
+ let usedChars = 0;
845
+ const addIfFits = (index: number): void => {
846
+ if (selected.has(index) || blocks[index].kind === "tool_result") return;
847
+ const rendered = briefBlockText(blocks[index]);
848
+ if (!rendered || usedChars + rendered.length > effectiveMaxChars) return;
849
+ selected.add(index);
850
+ usedChars += rendered.length + 1;
851
+ };
852
+ for (let i = blocks.length - 1; i >= Math.max(0, blocks.length - 16); i--) addIfFits(i);
853
+ const ranked = blocks.map((block, index) => ({ index, score: scoreBlock(block, index, blocks.length, fileOps) }))
854
+ .sort((a, b) => b.score - a.score || b.index - a.index);
855
+ for (const item of ranked) {
856
+ if (selected.size >= 80) break;
857
+ addIfFits(item.index);
858
+ }
859
+ return [...selected].sort((a, b) => a - b).map((index) => blocks[index]);
860
+ }
861
+
862
+ function stringifyBrief(blocks: NormalizedBlock[]): string {
863
+ const lines: string[] = [];
864
+ let lastHeader = "";
865
+ for (const block of blocks) {
866
+ const rendered = briefBlockText(block);
867
+ if (!rendered) continue;
868
+ const [header, ...body] = rendered.split("\n");
869
+ if (header !== lastHeader) {
870
+ if (lines.length && !(header === "[assistant]" && lastHeader === "[assistant]" && body.every((line) => line.startsWith("* ")))) lines.push("");
871
+ lines.push(header);
872
+ lastHeader = header;
873
+ }
874
+ lines.push(...body);
875
+ }
876
+ return lines.join("\n");
877
+ }
878
+
879
+ function capBrief(text: string, maxLines = 120): string {
880
+ const lines = text.split("\n");
881
+ if (lines.length <= maxLines) return text;
882
+ const kept = lines.slice(-maxLines);
883
+ const firstHeader = kept.findIndex((line) => /^\[.+\]/.test(line));
884
+ const clean = firstHeader > 0 ? kept.slice(firstHeader) : kept;
885
+ return `...(${lines.length - clean.length} earlier lines omitted)\n\n${clean.join("\n")}`;
886
+ }
887
+
888
+ const HEADER_NAMES = ["Session Goal", "Files And Changes", "Commits", "Outstanding Context", "User Preferences"] as const;
889
+ const SUMMARY_SEPARATOR = "\n\n---\n\n";
890
+
891
+ function section(title: typeof HEADER_NAMES[number], items: string[]): string {
892
+ const body = items.length ? items.map((item) => `- ${item}`).join("\n") : "- (none)";
893
+ return `[${title}]\n${body}`;
894
+ }
895
+
896
+ function sectionOf(text: string, header: string): string {
897
+ const tag = `[${header}]`;
898
+ const start = text.indexOf(tag);
899
+ if (start < 0) return "";
900
+ const after = text.slice(start);
901
+ const nextSection = HEADER_NAMES
902
+ .filter((candidate) => candidate !== header)
903
+ .map((candidate) => after.indexOf(`[${candidate}]`))
904
+ .filter((index) => index > 0);
905
+ const nextSep = after.indexOf(SUMMARY_SEPARATOR);
906
+ const candidates = [...nextSection, ...(nextSep > 0 ? [nextSep] : [])].sort((a, b) => a - b);
907
+ const end = candidates[0];
908
+ return (end ? after.slice(0, end) : after).trim();
909
+ }
910
+
911
+ function briefOf(text: string): string {
912
+ const idx = text.indexOf(SUMMARY_SEPARATOR);
913
+ return idx < 0 ? "" : text.slice(idx + SUMMARY_SEPARATOR.length).trim();
914
+ }
915
+
916
+ function sectionLines(sectionText: string): string[] {
917
+ return sectionText.split("\n").slice(1).map((line) => line.trim()).filter((line) => line && line !== "- (none)");
918
+ }
919
+
920
+ function mergeFileLines(prev: string[], fresh: string[]): string[] {
921
+ const categories = ["Modified", "Created", "Read"];
922
+ const merged: Record<string, Set<string>> = { Modified: new Set(), Created: new Set(), Read: new Set() };
923
+ for (const line of [...prev, ...fresh]) {
924
+ for (const category of categories) {
925
+ const prefix = `- ${category}: `;
926
+ if (!line.startsWith(prefix)) continue;
927
+ const rest = line.slice(prefix.length).replace(/\s*\(\+\d+ more\)\s*$/, "");
928
+ for (const item of rest.split(",")) {
929
+ const value = item.trim();
930
+ if (value) merged[category].add(value);
931
+ }
932
+ }
933
+ }
934
+ for (const item of merged.Modified) merged.Created.delete(item);
935
+ const cap = (set: Set<string>, limit: number) => {
936
+ const values = [...set];
937
+ return values.length <= limit ? values.join(", ") : `${values.slice(0, limit).join(", ")} (+${values.length - limit} more)`;
938
+ };
939
+ const lines: string[] = [];
940
+ if (merged.Modified.size) lines.push(`- Modified: ${cap(merged.Modified, 10)}`);
941
+ if (merged.Created.size) lines.push(`- Created: ${cap(merged.Created, 10)}`);
942
+ if (merged.Read.size) lines.push(`- Read: ${cap(merged.Read, 10)}`);
943
+ return lines;
944
+ }
945
+
946
+ function mergeSections(previousSummary: string | undefined, freshSections: Record<typeof HEADER_NAMES[number], string[]>): Record<typeof HEADER_NAMES[number], string[]> {
947
+ const merged = { ...freshSections };
948
+ if (!previousSummary) return merged;
949
+ for (const header of HEADER_NAMES) {
950
+ const prevLines = sectionLines(sectionOf(previousSummary, header));
951
+ if (!prevLines.length) continue;
952
+ const freshLines = freshSections[header].map((line) => `- ${line}`);
953
+ const combined = header === "Files And Changes"
954
+ ? mergeFileLines(prevLines, freshLines)
955
+ : [...new Set([...prevLines, ...freshLines])].slice(header === "User Preferences" ? -15 : -8);
956
+ merged[header] = combined.map((line) => line.replace(/^-\s+/, ""));
957
+ }
958
+ return merged;
959
+ }
960
+
961
+ function wrapLongLines(text: string, maxChars = 120): string {
962
+ const wrapped: string[] = [];
963
+ for (const line of text.split("\n")) {
964
+ let remaining = line;
965
+ const indent = line.match(/^\s*(?:[-*]\s+|\d+\.\s+)?/)?.[0] ?? "";
966
+ const continuationIndent = indent ? " ".repeat(Math.min(indent.length, 8)) : "";
967
+ let prefix = "";
968
+ while (prefix.length + remaining.length > maxChars) {
969
+ const available = Math.max(20, maxChars - prefix.length);
970
+ let splitAt = remaining.lastIndexOf(" ", available);
971
+ if (splitAt < Math.floor(available * 0.5)) splitAt = available;
972
+ wrapped.push(prefix + remaining.slice(0, splitAt).trimEnd());
973
+ remaining = remaining.slice(splitAt).trimStart();
974
+ prefix = continuationIndent;
975
+ }
976
+ wrapped.push(prefix + remaining);
977
+ }
978
+ return wrapped.join("\n");
979
+ }
980
+
981
+ function phaseContextLines(context?: PiPlansVccPhaseContext): Partial<Record<typeof HEADER_NAMES[number], string[]>> {
982
+ if (!context) return {};
983
+ const sessionGoal: string[] = [];
984
+ const outstandingContext: string[] = [];
985
+ if (context.phase === "execution") {
986
+ sessionGoal.push(context.planPath ? `Execute accepted plan ${context.planPath}` : "Execute the accepted pi-plans plan");
987
+ if (context.currentI) outstandingContext.push(`Current implementation item: ${context.currentI}`);
988
+ if (context.remainingVerifierIds?.length) outstandingContext.push(`Remaining verifier items: ${context.remainingVerifierIds.slice(0, 12).join(", ")}`);
989
+ if (context.implementationIds?.length) outstandingContext.push(`Implementation items: ${context.implementationIds.slice(0, 16).join(", ")}`);
990
+ } else {
991
+ sessionGoal.push(context.runId ? `Continue active planning run ${context.runId}` : "Continue active pi-plans planning");
992
+ if (context.planPath) outstandingContext.push(`Latest plan path from session: ${context.planPath}`);
993
+ if (context.artifactDir) outstandingContext.push(`Planning artifact directory from session: ${context.artifactDir}`);
994
+ if (context.currentI) outstandingContext.push(`Current implementation marker observed during planning: ${context.currentI}`);
995
+ }
996
+ return { "Session Goal": sessionGoal, "Outstanding Context": outstandingContext };
997
+ }
998
+
999
+ function legacyPreviousSummaryLine(previousSummary?: string | null): string | null {
1000
+ if (!previousSummary?.trim()) return null;
1001
+ if (HEADER_NAMES.some((header) => previousSummary.includes(`[${header}]`))) return null;
1002
+ const stripped = previousSummary.replace(/#+\s*/g, "").replace(/\s+/g, " ").trim();
1003
+ return stripped ? `Previous compact summary: ${compactText(stripped, 260)}` : null;
1004
+ }
1005
+
1006
+ function objectLike(value: unknown): Record<string, unknown> | null {
1007
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
1008
+ }
1009
+
1010
+ function legacyReadRecordLine(value: unknown): string | null {
1011
+ const record = objectLike(value);
1012
+ if (!record) return null;
1013
+ if (typeof record.formatted === "string" && record.formatted.trim()) return compactText(record.formatted, 260);
1014
+ if (typeof record.path !== "string" || !record.path.trim()) return null;
1015
+ let range = typeof record.range === "string" && record.range.trim() ? record.range.trim() : "unknown";
1016
+ const start = record.lineStart;
1017
+ const end = record.lineEnd;
1018
+ if (range === "unknown" && (typeof start === "number" || start === "unknown") && (typeof end === "number" || end === "unknown")) {
1019
+ range = start === "unknown" || end === "unknown" ? "unknown" : `${start}-${end}`;
1020
+ }
1021
+ const summary = typeof record.summary === "string" && record.summary.trim()
1022
+ ? compactText(record.summary, 160)
1023
+ : "legacy read record";
1024
+ return `Read: ${record.path.trim()} line ${range} Extracted information summary: ${summary}`;
1025
+ }
1026
+
1027
+ function legacyCompactionContext(branchEntries: CompactionEntryLike[]): { filesAndChanges: string[]; outstandingContext: string[] } {
1028
+ const filesAndChanges: string[] = [];
1029
+ const outstandingContext: string[] = [];
1030
+ for (const entry of branchEntries) {
1031
+ if (entry.type !== "compaction") continue;
1032
+ const details = objectLike(entry.details ?? entry.data);
1033
+ if (!details) continue;
1034
+ const readRecords = Array.isArray(details.readRecords) ? details.readRecords : [];
1035
+ for (const record of readRecords) {
1036
+ const line = legacyReadRecordLine(record);
1037
+ if (line && !filesAndChanges.includes(line)) filesAndChanges.push(line);
1038
+ }
1039
+ const metrics = objectLike(details.metrics);
1040
+ const hardFloorReason = metrics?.hardFloorReason;
1041
+ if (typeof hardFloorReason === "string" && hardFloorReason.trim()) {
1042
+ const line = `Previous compaction hard floor: ${compactText(hardFloorReason, 180)}`;
1043
+ if (!outstandingContext.includes(line)) outstandingContext.push(line);
1044
+ }
1045
+ }
1046
+ return {
1047
+ filesAndChanges: filesAndChanges.slice(-10),
1048
+ outstandingContext: outstandingContext.slice(-5),
1049
+ };
1050
+ }
1051
+
1052
+ export function compilePiPlansVccSummary(input: {
1053
+ messages: CompactionMessage[];
1054
+ previousSummary?: string | null;
1055
+ fileOps?: FileOpsLike;
1056
+ phaseContext?: PiPlansVccPhaseContext;
1057
+ legacyFilesAndChanges?: string[];
1058
+ legacyOutstandingContext?: string[];
1059
+ charsPerToken?: number;
1060
+ }): { summary: string; sections: string[]; sourceMessageCount: number } {
1061
+ const blocks = normalize(input.messages).filter((block) => block.kind !== "user" || block.text.trim());
1062
+ const goals = extractGoals(blocks);
1063
+ const prefs = extractPreferences(blocks, goals);
1064
+ const phase = phaseContextLines(input.phaseContext);
1065
+ const outstanding = [...(input.legacyOutstandingContext ?? []), ...extractOutstandingContext(blocks)];
1066
+ const legacy = legacyPreviousSummaryLine(input.previousSummary);
1067
+ if (legacy) outstanding.unshift(legacy);
1068
+ const briefBlocks = selectRankedBriefBlocks(
1069
+ blocks,
1070
+ input.fileOps,
1071
+ Math.round(1_100 * (input.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN)),
1072
+ Math.round(2_000 * (input.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN)),
1073
+ Math.round(15 * (input.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN)),
1074
+ );
1075
+ const freshSections: Record<typeof HEADER_NAMES[number], string[]> = {
1076
+ "Session Goal": [...(phase["Session Goal"] ?? []), ...goals],
1077
+ "Files And Changes": [...formatFileActivity(blocks, input.fileOps), ...(input.legacyFilesAndChanges ?? [])],
1078
+ "Commits": extractCommits(blocks),
1079
+ "Outstanding Context": [...(phase["Outstanding Context"] ?? []), ...outstanding],
1080
+ "User Preferences": prefs,
1081
+ };
1082
+ const mergedSections = mergeSections(input.previousSummary ?? undefined, freshSections);
1083
+ const freshBrief = stringifyBrief(briefBlocks);
1084
+ const previousBrief = input.previousSummary ? briefOf(input.previousSummary) : "";
1085
+ const brief = capBrief(previousBrief ? `${previousBrief}\n\n${freshBrief}` : freshBrief);
1086
+ const headers = HEADER_NAMES.map((header) => section(header, mergedSections[header])).join("\n\n");
1087
+ const summary = wrapLongLines(brief ? `${headers}${SUMMARY_SEPARATOR}${brief}` : headers);
1088
+ return { summary, sections: [...HEADER_NAMES], sourceMessageCount: input.messages.length };
1089
+ }
1090
+
1091
+ function fileOpsFromPreparation(fileOps?: FileOpsLike): FileOpsLike {
1092
+ return {
1093
+ readFiles: fileOps?.readFiles ?? fileOps?.read ?? [],
1094
+ modifiedFiles: fileOps?.modifiedFiles ?? [...(fileOps?.written ?? []), ...(fileOps?.edited ?? [])],
1095
+ createdFiles: fileOps?.createdFiles ?? [],
1096
+ };
1097
+ }
1098
+
1099
+ const REASON_MESSAGES: Record<OwnCutCancelReason, string> = {
1100
+ no_live_messages: "pi-vcc: Nothing to compact (no live messages)",
1101
+ too_few_live_messages: "pi-vcc: Too few messages to compact",
1102
+ };
1103
+
1104
+ function writeDebug(settings: PiPlansVccSettings, data: Record<string, unknown>): void {
1105
+ if (!settings.debug) return;
1106
+ try {
1107
+ writeFileSync("/tmp/pi-vcc-debug.json", JSON.stringify(data, null, 2));
1108
+ } catch {
1109
+ // Debug snapshots are best-effort.
1110
+ }
1111
+ }
1112
+
1113
+ export function buildPiPlansVccCompaction(options: {
1114
+ branchEntries: CompactionEntryLike[];
1115
+ preparation: { firstKeptEntryId?: string; tokensBefore?: number; previousSummary?: string | null; fileOps?: FileOpsLike };
1116
+ customInstructions?: string;
1117
+ reason?: CompactionReason;
1118
+ willRetry?: boolean;
1119
+ settings: PiPlansVccSettings;
1120
+ phaseContext: PiPlansVccPhaseContext;
1121
+ }): VccCompactionBuildResult {
1122
+ const { branchEntries, preparation, settings, phaseContext } = options;
1123
+ const parsed = parseCompactionInstructions(options.customInstructions);
1124
+ if (!parsed.isPiVcc && !parsed.isInternalPiPlans && !settings.overrideDefaultCompaction) {
1125
+ return { kind: "fallback", reason: "override-disabled" };
1126
+ }
1127
+ const calibrationCut = buildOwnCut(branchEntries, 0);
1128
+ const calibrationMessageChars = calibrationCut.ok
1129
+ ? calibrationCut.messages.reduce((sum, message) => sum + estimateMessageContentChars(message.content), 0)
1130
+ : 0;
1131
+ const charsPerToken = calibrateCharsPerToken(
1132
+ calibrationMessageChars + (preparation.previousSummary?.length ?? 0),
1133
+ preparation.tokensBefore,
1134
+ );
1135
+ const smartKeep = resolveSmartKeepUserTurns({
1136
+ branchEntries,
1137
+ requestedKeepUserTurns: parsed.keepUserTurnsExplicit ? parsed.keepUserTurns : null,
1138
+ explicit: parsed.keepUserTurnsExplicit,
1139
+ smartKeepTail: settings.smartKeepTail,
1140
+ charsPerToken,
1141
+ });
1142
+ let cut = buildOwnCut(branchEntries, smartKeep.keepUserTurns);
1143
+ if (cut.ok && !parsed.keepUserTurnsExplicit) {
1144
+ cut = applyTailBudget(branchEntries, cut, { charsPerToken });
1145
+ }
1146
+ if (!cut.ok) {
1147
+ const fallbackToCore = !parsed.isPiVcc && !parsed.isInternalPiPlans && (options.reason === "overflow" || options.willRetry === true);
1148
+ writeDebug(settings, {
1149
+ cancelled: !fallbackToCore,
1150
+ fallbackToCore,
1151
+ reason: cut.reason,
1152
+ compaction: { reason: options.reason, willRetry: options.willRetry },
1153
+ phase: phaseContext.phase,
1154
+ branchEntryCount: branchEntries.length,
1155
+ });
1156
+ return fallbackToCore ? { kind: "fallback", reason: cut.reason } : { kind: "cancel", message: REASON_MESSAGES[cut.reason], reason: cut.reason };
1157
+ }
1158
+ const fileOps = fileOpsFromPreparation(preparation.fileOps);
1159
+ const legacyContext = legacyCompactionContext(branchEntries);
1160
+ const compiled = compilePiPlansVccSummary({
1161
+ messages: cut.messages,
1162
+ previousSummary: preparation.previousSummary,
1163
+ fileOps,
1164
+ phaseContext,
1165
+ legacyFilesAndChanges: legacyContext.filesAndChanges,
1166
+ legacyOutstandingContext: legacyContext.outstandingContext,
1167
+ charsPerToken,
1168
+ });
1169
+ const live = collectLiveMessages(branchEntries);
1170
+ const cutIndex = cut.firstKeptEntryId ? live.findIndex((item) => item.entry.id === cut.firstKeptEntryId) : -1;
1171
+ const keptLive = cutIndex >= 0 ? live.slice(cutIndex) : [];
1172
+ const keptTokensEst = keptLive.reduce((sum, item) => sum + estimateMessageContentTokens(item.message.content, charsPerToken), 0);
1173
+ const estimatedSummaryTokens = estimateTokensFromChars(compiled.summary.length, charsPerToken);
1174
+ const stats: VccCompactionStats = {
1175
+ summarized: cut.messages.length,
1176
+ kept: keptLive.length,
1177
+ keptUserTurns: cut.keptUserTurns,
1178
+ totalUserTurns: cut.totalUserTurns,
1179
+ requestedKeepUserTurns: cut.requestedKeepUserTurns,
1180
+ keepUserTurnsExplicit: parsed.keepUserTurnsExplicit,
1181
+ keepFallbackToCompactAll: cut.keepFallbackToCompactAll,
1182
+ budgetCut: cut.budgetCut,
1183
+ keptTokensEst,
1184
+ estimatedSummaryTokens,
1185
+ estimatedTokensAfter: keptTokensEst + estimatedSummaryTokens,
1186
+ smartKeepAdjusted: smartKeep.smartAdjusted,
1187
+ smartFromKeep: smartKeep.fromKeep,
1188
+ reason: options.reason,
1189
+ willRetry: options.willRetry,
1190
+ };
1191
+ const details: PiPlansCompactionDetails = {
1192
+ compactor: "pi-vcc",
1193
+ version: 1,
1194
+ sections: compiled.sections,
1195
+ sourceMessageCount: compiled.sourceMessageCount,
1196
+ previousSummaryUsed: Boolean(preparation.previousSummary),
1197
+ reason: options.reason,
1198
+ willRetry: options.willRetry,
1199
+ phase: phaseContext.phase,
1200
+ stats,
1201
+ };
1202
+ writeDebug(settings, {
1203
+ usedOwnCut: true,
1204
+ phase: phaseContext.phase,
1205
+ budgetCut: cut.budgetCut,
1206
+ compaction: { reason: options.reason, willRetry: options.willRetry },
1207
+ messagesToSummarize: cut.messages.length,
1208
+ firstKeptEntryId: cut.firstKeptEntryId,
1209
+ tokensBefore: preparation.tokensBefore,
1210
+ charsPerToken,
1211
+ sections: compiled.sections,
1212
+ });
1213
+ return {
1214
+ kind: "compaction",
1215
+ compaction: {
1216
+ summary: compiled.summary,
1217
+ firstKeptEntryId: cut.firstKeptEntryId,
1218
+ tokensBefore: preparation.tokensBefore ?? 0,
1219
+ estimatedTokensAfter: stats.estimatedTokensAfter,
1220
+ details,
1221
+ },
1222
+ stats,
1223
+ followUpPrompt: parsed.followUpPrompt,
1224
+ settings,
1225
+ };
1226
+ }
1227
+
1228
+ function formatTokens(tokens: number): string {
1229
+ return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(Math.max(0, Math.round(tokens)));
1230
+ }
1231
+
1232
+ export function formatVccCompactionStats(stats: VccCompactionStats): string {
1233
+ if (stats.budgetCut) {
1234
+ const reason = stats.budgetCut === "no_anchor" ? "no user anchor" : "oversized tail";
1235
+ return `pi-vcc: kept ~${formatTokens(stats.keptTokensEst)} tok tail (mid-turn cut, ${reason}), summarized ${stats.summarized}.`;
1236
+ }
1237
+ const notes = [`summarized ${stats.summarized}`];
1238
+ if (stats.smartKeepAdjusted) notes.push(`smart-keep ${stats.smartFromKeep ?? 1}->${stats.requestedKeepUserTurns}`);
1239
+ return `pi-vcc: kept ${stats.keptUserTurns}/${stats.totalUserTurns} turns, ~${formatTokens(stats.keptTokensEst)} tok (${notes.join(", ")}).`;
1240
+ }
1241
+
1242
+ function parseVersionCore(version: unknown): [number, number, number] | null {
1243
+ if (typeof version !== "string") return null;
1244
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim());
1245
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
1246
+ }
1247
+
1248
+ export function shouldScheduleAutoContinue(settingEnabled: boolean, piVersion: unknown): boolean {
1249
+ if (!settingEnabled) return false;
1250
+ const running = parseVersionCore(piVersion);
1251
+ if (!running) return false;
1252
+ for (let i = 0; i < 3; i++) {
1253
+ if (running[i] !== PI_SELF_RESUME_VERSION[i]) return running[i] < PI_SELF_RESUME_VERSION[i];
1254
+ }
1255
+ return false;
1256
+ }