pi-plans 0.2.0 → 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 (65) hide show
  1. package/README.md +74 -21
  2. package/index.ts +115 -9
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +18 -3
  5. package/references/state-and-config.md +34 -2
  6. package/scripts/validate.ts +4 -0
  7. package/src/code-graph/commands.ts +437 -0
  8. package/src/code-graph/discovery.ts +118 -0
  9. package/src/code-graph/git.ts +108 -0
  10. package/src/code-graph/identity.ts +59 -0
  11. package/src/code-graph/indexer.ts +281 -0
  12. package/src/code-graph/materialize.ts +166 -0
  13. package/src/code-graph/mode.ts +28 -0
  14. package/src/code-graph/mutations.ts +160 -0
  15. package/src/code-graph/parser.ts +51 -0
  16. package/src/code-graph/parsers/javascript.ts +35 -0
  17. package/src/code-graph/parsers/python.ts +160 -0
  18. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  19. package/src/code-graph/paths.ts +85 -0
  20. package/src/code-graph/prompts.ts +18 -0
  21. package/src/code-graph/resolver.ts +69 -0
  22. package/src/code-graph/runtime.ts +158 -0
  23. package/src/code-graph/schema.ts +135 -0
  24. package/src/code-graph/screening.ts +82 -0
  25. package/src/code-graph/store.ts +278 -0
  26. package/src/code-graph/summary.ts +435 -0
  27. package/src/code-graph/types.ts +163 -0
  28. package/src/compaction.ts +1125 -371
  29. package/src/config-command.ts +326 -0
  30. package/src/exec.ts +356 -686
  31. package/src/refine-prompts.ts +50 -0
  32. package/src/refine-ui-helpers.ts +71 -18
  33. package/src/refine-ui-state.ts +87 -21
  34. package/src/refine-ui.ts +210 -102
  35. package/src/state.ts +19 -6
  36. package/src/subagent.ts +163 -61
  37. package/tests/ask-choice.test.ts +263 -0
  38. package/tests/autocomplete.test.ts +6 -1
  39. package/tests/code-graph-apply.test.ts +185 -0
  40. package/tests/code-graph-commands.test.ts +211 -0
  41. package/tests/code-graph-db.test.ts +166 -0
  42. package/tests/code-graph-discovery.test.ts +38 -0
  43. package/tests/code-graph-git.test.ts +94 -0
  44. package/tests/code-graph-index.test.ts +175 -0
  45. package/tests/code-graph-loop.e2e.test.ts +159 -0
  46. package/tests/code-graph-mutations.test.ts +117 -0
  47. package/tests/code-graph-parser.test.ts +85 -0
  48. package/tests/code-graph-rollback.test.ts +100 -0
  49. package/tests/code-graph-summary-batching.test.ts +518 -0
  50. package/tests/code-graph-summary.test.ts +148 -0
  51. package/tests/compaction.test.ts +371 -57
  52. package/tests/config-command.test.ts +255 -0
  53. package/tests/exec.test.ts +665 -241
  54. package/tests/fixtures/code-graph/sample.js +36 -0
  55. package/tests/fixtures/code-graph/sample.py +20 -0
  56. package/tests/fixtures/code-graph/sample.ts +15 -0
  57. package/tests/graph-aware-file-tools.test.ts +411 -0
  58. package/tests/refine-prompts.test.ts +67 -2
  59. package/tests/refine-ui.test.ts +337 -72
  60. package/tests/subagent.test.ts +26 -20
  61. package/tools/ask-choice.ts +158 -11
  62. package/tools/code-graph.ts +254 -0
  63. package/tools/graph-aware-file-tools.ts +392 -0
  64. package/tools/plans.ts +84 -1
  65. package/tools/refine.ts +61 -15
