import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { StringEnum } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { SESSION_RECOVERY_TOOL_DESCRIPTIONS } from "../tool-descriptions.js"; type Scope = "active" | "all"; type UnknownRecord = Record; type SessionManagerLike = { getBranch?: () => unknown; getEntries?: () => unknown; getHeader?: () => unknown; getSessionFile?: () => unknown; getSessionId?: () => unknown; getSessionName?: () => unknown; }; type EntryLike = UnknownRecord & { id: string; type: string; parentId?: string | null; timestamp?: string; }; type ToolCallLike = { id: string; name: string; arguments: unknown; entryId: string; }; type FileEvidence = { readFiles: string[]; modifiedFiles: string[]; }; type Section = { id: string; startEntryId: string; endEntryId: string; entries: EntryLike[]; label: string; }; type RecoveryCursor = | { version: 1; kind: "overview"; scope: Scope; afterSectionId: string } | { version: 1; kind: "search"; scope: Scope; query: string; caseSensitive: boolean; afterEntryId: string } | { version: 1; kind: "read"; scope: Scope; sectionId?: string; entryId: string; offset: number }; const SCOPE_SCHEMA = StringEnum(["active", "all"] as const, { description: "Raw session scope. Defaults to the active root-to-leaf branch; all includes abandoned branches.", }); const MAX_OUTPUT_CHARS = 30_000; const DEFAULT_BODY_CHARS = 2_000; const MAX_BODY_CHARS = 8_000; const DEFAULT_SECTION_ENTRIES = 20; const MAX_SECTION_ENTRIES = 50; const DEFAULT_SEARCH_RESULTS = 20; const MAX_SEARCH_RESULTS = 50; const DEFAULT_OVERVIEW_SECTIONS = 20; const MAX_OVERVIEW_SECTIONS = 100; const DEFAULT_RECENT_ERRORS = 5; const MAX_RECENT_ERRORS = 20; const MAX_RECOVERY_FILES = 200; const MAX_PENDING_TOOL_CALLS = 50; const MAX_SEARCH_QUERY_CHARS = 500; const CURSOR_VERSION = 1; const DCP_CONTROL_CUSTOM_TYPES = new Set(["dcp-journal", "dcp-nudge"]); const READ_TOOL_NAMES = new Set([ "read", "grep", "glob", "find", "ls", "ast_grep", "repo_architecture", "repo_structure", "repo_ast", "repo_search", "repo_explain", "repo_deps", ]); const MUTATING_TOOL_NAMES = new Set(["write", "edit", "apply_patch", "ast_apply"]); function isRecord(value: unknown): value is UnknownRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } function isEntry(value: unknown): value is EntryLike { return isRecord(value) && typeof value.id === "string" && value.id.length > 0 && typeof value.type === "string" && value.type.length > 0; } function isDcpControlEntry(entry: EntryLike): boolean { return entry.type === "custom" && typeof entry.customType === "string" && DCP_CONTROL_CUSTOM_TYPES.has(entry.customType); } function clampInteger(value: unknown, fallback: number, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isFinite(value)) return fallback; return Math.min(maximum, Math.max(minimum, Math.floor(value))); } function scopeFrom(value: unknown): Scope { return value === "all" ? "all" : "active"; } function callSafely(operation: (() => unknown) | undefined, fallback: T): T { if (!operation) return fallback; try { return operation() as T; } catch { return fallback; } } function sessionManagerFrom(context: unknown): SessionManagerLike | undefined { if (!isRecord(context) || !isRecord(context.sessionManager)) return undefined; return context.sessionManager as SessionManagerLike; } function entriesFor(manager: SessionManagerLike | undefined, scope: Scope): EntryLike[] { if (!manager) return []; const raw = callSafely( scope === "all" ? () => manager.getEntries?.() : () => manager.getBranch?.(), [], ); return Array.isArray(raw) ? raw.filter(isEntry).filter((entry) => !isDcpControlEntry(entry)) : []; } function encodeCursor(cursor: RecoveryCursor): string { return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); } function decodeCursor(value: unknown): RecoveryCursor | undefined { if (typeof value !== "string" || value.length === 0 || value.length > 2_000) return undefined; try { const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); if (!isRecord(parsed) || parsed.version !== CURSOR_VERSION || typeof parsed.kind !== "string") return undefined; if (parsed.scope !== "active" && parsed.scope !== "all") return undefined; if (parsed.kind === "overview" && typeof parsed.afterSectionId === "string") { return parsed as RecoveryCursor; } if ( parsed.kind === "search" && typeof parsed.query === "string" && typeof parsed.caseSensitive === "boolean" && typeof parsed.afterEntryId === "string" ) { return parsed as RecoveryCursor; } if ( parsed.kind === "read" && typeof parsed.entryId === "string" && typeof parsed.offset === "number" && Number.isInteger(parsed.offset) && parsed.offset >= 0 && (parsed.sectionId === undefined || typeof parsed.sectionId === "string") ) { return parsed as RecoveryCursor; } } catch { return undefined; } return undefined; } function messageFrom(entry: EntryLike): UnknownRecord | undefined { return entry.type === "message" && isRecord(entry.message) ? entry.message : undefined; } function messageRole(entry: EntryLike): string | undefined { const role = messageFrom(entry)?.role; return typeof role === "string" ? role : undefined; } function textFromContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string") .map((part) => (part as UnknownRecord).text as string) .join("\n"); } function toolCallsFrom(entry: EntryLike): ToolCallLike[] { const message = messageFrom(entry); if (!message || !Array.isArray(message.content)) return []; const calls: ToolCallLike[] = []; for (const part of message.content) { if (!isRecord(part) || part.type !== "toolCall" || typeof part.id !== "string" || typeof part.name !== "string") continue; calls.push({ id: part.id, name: part.name, arguments: part.arguments, entryId: entry.id }); } return calls; } function toolResultFrom(entry: EntryLike): UnknownRecord | undefined { const message = messageFrom(entry); return message?.role === "toolResult" ? message : undefined; } function recoverableGatewayDetails(message: UnknownRecord): UnknownRecord | undefined { const toolName = typeof message.toolName === "string" ? message.toolName.toLowerCase() : ""; if (toolName !== "web_search" && toolName !== "web_fetch") return undefined; if (!isRecord(message.details) || !isRecord(message.details.contextGateway)) return undefined; if (message.details.contextGateway.representation !== "web-recoverable-compact") return undefined; return message.details; } function summaryFrom(entry: EntryLike): string { if ((entry.type === "compaction" || entry.type === "branch_summary") && typeof entry.summary === "string") { return entry.summary; } return ""; } function entryText(entry: EntryLike): string { const message = messageFrom(entry); const chunks: string[] = []; if (message) { const text = textFromContent(message.content); if (text) chunks.push(text); for (const call of toolCallsFrom(entry)) { chunks.push(call.name, serializeJson(call.arguments)); } if (message.role === "toolResult") { const toolName = typeof message.toolName === "string" ? message.toolName : "unknown"; const callId = typeof message.toolCallId === "string" ? message.toolCallId : "unknown"; chunks.push(`tool_result ${toolName}#${callId}`); } } else if (entry.type === "custom_message") { const text = textFromContent(entry.content); if (text) chunks.push(text); } const summary = summaryFrom(entry); if (summary) chunks.push(summary); return chunks.join("\n"); } function preview(value: string, maximum = 120): string { const singleLine = value.replace(/\s+/g, " ").trim(); if (singleLine.length <= maximum) return singleLine; return `${singleLine.slice(0, Math.max(0, maximum - 1))}…`; } function truncate(value: string, maximum: number): string { if (value.length <= maximum) return value; return `${value.slice(0, Math.max(0, maximum - 20))}\n…[truncated]`; } function safeJson(value: unknown, maximum = 2_000): string { return truncate(serializeJson(value), maximum); } function serializeJson(value: unknown): string { try { const json = JSON.stringify(value); return json ?? String(value); } catch { return "[unserializable]"; } } function sectionLabel(entry: EntryLike): string { const role = messageRole(entry); const text = entryText(entry); if (role === "user") return preview(text) || "User message"; if (entry.type === "compaction") return `Compaction: ${preview(text) || entry.id}`; if (entry.type === "branch_summary") return `Branch summary: ${preview(text) || entry.id}`; return preview(text) || `${entry.type} ${entry.id}`; } function isSectionStart(entry: EntryLike, index: number): boolean { return index === 0 || messageRole(entry) === "user" || entry.type === "compaction" || entry.type === "branch_summary"; } function buildSections(entries: EntryLike[]): Section[] { const sections: Section[] = []; for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]!; if (isSectionStart(entry, index)) { sections.push({ id: `section:${entry.id}`, startEntryId: entry.id, endEntryId: entry.id, entries: [entry], label: sectionLabel(entry), }); continue; } const section = sections[sections.length - 1]!; section.entries.push(entry); section.endEntryId = entry.id; } return sections; } function stringValues(value: unknown): string[] { if (typeof value === "string") return value.trim() ? [value.trim()] : []; if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0).map((item) => item.trim()); } function pathsFromArguments(argumentsValue: unknown): string[] { if (!isRecord(argumentsValue)) return []; const paths = [ ...stringValues(argumentsValue.path), ...stringValues(argumentsValue.file_path), ...stringValues(argumentsValue.paths), ]; return [...new Set(paths)]; } function patchPaths(argumentsValue: unknown): string[] { if (!isRecord(argumentsValue)) return []; let patch = ""; if (typeof argumentsValue.input === "string") patch = argumentsValue.input; else if (typeof argumentsValue.patch === "string") patch = argumentsValue.patch; if (!patch) return []; const paths: string[] = []; for (const line of patch.split(/\r?\n/)) { const piHeader = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/) ?? line.match(/^\*\*\* Move to: (.+)$/); const unifiedHeader = line.match(/^\+\+\+ (?:b\/)?(.+)$/); const candidate = piHeader?.[1] ?? unifiedHeader?.[1]; if (candidate && candidate !== "/dev/null") paths.push(candidate.trim()); } return [...new Set(paths.filter(Boolean))]; } function evidenceFromEntries(entries: EntryLike[]): FileEvidence { const readFiles = new Set(); const modifiedFiles = new Set(); for (const entry of entries) { for (const call of toolCallsFrom(entry)) { const normalizedName = call.name.toLowerCase(); if (READ_TOOL_NAMES.has(normalizedName)) { for (const file of pathsFromArguments(call.arguments)) readFiles.add(file); } if (MUTATING_TOOL_NAMES.has(normalizedName)) { for (const file of pathsFromArguments(call.arguments)) modifiedFiles.add(file); if (normalizedName === "apply_patch") { for (const file of patchPaths(call.arguments)) modifiedFiles.add(file); } } } if ((entry.type === "compaction" || entry.type === "branch_summary") && isRecord(entry.details)) { for (const file of stringValues(entry.details.readFiles)) readFiles.add(file); for (const file of stringValues(entry.details.modifiedFiles)) modifiedFiles.add(file); } } return { readFiles: [...readFiles], modifiedFiles: [...modifiedFiles] }; } function sectionSummary(section: Section): UnknownRecord { const roles: Record = {}; let toolCalls = 0; let toolResults = 0; let errors = 0; for (const entry of section.entries) { const role = messageRole(entry); if (role) roles[role] = (roles[role] ?? 0) + 1; toolCalls += toolCallsFrom(entry).length; const result = toolResultFrom(entry); if (result) { toolResults += 1; if (result.isError === true) errors += 1; } } const files = evidenceFromEntries(section.entries); return { id: section.id, label: section.label, startEntryId: section.startEntryId, endEntryId: section.endEntryId, entryCount: section.entries.length, roles, toolCalls, toolResults, errors, readFileCount: files.readFiles.length, modifiedFileCount: files.modifiedFiles.length, }; } function boundedHeadAndTail(values: T[], maximum: number): { values: T[]; omitted: number } { if (values.length <= maximum) return { values, omitted: 0 }; const headCount = Math.ceil(maximum / 2); const tailCount = Math.floor(maximum / 2); return { values: [...values.slice(0, headCount), ...values.slice(values.length - tailCount)], omitted: values.length - maximum, }; } function leafCount(entries: EntryLike[]): number { if (entries.length === 0) return 0; const ids = new Set(entries.map((entry) => entry.id)); const parents = new Set( entries .map((entry) => entry.parentId) .filter((parentId): parentId is string => typeof parentId === "string" && ids.has(parentId)), ); return entries.reduce((count, entry) => count + (parents.has(entry.id) ? 0 : 1), 0); } function renderEntryFull(entry: EntryLike): string { const heading = [`[${entry.id}]`, entry.timestamp, entry.type, messageRole(entry)].filter(Boolean).join(" "); const lines = [heading]; const message = messageFrom(entry); if (message) { const text = textFromContent(message.content); if (text) lines.push(text); for (const call of toolCallsFrom(entry)) { lines.push(`tool_call ${call.name}#${call.id} ${serializeJson(call.arguments)}`); } if (message.role === "toolResult") { const toolName = typeof message.toolName === "string" ? message.toolName : "unknown"; const callId = typeof message.toolCallId === "string" ? message.toolCallId : "unknown"; lines.push(`tool_result ${toolName}#${callId}${message.isError === true ? " error" : ""}`); const recoverableDetails = recoverableGatewayDetails(message); if (recoverableDetails) { lines.push("recoverable_raw_details:", serializeJson(recoverableDetails)); } } } else if (entry.type === "compaction") { lines.push(summaryFrom(entry)); if (typeof entry.firstKeptEntryId === "string") lines.push(`firstKeptEntryId: ${entry.firstKeptEntryId}`); if (typeof entry.tokensBefore === "number") lines.push(`tokensBefore: ${entry.tokensBefore}`); } else if (entry.type === "branch_summary") { lines.push(summaryFrom(entry)); if (typeof entry.fromId === "string") lines.push(`fromId: ${entry.fromId}`); } else if (entry.type === "custom_message") { const text = textFromContent(entry.content); if (text) lines.push(text); } else { const fields = Object.fromEntries( Object.entries(entry).filter(([key]) => !["id", "parentId", "timestamp", "type"].includes(key)), ); if (Object.keys(fields).length > 0) lines.push(safeJson(fields, MAX_BODY_CHARS)); } return lines.join("\n"); } function renderEntry(entry: EntryLike, bodyChars: number): string { return truncate(renderEntryFull(entry), bodyChars); } function readEntryChunk(entry: EntryLike, offset: number, bodyChars: number): { text: string; nextOffset?: number } { const rendered = renderEntryFull(entry); const start = Math.min(Math.max(0, offset), rendered.length); const end = Math.min(rendered.length, start + bodyChars); return { text: rendered.slice(start, end), ...(end < rendered.length ? { nextOffset: end } : {}), }; } function contentResult(payload: unknown, details: UnknownRecord): { content: Array<{ type: "text"; text: string }>; details: UnknownRecord } { const serialized = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2); return { content: [{ type: "text", text: truncate(serialized, MAX_OUTPUT_CHARS) }], details, }; } function emptyResult(scope: Scope): ReturnType { return contentResult( `No raw session entries are available for scope ${scope}. The session may be new, ephemeral, or unavailable in this context.`, { scope, entryCount: 0 }, ); } function sectionIdByEntry(sections: Section[]): Map { const map = new Map(); for (const section of sections) { for (const entry of section.entries) map.set(entry.id, section.id); } return map; } function searchSnippet(text: string, query: string, caseSensitive: boolean, maximum = 360): string { const haystack = caseSensitive ? text : text.toLocaleLowerCase(); const needle = caseSensitive ? query : query.toLocaleLowerCase(); const index = haystack.indexOf(needle); if (index < 0) return preview(text, maximum); const start = Math.max(0, index - Math.floor((maximum - needle.length) / 2)); const end = Math.min(text.length, start + maximum); return `${start > 0 ? "…" : ""}${text.slice(start, end).replace(/\s+/g, " ").trim()}${end < text.length ? "…" : ""}`; } function meaningfulAction(entry: EntryLike, excludedToolCallId: string): UnknownRecord | undefined { const role = messageRole(entry); const calls = toolCallsFrom(entry).filter((call) => call.id !== excludedToolCallId); const message = messageFrom(entry); const messageText = message ? textFromContent(message.content) : ""; const text = messageText || summaryFrom(entry) || (entry.type === "custom_message" ? textFromContent(entry.content) : ""); if (!text && calls.length === 0 && entry.type !== "compaction" && entry.type !== "branch_summary") return undefined; return { entryId: entry.id, type: entry.type, ...(role ? { role } : {}), ...(text ? { preview: preview(text, 300) } : {}), ...(calls.length > 0 ? { toolCalls: calls.map((call) => ({ id: call.id, name: call.name })) } : {}), }; } export default function sessionRecovery(pi: ExtensionAPI): void { pi.registerTool({ ...SESSION_RECOVERY_TOOL_DESCRIPTIONS.overview, parameters: Type.Object({ scope: Type.Optional(SCOPE_SCHEMA), cursor: Type.Optional(Type.String({ description: "Opaque continuation cursor returned by a previous session_overview call.", maxLength: 2_000 })), max_sections: Type.Optional(Type.Number({ description: "Maximum consecutive section summaries to return.", minimum: 1, maximum: MAX_OVERVIEW_SECTIONS, })), }, { additionalProperties: false }), async execute(_toolCallId: string, params: { scope?: Scope; cursor?: string; max_sections?: number }, _signal: AbortSignal, _onUpdate: unknown, ctx: unknown) { const scope = scopeFrom(params.scope); const cursor = params.cursor ? decodeCursor(params.cursor) : undefined; if (params.cursor && (!cursor || cursor.kind !== "overview" || cursor.scope !== scope)) { return contentResult("Invalid or stale session_overview cursor for this scope.", { scope, cursorValid: false }); } const manager = sessionManagerFrom(ctx); const entries = entriesFor(manager, scope); if (entries.length === 0) return emptyResult(scope); const activeEntries = entriesFor(manager, "active"); const allEntries = entriesFor(manager, "all"); const sections = buildSections(entries); const maximum = clampInteger(params.max_sections, DEFAULT_OVERVIEW_SECTIONS, 1, MAX_OVERVIEW_SECTIONS); let startIndex = 0; if (cursor?.kind === "overview") { const index = sections.findIndex((section) => section.id === cursor.afterSectionId); if (index < 0) { return contentResult("The session changed and the overview cursor no longer resolves on this branch.", { scope, cursorValid: false }); } startIndex = index + 1; } const selected = sections.slice(startIndex, startIndex + maximum); const hasMore = startIndex + selected.length < sections.length; const nextCursor = hasMore && selected.length > 0 ? encodeCursor({ version: CURSOR_VERSION, kind: "overview", scope, afterSectionId: selected[selected.length - 1]!.id }) : undefined; const allLeaves = leafCount(allEntries); const header = callSafely(() => manager?.getHeader?.(), undefined); const sessionId = callSafely(() => manager?.getSessionId?.(), undefined); const sessionName = callSafely(() => manager?.getSessionName?.(), undefined); const sessionFile = callSafely(() => manager?.getSessionFile?.(), undefined); const payload = { scope, session: { id: typeof sessionId === "string" ? sessionId : null, name: typeof sessionName === "string" ? sessionName : null, persisted: typeof sessionFile === "string" && sessionFile.length > 0, hasParentSession: isRecord(header) && header.parentSession != null, }, counts: { selectedEntries: entries.length, activeEntries: activeEntries.length, allEntries: allEntries.length, sections: sections.length, compactions: entries.filter((entry) => entry.type === "compaction").length, leaves: allLeaves, otherBranches: Math.max(0, allLeaves - (activeEntries.length > 0 ? 1 : 0)), }, sections: selected.map(sectionSummary), hasMore, nextCursor: nextCursor ?? null, }; return contentResult(payload, { scope, entryCount: entries.length, sectionCount: sections.length, returnedSections: selected.length, hasMore, nextCursor }); }, }); pi.registerTool({ ...SESSION_RECOVERY_TOOL_DESCRIPTIONS.readSection, parameters: Type.Object({ section_id: Type.Optional(Type.String({ description: "Stable section ID returned by session_overview or session_search.", maxLength: 200 })), entry_id: Type.Optional(Type.String({ description: "Read one exact raw session entry by ID without scanning from the start of its section.", maxLength: 200 })), cursor: Type.Optional(Type.String({ description: "Opaque continuation cursor returned by a previous session_read_section call.", maxLength: 2_000 })), scope: Type.Optional(SCOPE_SCHEMA), max_entries: Type.Optional(Type.Number({ description: "Maximum entries to render from the section.", minimum: 1, maximum: MAX_SECTION_ENTRIES })), max_body_chars: Type.Optional(Type.Number({ description: "Maximum rendered characters per entry page. Use the returned cursor to continue long entries.", minimum: 100, maximum: MAX_BODY_CHARS })), }, { additionalProperties: false }), async execute(_toolCallId: string, params: { section_id?: string; entry_id?: string; cursor?: string; scope?: Scope; max_entries?: number; max_body_chars?: number }, _signal: AbortSignal, _onUpdate: unknown, ctx: unknown) { const scope = scopeFrom(params.scope); if (!params.cursor && Boolean(params.section_id) === Boolean(params.entry_id)) { return contentResult("Pass exactly one of section_id or entry_id, or continue with cursor.", { scope, found: false }); } const cursor = params.cursor ? decodeCursor(params.cursor) : undefined; if (params.cursor && (!cursor || cursor.kind !== "read" || cursor.scope !== scope)) { return contentResult("Invalid or stale session_read_section cursor for this scope.", { scope, cursorValid: false, sourceAvailable: false }); } const entries = entriesFor(sessionManagerFrom(ctx), scope); if (entries.length === 0) return emptyResult(scope); const sections = buildSections(entries); const requestedSectionId = cursor?.kind === "read" ? cursor.sectionId : params.section_id; const requestedEntryId = cursor?.kind === "read" ? cursor.entryId : params.entry_id; const offset = cursor?.kind === "read" ? cursor.offset : 0; const section = requestedSectionId ? sections.find((candidate) => candidate.id === requestedSectionId) : undefined; if (requestedSectionId && !section) { return contentResult( `Section ${requestedSectionId} was not found in scope ${scope}. Run session_overview with the same scope to refresh section IDs.`, { scope, sectionId: requestedSectionId, found: false, sourceAvailable: false }, ); } const maximum = clampInteger(params.max_entries, DEFAULT_SECTION_ENTRIES, 1, MAX_SECTION_ENTRIES); const bodyChars = clampInteger(params.max_body_chars, DEFAULT_BODY_CHARS, 100, MAX_BODY_CHARS); const candidates = section ? section.entries : entries; let startIndex = requestedEntryId ? candidates.findIndex((entry) => entry.id === requestedEntryId) : 0; if (startIndex < 0) { return contentResult( `Entry ${requestedEntryId} was not found in scope ${scope}${section ? ` within ${section.id}` : ""}.`, { scope, sectionId: section?.id, entryId: requestedEntryId, found: false, sourceAvailable: false }, ); } const rendered: string[] = []; let renderedChars = 0; let nextCursor: string | undefined; let renderedCount = 0; for (let index = startIndex; index < candidates.length && renderedCount < maximum; index += 1) { const entry = candidates[index]!; const entryOffset = index === startIndex ? offset : 0; const chunk = readEntryChunk(entry, entryOffset, bodyChars); const heading = entryOffset > 0 ? `[${entry.id}] continuation @${entryOffset}` : undefined; const next = [heading, chunk.text].filter(Boolean).join("\n"); if (renderedChars + next.length + 2 > MAX_OUTPUT_CHARS - 1_000) break; rendered.push(next); renderedChars += next.length + 2; renderedCount += 1; if (chunk.nextOffset !== undefined) { nextCursor = encodeCursor({ version: CURSOR_VERSION, kind: "read", scope, sectionId: section?.id, entryId: entry.id, offset: chunk.nextOffset }); break; } const nextEntry = candidates[index + 1]; if (nextEntry && (renderedCount >= maximum || renderedChars >= MAX_OUTPUT_CHARS - 1_000)) { nextCursor = encodeCursor({ version: CURSOR_VERSION, kind: "read", scope, sectionId: section?.id, entryId: nextEntry.id, offset: 0 }); break; } } if (!nextCursor) { const lastRendered = renderedCount > 0 ? startIndex + renderedCount - 1 : startIndex - 1; const nextEntry = candidates[lastRendered + 1]; if (nextEntry) nextCursor = encodeCursor({ version: CURSOR_VERSION, kind: "read", scope, sectionId: section?.id, entryId: nextEntry.id, offset: 0 }); } const hasMore = nextCursor !== undefined; const title = section ? `Section ${section.id}: ${section.label}` : `Entry ${requestedEntryId}`; return contentResult( [title, ...rendered, hasMore ? "… more available; continue with next_cursor" : ""].filter(Boolean).join("\n\n"), { scope, sectionId: section?.id, entryId: requestedEntryId, entryCount: candidates.length, renderedCount, found: true, sourceAvailable: true, hasMore, truncated: hasMore, nextCursor, }, ); }, }); pi.registerTool({ ...SESSION_RECOVERY_TOOL_DESCRIPTIONS.search, parameters: Type.Object({ query: Type.String({ description: "Literal substring to find in raw session text and tool arguments.", minLength: 1, maxLength: MAX_SEARCH_QUERY_CHARS, }), scope: Type.Optional(SCOPE_SCHEMA), cursor: Type.Optional(Type.String({ description: "Opaque continuation cursor returned by a previous session_search call.", maxLength: 2_000 })), case_sensitive: Type.Optional(Type.Boolean({ description: "Use exact case matching. Defaults to false." })), limit: Type.Optional(Type.Number({ description: "Maximum matches to return.", minimum: 1, maximum: MAX_SEARCH_RESULTS })), }, { additionalProperties: false }), async execute(_toolCallId: string, params: { query: string; scope?: Scope; cursor?: string; case_sensitive?: boolean; limit?: number }, _signal: AbortSignal, _onUpdate: unknown, ctx: unknown) { const scope = scopeFrom(params.scope); const entries = entriesFor(sessionManagerFrom(ctx), scope); if (entries.length === 0) return emptyResult(scope); const query = params.query.trim().slice(0, MAX_SEARCH_QUERY_CHARS); if (!query) return contentResult("Search query must not be empty.", { scope, query, matchCount: 0 }); const caseSensitive = params.case_sensitive === true; const cursor = params.cursor ? decodeCursor(params.cursor) : undefined; if ( params.cursor && (!cursor || cursor.kind !== "search" || cursor.scope !== scope || cursor.query !== query || cursor.caseSensitive !== caseSensitive) ) { return contentResult("Invalid or stale session_search cursor for this scope/query.", { scope, query, cursorValid: false }); } const needle = caseSensitive ? query : query.toLocaleLowerCase(); const limit = clampInteger(params.limit, DEFAULT_SEARCH_RESULTS, 1, MAX_SEARCH_RESULTS); const sections = buildSections(entries); const entrySections = sectionIdByEntry(sections); const allMatches: UnknownRecord[] = []; for (const entry of entries) { const text = entryText(entry); const haystack = caseSensitive ? text : text.toLocaleLowerCase(); if (!text || !haystack.includes(needle)) continue; allMatches.push({ entryId: entry.id, sectionId: entrySections.get(entry.id), type: entry.type, ...(messageRole(entry) ? { role: messageRole(entry) } : {}), timestamp: entry.timestamp ?? null, snippet: searchSnippet(text, query, caseSensitive), }); } let startIndex = 0; if (cursor?.kind === "search") { const index = allMatches.findIndex((match) => match.entryId === cursor.afterEntryId); if (index < 0) { return contentResult("The session changed and the search cursor no longer resolves on this branch.", { scope, query, cursorValid: false }); } startIndex = index + 1; } const matches = allMatches.slice(startIndex, startIndex + limit); const hasMore = startIndex + matches.length < allMatches.length; const lastEntryId = matches.at(-1)?.entryId; const nextCursor = hasMore && typeof lastEntryId === "string" ? encodeCursor({ version: CURSOR_VERSION, kind: "search", scope, query, caseSensitive, afterEntryId: lastEntryId }) : undefined; const payload = { scope, query, caseSensitive, totalMatches: allMatches.length, returnedMatches: matches.length, hasMore, nextCursor: nextCursor ?? null, matches, }; return contentResult(payload, { scope, query, matchCount: allMatches.length, returnedCount: matches.length, hasMore, truncated: hasMore, nextCursor }); }, }); pi.registerTool({ ...SESSION_RECOVERY_TOOL_DESCRIPTIONS.recoveryContext, parameters: Type.Object({ scope: Type.Optional(SCOPE_SCHEMA), recent_error_limit: Type.Optional(Type.Number({ description: "Maximum recent error tool results to report.", minimum: 1, maximum: MAX_RECENT_ERRORS })), }, { additionalProperties: false }), async execute(toolCallId: string, params: { scope?: Scope; recent_error_limit?: number }, _signal: AbortSignal, _onUpdate: unknown, ctx: unknown) { const scope = scopeFrom(params.scope); const entries = entriesFor(sessionManagerFrom(ctx), scope); if (entries.length === 0) return emptyResult(scope); const sections = buildSections(entries); const entrySections = sectionIdByEntry(sections); const userEntries = entries.filter((entry) => messageRole(entry) === "user" && entryText(entry).trim().length > 0); const pending = new Map(); const errors: UnknownRecord[] = []; for (const entry of entries) { for (const call of toolCallsFrom(entry)) pending.set(call.id, call); const result = toolResultFrom(entry); if (!result) continue; if (typeof result.toolCallId === "string") pending.delete(result.toolCallId); if (result.isError === true) { errors.push({ entryId: entry.id, sectionId: entrySections.get(entry.id), toolCallId: typeof result.toolCallId === "string" ? result.toolCallId : null, toolName: typeof result.toolName === "string" ? result.toolName : null, message: preview(textFromContent(result.content), 500), }); } } pending.delete(toolCallId); const errorLimit = clampInteger(params.recent_error_limit, DEFAULT_RECENT_ERRORS, 1, MAX_RECENT_ERRORS); const firstUser = userEntries[0]; const latestUser = userEntries[userEntries.length - 1]; const files = evidenceFromEntries(entries); const readFiles = boundedHeadAndTail(files.readFiles, MAX_RECOVERY_FILES); const modifiedFiles = boundedHeadAndTail(files.modifiedFiles, MAX_RECOVERY_FILES); const pendingCalls = boundedHeadAndTail([...pending.values()], MAX_PENDING_TOOL_CALLS); const lastAction = [...entries].reverse().map((entry) => meaningfulAction(entry, toolCallId)).find(Boolean) ?? null; const payload = { scope, originalUserRequest: firstUser ? { entryId: firstUser.id, sectionId: entrySections.get(firstUser.id), text: truncate(entryText(firstUser), DEFAULT_BODY_CHARS), } : null, latestUserInstruction: latestUser ? { entryId: latestUser.id, sectionId: entrySections.get(latestUser.id), text: truncate(entryText(latestUser), DEFAULT_BODY_CHARS), } : null, readFiles: readFiles.values, modifiedFiles: modifiedFiles.values, omittedReadFiles: readFiles.omitted, omittedModifiedFiles: modifiedFiles.omitted, recentErrors: errors.slice(-errorLimit), pendingToolCalls: pendingCalls.values.map((call) => ({ id: call.id, name: call.name, entryId: call.entryId, sectionId: entrySections.get(call.entryId), })), omittedPendingToolCalls: pendingCalls.omitted, lastMeaningfulAction: lastAction, compactionCount: entries.filter((entry) => entry.type === "compaction").length, entryCount: entries.length, sectionCount: sections.length, }; return contentResult(payload, { scope, entryCount: entries.length, sectionCount: sections.length, recentErrorCount: payload.recentErrors.length, pendingToolCallCount: payload.pendingToolCalls.length, }); }, }); }