pi-ui-extend 1.0.35 → 1.0.37

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.
@@ -0,0 +1,674 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
+ import { Type } from "typebox";
4
+
5
+ import { SESSION_RECOVERY_TOOL_DESCRIPTIONS } from "../tool-descriptions.js";
6
+
7
+ type Scope = "active" | "all";
8
+ type UnknownRecord = Record<string, unknown>;
9
+
10
+ type SessionManagerLike = {
11
+ getBranch?: () => unknown;
12
+ getEntries?: () => unknown;
13
+ getHeader?: () => unknown;
14
+ getSessionFile?: () => unknown;
15
+ getSessionId?: () => unknown;
16
+ getSessionName?: () => unknown;
17
+ };
18
+
19
+ type EntryLike = UnknownRecord & {
20
+ id: string;
21
+ type: string;
22
+ parentId?: string | null;
23
+ timestamp?: string;
24
+ };
25
+
26
+ type ToolCallLike = {
27
+ id: string;
28
+ name: string;
29
+ arguments: unknown;
30
+ entryId: string;
31
+ };
32
+
33
+ type FileEvidence = {
34
+ readFiles: string[];
35
+ modifiedFiles: string[];
36
+ };
37
+
38
+ type Section = {
39
+ id: string;
40
+ startEntryId: string;
41
+ endEntryId: string;
42
+ entries: EntryLike[];
43
+ label: string;
44
+ };
45
+
46
+ const SCOPE_SCHEMA = StringEnum(["active", "all"] as const, {
47
+ description: "Raw session scope. Defaults to the active root-to-leaf branch; all includes abandoned branches.",
48
+ });
49
+
50
+ const MAX_OUTPUT_CHARS = 30_000;
51
+ const DEFAULT_BODY_CHARS = 2_000;
52
+ const MAX_BODY_CHARS = 8_000;
53
+ const DEFAULT_SECTION_ENTRIES = 20;
54
+ const MAX_SECTION_ENTRIES = 50;
55
+ const DEFAULT_SEARCH_RESULTS = 20;
56
+ const MAX_SEARCH_RESULTS = 50;
57
+ const DEFAULT_OVERVIEW_SECTIONS = 20;
58
+ const MAX_OVERVIEW_SECTIONS = 100;
59
+ const DEFAULT_RECENT_ERRORS = 5;
60
+ const MAX_RECENT_ERRORS = 20;
61
+ const MAX_RECOVERY_FILES = 200;
62
+ const MAX_PENDING_TOOL_CALLS = 50;
63
+ const MAX_SEARCH_QUERY_CHARS = 500;
64
+
65
+ const READ_TOOL_NAMES = new Set([
66
+ "read",
67
+ "grep",
68
+ "glob",
69
+ "find",
70
+ "ls",
71
+ "ast_grep",
72
+ "repo_architecture",
73
+ "repo_structure",
74
+ "repo_ast",
75
+ "repo_search",
76
+ "repo_explain",
77
+ "repo_deps",
78
+ ]);
79
+
80
+ const MUTATING_TOOL_NAMES = new Set(["write", "edit", "apply_patch", "ast_apply"]);
81
+
82
+ function isRecord(value: unknown): value is UnknownRecord {
83
+ return typeof value === "object" && value !== null && !Array.isArray(value);
84
+ }
85
+
86
+ function isEntry(value: unknown): value is EntryLike {
87
+ return isRecord(value) && typeof value.id === "string" && value.id.length > 0
88
+ && typeof value.type === "string" && value.type.length > 0;
89
+ }
90
+
91
+ function clampInteger(value: unknown, fallback: number, minimum: number, maximum: number): number {
92
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
93
+ return Math.min(maximum, Math.max(minimum, Math.floor(value)));
94
+ }
95
+
96
+ function scopeFrom(value: unknown): Scope {
97
+ return value === "all" ? "all" : "active";
98
+ }
99
+
100
+ function callSafely<T>(operation: (() => unknown) | undefined, fallback: T): T {
101
+ if (!operation) return fallback;
102
+ try {
103
+ return operation() as T;
104
+ } catch {
105
+ return fallback;
106
+ }
107
+ }
108
+
109
+ function sessionManagerFrom(context: unknown): SessionManagerLike | undefined {
110
+ if (!isRecord(context) || !isRecord(context.sessionManager)) return undefined;
111
+ return context.sessionManager as SessionManagerLike;
112
+ }
113
+
114
+ function entriesFor(manager: SessionManagerLike | undefined, scope: Scope): EntryLike[] {
115
+ if (!manager) return [];
116
+ const raw = callSafely<unknown>(
117
+ scope === "all"
118
+ ? () => manager.getEntries?.()
119
+ : () => manager.getBranch?.(),
120
+ [],
121
+ );
122
+ return Array.isArray(raw) ? raw.filter(isEntry) : [];
123
+ }
124
+
125
+ function messageFrom(entry: EntryLike): UnknownRecord | undefined {
126
+ return entry.type === "message" && isRecord(entry.message) ? entry.message : undefined;
127
+ }
128
+
129
+ function messageRole(entry: EntryLike): string | undefined {
130
+ const role = messageFrom(entry)?.role;
131
+ return typeof role === "string" ? role : undefined;
132
+ }
133
+
134
+ function textFromContent(content: unknown): string {
135
+ if (typeof content === "string") return content;
136
+ if (!Array.isArray(content)) return "";
137
+
138
+ return content
139
+ .filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
140
+ .map((part) => (part as UnknownRecord).text as string)
141
+ .join("\n");
142
+ }
143
+
144
+ function toolCallsFrom(entry: EntryLike): ToolCallLike[] {
145
+ const message = messageFrom(entry);
146
+ if (!message || !Array.isArray(message.content)) return [];
147
+
148
+ const calls: ToolCallLike[] = [];
149
+ for (const part of message.content) {
150
+ if (!isRecord(part) || part.type !== "toolCall" || typeof part.id !== "string" || typeof part.name !== "string") continue;
151
+ calls.push({ id: part.id, name: part.name, arguments: part.arguments, entryId: entry.id });
152
+ }
153
+ return calls;
154
+ }
155
+
156
+ function toolResultFrom(entry: EntryLike): UnknownRecord | undefined {
157
+ const message = messageFrom(entry);
158
+ return message?.role === "toolResult" ? message : undefined;
159
+ }
160
+
161
+ function summaryFrom(entry: EntryLike): string {
162
+ if ((entry.type === "compaction" || entry.type === "branch_summary") && typeof entry.summary === "string") {
163
+ return entry.summary;
164
+ }
165
+ return "";
166
+ }
167
+
168
+ function entryText(entry: EntryLike): string {
169
+ const message = messageFrom(entry);
170
+ const chunks: string[] = [];
171
+
172
+ if (message) {
173
+ const text = textFromContent(message.content);
174
+ if (text) chunks.push(text);
175
+ for (const call of toolCallsFrom(entry)) {
176
+ chunks.push(call.name, serializeJson(call.arguments));
177
+ }
178
+ } else if (entry.type === "custom_message") {
179
+ const text = textFromContent(entry.content);
180
+ if (text) chunks.push(text);
181
+ }
182
+
183
+ const summary = summaryFrom(entry);
184
+ if (summary) chunks.push(summary);
185
+ return chunks.join("\n");
186
+ }
187
+
188
+ function preview(value: string, maximum = 120): string {
189
+ const singleLine = value.replace(/\s+/g, " ").trim();
190
+ if (singleLine.length <= maximum) return singleLine;
191
+ return `${singleLine.slice(0, Math.max(0, maximum - 1))}…`;
192
+ }
193
+
194
+ function truncate(value: string, maximum: number): string {
195
+ if (value.length <= maximum) return value;
196
+ return `${value.slice(0, Math.max(0, maximum - 20))}\n…[truncated]`;
197
+ }
198
+
199
+ function safeJson(value: unknown, maximum = 2_000): string {
200
+ return truncate(serializeJson(value), maximum);
201
+ }
202
+
203
+ function serializeJson(value: unknown): string {
204
+ try {
205
+ const json = JSON.stringify(value);
206
+ return json ?? String(value);
207
+ } catch {
208
+ return "[unserializable]";
209
+ }
210
+ }
211
+
212
+ function sectionLabel(entry: EntryLike): string {
213
+ const role = messageRole(entry);
214
+ const text = entryText(entry);
215
+ if (role === "user") return preview(text) || "User message";
216
+ if (entry.type === "compaction") return `Compaction: ${preview(text) || entry.id}`;
217
+ if (entry.type === "branch_summary") return `Branch summary: ${preview(text) || entry.id}`;
218
+ return preview(text) || `${entry.type} ${entry.id}`;
219
+ }
220
+
221
+ function isSectionStart(entry: EntryLike, index: number): boolean {
222
+ return index === 0 || messageRole(entry) === "user" || entry.type === "compaction" || entry.type === "branch_summary";
223
+ }
224
+
225
+ function buildSections(entries: EntryLike[]): Section[] {
226
+ const sections: Section[] = [];
227
+ for (let index = 0; index < entries.length; index += 1) {
228
+ const entry = entries[index]!;
229
+ if (isSectionStart(entry, index)) {
230
+ sections.push({
231
+ id: `section:${entry.id}`,
232
+ startEntryId: entry.id,
233
+ endEntryId: entry.id,
234
+ entries: [entry],
235
+ label: sectionLabel(entry),
236
+ });
237
+ continue;
238
+ }
239
+
240
+ const section = sections[sections.length - 1]!;
241
+ section.entries.push(entry);
242
+ section.endEntryId = entry.id;
243
+ }
244
+ return sections;
245
+ }
246
+
247
+ function stringValues(value: unknown): string[] {
248
+ if (typeof value === "string") return value.trim() ? [value.trim()] : [];
249
+ if (!Array.isArray(value)) return [];
250
+ return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0).map((item) => item.trim());
251
+ }
252
+
253
+ function pathsFromArguments(argumentsValue: unknown): string[] {
254
+ if (!isRecord(argumentsValue)) return [];
255
+ const paths = [
256
+ ...stringValues(argumentsValue.path),
257
+ ...stringValues(argumentsValue.file_path),
258
+ ...stringValues(argumentsValue.paths),
259
+ ];
260
+ return [...new Set(paths)];
261
+ }
262
+
263
+ function patchPaths(argumentsValue: unknown): string[] {
264
+ if (!isRecord(argumentsValue)) return [];
265
+ let patch = "";
266
+ if (typeof argumentsValue.input === "string") patch = argumentsValue.input;
267
+ else if (typeof argumentsValue.patch === "string") patch = argumentsValue.patch;
268
+ if (!patch) return [];
269
+
270
+ const paths: string[] = [];
271
+ for (const line of patch.split(/\r?\n/)) {
272
+ const piHeader = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/) ?? line.match(/^\*\*\* Move to: (.+)$/);
273
+ const unifiedHeader = line.match(/^\+\+\+ (?:b\/)?(.+)$/);
274
+ const candidate = piHeader?.[1] ?? unifiedHeader?.[1];
275
+ if (candidate && candidate !== "/dev/null") paths.push(candidate.trim());
276
+ }
277
+ return [...new Set(paths.filter(Boolean))];
278
+ }
279
+
280
+ function evidenceFromEntries(entries: EntryLike[]): FileEvidence {
281
+ const readFiles = new Set<string>();
282
+ const modifiedFiles = new Set<string>();
283
+
284
+ for (const entry of entries) {
285
+ for (const call of toolCallsFrom(entry)) {
286
+ const normalizedName = call.name.toLowerCase();
287
+ if (READ_TOOL_NAMES.has(normalizedName)) {
288
+ for (const file of pathsFromArguments(call.arguments)) readFiles.add(file);
289
+ }
290
+ if (MUTATING_TOOL_NAMES.has(normalizedName)) {
291
+ for (const file of pathsFromArguments(call.arguments)) modifiedFiles.add(file);
292
+ if (normalizedName === "apply_patch") {
293
+ for (const file of patchPaths(call.arguments)) modifiedFiles.add(file);
294
+ }
295
+ }
296
+ }
297
+
298
+ if ((entry.type === "compaction" || entry.type === "branch_summary") && isRecord(entry.details)) {
299
+ for (const file of stringValues(entry.details.readFiles)) readFiles.add(file);
300
+ for (const file of stringValues(entry.details.modifiedFiles)) modifiedFiles.add(file);
301
+ }
302
+ }
303
+
304
+ return { readFiles: [...readFiles], modifiedFiles: [...modifiedFiles] };
305
+ }
306
+
307
+ function sectionSummary(section: Section): UnknownRecord {
308
+ const roles: Record<string, number> = {};
309
+ let toolCalls = 0;
310
+ let toolResults = 0;
311
+ let errors = 0;
312
+
313
+ for (const entry of section.entries) {
314
+ const role = messageRole(entry);
315
+ if (role) roles[role] = (roles[role] ?? 0) + 1;
316
+ toolCalls += toolCallsFrom(entry).length;
317
+ const result = toolResultFrom(entry);
318
+ if (result) {
319
+ toolResults += 1;
320
+ if (result.isError === true) errors += 1;
321
+ }
322
+ }
323
+
324
+ const files = evidenceFromEntries(section.entries);
325
+ return {
326
+ id: section.id,
327
+ label: section.label,
328
+ startEntryId: section.startEntryId,
329
+ endEntryId: section.endEntryId,
330
+ entryCount: section.entries.length,
331
+ roles,
332
+ toolCalls,
333
+ toolResults,
334
+ errors,
335
+ readFileCount: files.readFiles.length,
336
+ modifiedFileCount: files.modifiedFiles.length,
337
+ };
338
+ }
339
+
340
+ function boundedHeadAndTail<T>(values: T[], maximum: number): { values: T[]; omitted: number } {
341
+ if (values.length <= maximum) return { values, omitted: 0 };
342
+ const headCount = Math.ceil(maximum / 2);
343
+ const tailCount = Math.floor(maximum / 2);
344
+ return {
345
+ values: [...values.slice(0, headCount), ...values.slice(values.length - tailCount)],
346
+ omitted: values.length - maximum,
347
+ };
348
+ }
349
+
350
+ function leafCount(entries: EntryLike[]): number {
351
+ if (entries.length === 0) return 0;
352
+ const ids = new Set(entries.map((entry) => entry.id));
353
+ const parents = new Set(
354
+ entries
355
+ .map((entry) => entry.parentId)
356
+ .filter((parentId): parentId is string => typeof parentId === "string" && ids.has(parentId)),
357
+ );
358
+ return entries.reduce((count, entry) => count + (parents.has(entry.id) ? 0 : 1), 0);
359
+ }
360
+
361
+ function renderEntry(entry: EntryLike, bodyChars: number): string {
362
+ const heading = [`[${entry.id}]`, entry.timestamp, entry.type, messageRole(entry)].filter(Boolean).join(" ");
363
+ const lines = [heading];
364
+ const message = messageFrom(entry);
365
+
366
+ if (message) {
367
+ const text = textFromContent(message.content);
368
+ if (text) lines.push(truncate(text, bodyChars));
369
+ for (const call of toolCallsFrom(entry)) {
370
+ lines.push(`tool_call ${call.name}#${call.id} ${safeJson(call.arguments, bodyChars)}`);
371
+ }
372
+ if (message.role === "toolResult") {
373
+ const toolName = typeof message.toolName === "string" ? message.toolName : "unknown";
374
+ const callId = typeof message.toolCallId === "string" ? message.toolCallId : "unknown";
375
+ lines.push(`tool_result ${toolName}#${callId}${message.isError === true ? " error" : ""}`);
376
+ }
377
+ } else if (entry.type === "compaction") {
378
+ lines.push(truncate(summaryFrom(entry), bodyChars));
379
+ if (typeof entry.firstKeptEntryId === "string") lines.push(`firstKeptEntryId: ${entry.firstKeptEntryId}`);
380
+ if (typeof entry.tokensBefore === "number") lines.push(`tokensBefore: ${entry.tokensBefore}`);
381
+ } else if (entry.type === "branch_summary") {
382
+ lines.push(truncate(summaryFrom(entry), bodyChars));
383
+ if (typeof entry.fromId === "string") lines.push(`fromId: ${entry.fromId}`);
384
+ } else if (entry.type === "custom_message") {
385
+ const text = textFromContent(entry.content);
386
+ if (text) lines.push(truncate(text, bodyChars));
387
+ } else {
388
+ const fields = Object.fromEntries(
389
+ Object.entries(entry).filter(([key]) => !["id", "parentId", "timestamp", "type"].includes(key)),
390
+ );
391
+ if (Object.keys(fields).length > 0) lines.push(safeJson(fields, bodyChars));
392
+ }
393
+
394
+ return lines.join("\n");
395
+ }
396
+
397
+ function contentResult(payload: unknown, details: UnknownRecord): { content: Array<{ type: "text"; text: string }>; details: UnknownRecord } {
398
+ const serialized = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
399
+ return {
400
+ content: [{ type: "text", text: truncate(serialized, MAX_OUTPUT_CHARS) }],
401
+ details,
402
+ };
403
+ }
404
+
405
+ function emptyResult(scope: Scope): ReturnType<typeof contentResult> {
406
+ return contentResult(
407
+ `No raw session entries are available for scope ${scope}. The session may be new, ephemeral, or unavailable in this context.`,
408
+ { scope, entryCount: 0 },
409
+ );
410
+ }
411
+
412
+ function sectionIdByEntry(sections: Section[]): Map<string, string> {
413
+ const map = new Map<string, string>();
414
+ for (const section of sections) {
415
+ for (const entry of section.entries) map.set(entry.id, section.id);
416
+ }
417
+ return map;
418
+ }
419
+
420
+ function searchSnippet(text: string, query: string, caseSensitive: boolean, maximum = 360): string {
421
+ const haystack = caseSensitive ? text : text.toLocaleLowerCase();
422
+ const needle = caseSensitive ? query : query.toLocaleLowerCase();
423
+ const index = haystack.indexOf(needle);
424
+ if (index < 0) return preview(text, maximum);
425
+ const start = Math.max(0, index - Math.floor((maximum - needle.length) / 2));
426
+ const end = Math.min(text.length, start + maximum);
427
+ return `${start > 0 ? "…" : ""}${text.slice(start, end).replace(/\s+/g, " ").trim()}${end < text.length ? "…" : ""}`;
428
+ }
429
+
430
+ function meaningfulAction(entry: EntryLike, excludedToolCallId: string): UnknownRecord | undefined {
431
+ const role = messageRole(entry);
432
+ const calls = toolCallsFrom(entry).filter((call) => call.id !== excludedToolCallId);
433
+ const message = messageFrom(entry);
434
+ const messageText = message ? textFromContent(message.content) : "";
435
+ const text = messageText || summaryFrom(entry) || (entry.type === "custom_message" ? textFromContent(entry.content) : "");
436
+ if (!text && calls.length === 0 && entry.type !== "compaction" && entry.type !== "branch_summary") return undefined;
437
+ return {
438
+ entryId: entry.id,
439
+ type: entry.type,
440
+ ...(role ? { role } : {}),
441
+ ...(text ? { preview: preview(text, 300) } : {}),
442
+ ...(calls.length > 0 ? { toolCalls: calls.map((call) => ({ id: call.id, name: call.name })) } : {}),
443
+ };
444
+ }
445
+
446
+ export default function sessionRecovery(pi: ExtensionAPI): void {
447
+ pi.registerTool({
448
+ ...SESSION_RECOVERY_TOOL_DESCRIPTIONS.overview,
449
+ parameters: Type.Object({
450
+ scope: Type.Optional(SCOPE_SCHEMA),
451
+ max_sections: Type.Optional(Type.Number({
452
+ description: "Maximum section summaries to return, split between the head and tail.",
453
+ minimum: 1,
454
+ maximum: MAX_OVERVIEW_SECTIONS,
455
+ })),
456
+ }, { additionalProperties: false }),
457
+ async execute(_toolCallId: string, params: { scope?: Scope; max_sections?: number }, _signal: AbortSignal, _onUpdate: unknown, ctx: unknown) {
458
+ const scope = scopeFrom(params.scope);
459
+ const manager = sessionManagerFrom(ctx);
460
+ const entries = entriesFor(manager, scope);
461
+ if (entries.length === 0) return emptyResult(scope);
462
+
463
+ const activeEntries = entriesFor(manager, "active");
464
+ const allEntries = entriesFor(manager, "all");
465
+ const sections = buildSections(entries);
466
+ const maximum = clampInteger(params.max_sections, DEFAULT_OVERVIEW_SECTIONS, 1, MAX_OVERVIEW_SECTIONS);
467
+ const selected = boundedHeadAndTail(sections, maximum);
468
+ const allLeaves = leafCount(allEntries);
469
+ const header = callSafely<unknown>(() => manager?.getHeader?.(), undefined);
470
+ const sessionId = callSafely<unknown>(() => manager?.getSessionId?.(), undefined);
471
+ const sessionName = callSafely<unknown>(() => manager?.getSessionName?.(), undefined);
472
+ const sessionFile = callSafely<unknown>(() => manager?.getSessionFile?.(), undefined);
473
+ const payload = {
474
+ scope,
475
+ session: {
476
+ id: typeof sessionId === "string" ? sessionId : null,
477
+ name: typeof sessionName === "string" ? sessionName : null,
478
+ persisted: typeof sessionFile === "string" && sessionFile.length > 0,
479
+ hasParentSession: isRecord(header) && header.parentSession != null,
480
+ },
481
+ counts: {
482
+ selectedEntries: entries.length,
483
+ activeEntries: activeEntries.length,
484
+ allEntries: allEntries.length,
485
+ sections: sections.length,
486
+ compactions: entries.filter((entry) => entry.type === "compaction").length,
487
+ leaves: allLeaves,
488
+ otherBranches: Math.max(0, allLeaves - (activeEntries.length > 0 ? 1 : 0)),
489
+ },
490
+ sections: selected.values.map(sectionSummary),
491
+ omittedSections: selected.omitted,
492
+ };
493
+ return contentResult(payload, { scope, entryCount: entries.length, sectionCount: sections.length, omittedSections: selected.omitted });
494
+ },
495
+ });
496
+
497
+ pi.registerTool({
498
+ ...SESSION_RECOVERY_TOOL_DESCRIPTIONS.readSection,
499
+ parameters: Type.Object({
500
+ section_id: Type.String({ description: "Stable section ID returned by session_overview or session_search.", maxLength: 200 }),
501
+ scope: Type.Optional(SCOPE_SCHEMA),
502
+ max_entries: Type.Optional(Type.Number({ description: "Maximum entries to render from the section.", minimum: 1, maximum: MAX_SECTION_ENTRIES })),
503
+ max_body_chars: Type.Optional(Type.Number({ description: "Maximum rendered body characters per entry.", minimum: 100, maximum: MAX_BODY_CHARS })),
504
+ }, { additionalProperties: false }),
505
+ async execute(_toolCallId: string, params: { section_id: string; scope?: Scope; max_entries?: number; max_body_chars?: number }, _signal: AbortSignal, _onUpdate: unknown, ctx: unknown) {
506
+ const scope = scopeFrom(params.scope);
507
+ const entries = entriesFor(sessionManagerFrom(ctx), scope);
508
+ if (entries.length === 0) return emptyResult(scope);
509
+ const section = buildSections(entries).find((candidate) => candidate.id === params.section_id);
510
+ if (!section) {
511
+ return contentResult(
512
+ `Section ${params.section_id} was not found in scope ${scope}. Run session_overview with the same scope to refresh section IDs.`,
513
+ { scope, sectionId: params.section_id, found: false },
514
+ );
515
+ }
516
+
517
+ const maximum = clampInteger(params.max_entries, DEFAULT_SECTION_ENTRIES, 1, MAX_SECTION_ENTRIES);
518
+ const bodyChars = clampInteger(params.max_body_chars, DEFAULT_BODY_CHARS, 100, MAX_BODY_CHARS);
519
+ const rendered: string[] = [];
520
+ let renderedChars = 0;
521
+ let truncatedOutput = false;
522
+ for (const entry of section.entries.slice(0, maximum)) {
523
+ const next = renderEntry(entry, bodyChars);
524
+ if (renderedChars + next.length + 2 > MAX_OUTPUT_CHARS - 500) {
525
+ truncatedOutput = true;
526
+ break;
527
+ }
528
+ rendered.push(next);
529
+ renderedChars += next.length + 2;
530
+ }
531
+ const omittedEntries = section.entries.length - rendered.length;
532
+ return contentResult(
533
+ [`Section ${section.id}: ${section.label}`, ...rendered, omittedEntries > 0 ? `… ${omittedEntries} entries omitted` : ""].filter(Boolean).join("\n\n"),
534
+ {
535
+ scope,
536
+ sectionId: section.id,
537
+ entryCount: section.entries.length,
538
+ renderedCount: rendered.length,
539
+ omittedEntries,
540
+ truncated: truncatedOutput || omittedEntries > 0,
541
+ },
542
+ );
543
+ },
544
+ });
545
+
546
+ pi.registerTool({
547
+ ...SESSION_RECOVERY_TOOL_DESCRIPTIONS.search,
548
+ parameters: Type.Object({
549
+ query: Type.String({
550
+ description: "Literal substring to find in raw session text and tool arguments.",
551
+ minLength: 1,
552
+ maxLength: MAX_SEARCH_QUERY_CHARS,
553
+ }),
554
+ scope: Type.Optional(SCOPE_SCHEMA),
555
+ case_sensitive: Type.Optional(Type.Boolean({ description: "Use exact case matching. Defaults to false." })),
556
+ limit: Type.Optional(Type.Number({ description: "Maximum matches to return.", minimum: 1, maximum: MAX_SEARCH_RESULTS })),
557
+ }, { additionalProperties: false }),
558
+ async execute(_toolCallId: string, params: { query: string; scope?: Scope; case_sensitive?: boolean; limit?: number }, _signal: AbortSignal, _onUpdate: unknown, ctx: unknown) {
559
+ const scope = scopeFrom(params.scope);
560
+ const entries = entriesFor(sessionManagerFrom(ctx), scope);
561
+ if (entries.length === 0) return emptyResult(scope);
562
+ const query = params.query.trim().slice(0, MAX_SEARCH_QUERY_CHARS);
563
+ if (!query) return contentResult("Search query must not be empty.", { scope, query, matchCount: 0 });
564
+
565
+ const caseSensitive = params.case_sensitive === true;
566
+ const needle = caseSensitive ? query : query.toLocaleLowerCase();
567
+ const limit = clampInteger(params.limit, DEFAULT_SEARCH_RESULTS, 1, MAX_SEARCH_RESULTS);
568
+ const sections = buildSections(entries);
569
+ const entrySections = sectionIdByEntry(sections);
570
+ const matches: UnknownRecord[] = [];
571
+ let totalMatches = 0;
572
+
573
+ for (const entry of entries) {
574
+ const text = entryText(entry);
575
+ const haystack = caseSensitive ? text : text.toLocaleLowerCase();
576
+ if (!text || !haystack.includes(needle)) continue;
577
+ totalMatches += 1;
578
+ if (matches.length >= limit) continue;
579
+ matches.push({
580
+ entryId: entry.id,
581
+ sectionId: entrySections.get(entry.id),
582
+ type: entry.type,
583
+ ...(messageRole(entry) ? { role: messageRole(entry) } : {}),
584
+ timestamp: entry.timestamp ?? null,
585
+ snippet: searchSnippet(text, query, caseSensitive),
586
+ });
587
+ }
588
+
589
+ const payload = { scope, query, caseSensitive, totalMatches, returnedMatches: matches.length, matches };
590
+ return contentResult(payload, { scope, query, matchCount: totalMatches, returnedCount: matches.length, truncated: totalMatches > matches.length });
591
+ },
592
+ });
593
+
594
+ pi.registerTool({
595
+ ...SESSION_RECOVERY_TOOL_DESCRIPTIONS.recoveryContext,
596
+ parameters: Type.Object({
597
+ scope: Type.Optional(SCOPE_SCHEMA),
598
+ recent_error_limit: Type.Optional(Type.Number({ description: "Maximum recent error tool results to report.", minimum: 1, maximum: MAX_RECENT_ERRORS })),
599
+ }, { additionalProperties: false }),
600
+ async execute(toolCallId: string, params: { scope?: Scope; recent_error_limit?: number }, _signal: AbortSignal, _onUpdate: unknown, ctx: unknown) {
601
+ const scope = scopeFrom(params.scope);
602
+ const entries = entriesFor(sessionManagerFrom(ctx), scope);
603
+ if (entries.length === 0) return emptyResult(scope);
604
+
605
+ const sections = buildSections(entries);
606
+ const entrySections = sectionIdByEntry(sections);
607
+ const userEntries = entries.filter((entry) => messageRole(entry) === "user" && entryText(entry).trim().length > 0);
608
+ const pending = new Map<string, ToolCallLike>();
609
+ const errors: UnknownRecord[] = [];
610
+
611
+ for (const entry of entries) {
612
+ for (const call of toolCallsFrom(entry)) pending.set(call.id, call);
613
+ const result = toolResultFrom(entry);
614
+ if (!result) continue;
615
+ if (typeof result.toolCallId === "string") pending.delete(result.toolCallId);
616
+ if (result.isError === true) {
617
+ errors.push({
618
+ entryId: entry.id,
619
+ sectionId: entrySections.get(entry.id),
620
+ toolCallId: typeof result.toolCallId === "string" ? result.toolCallId : null,
621
+ toolName: typeof result.toolName === "string" ? result.toolName : null,
622
+ message: preview(textFromContent(result.content), 500),
623
+ });
624
+ }
625
+ }
626
+ pending.delete(toolCallId);
627
+
628
+ const errorLimit = clampInteger(params.recent_error_limit, DEFAULT_RECENT_ERRORS, 1, MAX_RECENT_ERRORS);
629
+ const firstUser = userEntries[0];
630
+ const latestUser = userEntries[userEntries.length - 1];
631
+ const files = evidenceFromEntries(entries);
632
+ const readFiles = boundedHeadAndTail(files.readFiles, MAX_RECOVERY_FILES);
633
+ const modifiedFiles = boundedHeadAndTail(files.modifiedFiles, MAX_RECOVERY_FILES);
634
+ const pendingCalls = boundedHeadAndTail([...pending.values()], MAX_PENDING_TOOL_CALLS);
635
+ const lastAction = [...entries].reverse().map((entry) => meaningfulAction(entry, toolCallId)).find(Boolean) ?? null;
636
+ const payload = {
637
+ scope,
638
+ originalUserRequest: firstUser ? {
639
+ entryId: firstUser.id,
640
+ sectionId: entrySections.get(firstUser.id),
641
+ text: truncate(entryText(firstUser), DEFAULT_BODY_CHARS),
642
+ } : null,
643
+ latestUserInstruction: latestUser ? {
644
+ entryId: latestUser.id,
645
+ sectionId: entrySections.get(latestUser.id),
646
+ text: truncate(entryText(latestUser), DEFAULT_BODY_CHARS),
647
+ } : null,
648
+ readFiles: readFiles.values,
649
+ modifiedFiles: modifiedFiles.values,
650
+ omittedReadFiles: readFiles.omitted,
651
+ omittedModifiedFiles: modifiedFiles.omitted,
652
+ recentErrors: errors.slice(-errorLimit),
653
+ pendingToolCalls: pendingCalls.values.map((call) => ({
654
+ id: call.id,
655
+ name: call.name,
656
+ entryId: call.entryId,
657
+ sectionId: entrySections.get(call.entryId),
658
+ })),
659
+ omittedPendingToolCalls: pendingCalls.omitted,
660
+ lastMeaningfulAction: lastAction,
661
+ compactionCount: entries.filter((entry) => entry.type === "compaction").length,
662
+ entryCount: entries.length,
663
+ sectionCount: sections.length,
664
+ };
665
+ return contentResult(payload, {
666
+ scope,
667
+ entryCount: entries.length,
668
+ sectionCount: sections.length,
669
+ recentErrorCount: payload.recentErrors.length,
670
+ pendingToolCallCount: payload.pendingToolCalls.length,
671
+ });
672
+ },
673
+ });
674
+ }