pi-vcc 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/demo.gif +0 -0
  4. package/flow/plans/20260515-1300/plan.md +206 -0
  5. package/index.ts +14 -0
  6. package/package.json +36 -0
  7. package/pi-vcc-config.schema.json +131 -0
  8. package/scripts/audit-sessions.ts +88 -0
  9. package/scripts/benchmark-real-sessions.ts +25 -0
  10. package/scripts/compare-before-after.ts +36 -0
  11. package/scripts/dump-branch-output.ts +20 -0
  12. package/src/commands/pi-vcc.ts +33 -0
  13. package/src/commands/vcc-recall.ts +65 -0
  14. package/src/core/brief.ts +381 -0
  15. package/src/core/build-sections.ts +87 -0
  16. package/src/core/content.ts +60 -0
  17. package/src/core/filter-noise.ts +42 -0
  18. package/src/core/format-recall.ts +27 -0
  19. package/src/core/format.ts +56 -0
  20. package/src/core/lineage.ts +26 -0
  21. package/src/core/load-messages.ts +63 -0
  22. package/src/core/normalize.ts +66 -0
  23. package/src/core/recall-scope.ts +14 -0
  24. package/src/core/render-entries.ts +68 -0
  25. package/src/core/report.ts +237 -0
  26. package/src/core/sanitize.ts +5 -0
  27. package/src/core/search-entries.ts +230 -0
  28. package/src/core/settings.ts +215 -0
  29. package/src/core/skill-collapse.ts +35 -0
  30. package/src/core/summarize.ts +159 -0
  31. package/src/core/tool-args.ts +14 -0
  32. package/src/details.ts +7 -0
  33. package/src/extract/commits.ts +69 -0
  34. package/src/extract/files.ts +80 -0
  35. package/src/extract/goals.ts +79 -0
  36. package/src/extract/preferences.ts +55 -0
  37. package/src/extract/references.ts +214 -0
  38. package/src/extract/signals.ts +145 -0
  39. package/src/hooks/before-compact.ts +405 -0
  40. package/src/sections.ts +14 -0
  41. package/src/tools/recall.ts +109 -0
  42. package/src/types.ts +14 -0
  43. package/tests/before-compact-hook.test.ts +181 -0
  44. package/tests/before-compact.test.ts +140 -0
  45. package/tests/brief.test.ts +206 -0
  46. package/tests/build-sections.test.ts +90 -0
  47. package/tests/compile.test.ts +110 -0
  48. package/tests/config-integration.test.ts +107 -0
  49. package/tests/content.test.ts +31 -0
  50. package/tests/edge-cases.test.ts +368 -0
  51. package/tests/extract-goals.test.ts +86 -0
  52. package/tests/extract-preferences.test.ts +30 -0
  53. package/tests/extract-references.test.ts +475 -0
  54. package/tests/extract-signals.test.ts +561 -0
  55. package/tests/filter-noise.test.ts +61 -0
  56. package/tests/fixtures.ts +61 -0
  57. package/tests/format-recall.test.ts +30 -0
  58. package/tests/format.test.ts +91 -0
  59. package/tests/lineage.test.ts +33 -0
  60. package/tests/load-messages.test.ts +51 -0
  61. package/tests/normalize.test.ts +97 -0
  62. package/tests/real-sessions.test.ts +38 -0
  63. package/tests/recall-expand.test.ts +15 -0
  64. package/tests/recall-scope.test.ts +32 -0
  65. package/tests/recall-tool-scope.test.ts +67 -0
  66. package/tests/render-entries.test.ts +62 -0
  67. package/tests/report.test.ts +44 -0
  68. package/tests/sanitize.test.ts +24 -0
  69. package/tests/search-entries.test.ts +144 -0
  70. package/tests/settings-scaffold.test.ts +120 -0
  71. package/tests/settings.test.ts +32 -0
  72. package/tests/support/load-session.ts +23 -0
  73. package/tests/support/real-sessions.ts +51 -0
  74. package/tsconfig.json +14 -0
  75. package/vitest.config.ts +7 -0