package/src/compaction.ts CHANGED
@@ -1,14 +1,114 @@
1
1
  /**
2
- * Pure, bounded history policy helpers used by the planning and execution
3
- * compaction hooks. SessionManager remains the owner of persistence.
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.
4
5
  */
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
+
6
102
  export interface CompactionContentPart {
7
103
  type?: string;
8
104
  text?: string;
105
+ thinking?: string;
9
106
  id?: string;
10
107
  name?: string;
11
- arguments?: Record<string, unknown>;
108
+ arguments?: Record<string, unknown> | string;
109
+ input?: Record<string, unknown> | string;
110
+ content?: unknown;
111
+ mimeType?: string;
12
112
  }
13
113
 
14
114
  export interface CompactionMessage {
@@ -18,6 +118,7 @@ export interface CompactionMessage {
18
118
  toolName?: string;
19
119
  details?: Record<string, unknown>;
20
120
  isError?: boolean;
121
+ [key: string]: unknown;
21
122
  }
22
123
 
23
124
  export interface CompactionEntryLike {
@@ -25,79 +126,115 @@ export interface CompactionEntryLike {
25
126
  type?: string;
26
127
  customType?: string;
27
128
  message?: CompactionMessage;
129
+ content?: string | CompactionContentPart[];
28
130
  data?: Record<string, unknown>;
29
131
  details?: Record<string, unknown>;
30
- /** Test fixtures and callers may provide a native estimate. */
132
+ firstKeptEntryId?: string;
31
133
  tokens?: number;
134
+ timestamp?: number | string;
135
+ [key: string]: unknown;
32
136
  }
33
137
 
34
- export interface ReadRecord {
35
- path: string;
36
- lineStart: number | "unknown";
37
- lineEnd: number | "unknown";
38
- range: string;
39
- summary: string;
40
- key: string;
41
- formatted: string;
42
- }
43
-
44
- export interface ImplementationSlice {
45
- id: string | null;
46
- entries: CompactionEntryLike[];
47
- current: boolean;
48
- }
49
-
50
- export interface CompactionMetrics {
51
- contextWindow: number | null;
52
- tokensBefore: number;
53
- currentITokens: number;
54
- summaryTokens: number;
55
- keptSuffixTokens: number;
56
- estimatedAfterTokens: number | null;
57
- targetRatio: number;
58
- currentI: string | null;
59
- firstKeptEntryId: string | null;
60
- targetMet: boolean;
61
- hardFloorReason: string | null;
62
- }
63
-
64
- export interface ICompactionPlan {
65
- currentI: string | null;
66
- currentITokens: number;
67
- currentStartIndex: number;
68
- firstKeptEntryIndex: number | null;
69
- firstKeptEntryId: string | null;
70
- summaryEntries: CompactionEntryLike[];
71
- keptEntries: CompactionEntryLike[];
72
- slices: ImplementationSlice[];
73
- readRecords: ReadRecord[];
74
- metrics: CompactionMetrics;
138
+ export interface FileOpsLike {
139
+ read?: string[];
140
+ written?: string[];
141
+ edited?: string[];
142
+ readFiles?: string[];
143
+ modifiedFiles?: string[];
144
+ createdFiles?: string[];
75
145
  }
76
146
 
77
- const CURRENT_I_RE = /\[(I-\d+):current\]/g;
78
- const TOOL_RESULT_ROLES = new Set(["toolResult", "tool_result"]);
79
- const INTERNAL_CUSTOM_TYPES = new Set([
80
- "pi-plans-exec",
81
- "pi-plans-exec-cleared",
82
- "pi-plans-exec-start",
83
- "pi-plans-exec-context",
84
- "pi-plans-exec-resume",
85
- "pi-plans-run-start",
86
- "pi-plans-plan-written",
87
- "pi-plans-plan-resume",
88
- ]);
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 };
89
226
 
90
227
  function contentParts(message: CompactionMessage | undefined): CompactionContentPart[] {
91
228
  if (!message) return [];
92
229
  if (typeof message.content === "string") return [{ type: "text", text: message.content }];
93
- return message.content ?? [];
230
+ return Array.isArray(message.content) ? message.content : [];
94
231
  }
95
232
 
96
233
  export function compactText(text: string, limit = 180): string {
97
234
  const normalized = text.replace(/\s+/g, " ").trim();
98
235
  if (!normalized) return "";
99
- if (normalized.length < limit) return normalized;
100
- return `${normalized.slice(0, Math.max(0, limit - 1))}…`;
236
+ if (normalized.length <= limit) return normalized;
237
+ return `${normalized.slice(0, Math.max(0, limit - 1))}...`;
101
238
  }
102
239
 
103
240
  export function messageText(message: CompactionMessage | undefined): string {
@@ -108,6 +245,8 @@ export function messageText(message: CompactionMessage | undefined): string {
108
245
  .trim();
109
246
  }
110
247
 
248
+ const CURRENT_I_RE = /\[(I-\d+):current\]/g;
249
+
111
250
  export function scanCurrentIMarkers(text: string): string[] {
112
251
  return [...text.matchAll(CURRENT_I_RE)].map((match) => match[1]);
113
252
  }
@@ -116,387 +255,1002 @@ export function entryCurrentIMarkers(entry: CompactionEntryLike): string[] {
116
255
  return scanCurrentIMarkers(messageText(entry.message));
117
256
  }
118
257
 
119
- export function isToolResultEntry(entry: CompactionEntryLike): boolean {
120
- return TOOL_RESULT_ROLES.has(entry.message?.role ?? "") || entry.type === "tool_result";
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);
121
289
  }
122
290
 
123
- function toolCallIds(entry: CompactionEntryLike): string[] {
124
- if (entry.message?.role !== "assistant") return [];
125
- return contentParts(entry.message)
126
- .filter((part) => part.type === "toolCall" && typeof part.id === "string")
127
- .map((part) => part.id as string);
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
+ }
128
298
  }
129
299
 
130
- function toolResultId(entry: CompactionEntryLike): string | undefined {
131
- return entry.message?.toolCallId;
300
+ export function estimateTokensFromChars(chars: number, charsPerToken = DEFAULT_CHARS_PER_TOKEN): number {
301
+ return Math.ceil(Math.max(0, chars) / charsPerToken);
132
302
  }
133
303
 
134
- function isInternalEntry(entry: CompactionEntryLike): boolean {
135
- return entry.type === "custom" && INTERNAL_CUSTOM_TYPES.has(entry.customType ?? "");
304
+ export function estimateMessageContentTokens(content: unknown, charsPerToken = DEFAULT_CHARS_PER_TOKEN): number {
305
+ return estimateTokensFromChars(estimateMessageContentChars(content), charsPerToken);
136
306
  }
137
307
 
138
- export function estimateEntryTokens(entry: CompactionEntryLike): number {
308
+ export function estimateEntryTokens(entry: CompactionEntryLike, charsPerToken = DEFAULT_CHARS_PER_TOKEN): number {
139
309
  if (typeof entry.tokens === "number" && Number.isFinite(entry.tokens)) return Math.max(0, entry.tokens);
140
- if (isInternalEntry(entry)) return 0;
141
- const message = entry.message;
142
- if (!message) return 0;
143
- let text = messageText(message);
144
- if (message.role === "assistant") {
145
- for (const part of contentParts(message)) {
146
- if (part.type === "toolCall") {
147
- text += ` ${part.name ?? "tool"} ${safeJson(part.arguments)}`;
148
- }
149
- }
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 };
150
328
  }
151
- if (message.role === "toolResult") {
152
- text += ` ${message.toolName ?? "tool"} ${safeJson(message.details)}`;
329
+ if (entry.type === "branch_summary") {
330
+ return { role: "branchSummary", summary: entry.summary, content: undefined };
153
331
  }
154
- return Math.max(1, Math.ceil(text.length / 4));
332
+ return null;
155
333
  }
156
334
 
157
- function safeJson(value: unknown): string {
158
- try {
159
- return value === undefined ? "" : JSON.stringify(value);
160
- } catch {
161
- return "[unserializable]";
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;
162
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;
163
373
  }
164
374
 
165
- function entryHasToolCall(entry: CompactionEntryLike, id: string): boolean {
166
- return toolCallIds(entry).includes(id);
375
+ function normalizeKeepUserTurns(keepUserTurns: number): number {
376
+ if (!Number.isFinite(keepUserTurns)) return 0;
377
+ return Math.max(0, Math.floor(keepUserTurns));
167
378
  }
168
379
 
169
- /**
170
- * Return the first entry to keep for a requested boundary. Tool results always
171
- * move the boundary back to their matching assistant call, preserving the
172
- * call/result pair as one indivisible context unit.
173
- */
174
- export function legalFirstKeptEntryIndex(entries: CompactionEntryLike[], requestedIndex: number): number | null {
175
- if (!entries.length) return null;
176
- let index = Math.max(0, Math.min(entries.length - 1, Math.floor(requestedIndex)));
177
- if (!isToolResultEntry(entries[index])) return index;
178
- const resultId = toolResultId(entries[index]);
179
- if (resultId) {
180
- for (let i = index - 1; i >= 0; i--) {
181
- if (entryHasToolCall(entries[i], resultId)) return i;
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;
182
427
  }
183
428
  }
184
- while (index > 0 && isToolResultEntry(entries[index])) index -= 1;
185
- return isToolResultEntry(entries[index]) ? null : index;
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;
186
434
  }
187
435
 
188
- export function legalFirstKeptEntryId(entries: CompactionEntryLike[], requestedIndex: number, fallback: string): string {
189
- const index = legalFirstKeptEntryIndex(entries, requestedIndex);
190
- return index !== null && entries[index]?.id ? entries[index].id as string : fallback;
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");
191
470
  }
192
471
 
193
- function markerStarts(entries: CompactionEntryLike[], knownIds?: Set<string>): Array<{ index: number; id: string }> {
194
- const starts: Array<{ index: number; id: string }> = [];
195
- for (let index = 0; index < entries.length; index++) {
196
- for (const id of entryCurrentIMarkers(entries[index])) {
197
- if (knownIds && !knownIds.has(id)) continue;
198
- starts.push({ index, id });
199
- }
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;
200
506
  }
201
- return starts;
507
+ return { keepUserTurns: selected, smartAdjusted: selected !== baseKeep, fromKeep: baseKeep };
202
508
  }
203
509
 
204
- export function compactionCurrentI(entry: CompactionEntryLike): string | undefined {
205
- if (entry.type !== "compaction") return undefined;
206
- const raw = entry.details ?? entry.data;
207
- if (!raw || typeof raw !== "object") return undefined;
208
- const currentI = (raw as { currentI?: unknown }).currentI;
209
- return typeof currentI === "string" && currentI ? currentI : undefined;
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;
210
515
  }
211
516
 
212
- function latestCompactionCurrentIStart(entries: CompactionEntryLike[], currentI: string | undefined): number | undefined {
213
- if (!currentI) return undefined;
214
- for (let index = entries.length - 1; index >= 0; index--) {
215
- if (compactionCurrentI(entries[index]) === currentI) return index + 1;
216
- }
217
- return undefined;
218
- }
219
-
220
- export function sliceHistoryByImplementation(
221
- entries: CompactionEntryLike[],
222
- currentI?: string | null,
223
- knownIds?: Iterable<string>,
224
- ): { slices: ImplementationSlice[]; currentStartIndex: number; currentI: string | null } {
225
- const known = knownIds ? new Set(knownIds) : undefined;
226
- const starts = markerStarts(entries, known);
227
- const effectiveCurrent = currentI && (!known || known.has(currentI))
228
- ? currentI
229
- : starts.at(-1)?.id ?? null;
230
- const markerStart = starts.findLast((start) => start.id === effectiveCurrent)?.index;
231
- const compactionStart = latestCompactionCurrentIStart(entries, effectiveCurrent);
232
- const currentStart = Math.max(markerStart ?? 0, compactionStart ?? 0);
233
- const slices: ImplementationSlice[] = [];
234
- if (starts.length && starts[0].index > 0) {
235
- slices.push({ id: null, entries: entries.slice(0, starts[0].index), current: false });
236
- }
237
- for (let i = 0; i < starts.length; i++) {
238
- const start = starts[i];
239
- const end = starts[i + 1]?.index ?? entries.length;
240
- const prior = slices.find((slice) => slice.id === start.id && !slice.current);
241
- if (prior) prior.entries.push(...entries.slice(start.index, end));
242
- else slices.push({ id: start.id, entries: entries.slice(start.index, end), current: start.id === effectiveCurrent && i === starts.findLastIndex((candidate) => candidate.id === effectiveCurrent) });
243
- }
244
- if (!starts.length && entries.length) {
245
- if (currentStart > 0 && currentStart < entries.length) {
246
- slices.push({ id: null, entries: entries.slice(0, currentStart), current: false });
247
- slices.push({ id: effectiveCurrent, entries: entries.slice(currentStart), current: true });
248
- } else {
249
- slices.push({ id: null, entries: [...entries], current: true });
250
- }
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 };
251
549
  }
252
- if (effectiveCurrent && currentStart > 0) {
253
- for (const slice of slices) {
254
- if (slice.id === effectiveCurrent) slice.current = false;
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 });
255
601
  }
256
- const hasExactCurrentSlice = slices.some((slice) => slice.id === effectiveCurrent && slice.entries[0] === entries[currentStart]);
257
- if (!hasExactCurrentSlice && currentStart < entries.length) {
258
- slices.push({ id: effectiveCurrent, entries: entries.slice(currentStart), current: true });
259
- } else {
260
- const exact = slices.find((slice) => slice.id === effectiveCurrent && slice.entries[0] === entries[currentStart]);
261
- if (exact) exact.current = true;
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
+ }
262
620
  }
621
+ return blocks;
263
622
  }
264
- return { slices, currentStartIndex: currentStart, currentI: effectiveCurrent };
623
+ return [];
624
+ }
625
+
626
+ function normalize(messages: CompactionMessage[]): NormalizedBlock[] {
627
+ return messages.flatMap((message, index) => normalizeOne(message, index));
265
628
  }
266
629
 
267
- function lineRangeFrom(value: Record<string, unknown> | undefined, resultText: string): [number | "unknown", number | "unknown"] {
268
- const start = numberValue(value?.lineStart ?? value?.startLine ?? value?.line_start);
269
- const end = numberValue(value?.lineEnd ?? value?.endLine ?? value?.line_end);
270
- if (start !== undefined && end !== undefined) return [start, end];
271
- const offset = numberValue(value?.offset);
272
- const limit = numberValue(value?.limit);
273
- if (limit !== undefined && limit > 0) {
274
- const first = offset !== undefined && offset > 0 ? offset : 1;
275
- return [first, first + limit - 1];
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);
276
648
  }
277
- const match = resultText.match(/\b(?:lines?|line)\s*(\d+)\s*[-–]\s*(\d+)\b/i);
278
- if (match) return [Number(match[1]), Number(match[2])];
279
- return ["unknown", "unknown"];
649
+ return clip(text, max);
280
650
  }
281
651
 
282
- function numberValue(value: unknown): number | undefined {
283
- if (typeof value === "number" && Number.isFinite(value)) return Math.max(1, Math.floor(value));
284
- if (typeof value === "string" && /^\d+$/.test(value)) return Math.max(1, Number(value));
285
- return undefined;
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);
286
677
  }
