@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.
Files changed (44) hide show
  1. package/README.md +5 -4
  2. package/package.json +1 -1
  3. package/skills/compactor/SKILL.md +1 -1
  4. package/skills/compactor-detail/SKILL.md +3 -5
  5. package/skills/compactor-doctor/SKILL.md +1 -1
  6. package/skills/compactor-stats/SKILL.md +1 -1
  7. package/src/commands/index.ts +47 -78
  8. package/src/compaction/brief.ts +161 -90
  9. package/src/compaction/build-sections.ts +3 -4
  10. package/src/compaction/compact-args.ts +86 -0
  11. package/src/compaction/cut.ts +270 -28
  12. package/src/compaction/drill-down.ts +261 -0
  13. package/src/compaction/format-recall.ts +96 -0
  14. package/src/compaction/format.ts +8 -3
  15. package/src/compaction/hooks.ts +248 -72
  16. package/src/compaction/merge.ts +34 -4
  17. package/src/compaction/rank.ts +270 -0
  18. package/src/compaction/recall-scope.ts +28 -0
  19. package/src/compaction/search-entries.ts +333 -96
  20. package/src/compaction/skill-collapse.ts +35 -0
  21. package/src/compaction/summarize.ts +37 -6
  22. package/src/compaction/token-estimate.ts +104 -0
  23. package/src/compaction/touched-files.ts +35 -0
  24. package/src/config/manager.ts +2 -27
  25. package/src/config/presets.ts +0 -2
  26. package/src/config/schema.ts +2 -16
  27. package/src/executor/executor.ts +6 -15
  28. package/src/executor/runtime.ts +2 -12
  29. package/src/index.ts +12 -122
  30. package/src/info-screen.ts +3 -10
  31. package/src/security/evaluator.ts +0 -53
  32. package/src/security/policy.ts +7 -8
  33. package/src/session/db.ts +0 -6
  34. package/src/tools/ctx-execute-file.ts +0 -5
  35. package/src/tools/register.ts +27 -50
  36. package/src/tools/vcc-recall.ts +86 -48
  37. package/src/tui/settings-overlay.ts +20 -40
  38. package/src/types.ts +43 -100
  39. package/src/display/diff-renderer.ts +0 -281
  40. package/src/display/line-width-safety.ts +0 -28
  41. package/src/display/render-utils.ts +0 -52
  42. package/src/display/thinking-label.ts +0 -18
  43. package/src/display/tool-overrides.ts +0 -136
  44. 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
+ };
@@ -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
- | { ok: true; messages: AgentMessage[]; firstKeptEntryId: string; compactAll: boolean }
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
- export function buildOwnCut(branchEntries: SessionEntry[]): OwnCutResult {
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
- if (e.type === "message" && e.message) {
44
- liveMessages.push({ entry: e, message: e.message });
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
- if (e.type === "message" && e.message) {
54
- liveMessages.push({ entry: e, message: e.message });
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 < 2) return { ok: false, reason: "too_few_live_messages" };
115
+ if (liveMessages.length <= 2) return { ok: false, reason: "too_few_live_messages" };
61
116
 
62
- let cutIdx = liveMessages.length - 1;
63
- while (cutIdx > 0 && liveMessages[cutIdx].message.role !== "user") {
64
- cutIdx--;
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
- const hasUser = liveMessages.some((m) => m.message.role === "user");
69
- if (!hasUser) return { ok: false, reason: "no_user_message" };
70
- return {
71
- ok: true,
72
- messages: liveMessages.map((e) => e.message),
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
+ }