@@ -0,0 +1,405 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { convertToLlm } from "@mariozechner/pi-coding-agent";
3
+ import { writeFile } from "fs/promises";
4
+ import { compile } from "../core/summarize";
5
+ import { loadSettings, type PiVccSettings } from "../core/settings";
6
+ import type { PiVccCompactionDetails } from "../details";
7
+
8
+ interface BranchEntry {
9
+ id: string;
10
+ type: string;
11
+ firstKeptEntryId?: string;
12
+ message?: { role: string; content: unknown };
13
+ }
14
+
15
+ export const PI_VCC_COMPACT_INSTRUCTION = "__pi_vcc__";
16
+
17
+ export interface CompactionStats {
18
+ summarized: number;
19
+ kept: number;
20
+ keptTokensEst: number;
21
+ }
22
+
23
+ interface SessionState {
24
+ stats: CompactionStats | null;
25
+ wasPiVcc: boolean;
26
+ }
27
+ const sessionState = new WeakMap<object, SessionState>();
28
+ let currentSessionStats: CompactionStats | null = null;
29
+
30
+ export const getLastCompactionStats = () => currentSessionStats;
31
+
32
+ import { formatTokens } from "../core/format";
33
+
34
+ const UI_SETTLE_DELAY_MS = 500;
35
+
36
+ const dbg = async (settings: PiVccSettings, data: Record<string, unknown>) => {
37
+ if (!settings.debug) return;
38
+ try {
39
+ await writeFile("/tmp/pi-vcc-debug.json", JSON.stringify(data, null, 2));
40
+ } catch (e) {
41
+ // Intentional fallback: UI notifications are best-effort and should not crash the compaction.
42
+ }
43
+ };
44
+
45
+ const previewContent = (content: unknown): string => {
46
+ if (typeof content === "string") return content.slice(0, 300);
47
+ if (Array.isArray(content)) {
48
+ return content
49
+ .map((c: any) => {
50
+ if (c?.type === "text") return c.text ?? "";
51
+ if (c?.type === "toolCall") return `[toolCall:${c.name}]`;
52
+ if (c?.type === "thinking") return `[thinking]`;
53
+ if (c?.type === "image") return `[image:${c.mimeType}]`;
54
+ return `[${c?.type ?? "unknown"}]`;
55
+ })
56
+ .join("\n")
57
+ .slice(0, 300);
58
+ }
59
+ return "";
60
+ };
61
+
62
+ interface EntryWithMessage {
63
+ entry: { id: string; type: string };
64
+ message: { role: string; content: unknown };
65
+ }
66
+
67
+ export type OwnCutCancelReason = "no_live_messages" | "too_few_live_messages" | "no_user_message";
68
+
69
+ export type OwnCutResult =
70
+ | { ok: true; messages: any[]; firstKeptEntryId: string; compactAll: boolean }
71
+ | { ok: false; reason: OwnCutCancelReason };
72
+
73
+ export function buildOwnCut(branchEntries: BranchEntry[]): OwnCutResult {
74
+ // Find the last compaction entry and its firstKeptEntryId
75
+ let lastCompactionIdx = -1;
76
+ let lastKeptId: string | undefined;
77
+ for (let i = branchEntries.length - 1; i >= 0; i--) {
78
+ if (branchEntries[i].type === "compaction") {
79
+ lastCompactionIdx = i;
80
+ lastKeptId = branchEntries[i].firstKeptEntryId;
81
+ break;
82
+ }
83
+ }
84
+
85
+ // Orphan recovery: triggers when lastKeptId is set to "" (sentinel from prior
86
+ // compact-all) OR set to an id that no longer exists in the branch. In both cases,
87
+ // start collecting from right after the last compaction entry.
88
+ const hasPriorCompaction = lastCompactionIdx >= 0;
89
+ const hasValidKeptId = !!lastKeptId && branchEntries.some((e) => e.id === lastKeptId);
90
+ const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
91
+
92
+ // Collect live messages
93
+ const liveMessages: EntryWithMessage[] = [];
94
+ if (orphanRecovery) {
95
+ for (let i = lastCompactionIdx + 1; i < branchEntries.length; i++) {
96
+ const e = branchEntries[i];
97
+ if (e.type === "compaction") continue;
98
+ if (e.type === "message" && e.message) {
99
+ liveMessages.push({ entry: e, message: e.message });
100
+ }
101
+ }
102
+ } else {
103
+ let foundKept = !lastKeptId; // if no prior compaction, start collecting immediately
104
+ for (const e of branchEntries) {
105
+ if (!foundKept && e.id === lastKeptId) foundKept = true;
106
+ if (!foundKept) continue;
107
+ if (e.type === "compaction") continue;
108
+ if (e.type === "message" && e.message) {
109
+ liveMessages.push({ entry: e, message: e.message });
110
+ }
111
+ }
112
+ }
113
+
114
+ if (liveMessages.length === 0) return { ok: false, reason: "no_live_messages" };
115
+ if (liveMessages.length <= 2) return { ok: false, reason: "too_few_live_messages" };
116
+
117
+ // Summarize all messages, keep only the last user message as context
118
+ let cutIdx = liveMessages.length - 1;
119
+ while (cutIdx > 0 && liveMessages[cutIdx].message.role !== "user") {
120
+ cutIdx--;
121
+ }
122
+
123
+ if (cutIdx <= 0) {
124
+ // Single user prompt scenario (or no user at all).
125
+ // If there's at least one user message, compact EVERYTHING and keep no tail.
126
+ // firstKeptEntryId="" is a sentinel: pi-core's buildSessionContext won't match it
127
+ // (so 0 kept from pre-compaction), and next buildOwnCut triggers orphan recovery.
128
+ const hasUser = liveMessages.some((m) => m.message.role === "user");
129
+ if (!hasUser) return { ok: false, reason: "no_user_message" };
130
+ return {
131
+ ok: true,
132
+ messages: liveMessages.map((e) => e.message),
133
+ firstKeptEntryId: "",
134
+ compactAll: true,
135
+ };
136
+ }
137
+
138
+ return {
139
+ ok: true,
140
+ messages: liveMessages.slice(0, cutIdx).map((e) => e.message),
141
+ firstKeptEntryId: liveMessages[cutIdx].entry.id,
142
+ compactAll: false,
143
+ };
144
+ }
145
+
146
+ const REASON_MESSAGES: Record<OwnCutCancelReason, string> = {
147
+ no_live_messages: "pi-vcc: Nothing to compact (no live messages)",
148
+ too_few_live_messages: "pi-vcc: Too few messages to compact",
149
+ no_user_message: "pi-vcc: Cannot compact — no user message found",
150
+ };
151
+
152
+ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
153
+ pi.on("session_before_compact", async (event, ctx) => {
154
+ const { preparation, branchEntries, customInstructions } = event;
155
+ const settings = await loadSettings();
156
+
157
+ let state = sessionState.get(event);
158
+ if (!state) {
159
+ state = { stats: null, wasPiVcc: false };
160
+ sessionState.set(event, state);
161
+ }
162
+
163
+ // Always handle explicit /pi-vcc marker.
164
+ // Otherwise, only handle when user opted in via settings.
165
+ const isPiVcc = customInstructions === PI_VCC_COMPACT_INSTRUCTION;
166
+ if (!isPiVcc && !settings.overrideDefaultCompaction) return;
167
+
168
+ const typedBranchEntries = branchEntries as BranchEntry[];
169
+ const ownCut = buildOwnCut(typedBranchEntries);
170
+ if (!ownCut.ok) {
171
+ const lastComp = [...typedBranchEntries].reverse().find((e) => e.type === "compaction");
172
+ const lastCompIdx = lastComp ? typedBranchEntries.indexOf(lastComp) : -1;
173
+
174
+ // Recompute liveMessages view (same logic as buildOwnCut) for diagnostic
175
+ const lastKeptId: string | undefined = lastComp?.firstKeptEntryId;
176
+ const hasPriorCompaction = lastCompIdx >= 0;
177
+ const hasValidKeptId = !!lastKeptId && typedBranchEntries.some((e) => e.id === lastKeptId);
178
+ const diagOrphan = hasPriorCompaction && !hasValidKeptId;
179
+ const liveRoles: string[] = [];
180
+ if (diagOrphan) {
181
+ for (let i = lastCompIdx + 1; i < typedBranchEntries.length; i++) {
182
+ const e = typedBranchEntries[i];
183
+ if (e.type === "compaction") continue;
184
+ if (e.type === "message" && e.message) liveRoles.push(e.message.role);
185
+ }
186
+ } else {
187
+ let foundKept = !lastKeptId;
188
+ for (const e of typedBranchEntries) {
189
+ if (!foundKept && e.id === lastKeptId) foundKept = true;
190
+ if (!foundKept) continue;
191
+ if (e.type === "compaction") continue;
192
+ if (e.type === "message" && e.message) liveRoles.push(e.message.role);
193
+ }
194
+ }
195
+ const userIndices = liveRoles.reduce<number[]>((acc, r, i) => (r === "user" ? (acc.push(i), acc) : acc), []);
196
+
197
+ await dbg(settings, {
198
+ cancelled: true,
199
+ reason: ownCut.reason,
200
+ isPiVcc,
201
+ counts: {
202
+ total: typedBranchEntries.length,
203
+ messages: typedBranchEntries.filter((e) => e.type === "message").length,
204
+ compactions: typedBranchEntries.filter((e) => e.type === "compaction").length,
205
+ entriesAfterLastCompaction: lastCompIdx >= 0 ? typedBranchEntries.length - lastCompIdx - 1 : null,
206
+ },
207
+ liveMessages: {
208
+ count: liveRoles.length,
209
+ userCount: userIndices.length,
210
+ firstUserIdx: userIndices[0] ?? null,
211
+ lastUserIdx: userIndices[userIndices.length - 1] ?? null,
212
+ roleSequence:
213
+ liveRoles.length <= 30 ? liveRoles : [...liveRoles.slice(0, 10), "...", ...liveRoles.slice(-10)],
214
+ },
215
+ lastCompaction: lastComp
216
+ ? {
217
+ hasFirstKeptEntryId: !!lastComp.firstKeptEntryId,
218
+ foundInBranch: lastComp.firstKeptEntryId
219
+ ? typedBranchEntries.some((e) => e.id === lastComp.firstKeptEntryId)
220
+ : null,
221
+ }
222
+ : null,
223
+ tail: typedBranchEntries.slice(-5).map((e) => ({
224
+ type: e.type,
225
+ role: e.type === "message" ? e.message?.role : undefined,
226
+ hasContent: e.type === "message" ? e.message?.content != null : undefined,
227
+ })),
228
+ });
229
+
230
+ try {
231
+ ctx?.ui?.notify?.(REASON_MESSAGES[ownCut.reason], "warning");
232
+ } catch (e) {
233
+ // Intentional fallback: UI notifications are best-effort and should not crash the compaction.
234
+ }
235
+ return { cancel: true };
236
+ }
237
+
238
+ const agentMessages = ownCut.messages;
239
+ const firstKeptEntryId = ownCut.firstKeptEntryId;
240
+ const messages = convertToLlm(agentMessages);
241
+
242
+ // Count kept messages and estimate tokens
243
+ const keptIdx = typedBranchEntries.findIndex((e) => e.id === firstKeptEntryId);
244
+ const keptEntries =
245
+ keptIdx >= 0 ? typedBranchEntries.slice(keptIdx).filter((e) => e.type === "message") : [];
246
+ const keptChars = keptEntries.reduce((sum: number, e: BranchEntry) => {
247
+ const c = e.message?.content;
248
+ if (typeof c === "string") return sum + c.length;
249
+ if (Array.isArray(c))
250
+ return (
251
+ sum +
252
+ c.reduce((s: number, p: any) => {
253
+ if (p.text) return s + p.text.length;
254
+ if (p.type === "toolCall")
255
+ return (
256
+ s +
257
+ (p.name?.length ?? 0) +
258
+ (typeof p.input === "string" ? p.input.length : JSON.stringify(p.input ?? "").length)
259
+ );
260
+ if (p.type === "toolResult")
261
+ return (
262
+ s +
263
+ (typeof p.content === "string" ? p.content.length : JSON.stringify(p.content ?? "").length)
264
+ );
265
+ return s;
266
+ }, 0)
267
+ );
268
+ return sum;
269
+ }, 0);
270
+ state.stats = {
271
+ summarized: agentMessages.length,
272
+ kept: keptEntries.length,
273
+ keptTokensEst: Math.round(keptChars / 4),
274
+ };
275
+ currentSessionStats = state.stats;
276
+
277
+ const config = settings;
278
+
279
+ const summary = await compile({
280
+ messages,
281
+ previousSummary: preparation.previousSummary,
282
+ fileOps: {
283
+ readFiles: [...preparation.fileOps.read],
284
+ modifiedFiles: [...preparation.fileOps.written, ...preparation.fileOps.edited],
285
+ },
286
+ });
287
+
288
+ // ── Guard: empty summary (edge-case #16) ──
289
+ if (!summary || !summary.trim()) {
290
+ await dbg(settings, {
291
+ cancelled: true,
292
+ reason: "empty-summary-guard",
293
+ summaryLength: summary?.length ?? 0,
294
+ });
295
+ try {
296
+ ctx?.ui?.notify?.(
297
+ "pi-vcc: Compaction summary is empty. Cancelling — falling back to pi-core default compaction.",
298
+ "warning",
299
+ );
300
+ } catch (e) {
301
+ // Intentional fallback: UI notifications are best-effort and should not crash the compaction.
302
+ }
303
+ return { cancel: true };
304
+ }
305
+
306
+ // ── Guard: zero-token post-compaction result (edge-case #16) ──
307
+ // If compaction would leave zero or near-zero context, cancel and let
308
+ // pi-core default compaction handle it (which keeps keepRecentTokens).
309
+ const MIN_POST_COMPACTION_TOKENS = 4096;
310
+ const summaryTokensEst = Math.round(summary.length / 4);
311
+ const postCompactTokensEst = (state.stats.keptTokensEst ?? 0) + summaryTokensEst;
312
+ if (postCompactTokensEst < MIN_POST_COMPACTION_TOKENS && ownCut.compactAll) {
313
+ await dbg(settings, {
314
+ cancelled: true,
315
+ reason: "zero-token-guard",
316
+ postCompactTokensEst,
317
+ keptTokensEst: state.stats.keptTokensEst,
318
+ summaryTokensEst,
319
+ MIN_POST_COMPACTION_TOKENS,
320
+ compactAll: ownCut.compactAll,
321
+ });
322
+ try {
323
+ ctx?.ui?.notify?.(
324
+ `pi-vcc: Post-compaction context would be ~${postCompactTokensEst} tokens (min ${MIN_POST_COMPACTION_TOKENS}). ` +
325
+ "Cancelling — falling back to pi-core default compaction.",
326
+ "warning",
327
+ );
328
+ } catch (e) {
329
+ // Intentional fallback: UI notifications are best-effort and should not crash the compaction.
330
+ }
331
+ return { cancel: true };
332
+ }
333
+
334
+ const branchIds = typedBranchEntries.map((e) => e.id);
335
+ const cutIdx = branchIds.indexOf(firstKeptEntryId);
336
+ const cutWindow =
337
+ cutIdx >= 0
338
+ ? typedBranchEntries.slice(Math.max(0, cutIdx - 3), Math.min(branchEntries.length, cutIdx + 3)).map((e) => ({
339
+ id: e.id,
340
+ type: e.type,
341
+ role: e.type === "message" ? e.message?.role : undefined,
342
+ preview: e.type === "message" ? previewContent(e.message?.content) : undefined,
343
+ }))
344
+ : [];
345
+
346
+ await dbg(config, {
347
+ usedOwnCut: true,
348
+ messagesToSummarize: agentMessages.length,
349
+ messagesPreviewHead: agentMessages
350
+ .slice(0, 3)
351
+ .map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
352
+ messagesPreviewTail: agentMessages
353
+ .slice(-3)
354
+ .map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
355
+ convertedMessages: messages.length,
356
+ firstKeptEntryId,
357
+ cutWindow,
358
+ tokensBefore: preparation.tokensBefore,
359
+ summaryLength: summary.length,
360
+ summaryPreview: summary.slice(0, 500),
361
+ sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
362
+ });
363
+
364
+ const details: PiVccCompactionDetails = {
365
+ compactor: "pi-vcc",
366
+ version: 1,
367
+ sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
368
+ sourceMessageCount: agentMessages.length,
369
+ previousSummaryUsed: Boolean(preparation.previousSummary),
370
+ };
371
+
372
+ state.wasPiVcc = isPiVcc;
373
+
374
+ return {
375
+ compaction: {
376
+ summary,
377
+ details,
378
+ tokensBefore: preparation.tokensBefore,
379
+ firstKeptEntryId,
380
+ },
381
+ };
382
+ });
383
+
384
+ // Fire success toast for /compact path only (delayed to let UI settle).
385
+ // /pi-vcc path uses its own onComplete callback in the command handler.
386
+ pi.on("session_compact", (event, ctx) => {
387
+ const state = sessionState.get(event);
388
+ if (!event.fromExtension) return;
389
+ if (state?.wasPiVcc) return; // /pi-vcc handles its own toast via onComplete
390
+ const stats = state?.stats;
391
+ if (!stats) return;
392
+ setTimeout(() => {
393
+ try {
394
+ ctx?.ui?.notify?.(
395
+ `pi-vcc: ${stats.summarized} source entries processed; tail kept ${
396
+ stats.kept
397
+ } (~${formatTokens(stats.keptTokensEst)} tok).`,
398
+ "info",
399
+ );
400
+ } catch (e) {
401
+ // Intentional fallback: UI notifications are best-effort and should not crash the compaction.
402
+ }
403
+ }, UI_SETTLE_DELAY_MS);
404
+ });
405
+ };
@@ -0,0 +1,14 @@
1
+ import type { TranscriptEntry } from "./core/brief";
2
+
3
+ export interface SectionData {
4
+ sessionGoal: string[];
5
+ outstandingContext: string[];
6
+ filesAndChanges: string[];
7
+ commits: string[];
8
+ references: string[];
9
+ keySignals: string[];
10
+ userPreferences: string[];
11
+ briefTranscript: string;
12
+ /** Structured transcript entries (verbose object format) */
13
+ transcriptEntries: TranscriptEntry[];
14
+ }
@@ -0,0 +1,109 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import { loadAllMessages } from "../core/load-messages";
4
+ import { searchEntries } from "../core/search-entries";
5
+ import { formatRecallOutput } from "../core/format-recall";
6
+ import { getActiveLineageEntryIds } from "../core/lineage";
7
+ import { normalizeRecallScope } from "../core/recall-scope";
8
+
9
+ const DEFAULT_RECENT = 25;
10
+ const PAGE_SIZE = 5;
11
+
12
+ export const invalidExpandIndices = (requested: number[], available: Set<number>): number[] =>
13
+ requested.filter((i) => !Number.isInteger(i) || !available.has(i));
14
+
15
+ export const registerRecallTool = (pi: ExtensionAPI) => {
16
+ pi.registerTool({
17
+ name: "vcc_recall",
18
+ label: "VCC Recall",
19
+ description:
20
+ "Search session history. Defaults to active lineage; use scope:'all' to include off-lineage branches." +
21
+ " Supports regex queries, paging, and expand indices.",
22
+ promptSnippet:
23
+ "vcc_recall: Search history; default scope is active lineage. Use scope:'all' for off-lineage branches.",
24
+ parameters: Type.Object({
25
+ query: Type.Optional(
26
+ Type.String({ description: "Search terms or regex pattern (e.g. 'hook|inject', 'fail.*build'). Multi-word = OR ranked by relevance." }),
27
+ ),
28
+ expand: Type.Optional(
29
+ Type.Array(Type.Number(), { description: "Entry indices to return full untruncated content for" }),
30
+ ),
31
+ page: Type.Optional(
32
+ Type.Number({ description: "Page number (1-based) for paginated search results. Default: 1." }),
33
+ ),
34
+ scope: Type.Optional(
35
+ Type.Union([
36
+ Type.Literal("lineage"),
37
+ Type.Literal("all"),
38
+ ], { description: "Search scope. Default: lineage; all includes off-lineage branches." }),
39
+ ),
40
+ }),
41
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
42
+ const sessionFile = ctx.sessionManager.getSessionFile();
43
+ if (!sessionFile) {
44
+ return {
45
+ content: [{ type: "text", text: "No session file available." }],
46
+ details: undefined,
47
+ };
48
+ }
49
+
50
+ const scope = normalizeRecallScope(params.scope);
51
+ const lineageEntryIds = scope === "lineage"
52
+ ? getActiveLineageEntryIds(ctx.sessionManager)
53
+ : undefined;
54
+ const expandSet = new Set(params.expand ?? []);
55
+ const hasExpand = expandSet.size > 0;
56
+
57
+ if (hasExpand && !params.query) {
58
+ const { rendered: fullMsgs } = await loadAllMessages(sessionFile, true, lineageEntryIds);
59
+ const requested = [...expandSet];
60
+ const byIndex = new Map(fullMsgs.map((m) => [m.index, m]));
61
+ const invalid = invalidExpandIndices(requested, new Set(byIndex.keys()));
62
+ if (invalid.length > 0) {
63
+ return {
64
+ content: [{ type: "text", text: `Cannot expand indices outside ${scope === "all" ? "session history" : "active lineage"}: ${invalid.join(", ")}` }],
65
+ details: undefined,
66
+ };
67
+ }
68
+
69
+ const expanded = requested.map((i) => byIndex.get(i)).filter((m): m is NonNullable<typeof m> => Boolean(m));
70
+ const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(expanded);
71
+ return {
72
+ content: [{ type: "text", text: output }],
73
+ details: undefined,
74
+ };
75
+ }
76
+
77
+ const { rendered: msgs, rawMessages } = await loadAllMessages(sessionFile, false, lineageEntryIds);
78
+ const allResults = params.query?.trim()
79
+ ? searchEntries(msgs, rawMessages, params.query)
80
+ : msgs.slice(-DEFAULT_RECENT);
81
+
82
+ if (params.query?.trim()) {
83
+ const page = Math.max(1, params.page ?? 1);
84
+ const start = (page - 1) * PAGE_SIZE;
85
+ const pageResults = allResults.slice(start, start + PAGE_SIZE);
86
+ const totalPages = Math.ceil(allResults.length / PAGE_SIZE);
87
+ const scopeSuffix = scope === "all" ? " (scope: all)" : "";
88
+ const header = totalPages > 1
89
+ ? `Page ${page}/${totalPages} (${allResults.length} total matches${scopeSuffix})`
90
+ : `${allResults.length} matches${scopeSuffix}`;
91
+ const footer = page < totalPages
92
+ ? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---`
93
+ : "";
94
+ const output = formatRecallOutput(pageResults, params.query, header) + footer;
95
+ return {
96
+ content: [{ type: "text", text: output }],
97
+ details: undefined,
98
+ };
99
+ }
100
+
101
+ const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(allResults, params.query);
102
+ return {
103
+ content: [{ type: "text", text: output }],
104
+ details: undefined,
105
+ };
106
+ },
107
+ });
108
+ };
109
+
package/src/types.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { Message } from "@mariozechner/pi-ai";
2
+
3
+ export interface FileOps {
4
+ readFiles?: string[];
5
+ modifiedFiles?: string[];
6
+ createdFiles?: string[];
7
+ }
8
+
9
+ export type NormalizedBlock =
10
+ | { kind: "user"; text: string; sourceIndex?: number }
11
+ | { kind: "assistant"; text: string; sourceIndex?: number }
12
+ | { kind: "tool_call"; name: string; args: Record<string, unknown>; sourceIndex?: number }
13
+ | { kind: "tool_result"; name: string; text: string; isError: boolean; sourceIndex?: number }
14
+ | { kind: "thinking"; text: string; redacted: boolean; sourceIndex?: number };