@pi-unipi/compactor 2.6.1 → 2.6.2
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 +5 -4
- package/package.json +1 -1
- package/skills/compactor/SKILL.md +1 -1
- package/skills/compactor-detail/SKILL.md +3 -5
- package/skills/compactor-doctor/SKILL.md +1 -1
- package/skills/compactor-stats/SKILL.md +1 -1
- package/src/commands/index.ts +47 -78
- package/src/compaction/brief.ts +161 -90
- package/src/compaction/build-sections.ts +3 -4
- package/src/compaction/compact-args.ts +86 -0
- package/src/compaction/cut.ts +270 -28
- package/src/compaction/drill-down.ts +261 -0
- package/src/compaction/format-recall.ts +96 -0
- package/src/compaction/format.ts +8 -3
- package/src/compaction/hooks.ts +248 -72
- package/src/compaction/merge.ts +34 -4
- package/src/compaction/rank.ts +270 -0
- package/src/compaction/recall-scope.ts +28 -0
- package/src/compaction/search-entries.ts +333 -96
- package/src/compaction/skill-collapse.ts +35 -0
- package/src/compaction/summarize.ts +37 -6
- package/src/compaction/token-estimate.ts +104 -0
- package/src/compaction/touched-files.ts +35 -0
- package/src/config/manager.ts +2 -27
- package/src/config/presets.ts +0 -2
- package/src/config/schema.ts +2 -16
- package/src/executor/executor.ts +6 -15
- package/src/executor/runtime.ts +2 -12
- package/src/index.ts +12 -122
- package/src/info-screen.ts +3 -10
- package/src/security/evaluator.ts +0 -53
- package/src/security/policy.ts +7 -8
- package/src/session/db.ts +0 -6
- package/src/tools/ctx-execute-file.ts +0 -5
- package/src/tools/register.ts +27 -50
- package/src/tools/vcc-recall.ts +86 -48
- package/src/tui/settings-overlay.ts +20 -40
- package/src/types.ts +43 -100
- package/src/display/diff-renderer.ts +0 -281
- package/src/display/line-width-safety.ts +0 -28
- package/src/display/render-utils.ts +0 -52
- package/src/display/thinking-label.ts +0 -18
- package/src/display/tool-overrides.ts +0 -136
- package/src/tools/compact.ts +0 -20
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse keep:N and follow-up prompt from compaction custom instructions
|
|
3
|
+
* (ported from pi-vcc compact-args.ts; marker constant stays ours: COMPACTOR_INSTRUCTION)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { COMPACTOR_INSTRUCTION } from "@pi-unipi/core";
|
|
7
|
+
|
|
8
|
+
const KEEP_TOKEN_RE = /^keep:(\d+)$/;
|
|
9
|
+
|
|
10
|
+
export interface ParsedCompactionArgs {
|
|
11
|
+
followUpPrompt: string;
|
|
12
|
+
keepUserTurns: number | null;
|
|
13
|
+
keepUserTurnsExplicit: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const parseKeepUserTurns = (raw: string): number => {
|
|
17
|
+
const value = Number(raw);
|
|
18
|
+
return Number.isSafeInteger(value) ? value : Number.MAX_SAFE_INTEGER;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const parseKeepAndPrompt = (args?: string): ParsedCompactionArgs => {
|
|
22
|
+
const trimmed = args?.trim() ?? "";
|
|
23
|
+
if (!trimmed) return { followUpPrompt: "", keepUserTurns: null, keepUserTurnsExplicit: false };
|
|
24
|
+
|
|
25
|
+
const startMatch = trimmed.match(/^keep:(\d+)(?:\s+|$)([\s\S]*)$/);
|
|
26
|
+
if (startMatch) {
|
|
27
|
+
return {
|
|
28
|
+
followUpPrompt: startMatch[2].trim(),
|
|
29
|
+
keepUserTurns: parseKeepUserTurns(startMatch[1]),
|
|
30
|
+
keepUserTurnsExplicit: true,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const parts = trimmed.split(/\s+/);
|
|
35
|
+
const endMatch = parts[parts.length - 1].match(KEEP_TOKEN_RE);
|
|
36
|
+
if (endMatch) {
|
|
37
|
+
return {
|
|
38
|
+
followUpPrompt: trimmed.slice(0, trimmed.length - parts[parts.length - 1].length).trim(),
|
|
39
|
+
keepUserTurns: parseKeepUserTurns(endMatch[1]),
|
|
40
|
+
keepUserTurnsExplicit: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { followUpPrompt: trimmed, keepUserTurns: null, keepUserTurnsExplicit: false };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export interface ParsedCompactionInstructions {
|
|
48
|
+
isCompactor: boolean;
|
|
49
|
+
keepUserTurns: number;
|
|
50
|
+
keepUserTurnsExplicit: boolean;
|
|
51
|
+
followUpPrompt: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse customInstructions arriving at session_before_compact.
|
|
56
|
+
* - Exactly COMPACTOR_INSTRUCTION → default path (keep 1, not explicit).
|
|
57
|
+
* - COMPACTOR_INSTRUCTION + args → parse keep:N / prompt after the marker.
|
|
58
|
+
* - Anything else → not ours (parse for a trailing keep:N anyway, pi-vcc parity).
|
|
59
|
+
*/
|
|
60
|
+
export const parseCompactionInstructions = (
|
|
61
|
+
customInstructions?: string,
|
|
62
|
+
): ParsedCompactionInstructions => {
|
|
63
|
+
const trimmed = customInstructions?.trim();
|
|
64
|
+
if (trimmed === COMPACTOR_INSTRUCTION) {
|
|
65
|
+
return { isCompactor: true, keepUserTurns: 1, keepUserTurnsExplicit: false, followUpPrompt: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const keepPrefix = `${COMPACTOR_INSTRUCTION} `;
|
|
69
|
+
if (trimmed?.startsWith(keepPrefix)) {
|
|
70
|
+
const parsed = parseKeepAndPrompt(trimmed.slice(keepPrefix.length));
|
|
71
|
+
return {
|
|
72
|
+
isCompactor: true,
|
|
73
|
+
keepUserTurns: parsed.keepUserTurns ?? 1,
|
|
74
|
+
keepUserTurnsExplicit: parsed.keepUserTurnsExplicit,
|
|
75
|
+
followUpPrompt: null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const parsed = parseKeepAndPrompt(customInstructions);
|
|
80
|
+
return {
|
|
81
|
+
isCompactor: false,
|
|
82
|
+
keepUserTurns: parsed.keepUserTurns ?? 1,
|
|
83
|
+
keepUserTurnsExplicit: parsed.keepUserTurnsExplicit,
|
|
84
|
+
followUpPrompt: parsed.followUpPrompt || null,
|
|
85
|
+
};
|
|
86
|
+
};
|
package/src/compaction/cut.ts
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cut logic — buildOwnCut for determining compaction boundaries
|
|
3
|
+
* (parity-aligned with pi-vcc before-compact.ts: keep:N user-turn cuts,
|
|
4
|
+
* token-budget tail rescue, orphan recovery via "" sentinel)
|
|
3
5
|
*/
|
|
4
6
|
|
|
5
|
-
export type OwnCutCancelReason =
|
|
6
|
-
| "no_live_messages"
|
|
7
|
-
| "too_few_live_messages"
|
|
8
|
-
| "no_user_message";
|
|
9
|
-
|
|
10
7
|
import type { SessionEntry, SessionMessageEntry, CompactionEntry } from "@earendil-works/pi-coding-agent";
|
|
11
8
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
9
|
+
import {
|
|
10
|
+
estimateMessageContentChars,
|
|
11
|
+
estimateMessageContentTokens,
|
|
12
|
+
estimateTokensFromChars,
|
|
13
|
+
} from "./token-estimate.js";
|
|
14
|
+
import type { BudgetCutKind } from "../types.js";
|
|
15
|
+
|
|
16
|
+
export type OwnCutCancelReason =
|
|
17
|
+
| "no_live_messages"
|
|
18
|
+
| "too_few_live_messages";
|
|
12
19
|
|
|
13
20
|
export type OwnCutResult =
|
|
14
|
-
| {
|
|
21
|
+
| {
|
|
22
|
+
ok: true;
|
|
23
|
+
messages: AgentMessage[];
|
|
24
|
+
firstKeptEntryId: string;
|
|
25
|
+
compactAll: boolean;
|
|
26
|
+
keptUserTurns: number;
|
|
27
|
+
totalUserTurns: number;
|
|
28
|
+
requestedKeepUserTurns: number;
|
|
29
|
+
keepFallbackToCompactAll: boolean;
|
|
30
|
+
budgetCut?: BudgetCutKind;
|
|
31
|
+
}
|
|
15
32
|
| { ok: false; reason: OwnCutCancelReason };
|
|
16
33
|
|
|
17
34
|
interface EntryWithMessage {
|
|
@@ -19,7 +36,35 @@ interface EntryWithMessage {
|
|
|
19
36
|
message: AgentMessage;
|
|
20
37
|
}
|
|
21
38
|
|
|
22
|
-
|
|
39
|
+
// Convert a non-message entry that carries LLM-context text (custom_message /
|
|
40
|
+
// branch_summary) into its agent-message form, mirroring pi-core's
|
|
41
|
+
// createCustomMessage / createBranchSummaryMessage (not root-exported, so inlined).
|
|
42
|
+
const toLiveMessage = (entry: any): { role: string; content: unknown; [key: string]: unknown } | null => {
|
|
43
|
+
if (entry.type === "message" && entry.message) return entry.message;
|
|
44
|
+
if (entry.type === "custom_message") {
|
|
45
|
+
return {
|
|
46
|
+
role: "custom",
|
|
47
|
+
customType: entry.customType,
|
|
48
|
+
content: entry.content,
|
|
49
|
+
display: entry.display,
|
|
50
|
+
details: entry.details,
|
|
51
|
+
timestamp: entry.timestamp != null ? new Date(entry.timestamp).getTime() : undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (entry.type === "branch_summary") {
|
|
55
|
+
return {
|
|
56
|
+
role: "branchSummary",
|
|
57
|
+
summary: entry.summary,
|
|
58
|
+
fromId: entry.fromId,
|
|
59
|
+
content: undefined,
|
|
60
|
+
timestamp: entry.timestamp != null ? new Date(entry.timestamp).getTime() : undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const collectLiveMessages = (branchEntries: any[]): EntryWithMessage[] => {
|
|
67
|
+
// Find the last compaction entry and its firstKeptEntryId
|
|
23
68
|
let lastCompactionIdx = -1;
|
|
24
69
|
let lastKeptId: string | undefined;
|
|
25
70
|
for (let i = branchEntries.length - 1; i >= 0; i--) {
|
|
@@ -31,48 +76,71 @@ export function buildOwnCut(branchEntries: SessionEntry[]): OwnCutResult {
|
|
|
31
76
|
}
|
|
32
77
|
}
|
|
33
78
|
|
|
79
|
+
// Orphan recovery: triggers when lastKeptId is set to "" (sentinel from prior
|
|
80
|
+
// compact-all) OR set to an id that no longer exists in the branch. In both cases,
|
|
81
|
+
// start collecting from right after the last compaction entry.
|
|
34
82
|
const hasPriorCompaction = lastCompactionIdx >= 0;
|
|
35
|
-
const hasValidKeptId = !!lastKeptId && branchEntries.some((e) => e.id === lastKeptId);
|
|
83
|
+
const hasValidKeptId = !!lastKeptId && branchEntries.some((e: any) => e.id === lastKeptId);
|
|
36
84
|
const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
|
|
37
85
|
|
|
86
|
+
// Collect live messages
|
|
38
87
|
const liveMessages: EntryWithMessage[] = [];
|
|
39
88
|
if (orphanRecovery) {
|
|
40
89
|
for (let i = lastCompactionIdx + 1; i < branchEntries.length; i++) {
|
|
41
90
|
const e = branchEntries[i];
|
|
42
91
|
if (e.type === "compaction") continue;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
92
|
+
const m = toLiveMessage(e);
|
|
93
|
+
if (m) liveMessages.push({ entry: e, message: m as unknown as AgentMessage });
|
|
46
94
|
}
|
|
47
95
|
} else {
|
|
48
|
-
let foundKept = !lastKeptId;
|
|
96
|
+
let foundKept = !lastKeptId; // if no prior compaction, start collecting immediately
|
|
49
97
|
for (const e of branchEntries) {
|
|
50
98
|
if (!foundKept && e.id === lastKeptId) foundKept = true;
|
|
51
99
|
if (!foundKept) continue;
|
|
52
100
|
if (e.type === "compaction") continue;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
101
|
+
const m = toLiveMessage(e);
|
|
102
|
+
if (m) liveMessages.push({ entry: e, message: m as unknown as AgentMessage });
|
|
56
103
|
}
|
|
57
104
|
}
|
|
105
|
+
return liveMessages;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export function buildOwnCut(branchEntries: SessionEntry[], keepUserTurns = 1): OwnCutResult {
|
|
109
|
+
const normalizedKeepUserTurns = Number.isFinite(keepUserTurns)
|
|
110
|
+
? Math.max(0, Math.floor(keepUserTurns))
|
|
111
|
+
: 0;
|
|
112
|
+
const liveMessages = collectLiveMessages(branchEntries);
|
|
58
113
|
|
|
59
114
|
if (liveMessages.length === 0) return { ok: false, reason: "no_live_messages" };
|
|
60
|
-
if (liveMessages.length
|
|
115
|
+
if (liveMessages.length <= 2) return { ok: false, reason: "too_few_live_messages" };
|
|
61
116
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
117
|
+
const userIndices = liveMessages.reduce<number[]>((acc, e, i) => {
|
|
118
|
+
if (e.message.role === "user") acc.push(i);
|
|
119
|
+
return acc;
|
|
120
|
+
}, []);
|
|
121
|
+
const compactAll = (keepFallbackToCompactAll: boolean): OwnCutResult => ({
|
|
122
|
+
ok: true,
|
|
123
|
+
messages: liveMessages.map((e) => e.message),
|
|
124
|
+
firstKeptEntryId: "",
|
|
125
|
+
compactAll: true,
|
|
126
|
+
keptUserTurns: 0,
|
|
127
|
+
totalUserTurns: userIndices.length,
|
|
128
|
+
requestedKeepUserTurns: normalizedKeepUserTurns,
|
|
129
|
+
keepFallbackToCompactAll,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (normalizedKeepUserTurns <= 0) return compactAll(false);
|
|
133
|
+
|
|
134
|
+
// Summarize all messages before the requested kept user-turn tail.
|
|
135
|
+
const targetUserIdx = userIndices.length - normalizedKeepUserTurns;
|
|
136
|
+
const cutIdx = targetUserIdx >= 0 ? userIndices[targetUserIdx] : -1;
|
|
66
137
|
|
|
67
138
|
if (cutIdx <= 0) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
firstKeptEntryId: "",
|
|
74
|
-
compactAll: true,
|
|
75
|
-
};
|
|
139
|
+
// Keep request cannot form a safe boundary (single user prompt, no user prompt,
|
|
140
|
+
// or keep larger than available user turns), so compact EVERYTHING and keep no tail.
|
|
141
|
+
// firstKeptEntryId="" is a sentinel: pi-core's buildSessionContext won't match it
|
|
142
|
+
// (so 0 kept from pre-compaction), and next buildOwnCut triggers orphan recovery.
|
|
143
|
+
return compactAll(true);
|
|
76
144
|
}
|
|
77
145
|
|
|
78
146
|
return {
|
|
@@ -80,5 +148,179 @@ export function buildOwnCut(branchEntries: SessionEntry[]): OwnCutResult {
|
|
|
80
148
|
messages: liveMessages.slice(0, cutIdx).map((e) => e.message),
|
|
81
149
|
firstKeptEntryId: liveMessages[cutIdx].entry.id,
|
|
82
150
|
compactAll: false,
|
|
151
|
+
keptUserTurns: userIndices.length - targetUserIdx,
|
|
152
|
+
totalUserTurns: userIndices.length,
|
|
153
|
+
requestedKeepUserTurns: normalizedKeepUserTurns,
|
|
154
|
+
keepFallbackToCompactAll: false,
|
|
83
155
|
};
|
|
84
156
|
}
|
|
157
|
+
|
|
158
|
+
// Token-budget tail cut: rescue default-path sessions when the user-turn
|
|
159
|
+
// anchored tail is absent (autonomous: no user boundary in the live window)
|
|
160
|
+
// or oversized (a single giant last user turn). Cuts at the nearest valid
|
|
161
|
+
// non-toolResult boundary, mirroring pi-core's findCutPoint.
|
|
162
|
+
export type BudgetCutKindLocal = BudgetCutKind;
|
|
163
|
+
export const OVERSIZED_TAIL_FACTOR = 2.5;
|
|
164
|
+
|
|
165
|
+
export const findBudgetCutIndex = (
|
|
166
|
+
live: EntryWithMessage[],
|
|
167
|
+
maxTokens: number,
|
|
168
|
+
charsPerToken?: number,
|
|
169
|
+
): number => {
|
|
170
|
+
let acc = 0;
|
|
171
|
+
let crossed = -1;
|
|
172
|
+
for (let i = live.length - 1; i >= 0; i--) {
|
|
173
|
+
acc += estimateMessageContentTokens((live[i].message as any).content, charsPerToken);
|
|
174
|
+
if (acc >= maxTokens) {
|
|
175
|
+
crossed = i;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (crossed < 0) return -1;
|
|
180
|
+
// Snap forward off any toolResult to the next valid boundary.
|
|
181
|
+
for (let j = Math.max(crossed, 1); j < live.length; j++) {
|
|
182
|
+
if (live[j].message.role !== "toolResult") return j;
|
|
183
|
+
}
|
|
184
|
+
return -1;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export interface TailBudgetOptions {
|
|
188
|
+
maxTokens?: number;
|
|
189
|
+
oversizedFactor?: number;
|
|
190
|
+
charsPerToken?: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const applyTailBudget = (
|
|
194
|
+
branchEntries: SessionEntry[],
|
|
195
|
+
cut: OwnCutResult,
|
|
196
|
+
opts: TailBudgetOptions = {},
|
|
197
|
+
): OwnCutResult => {
|
|
198
|
+
if (!cut.ok) return cut;
|
|
199
|
+
const maxTokens = opts.maxTokens ?? MAX_SMART_TAIL_TOKENS;
|
|
200
|
+
const factor = opts.oversizedFactor ?? OVERSIZED_TAIL_FACTOR;
|
|
201
|
+
const live = collectLiveMessages(branchEntries);
|
|
202
|
+
|
|
203
|
+
const budgetResult = (idx: number, budgetCut: BudgetCutKind): OwnCutResult => ({
|
|
204
|
+
ok: true,
|
|
205
|
+
messages: live.slice(0, idx).map((m) => m.message),
|
|
206
|
+
firstKeptEntryId: live[idx].entry.id,
|
|
207
|
+
compactAll: false,
|
|
208
|
+
keptUserTurns: live.slice(idx).filter((m) => m.message.role === "user").length,
|
|
209
|
+
totalUserTurns: live.filter((m) => m.message.role === "user").length,
|
|
210
|
+
requestedKeepUserTurns: cut.requestedKeepUserTurns,
|
|
211
|
+
keepFallbackToCompactAll: false,
|
|
212
|
+
budgetCut,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Case A: no user anchor → compact-all. Re-cut to a token budget unless the
|
|
216
|
+
// compact-all came from explicit keep:0 (which must be respected absolutely).
|
|
217
|
+
if (cut.compactAll) {
|
|
218
|
+
if (!cut.keepFallbackToCompactAll) return cut;
|
|
219
|
+
const idx = findBudgetCutIndex(live, maxTokens, opts.charsPerToken);
|
|
220
|
+
if (idx < 0) return cut;
|
|
221
|
+
return budgetResult(idx, "no_anchor");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Case B: oversized user-boundary tail. Only re-cut when the kept tail exceeds
|
|
225
|
+
// maxTokens * factor (tolerance zone below is unchanged).
|
|
226
|
+
const tailStart = cut.messages.length; // equals the cut index in the live window
|
|
227
|
+
let tailTokens = 0;
|
|
228
|
+
for (let i = tailStart; i < live.length; i++) {
|
|
229
|
+
tailTokens += estimateMessageContentTokens((live[i].message as any).content, opts.charsPerToken);
|
|
230
|
+
}
|
|
231
|
+
if (tailTokens <= maxTokens * factor) return cut;
|
|
232
|
+
const idx = findBudgetCutIndex(live, maxTokens, opts.charsPerToken);
|
|
233
|
+
if (idx <= tailStart) return cut;
|
|
234
|
+
return budgetResult(idx, "oversized_tail");
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// ── smart keep-tail: boost default keep when tail is small ──
|
|
238
|
+
|
|
239
|
+
export const MIN_SMART_TAIL_TOKENS = 5_000;
|
|
240
|
+
export const MAX_SMART_TAIL_TOKENS = 25_000;
|
|
241
|
+
|
|
242
|
+
export interface ResolveSmartKeepOptions {
|
|
243
|
+
branchEntries: SessionEntry[];
|
|
244
|
+
/** Requested keep:N; null when user did not specify (default path). */
|
|
245
|
+
requestedKeepUserTurns: number | null;
|
|
246
|
+
/** True when user typed keep:N explicitly — always respected. */
|
|
247
|
+
explicit: boolean;
|
|
248
|
+
/** Setting toggle. */
|
|
249
|
+
smartKeepTail: boolean;
|
|
250
|
+
/** Injectable thresholds for tests. */
|
|
251
|
+
minTokens?: number;
|
|
252
|
+
maxTokens?: number;
|
|
253
|
+
/** Calibrated chars/token for the current session; defaults to heuristic when omitted. */
|
|
254
|
+
charsPerToken?: number;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface ResolveSmartKeepResult {
|
|
258
|
+
keepUserTurns: number;
|
|
259
|
+
smartAdjusted: boolean;
|
|
260
|
+
/** Original base keep, for toast like "1→3". */
|
|
261
|
+
fromKeep: number;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Estimate tail tokens for a given keep:N.
|
|
266
|
+
* Returns null when keep would trigger compact-all (tail lost) or cancel,
|
|
267
|
+
* so the resolver can stop growing instead of selecting a value that
|
|
268
|
+
* discards the tail entirely.
|
|
269
|
+
*/
|
|
270
|
+
const tailTokensForKeep = (
|
|
271
|
+
branchEntries: SessionEntry[],
|
|
272
|
+
keepUserTurns: number,
|
|
273
|
+
charsPerToken?: number,
|
|
274
|
+
): number | null => {
|
|
275
|
+
const cut = buildOwnCut(branchEntries, keepUserTurns);
|
|
276
|
+
if (!cut.ok || cut.compactAll) return null;
|
|
277
|
+
const idx = branchEntries.findIndex((e: SessionEntry) => e.id === cut.firstKeptEntryId);
|
|
278
|
+
if (idx < 0) return null;
|
|
279
|
+
const kept = branchEntries.slice(idx).filter((e: SessionEntry): e is SessionMessageEntry =>
|
|
280
|
+
e.type === "message",
|
|
281
|
+
);
|
|
282
|
+
const chars = kept.reduce(
|
|
283
|
+
(sum: number, e: SessionMessageEntry) => sum + estimateMessageContentChars((e.message as any)?.content),
|
|
284
|
+
0,
|
|
285
|
+
);
|
|
286
|
+
return estimateTokensFromChars(chars, charsPerToken);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Resolve the effective keep:N.
|
|
291
|
+
* - Explicit keep:N from the user is always respected.
|
|
292
|
+
* - smartKeepTail=false → old behavior (default keep:1).
|
|
293
|
+
* - smartKeepTail=true → if keep:1 tail <= minTokens, grow keep to the
|
|
294
|
+
* largest N whose tail stays <= maxTokens. Stops at compact-all boundary.
|
|
295
|
+
*/
|
|
296
|
+
export const resolveSmartKeepUserTurns = (opts: ResolveSmartKeepOptions): ResolveSmartKeepResult => {
|
|
297
|
+
const minTokens = opts.minTokens ?? MIN_SMART_TAIL_TOKENS;
|
|
298
|
+
const maxTokens = opts.maxTokens ?? MAX_SMART_TAIL_TOKENS;
|
|
299
|
+
const baseKeep = opts.requestedKeepUserTurns ?? 1;
|
|
300
|
+
|
|
301
|
+
if (opts.explicit || !opts.smartKeepTail) {
|
|
302
|
+
return { keepUserTurns: baseKeep, smartAdjusted: false, fromKeep: baseKeep };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const baseTokens = tailTokensForKeep(opts.branchEntries, baseKeep, opts.charsPerToken);
|
|
306
|
+
// base tail already above min (or unmeasurable / compact-all) → don't grow.
|
|
307
|
+
if (baseTokens == null || baseTokens > minTokens) {
|
|
308
|
+
return { keepUserTurns: baseKeep, smartAdjusted: false, fromKeep: baseKeep };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const baseCut = buildOwnCut(opts.branchEntries, baseKeep);
|
|
312
|
+
const totalUserTurns = baseCut.ok ? baseCut.totalUserTurns : 0;
|
|
313
|
+
|
|
314
|
+
let selected = baseKeep;
|
|
315
|
+
for (let k = baseKeep + 1; k <= totalUserTurns; k++) {
|
|
316
|
+
const tokens = tailTokensForKeep(opts.branchEntries, k, opts.charsPerToken);
|
|
317
|
+
if (tokens == null || tokens > maxTokens) break;
|
|
318
|
+
selected = k;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
keepUserTurns: selected,
|
|
323
|
+
smartAdjusted: selected !== baseKeep,
|
|
324
|
+
fromKeep: baseKeep,
|
|
325
|
+
};
|
|
326
|
+
};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drill-down: resolve #N:path syntax to tool call file content (pi-vcc parity).
|
|
3
|
+
*
|
|
4
|
+
* Supports: #42:auth.ts (preview), #42:auth.ts:full (full content),
|
|
5
|
+
* #42:auth.ts:30 (offset), #42:auth.ts:30:20 (offset:limit).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { NormalizedBlock } from "../types.js";
|
|
9
|
+
|
|
10
|
+
// ── Types ─────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
interface ContentBearingCall {
|
|
13
|
+
name: string;
|
|
14
|
+
path: string;
|
|
15
|
+
content?: string;
|
|
16
|
+
oldText?: string;
|
|
17
|
+
newText?: string;
|
|
18
|
+
edits?: Array<{ oldText?: string; newText?: string }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PATH_KEYS = ["path", "file_path", "filePath", "file"] as const;
|
|
22
|
+
|
|
23
|
+
/** Extract a file path from tool args */
|
|
24
|
+
const extractPath = (args: Record<string, unknown>): string | undefined => {
|
|
25
|
+
for (const key of PATH_KEYS) {
|
|
26
|
+
const val = args[key];
|
|
27
|
+
if (typeof val === "string" && val.length > 0) return val;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A call is content-bearing if it has a path argument AND at least one
|
|
34
|
+
* large string/array field (content, edits, oldText, newText).
|
|
35
|
+
*/
|
|
36
|
+
export const isContentBearing = (args: Record<string, unknown>): boolean => {
|
|
37
|
+
if (!args || typeof args !== "object") return false;
|
|
38
|
+
const hasPath = PATH_KEYS.some((k) => typeof args[k] === "string");
|
|
39
|
+
if (!hasPath) return false;
|
|
40
|
+
if (typeof args.content === "string" && args.content.length > 0) return true;
|
|
41
|
+
if (
|
|
42
|
+
Array.isArray(args.edits) &&
|
|
43
|
+
args.edits.length > 0 &&
|
|
44
|
+
args.edits.every((e) => typeof e === "object" && e !== null)
|
|
45
|
+
)
|
|
46
|
+
return true;
|
|
47
|
+
if (typeof args.oldText === "string" && args.oldText.length > 0 && args.edits === undefined)
|
|
48
|
+
return true;
|
|
49
|
+
if (typeof args.newText === "string" && args.newText.length > 0 && args.edits === undefined)
|
|
50
|
+
return true;
|
|
51
|
+
return false;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/** Find content-bearing tool_call blocks with a path arg and content fields. */
|
|
57
|
+
function findContentBearingCalls(blocks: NormalizedBlock[]): ContentBearingCall[] {
|
|
58
|
+
const results: ContentBearingCall[] = [];
|
|
59
|
+
for (const b of blocks) {
|
|
60
|
+
if (b.kind !== "tool_call") continue;
|
|
61
|
+
if (!isContentBearing(b.args)) continue;
|
|
62
|
+
const path = extractPath(b.args);
|
|
63
|
+
if (!path) continue;
|
|
64
|
+
const entry: ContentBearingCall = { name: b.name, path };
|
|
65
|
+
if (typeof b.args.content === "string") entry.content = b.args.content;
|
|
66
|
+
if (Array.isArray(b.args.edits)) {
|
|
67
|
+
entry.edits = (b.args.edits as unknown[]).filter(
|
|
68
|
+
(e): e is { oldText?: string; newText?: string } =>
|
|
69
|
+
e !== null && typeof e === "object",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (typeof b.args.oldText === "string" && !Array.isArray(b.args.edits))
|
|
73
|
+
entry.oldText = b.args.oldText;
|
|
74
|
+
if (typeof b.args.newText === "string" && !Array.isArray(b.args.edits))
|
|
75
|
+
entry.newText = b.args.newText;
|
|
76
|
+
results.push(entry);
|
|
77
|
+
}
|
|
78
|
+
return results;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Format content for display with optional offset/limit slicing. */
|
|
82
|
+
function formatToolCallContent(
|
|
83
|
+
tc: ContentBearingCall,
|
|
84
|
+
entryIndex: number,
|
|
85
|
+
options?: { full?: boolean; offset?: number; limit?: number },
|
|
86
|
+
): string {
|
|
87
|
+
let body: string;
|
|
88
|
+
if (tc.content) {
|
|
89
|
+
body = tc.content;
|
|
90
|
+
} else if (tc.edits) {
|
|
91
|
+
body = tc.edits
|
|
92
|
+
.map(
|
|
93
|
+
(e, i) =>
|
|
94
|
+
`--- edit ${i + 1} ---\n${e.oldText ?? ""}\n--- becomes ---\n${e.newText ?? ""}`,
|
|
95
|
+
)
|
|
96
|
+
.join("\n\n");
|
|
97
|
+
} else if (tc.oldText && tc.newText) {
|
|
98
|
+
body = `--- old ---\n${tc.oldText}\n--- new ---\n${tc.newText}`;
|
|
99
|
+
} else {
|
|
100
|
+
body = "(no file content found in tool call arguments)";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const full = options?.full ?? false;
|
|
104
|
+
const offset = options?.offset;
|
|
105
|
+
const limit = options?.limit;
|
|
106
|
+
const allLines = body.split("\n");
|
|
107
|
+
const totalLines = allLines.length;
|
|
108
|
+
const previewLimit = 30;
|
|
109
|
+
const MAX_FULL_BYTES = 50 * 1024;
|
|
110
|
+
|
|
111
|
+
if (full) {
|
|
112
|
+
if (Buffer.byteLength(body, "utf8") > MAX_FULL_BYTES) {
|
|
113
|
+
const truncated = body.slice(0, MAX_FULL_BYTES);
|
|
114
|
+
return `File: ${tc.path}
|
|
115
|
+
Tool: ${tc.name}
|
|
116
|
+
|
|
117
|
+
${truncated}
|
|
118
|
+
|
|
119
|
+
... (${Buffer.byteLength(body, "utf8") - MAX_FULL_BYTES} more bytes — file exceeds 50KB display limit. Use #${entryIndex}:${tc.path}:${previewLimit} for next page.)`;
|
|
120
|
+
}
|
|
121
|
+
return `File: ${tc.path}
|
|
122
|
+
Tool: ${tc.name}
|
|
123
|
+
|
|
124
|
+
${body}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (offset !== undefined) {
|
|
128
|
+
const startLine = Math.max(0, offset);
|
|
129
|
+
const maxLines = limit ?? 30;
|
|
130
|
+
const endLine = Math.min(startLine + maxLines, totalLines);
|
|
131
|
+
const visible = allLines.slice(startLine, endLine);
|
|
132
|
+
const displayStart = startLine + 1;
|
|
133
|
+
|
|
134
|
+
if (visible.length === 0) {
|
|
135
|
+
return `Offset ${startLine} is beyond file length ${totalLines}. Use #${entryIndex}:${tc.path} for the first ${previewLimit} lines.`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let result = `File: ${tc.path}
|
|
139
|
+
Tool: ${tc.name}
|
|
140
|
+
Lines ${displayStart}-${endLine} (of ${totalLines}):
|
|
141
|
+
|
|
142
|
+
`;
|
|
143
|
+
result += visible.join("\n");
|
|
144
|
+
|
|
145
|
+
if (endLine < totalLines) {
|
|
146
|
+
result += `\n\n--- Use #${entryIndex}:${tc.path}:${endLine} or #${entryIndex}:${tc.path}:${endLine}:${maxLines} for next ${maxLines} lines, #${entryIndex}:${tc.path}:full for complete ---`;
|
|
147
|
+
} else if (offset > 0) {
|
|
148
|
+
result += `\n\n(End of file)`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Default preview mode: first ${previewLimit} lines
|
|
155
|
+
if (totalLines > previewLimit) {
|
|
156
|
+
const preview = allLines.slice(0, previewLimit).join("\n");
|
|
157
|
+
return `File: ${tc.path}
|
|
158
|
+
Tool: ${tc.name}
|
|
159
|
+
|
|
160
|
+
${preview}
|
|
161
|
+
|
|
162
|
+
...(${totalLines - previewLimit} more lines — use #${entryIndex}:${tc.path}:full for complete content, or #${entryIndex}:${tc.path}:${previewLimit} for next ${previewLimit} lines)`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return `File: ${tc.path}
|
|
166
|
+
Tool: ${tc.name}
|
|
167
|
+
|
|
168
|
+
${body}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Parse drill-down query ────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Pattern: #N:path, #N:path:full, #N:path:offset, or #N:path:offset:limit.
|
|
175
|
+
* The ^ anchor requires the entire query to be the drill-down pattern, so
|
|
176
|
+
* inline mentions like "see #42:auth.ts" are never treated as drill-down.
|
|
177
|
+
*/
|
|
178
|
+
const DRILLDOWN_PATTERN = /^#(\d+):(.+?)(?::(full|\d+(?::\d+)?))?$/;
|
|
179
|
+
|
|
180
|
+
export function parseDrillDown(query: string): {
|
|
181
|
+
index: number;
|
|
182
|
+
pathPattern: string;
|
|
183
|
+
full: boolean;
|
|
184
|
+
offset?: number;
|
|
185
|
+
limit?: number;
|
|
186
|
+
} | null {
|
|
187
|
+
const match = query.match(DRILLDOWN_PATTERN);
|
|
188
|
+
if (!match) return null;
|
|
189
|
+
const index = parseInt(match[1], 10);
|
|
190
|
+
const pathPattern = match[2];
|
|
191
|
+
const suffix = match[3];
|
|
192
|
+
|
|
193
|
+
if (suffix === "full") {
|
|
194
|
+
return { index, pathPattern, full: true, offset: undefined, limit: undefined };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (suffix !== undefined) {
|
|
198
|
+
const parts = suffix.split(":");
|
|
199
|
+
const offset = parseInt(parts[0], 10);
|
|
200
|
+
const limit = parts[1] !== undefined ? parseInt(parts[1], 10) : undefined;
|
|
201
|
+
if (!Number.isNaN(offset)) {
|
|
202
|
+
return { index, pathPattern, full: false, offset, limit };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { index, pathPattern, full: false, offset: undefined, limit: undefined };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Main export ───────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Expand a drill-down query (#N:path) to tool call content from blocks whose
|
|
213
|
+
* sourceIndex matches the entry index.
|
|
214
|
+
*/
|
|
215
|
+
export function expandEntryFile(
|
|
216
|
+
blocks: NormalizedBlock[],
|
|
217
|
+
entryIndex: number,
|
|
218
|
+
pathPattern: string,
|
|
219
|
+
full = false,
|
|
220
|
+
offset?: number,
|
|
221
|
+
limit?: number,
|
|
222
|
+
): string {
|
|
223
|
+
const entryBlocks = blocks.filter((b) => b.sourceIndex === entryIndex);
|
|
224
|
+
if (entryBlocks.length === 0) {
|
|
225
|
+
return `Entry #${entryIndex} not found in session history.`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const calls = findContentBearingCalls(entryBlocks);
|
|
229
|
+
|
|
230
|
+
// Special case: #42:file keyword
|
|
231
|
+
if (pathPattern === "file") {
|
|
232
|
+
if (calls.length === 0) {
|
|
233
|
+
return `No file content found in entry #${entryIndex}.`;
|
|
234
|
+
}
|
|
235
|
+
if (calls.length === 1) {
|
|
236
|
+
return formatToolCallContent(calls[0], entryIndex, { full, offset, limit });
|
|
237
|
+
}
|
|
238
|
+
const items = calls.map(
|
|
239
|
+
(tc) => ` [#${entryIndex}:${tc.path}] ${tc.name}(${tc.path})`,
|
|
240
|
+
);
|
|
241
|
+
return `Entry #${entryIndex} has ${calls.length} file operations:\n${items.join("\n")}\n\nUse #${entryIndex}:path to drill into a specific file.`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const matched = calls.filter((tc) => tc.path.includes(pathPattern));
|
|
245
|
+
|
|
246
|
+
if (matched.length === 0) {
|
|
247
|
+
return `No file content found in entry #${entryIndex} for "${pathPattern}".`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (matched.length > 1) {
|
|
251
|
+
const items = matched.map(
|
|
252
|
+
(tc) => ` [#${entryIndex}:${tc.path}] ${tc.name}(${tc.path})`,
|
|
253
|
+
);
|
|
254
|
+
return `Entry #${entryIndex} has ${matched.length} file operations matching "${pathPattern}":
|
|
255
|
+
${items.join("\n")}
|
|
256
|
+
|
|
257
|
+
Use #${entryIndex}:<more-specific-path> to drill into a specific file.`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return formatToolCallContent(matched[0], entryIndex, { full, offset, limit });
|
|
261
|
+
}
|