287
678
 
288
- function resultText(entry: CompactionEntryLike): string {
289
- const message = entry.message;
290
- if (!message) return "";
291
- return contentParts(message)
292
- .filter((part) => part.type === "text")
293
- .map((part) => part.text ?? "")
294
- .join("\n");
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);
295
721
  }
296
722
 
297
- function readCall(entry: CompactionEntryLike): { id: string; path: string; args: Record<string, unknown> } | null {
298
- if (entry.message?.role !== "assistant") return null;
299
- for (const part of contentParts(entry.message)) {
300
- if (part.type !== "toolCall" || part.name !== "read" || !part.id) continue;
301
- return { id: part.id, path: String(part.arguments?.path ?? "unknown"), args: part.arguments ?? {} };
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();
302
729
  }
303
730
  return null;
304
731
  }
305
732
 
306
- function boundedReadSummary(text: string): string {
307
- const normalized = text.replace(/\s+/g, " ").trim();
308
- if (!normalized) return "no textual extraction";
309
- const limit = 160;
310
- const clipped = normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized;
311
- return `${clipped} [bounded]`;
312
- }
313
-
314
- export function formatReadRecord(record: Pick<ReadRecord, "path" | "range" | "summary">): string {
315
- return `Read: ${record.path} line ${record.range} Extracted information summary: ${record.summary}`;
316
- }
317
-
318
- export function extractReadRecords(entries: CompactionEntryLike[]): ReadRecord[] {
319
- const records: ReadRecord[] = [];
320
- for (let callIndex = 0; callIndex < entries.length; callIndex++) {
321
- const call = readCall(entries[callIndex]);
322
- if (!call) continue;
323
- let resultIndex = -1;
324
- for (let i = callIndex + 1; i < entries.length; i++) {
325
- if (isToolResultEntry(entries[i]) && toolResultId(entries[i]) === call.id) {
326
- resultIndex = i;
327
- break;
328
- }
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;
329
776
  }
330
- if (resultIndex < 0) continue;
331
- const result = entries[resultIndex];
332
- const text = resultText(result);
333
- const [lineStart, lineEnd] = lineRangeFrom(call.args, text);
334
- const range = lineStart === "unknown" || lineEnd === "unknown" ? "unknown" : `${lineStart}-${lineEnd}`;
335
- const summary = boundedReadSummary(text);
336
- const key = `${call.path}|${range}`;
337
- const record = { path: call.path, lineStart, lineEnd, range, summary, key, formatted: "" };
338
- record.formatted = formatReadRecord(record);
339
- records.push(record);
777
+ const line = hash ? `${hash}: ${message}` : message;
778
+ if (!commits.includes(line)) commits.push(line);
340
779
  }
341
- return records;
780
+ return commits.slice(-8);
342
781
  }
