pi-plans 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -17
- package/index.ts +34 -59
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +6 -3
- package/references/state-and-config.md +1 -1
- package/src/autocomplete.ts +163 -0
- package/src/compaction.ts +502 -0
- package/src/exec.ts +557 -333
- package/src/plan.ts +37 -0
- package/src/query-hook.ts +82 -0
- package/src/refine-ui-helpers.ts +89 -0
- package/src/refine-ui-state.ts +78 -0
- package/src/refine-ui.ts +322 -0
- package/src/state.ts +9 -27
- package/src/subagent.ts +196 -69
- package/tests/autocomplete.test.ts +142 -0
- package/tests/compaction.test.ts +74 -0
- package/tests/exec.test.ts +153 -248
- package/tests/execute-plan.test.ts +65 -0
- package/tests/plan.test.ts +11 -1
- package/tests/plans.test.ts +6 -5
- package/tests/query-hook.test.ts +82 -0
- package/tests/refine-ui.test.ts +127 -0
- package/tests/state.test.ts +12 -15
- package/tests/subagent.test.ts +114 -0
- package/tools/ask-choice.ts +22 -0
- package/tools/execute-plan.ts +7 -39
- package/tools/plans.ts +1 -18
- package/tools/refine.ts +125 -71
- package/src/execution-panel.ts +0 -633
- package/tests/execution-panel.test.ts +0 -234
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, bounded history policy helpers used by the planning and execution
|
|
3
|
+
* compaction hooks. SessionManager remains the owner of persistence.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface CompactionContentPart {
|
|
7
|
+
type?: string;
|
|
8
|
+
text?: string;
|
|
9
|
+
id?: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
arguments?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CompactionMessage {
|
|
15
|
+
role?: string;
|
|
16
|
+
content?: string | CompactionContentPart[];
|
|
17
|
+
toolCallId?: string;
|
|
18
|
+
toolName?: string;
|
|
19
|
+
details?: Record<string, unknown>;
|
|
20
|
+
isError?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CompactionEntryLike {
|
|
24
|
+
id?: string;
|
|
25
|
+
type?: string;
|
|
26
|
+
customType?: string;
|
|
27
|
+
message?: CompactionMessage;
|
|
28
|
+
data?: Record<string, unknown>;
|
|
29
|
+
details?: Record<string, unknown>;
|
|
30
|
+
/** Test fixtures and callers may provide a native estimate. */
|
|
31
|
+
tokens?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
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;
|
|
75
|
+
}
|
|
76
|
+
|
|
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
|
+
]);
|
|
89
|
+
|
|
90
|
+
function contentParts(message: CompactionMessage | undefined): CompactionContentPart[] {
|
|
91
|
+
if (!message) return [];
|
|
92
|
+
if (typeof message.content === "string") return [{ type: "text", text: message.content }];
|
|
93
|
+
return message.content ?? [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function compactText(text: string, limit = 180): string {
|
|
97
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
98
|
+
if (!normalized) return "";
|
|
99
|
+
if (normalized.length < limit) return normalized;
|
|
100
|
+
return `${normalized.slice(0, Math.max(0, limit - 1))}…`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function messageText(message: CompactionMessage | undefined): string {
|
|
104
|
+
return contentParts(message)
|
|
105
|
+
.filter((part) => part.type === "text")
|
|
106
|
+
.map((part) => part.text ?? "")
|
|
107
|
+
.join("\n")
|
|
108
|
+
.trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function scanCurrentIMarkers(text: string): string[] {
|
|
112
|
+
return [...text.matchAll(CURRENT_I_RE)].map((match) => match[1]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function entryCurrentIMarkers(entry: CompactionEntryLike): string[] {
|
|
116
|
+
return scanCurrentIMarkers(messageText(entry.message));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isToolResultEntry(entry: CompactionEntryLike): boolean {
|
|
120
|
+
return TOOL_RESULT_ROLES.has(entry.message?.role ?? "") || entry.type === "tool_result";
|
|
121
|
+
}
|
|
122
|
+
|
|
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);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function toolResultId(entry: CompactionEntryLike): string | undefined {
|
|
131
|
+
return entry.message?.toolCallId;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isInternalEntry(entry: CompactionEntryLike): boolean {
|
|
135
|
+
return entry.type === "custom" && INTERNAL_CUSTOM_TYPES.has(entry.customType ?? "");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function estimateEntryTokens(entry: CompactionEntryLike): number {
|
|
139
|
+
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
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (message.role === "toolResult") {
|
|
152
|
+
text += ` ${message.toolName ?? "tool"} ${safeJson(message.details)}`;
|
|
153
|
+
}
|
|
154
|
+
return Math.max(1, Math.ceil(text.length / 4));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function safeJson(value: unknown): string {
|
|
158
|
+
try {
|
|
159
|
+
return value === undefined ? "" : JSON.stringify(value);
|
|
160
|
+
} catch {
|
|
161
|
+
return "[unserializable]";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function entryHasToolCall(entry: CompactionEntryLike, id: string): boolean {
|
|
166
|
+
return toolCallIds(entry).includes(id);
|
|
167
|
+
}
|
|
168
|
+
|
|
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;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
while (index > 0 && isToolResultEntry(entries[index])) index -= 1;
|
|
185
|
+
return isToolResultEntry(entries[index]) ? null : index;
|
|
186
|
+
}
|
|
187
|
+
|
|
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;
|
|
191
|
+
}
|
|
192
|
+
|
|
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
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return starts;
|
|
202
|
+
}
|
|
203
|
+
|
|
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;
|
|
210
|
+
}
|
|
211
|
+
|
|
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
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (effectiveCurrent && currentStart > 0) {
|
|
253
|
+
for (const slice of slices) {
|
|
254
|
+
if (slice.id === effectiveCurrent) slice.current = false;
|
|
255
|
+
}
|
|
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;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { slices, currentStartIndex: currentStart, currentI: effectiveCurrent };
|
|
265
|
+
}
|
|
266
|
+
|
|
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];
|
|
276
|
+
}
|
|
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"];
|
|
280
|
+
}
|
|
281
|
+
|
|
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;
|
|
286
|
+
}
|
|
287
|
+
|
|
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");
|
|
295
|
+
}
|
|
296
|
+
|
|
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 ?? {} };
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
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
|
+
}
|
|
329
|
+
}
|
|
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);
|
|
340
|
+
}
|
|
341
|
+
return records;
|
|
342
|
+
}
|
|
343
|
+
|
|
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) });
|
|
348
|
+
}
|
|
349
|
+
return [...merged.values()];
|
|
350
|
+
}
|
|
351
|
+
|
|
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;
|
|
360
|
+
}
|
|
361
|
+
|
|
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 ?? {}) },
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
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;
|
|
384
|
+
}
|
|
385
|
+
return end;
|
|
386
|
+
}
|
|
387
|
+
|
|
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;
|
|
391
|
+
}
|
|
392
|
+
return start;
|
|
393
|
+
}
|
|
394
|
+
|
|
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));
|
|
398
|
+
}
|
|
399
|
+
|
|
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;
|
|
454
|
+
}
|
|
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,
|
|
484
|
+
};
|
|
485
|
+
const slices = sliced.slices.map((slice) => ({ ...slice, entries: [...slice.entries] }));
|
|
486
|
+
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,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function currentIExceedsTrigger(currentITokens: number, contextWindow: number | null | undefined): boolean {
|
|
501
|
+
return typeof contextWindow === "number" && contextWindow > 0 && currentITokens > contextWindow * 0.2;
|
|
502
|
+
}
|