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.
- package/README.md +74 -21
- package/index.ts +115 -9
- package/package.json +7 -1
- package/references/pi-planning-workflow.md +18 -3
- package/references/state-and-config.md +34 -2
- package/scripts/validate.ts +4 -0
- package/src/code-graph/commands.ts +437 -0
- package/src/code-graph/discovery.ts +118 -0
- package/src/code-graph/git.ts +108 -0
- package/src/code-graph/identity.ts +59 -0
- package/src/code-graph/indexer.ts +281 -0
- package/src/code-graph/materialize.ts +166 -0
- package/src/code-graph/mode.ts +28 -0
- package/src/code-graph/mutations.ts +160 -0
- package/src/code-graph/parser.ts +51 -0
- package/src/code-graph/parsers/javascript.ts +35 -0
- package/src/code-graph/parsers/python.ts +160 -0
- package/src/code-graph/parsers/tree-sitter.ts +316 -0
- package/src/code-graph/paths.ts +85 -0
- package/src/code-graph/prompts.ts +18 -0
- package/src/code-graph/resolver.ts +69 -0
- package/src/code-graph/runtime.ts +158 -0
- package/src/code-graph/schema.ts +135 -0
- package/src/code-graph/screening.ts +82 -0
- package/src/code-graph/store.ts +278 -0
- package/src/code-graph/summary.ts +435 -0
- package/src/code-graph/types.ts +163 -0
- package/src/compaction.ts +1125 -371
- package/src/config-command.ts +326 -0
- package/src/exec.ts +356 -686
- package/src/refine-prompts.ts +50 -0
- package/src/refine-ui-helpers.ts +71 -18
- package/src/refine-ui-state.ts +87 -21
- package/src/refine-ui.ts +210 -102
- package/src/state.ts +19 -6
- package/src/subagent.ts +163 -61
- package/tests/ask-choice.test.ts +263 -0
- package/tests/autocomplete.test.ts +6 -1
- package/tests/code-graph-apply.test.ts +185 -0
- package/tests/code-graph-commands.test.ts +211 -0
- package/tests/code-graph-db.test.ts +166 -0
- package/tests/code-graph-discovery.test.ts +38 -0
- package/tests/code-graph-git.test.ts +94 -0
- package/tests/code-graph-index.test.ts +175 -0
- package/tests/code-graph-loop.e2e.test.ts +159 -0
- package/tests/code-graph-mutations.test.ts +117 -0
- package/tests/code-graph-parser.test.ts +85 -0
- package/tests/code-graph-rollback.test.ts +100 -0
- package/tests/code-graph-summary-batching.test.ts +518 -0
- package/tests/code-graph-summary.test.ts +148 -0
- package/tests/compaction.test.ts +371 -57
- package/tests/config-command.test.ts +255 -0
- package/tests/exec.test.ts +665 -241
- package/tests/fixtures/code-graph/sample.js +36 -0
- package/tests/fixtures/code-graph/sample.py +20 -0
- package/tests/fixtures/code-graph/sample.ts +15 -0
- package/tests/graph-aware-file-tools.test.ts +411 -0
- package/tests/refine-prompts.test.ts +67 -2
- package/tests/refine-ui.test.ts +337 -72
- package/tests/subagent.test.ts +26 -20
- package/tools/ask-choice.ts +158 -11
- package/tools/code-graph.ts +254 -0
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +84 -1
- package/tools/refine.ts +61 -15
package/src/compaction.ts
CHANGED
|
@@ -1,14 +1,114 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
|
|
132
|
+
firstKeptEntryId?: string;
|
|
31
133
|
tokens?: number;
|
|
134
|
+
timestamp?: number | string;
|
|
135
|
+
[key: string]: unknown;
|
|
32
136
|
}
|
|
33
137
|
|
|
34
|
-
export interface
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
|
120
|
-
|
|
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
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
.
|
|
127
|
-
|
|
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
|
|
131
|
-
return
|
|
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
|
|
135
|
-
return
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 (
|
|
152
|
-
|
|
329
|
+
if (entry.type === "branch_summary") {
|
|
330
|
+
return { role: "branchSummary", summary: entry.summary, content: undefined };
|
|
153
331
|
}
|
|
154
|
-
return
|
|
332
|
+
return null;
|
|
155
333
|
}
|
|
156
334
|
|
|
157
|
-
function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
166
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
507
|
+
return { keepUserTurns: selected, smartAdjusted: selected !== baseKeep, fromKeep: baseKeep };
|
|
202
508
|
}
|
|
203
509
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
if (match) return [Number(match[1]), Number(match[2])];
|
|
279
|
-
return ["unknown", "unknown"];
|
|
649
|
+
return clip(text, max);
|
|
280
650
|
}
|
|
281
651
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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
|
|
780
|
+
return commits.slice(-8);
|
|
342
781
|
}
|
|
343
782
|
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
958
|
+
return merged;
|
|
386
959
|
}
|
|
387
960
|
|
|
388
|
-
function
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
978
|
+
return wrapped.join("\n");
|
|
393
979
|
}
|
|
394
980
|
|
|
395
|
-
function
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
501
|
-
|
|
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
|
}
|