343
782
 
344
- export function mergeReadRecords(...recordLists: ReadRecord[][]): ReadRecord[] {
345
- const merged = new Map<string, ReadRecord>();
346
- for (const records of recordLists) {
347
- for (const record of records) merged.set(record.key || `${record.path}|${record.range}`, { ...record, formatted: formatReadRecord(record) });
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 "";
348
813
  }
349
- return [...merged.values()];
350
814
  }
351
815
 
352
- export interface CompactionDetailsLike {
353
- kind?: string;
354
- version?: number;
355
- currentI?: string | null;
356
- iSections?: Array<{ id: string | null; entryIds?: string[] }>;
357
- readRecords?: ReadRecord[];
358
- metrics?: Partial<CompactionMetrics>;
359
- [key: string]: unknown;
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;
360
839
  }
361
840
 
362
- export function mergeCompactionDetails(
363
- previous: CompactionDetailsLike | undefined,
364
- current: CompactionDetailsLike,
365
- ): CompactionDetailsLike {
366
- const previousRecords = previous?.readRecords ?? [];
367
- const currentRecords = current.readRecords ?? [];
368
- return {
369
- ...(previous ?? {}),
370
- ...current,
371
- version: current.version ?? previous?.version ?? 1,
372
- readRecords: mergeReadRecords(previousRecords, currentRecords),
373
- metrics: { ...(previous?.metrics ?? {}), ...(current.metrics ?? {}) },
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)`;
374
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;
375
944
  }
376
945
 
377
- function tokenIndexForRatio(entries: CompactionEntryLike[], start: number, end: number, ratio: number): number {
378
- const total = entries.slice(start, end).reduce((sum, entry) => sum + estimateEntryTokens(entry), 0);
379
- const goal = total * ratio;
380
- let seen = 0;
381
- for (let index = start; index < end; index++) {
382
- seen += estimateEntryTokens(entries[index]);
383
- if (seen >= goal) return index + 1;
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+/, ""));
384
957
  }
385
- return end;
958
+ return merged;
386
959
  }
387
960
 
388
- function latestTurnStart(entries: CompactionEntryLike[], start: number): number {
389
- for (let index = entries.length - 1; index >= start; index--) {
390
- if (entries[index].message?.role === "user") return index;
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);
391
977
  }
392
- return start;
978
+ return wrapped.join("\n");
393
979
  }
394
980
 
395
- function summaryEstimate(entries: CompactionEntryLike[]): number {
396
- const source = entries.reduce((sum, entry) => sum + estimateEntryTokens(entry), 0);
397
- return source === 0 ? 0 : Math.max(16, Math.ceil(source * 0.12));
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 };
398
997
  }
399
998
 
400
- export function planIAwareCompaction(options: {
401
- entries: CompactionEntryLike[];
402
- currentI?: string | null;
403
- knownIIds?: Iterable<string>;
404
- contextWindow?: number | null;
405
- tokensBefore?: number;
406
- fallbackFirstKeptEntryId?: string;
407
- /** When Pi is splitting a turn, never discard more than its prepared boundary. */
408
- maxFirstKeptEntryIndex?: number;
409
- }): ICompactionPlan {
410
- const entries = options.entries;
411
- const tokensBefore = Math.max(0, options.tokensBefore ?? entries.reduce((sum, entry) => sum + estimateEntryTokens(entry), 0));
412
- const contextWindow = typeof options.contextWindow === "number" && options.contextWindow > 0 ? options.contextWindow : null;
413
- const sliced = sliceHistoryByImplementation(entries, options.currentI, options.knownIIds);
414
- const currentStartIndex = sliced.currentStartIndex;
415
- const currentEntries = entries.slice(currentStartIndex);
416
- const currentITokens = currentEntries.reduce((sum, entry) => sum + estimateEntryTokens(entry), 0);
417
- const targetRatio = 0.1;
418
- const targetTokens = contextWindow === null ? null : contextWindow * targetRatio;
419
- const protectedStart = latestTurnStart(entries, currentStartIndex);
420
- const boundaryLimit = Math.max(0, Math.min(entries.length - 1, Math.floor(options.maxFirstKeptEntryIndex ?? entries.length - 1)));
421
- const initialRequested = currentEntries.length
422
- ? tokenIndexForRatio(entries, currentStartIndex, entries.length, 0.8)
423
- : currentStartIndex;
424
- const initial = legalFirstKeptEntryIndex(entries, Math.min(initialRequested, Math.max(currentStartIndex, protectedStart), boundaryLimit));
425
- const candidateIndexes: number[] = [];
426
- if (initial !== null && initial > 0 && initial <= boundaryLimit) candidateIndexes.push(initial);
427
- if (protectedStart > 0) {
428
- const protectedIndex = legalFirstKeptEntryIndex(entries, protectedStart);
429
- if (protectedIndex !== null && protectedIndex > 0 && protectedIndex <= boundaryLimit) candidateIndexes.push(protectedIndex);
430
- }
431
- if (!candidateIndexes.length && currentStartIndex > 0) {
432
- const currentIndex = legalFirstKeptEntryIndex(entries, currentStartIndex);
433
- if (currentIndex !== null && currentIndex > 0 && currentIndex <= boundaryLimit) candidateIndexes.push(currentIndex);
434
- }
435
- const firstCandidate = candidateIndexes[0];
436
- const candidates = [...new Set([
437
- ...(firstCandidate === undefined ? [] : [firstCandidate]),
438
- ...candidateIndexes.slice(firstCandidate === undefined ? 0 : 1).sort((a, b) => a - b),
439
- ])];
440
- let chosen: number | null = null;
441
- let chosenAfter: number | null = null;
442
- let chosenSummaryTokens = 0;
443
- let chosenKeptTokens = 0;
444
- for (const candidate of candidates) {
445
- const summaryTokens = summaryEstimate(entries.slice(0, candidate));
446
- const keptTokens = entries.slice(candidate).reduce((sum, entry) => sum + estimateEntryTokens(entry), 0);
447
- const outsideTokens = Math.max(0, tokensBefore - entries.reduce((sum, entry) => sum + estimateEntryTokens(entry), 0));
448
- const after = outsideTokens + summaryTokens + keptTokens;
449
- if (chosen === null || (targetTokens !== null && after < targetTokens && (chosenAfter === null || after < chosenAfter))) {
450
- chosen = candidate;
451
- chosenAfter = after;
452
- chosenSummaryTokens = summaryTokens;
453
- chosenKeptTokens = keptTokens;
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);
454
1044
  }
455
- if (targetTokens !== null && after < targetTokens) break;
456
- }
457
- const firstKeptEntryIndex = chosen;
458
- const firstKeptEntryId = chosen !== null && entries[chosen]?.id
459
- ? entries[chosen].id as string
460
- : options.fallbackFirstKeptEntryId ?? null;
461
- const summaryEntries = chosen === null ? [] : entries.slice(0, chosen);
462
- const keptEntries = chosen === null ? entries : entries.slice(chosen);
463
- const estimatedAfterTokens = chosenAfter;
464
- const targetMet = targetTokens !== null && estimatedAfterTokens !== null && estimatedAfterTokens < targetTokens;
465
- const hardFloorReason = chosen === null
466
- ? "no legal eligible prefix"
467
- : targetTokens === null
468
- ? "context window unavailable"
469
- : targetMet
470
- ? null
471
- : "protected suffix, summary, or system context exceeds the 10% target";
472
- const metrics: CompactionMetrics = {
473
- contextWindow,
474
- tokensBefore,
475
- currentITokens,
476
- summaryTokens: chosenSummaryTokens,
477
- keptSuffixTokens: chosenKeptTokens,
478
- estimatedAfterTokens,
479
- targetRatio,
480
- currentI: sliced.currentI,
481
- firstKeptEntryId,
482
- targetMet,
483
- hardFloorReason,
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,
484
1081
  };
485
- const slices = sliced.slices.map((slice) => ({ ...slice, entries: [...slice.entries] }));
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 {
486
1092
  return {
487
- currentI: sliced.currentI,
488
- currentITokens,
489
- currentStartIndex,
490
- firstKeptEntryIndex,
491
- firstKeptEntryId,
492
- summaryEntries,
493
- keptEntries,
494
- slices,
495
- readRecords: extractReadRecords(summaryEntries),
496
- metrics,
1093
+ readFiles: fileOps?.readFiles ?? fileOps?.read ?? [],
1094
+ modifiedFiles: fileOps?.modifiedFiles ?? [...(fileOps?.written ?? []), ...(fileOps?.edited ?? [])],
1095
+ createdFiles: fileOps?.createdFiles ?? [],
497
1096
  };
498
1097
  }
499
1098
 
500
- export function currentIExceedsTrigger(currentITokens: number, contextWindow: number | null | undefined): boolean {
501
- return typeof contextWindow === "number" && contextWindow > 0 && currentITokens > contextWindow * 0.2;
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;
502
1256
  }