import type { ExtensionAPI, SessionEntry, Theme } from "@earendil-works/pi-coding-agent"; import { keyHint } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import type { MemoryMdSettings } from "../types.js"; import { toLocaleDateTime, toTimestamp } from "../utils.js"; import type { TapeAnchorMeta, TapeAnchorType } from "./tape-anchor.js"; import { DEFAULT_FORMATTED_ENTRY_CONTENT_CHARS, extractMessageContent, formatEntryLine } from "./tape-context.js"; import type { KeywordHandoffInstruction } from "./tape-gate.js"; import type { TapeService } from "./tape-service.js"; import type { RenderState } from "./tape-types.js"; export type TapeServiceGetter = () => TapeService | null; export type PendingHandoffMatch = | { trigger: "keyword"; instruction: KeywordHandoffInstruction } | { trigger: "manual" }; type TapeSettingsGetter = () => MemoryMdSettings; type ConsumeHandoffMatch = () => PendingHandoffMatch | null; type EntryScanParams = { types?: SessionEntry["type"][]; limit?: number; sinceAnchor?: string; lastAnchor?: boolean; betweenAnchors?: { start: string; end: string }; betweenDates?: { start: string; end: string }; entryScope?: "session" | "project"; anchorScope?: "session" | "project"; scan?: string; }; function renderText(text: string): Text { return new Text(text, 0, 0); } function renderWithExpandHint(text: string, theme: Theme, totalLines: number): Text { if (totalLines <= 1) return renderText(text); return renderText( text + "\n" + theme.fg("muted", `... (${totalLines - 1} more lines, `) + keyHint("app.tools.expand", "to expand") + theme.fg("muted", ")"), ); } function renderDefaultResult( result: { content: Array<{ type: string; text?: string }> }, state: RenderState, theme: Theme, collapsedSummary: string, ): Text { if (state.isPartial) return renderText(theme.fg("warning", "Loading...")); if (!state.expanded) return renderWithExpandHint( theme.fg("success", collapsedSummary), theme, result.content[0]?.text?.split("\n").length ?? 1, ); return renderText(theme.fg("toolOutput", result.content[0]?.text ?? "")); } function getAnchorSearchBounds( tapeService: TapeService, options: { sinceAnchor?: string; lastAnchor?: boolean; betweenAnchors?: { start: string; end: string }; betweenDates?: { start: string; end: string }; anchorScope?: "session" | "project"; entryScope?: "session" | "project"; }, ): { since?: string; until?: string; sessionId?: string } { const { sinceAnchor, lastAnchor, betweenAnchors, betweenDates, anchorScope = "session", entryScope } = options; if (betweenDates) { return { since: betweenDates.start, until: betweenDates.end, sessionId: entryScope === "session" ? tapeService.getSessionId() : undefined, }; } if (betweenAnchors) { return { since: tapeService.findAnchorByName(betweenAnchors.start, anchorScope)?.timestamp, until: tapeService.findAnchorByName(betweenAnchors.end, anchorScope)?.timestamp, sessionId: entryScope === "session" ? tapeService.getSessionId() : undefined, }; } if (lastAnchor) { return { since: tapeService.getLastAnchor(anchorScope)?.timestamp, sessionId: entryScope === "session" ? tapeService.getSessionId() : undefined, }; } return { since: sinceAnchor ? tapeService.findAnchorByName(sinceAnchor, anchorScope)?.timestamp : undefined, sessionId: entryScope === "session" ? tapeService.getSessionId() : undefined, }; } function isAnchorToolResultContent(content: string): boolean { try { const anchor = JSON.parse(content) as Record | null; return Boolean( anchor && typeof anchor.id === "string" && typeof anchor.name === "string" && typeof anchor.type === "string" && typeof anchor.timestamp === "string" && typeof anchor.sessionId === "string" && typeof anchor.sessionEntryId === "string", ); } catch { return false; } } function formatDialogueContext(entries: SessionEntry[]): string[] { return entries.flatMap((entry) => { if (entry.type !== "message") return []; const { role, content } = (entry as { message: { role: string; content?: unknown } }).message; if (role !== "user" && role !== "assistant") return []; const text = extractMessageContent(content).trim(); if (!text || isAnchorToolResultContent(text)) return []; const line = formatEntryLine(entry, DEFAULT_FORMATTED_ENTRY_CONTENT_CHARS); return line === null ? [] : [line]; }); } function getAnchorContext(entries: SessionEntry[], anchorTimestamp: string, contextLines: number): string[][] { const anchorTime = toTimestamp(anchorTimestamp); const beforeEntries = entries.filter((entry) => toTimestamp(entry.timestamp) < anchorTime); const afterEntries = entries.filter((entry) => toTimestamp(entry.timestamp) >= anchorTime); return [ formatDialogueContext(beforeEntries).slice(-contextLines), formatDialogueContext(afterEntries).slice(0, contextLines), ]; } const EntryTypeUnion = Type.Union([ Type.Literal("message"), Type.Literal("custom"), Type.Literal("thinking_level_change"), Type.Literal("model_change"), Type.Literal("compaction"), ]); function buildEntryScanOptions(options: EntryScanParams): Parameters[0] { const { entryScope = "project", anchorScope = "session", types, limit, scan } = options; const scanOptions: Parameters[0] = { types, limit, entryScope, anchorScope, scan }; if (options.betweenAnchors) scanOptions.betweenAnchors = options.betweenAnchors; else if (options.betweenDates) scanOptions.betweenDates = options.betweenDates; else if (options.sinceAnchor) scanOptions.sinceAnchor = options.sinceAnchor; else if (options.lastAnchor) scanOptions.lastAnchor = true; return scanOptions; } function getTapeUnavailableResult(): { content: Array<{ type: "text"; text: string }>; details: Record; } { return { content: [{ type: "text", text: "Tape is not enabled for the current settings." }], details: {}, }; } function normalizeKeywords(keywords: string[] | undefined): string[] | undefined { const normalized = [...new Set((keywords ?? []).map((keyword) => keyword.trim()).filter(Boolean))]; return normalized.length > 0 ? normalized : undefined; } function normalizeHandoffMeta( summary: string | undefined, purpose: string | undefined, trigger: "direct" | "keyword" | "manual" | undefined, keywords: string[] | undefined, ): TapeAnchorMeta | undefined { const mergedMeta: Record = {}; if (summary) mergedMeta.summary = summary; if (purpose) mergedMeta.purpose = purpose; mergedMeta.trigger = trigger === "keyword" || trigger === "manual" ? trigger : "direct"; const normalizedKeywords = normalizeKeywords(keywords); if (normalizedKeywords) mergedMeta.keywords = normalizedKeywords; return Object.keys(mergedMeta).length > 0 ? (mergedMeta as TapeAnchorMeta) : undefined; } export function registerTapeHandoff( pi: ExtensionAPI, getTapeService: TapeServiceGetter, getSettings: TapeSettingsGetter, consumeHandoffMatch: ConsumeHandoffMatch = () => null, ): void { pi.registerTool({ name: "tape_handoff", label: "Tape Handoff", description: "Create a handoff anchor in tape", parameters: Type.Object({ name: Type.String({ description: "Anchor name (e.g., 'task/begin', 'task/complete', 'handoff')" }), summary: Type.Optional(Type.String({ description: "Brief intent summary of current task (under 18 words)" })), purpose: Type.Optional(Type.String({ description: "1-2 word label for the anchor's purpose" })), }), async execute(_toolCallId, params) { const tapeService = getTapeService(); if (!tapeService) return getTapeUnavailableResult(); const { name, summary, purpose } = params as { name: string; summary?: string; purpose?: string; }; const handoffMode = getSettings().tape?.anchor?.mode ?? "auto"; const handoffMatch = consumeHandoffMatch(); const keywordHandoffMatch = handoffMatch?.trigger === "keyword" ? handoffMatch : null; const matchedKeywordHandoff = keywordHandoffMatch?.instruction.anchorName === name; const finalTrigger = handoffMatch?.trigger === "manual" ? "manual" : matchedKeywordHandoff ? "keyword" : "direct"; const finalKeywords = finalTrigger === "keyword" ? normalizeKeywords(keywordHandoffMatch?.instruction.matched) : undefined; if (handoffMode === "manual" && finalTrigger !== "keyword" && finalTrigger !== "manual") { return { content: [ { type: "text", text: 'tape_handoff is disabled when tape.anchor.mode="manual" unless a keyword or manual handoff match is present.', }, ], details: { disabled: true, handoffMode, allowedTriggers: ["keyword", "manual"], finalTrigger, hasHandoffMatch: handoffMatch !== null, matchedKeywordHandoff: false, }, }; } const mergedMeta = normalizeHandoffMeta(summary, purpose, finalTrigger, finalKeywords); const anchor = tapeService.createAnchor(name, "handoff", mergedMeta); return { content: [{ type: "text", text: JSON.stringify(anchor) }], details: { anchorId: anchor.id, name, meta: { ...mergedMeta, timestamp: anchor.timestamp }, finalTrigger, hasHandoffMatch: handoffMatch !== null, matchedKeywordHandoff: handoffMatch?.trigger === "keyword" ? matchedKeywordHandoff : false, }, }; }, renderCall(args, theme) { return renderText(theme.fg("toolTitle", theme.bold("tape_handoff ")) + theme.fg("accent", args.name)); }, renderResult(result, state: RenderState, theme) { if (state.isPartial) return renderText(theme.fg("warning", "Creating anchor...")); const details = result.details as { disabled?: boolean } | undefined; const text = (result.content[0] as { text?: string })?.text ?? ""; if (details?.disabled) return renderText(theme.fg("warning", text)); return renderText(`${theme.fg("success", "Anchor created:")}\n${theme.fg("toolOutput", text)}`); }, }); } /* Deprecated: tape_list is no longer registered. Use tape_search with contextLines instead. export function registerTapeAnchors(pi: ExtensionAPI, getTapeService: TapeServiceGetter): void { pi.registerTool({ name: "tape_list", label: "Tape List", description: "List tape anchors with nearby context", parameters: Type.Object({ limit: Type.Optional( Type.Integer({ description: "Maximum number of anchors to return (default: 20)", minimum: 1, maximum: 100 }), ), contextLines: Type.Optional( Type.Integer({ description: "Number of context lines before/after each anchor (default: 1)", minimum: 0, maximum: 5, }), ), }), async execute(_toolCallId, params) { const tapeService = getTapeService(); if (!tapeService) return getTapeUnavailableResult(); const { limit = 20, contextLines = 1 } = params as { limit?: number; contextLines?: number }; const anchorStore = tapeService.getAnchorStore(); const anchors = anchorStore.getAllAnchors().slice(-limit); const projectEntries = tapeService.scan({ entryScope: "project", anchorScope: "project" }); const anchorsWithContext = anchors.map((anchor) => { const [beforeContext, afterContext] = getAnchorContext(projectEntries, anchor.timestamp, contextLines); return { id: anchor.id, name: anchor.name, timestamp: anchor.timestamp, type: anchor.type, meta: anchor.meta ?? {}, beforeContext, afterContext, }; }); let summary = "No anchors found in tape. Use tape_handoff to create an anchor."; if (anchorsWithContext.length > 0) { summary = `Found ${anchorsWithContext.length} anchor(s):\n\n` + anchorsWithContext .map((anchor) => { const metaStr = Object.keys(anchor.meta).length > 0 ? `\n Meta: ${JSON.stringify(anchor.meta)}` : ""; const beforeStr = anchor.beforeContext.length > 0 ? `\n Before:\n ${anchor.beforeContext.join("\n ")}` : ""; const afterStr = anchor.afterContext.length > 0 ? `\n After:\n ${anchor.afterContext.join("\n ")}` : ""; return ` - ${anchor.name} [${anchor.type}] (${toLocaleDateTime(anchor.timestamp)})${metaStr}${beforeStr}${afterStr}`; }) .join("\n\n"); } return { content: [{ type: "text", text: summary }], details: { anchors: anchorsWithContext, count: anchorsWithContext.length }, }; }, renderCall(args, theme) { let text = theme.fg("toolTitle", theme.bold("tape_list")); if (args.limit) text += ` ${theme.fg("muted", `limit=${args.limit}`)}`; if (args.contextLines) text += ` ${theme.fg("muted", `context=${args.contextLines}`)}`; return renderText(text); }, renderResult(result, state: RenderState, theme) { if (state.isPartial) return renderText(theme.fg("warning", "Listing anchors...")); const details = result.details as | { anchors?: Array<{ name: string; timestamp: string; kind: string; meta: Record; beforeContext: string[]; afterContext: string[]; }>; count?: number; } | undefined; if (!state.expanded && details?.anchors && details.anchors.length > 0) { const first = details.anchors[0]; const time = toLocaleTime(first.timestamp); let summary = theme.fg("success", `${first.name} (${time})`); if (first.beforeContext.length > 0) summary += `\n${theme.fg("muted", "Before:\n ")}${first.beforeContext.map((c) => theme.fg("muted", c)).join("\n ")}`; if (first.afterContext.length > 0) summary += `\n${theme.fg("muted", "After:\n ")}${first.afterContext.map((c) => theme.fg("muted", c)).join("\n ")}`; if (Object.keys(first.meta).length > 0) summary += `\n${theme.fg("muted", `Meta: ${JSON.stringify(first.meta)}`)}`; return renderWithExpandHint(summary, theme, details.count ?? 1); } if (!state.expanded) return renderText(theme.fg("success", `${details?.count ?? 0} anchor(s)`)); return renderText(theme.fg("toolOutput", (result.content[0] as { text?: string })?.text ?? "")); }, }); } */ export function registerTapeAnchorDelete(pi: ExtensionAPI, getTapeService: TapeServiceGetter): void { pi.registerTool({ name: "tape_delete", label: "Tape Delete", description: "Delete anchor checkpoints by exact id only. Use tape_search first to find ids.", parameters: Type.Object({ id: Type.Optional(Type.String({ description: "Single anchor id to delete" })), ids: Type.Optional(Type.Array(Type.String(), { description: "Multiple anchor ids to delete" })), }), async execute(_toolCallId, params) { const tapeService = getTapeService(); if (!tapeService) return getTapeUnavailableResult(); const { id, ids } = params as { id?: string; ids?: string[] }; const requestedIds = [ ...new Set([...(id?.trim() ? [id.trim()] : []), ...(ids ?? []).map((value) => value.trim()).filter(Boolean)]), ]; if (requestedIds.length === 0) { return { content: [{ type: "text", text: "No anchor ids provided. Use tape_search first to find anchors." }], details: { ids: [], deleted: false, deletedCount: 0 }, }; } const removedAnchors = requestedIds .map((anchorId) => tapeService.deleteAnchor(anchorId)) .filter((anchor) => anchor !== null); if (removedAnchors.length === 0) { return { content: [{ type: "text", text: `Anchor not found: ${requestedIds.join(", ")}` }], details: { ids: requestedIds, deleted: false, deletedCount: 0 }, }; } return { content: [{ type: "text", text: removedAnchors.map((anchor) => JSON.stringify(anchor)).join("\n") }], details: { ids: requestedIds, deleted: true, deletedCount: removedAnchors.length, names: removedAnchors.map((anchor) => anchor.name), }, }; }, renderCall(args, theme) { const target = args.id ?? (args.ids?.length ? `${args.ids.length} ids` : "ids required"); return renderText(theme.fg("toolTitle", theme.bold("tape_delete ")) + theme.fg("accent", target)); }, renderResult(result, state: RenderState, theme) { if (state.isPartial) return renderText(theme.fg("warning", "Deleting anchor...")); const details = result.details as { deleted?: boolean; deletedCount?: number } | undefined; const text = (result.content[0] as { text?: string })?.text ?? ""; if (!details?.deleted) { return renderText(theme.fg("warning", text || "Not found")); } return renderText( `${theme.fg("success", `Deleted ${details.deletedCount ?? 0} anchor(s):`)}\n${theme.fg("toolOutput", text)}`, ); }, }); } export function registerTapeInfo(pi: ExtensionAPI, getTapeService: TapeServiceGetter): void { pi.registerTool({ name: "tape_info", label: "Tape Info", description: "Get tape summary and last-anchor info", parameters: Type.Object({}), async execute(_toolCallId) { const tapeService = getTapeService(); if (!tapeService) return getTapeUnavailableResult(); const info = tapeService.getInfo(); const lastAnchorName = info.lastAnchor?.name ?? "none"; const tapeFileCount = tapeService.getTapeFileCount(); let recommendation = ""; if (info.entriesSinceLastAnchor > 20) recommendation = "\n\nšŸ’” Recommendation: Context is getting large. Consider using tape_handoff to create a new checkpoint."; else if (info.entriesSinceLastAnchor > 10) recommendation = "\n\nāš ļø Warning: Context is growing. You may want to use tape_handoff soon."; const summary = [ `šŸ“Š Tape Information:`, ` Total entries: ${info.totalEntries}`, ` Anchors: ${info.anchorCount}`, ` Last anchor: ${lastAnchorName}`, ` Entries since last anchor: ${info.entriesSinceLastAnchor}`, recommendation, ].join("\n"); return { content: [{ type: "text", text: summary }], details: { tapeFileCount, totalEntries: info.totalEntries, anchorCount: info.anchorCount, lastAnchor: info.lastAnchor?.sessionEntryId, lastAnchorName, entriesSinceLastAnchor: info.entriesSinceLastAnchor, }, }; }, renderCall(_args, theme) { return renderText(theme.fg("toolTitle", theme.bold("tape_info"))); }, renderResult(result, state: RenderState, theme) { const details = result.details as { totalEntries?: number; anchorCount?: number } | undefined; return renderDefaultResult( result, state, theme, `šŸ“Š ${details?.totalEntries ?? 0} entries, ${details?.anchorCount ?? 0} anchors`, ); }, }); } const SearchKindsUnion = Type.Union([Type.Literal("entry"), Type.Literal("anchor"), Type.Literal("all")]); const QueryScopeUnion = Type.Union([Type.Literal("session"), Type.Literal("project")]); const AnchorScopeUnion = Type.Union([Type.Literal("session"), Type.Literal("project")]); export function registerTapeSearch(pi: ExtensionAPI, getTapeService: TapeServiceGetter): void { pi.registerTool({ name: "tape_search", label: "Tape Search", description: "Search tape entries and anchors by type, content, or time range", parameters: Type.Object({ kinds: Type.Optional( Type.Array(SearchKindsUnion, { description: "What to search: 'entry' (session entries), 'anchor' (anchors), 'all' (default: all)", }), ), types: Type.Optional( Type.Array(EntryTypeUnion, { description: "Filter entries by type (only for entries search)" }), ), limit: Type.Optional( Type.Integer({ description: "Maximum number of results (default: 20)", minimum: 1, maximum: 100 }), ), contextLines: Type.Optional( Type.Integer({ description: "Nearby context lines around matching anchors (default: 0)", minimum: 0, maximum: 5, }), ), sinceAnchor: Type.Optional(Type.String({ description: "Anchor name to search from" })), lastAnchor: Type.Optional(Type.Boolean({ description: "Search from last anchor" })), betweenAnchors: Type.Optional( Type.Object({ start: Type.String(), end: Type.String() }, { description: "Between two anchors" }), ), betweenDates: Type.Optional( Type.Object({ start: Type.String(), end: Type.String() }, { description: "Between dates (ISO)" }), ), entryScope: Type.Optional( Type.Unsafe({ ...QueryScopeUnion, description: "Entry scope: 'session' or 'project'", }), ), anchorScope: Type.Optional( Type.Unsafe({ ...AnchorScopeUnion, description: "Anchor resolution: 'session' or 'project' (default: session)", }), ), scan: Type.Optional(Type.String({ description: "Text search in entry/anchor content" })), anchorName: Type.Optional(Type.String({ description: "Filter anchors by name substring" })), anchorType: Type.Optional( Type.String({ description: "Filter anchors by exact type, e.g. 'handoff' or 'thread'" }), ), anchorSummary: Type.Optional(Type.String({ description: "Filter anchors by summary substring" })), anchorPurpose: Type.Optional(Type.String({ description: "Filter anchors by purpose substring" })), anchorKeywords: Type.Optional( Type.Array(Type.String(), { description: "Filter anchors that contain all given keywords" }), ), }), async execute(_toolCallId, params) { const tapeService = getTapeService(); if (!tapeService) return getTapeUnavailableResult(); const { kinds = ["all"], types, limit = 20, contextLines = 0, sinceAnchor, lastAnchor, betweenAnchors, betweenDates, entryScope, anchorScope = "session", scan, anchorName, anchorType, anchorSummary, anchorPurpose, anchorKeywords, } = params as { kinds?: string[]; types?: SessionEntry["type"][]; limit?: number; contextLines?: number; sinceAnchor?: string; lastAnchor?: boolean; betweenAnchors?: { start: string; end: string }; betweenDates?: { start: string; end: string }; entryScope?: "session" | "project"; anchorScope?: "session" | "project"; scan?: string; anchorName?: string; anchorType?: TapeAnchorType; anchorSummary?: string; anchorPurpose?: string; anchorKeywords?: string[]; }; // entryScope defaults to match anchorScope when not specified const entryScopeFallback = entryScope ?? (anchorScope === "session" ? "session" : "project"); const parts: string[] = []; const lines: string[] = []; let anchorCount = 0; let entryCount = 0; if (kinds.includes("anchor") || kinds.includes("all")) { const { since, until, sessionId } = getAnchorSearchBounds(tapeService, { sinceAnchor, lastAnchor, betweenAnchors, betweenDates, anchorScope, entryScope: entryScopeFallback, }); const searchOptions = { scan, limit, since, until, sessionId, name: anchorName, type: anchorType, summary: anchorSummary, purpose: anchorPurpose, keywords: anchorKeywords, }; const anchors = tapeService.searchAnchorsWithFallback(searchOptions); anchorCount = anchors.length; if (anchorCount > 0) { const contextEntries = contextLines > 0 ? tapeService.scan({ entryScope: "project", anchorScope: "project" }) : []; parts.push(`${anchorCount} anchors`); lines.push("Anchors:"); for (const anchor of anchors) { const metaStr = anchor.meta ? ` ${JSON.stringify(anchor.meta)}` : ""; lines.push( ` id=${anchor.id} ${anchor.name} [${anchor.type}] (${toLocaleDateTime(anchor.timestamp)})${metaStr}`, ); if (contextLines > 0) { const [beforeContext, afterContext] = getAnchorContext(contextEntries, anchor.timestamp, contextLines); if (beforeContext.length > 0) lines.push(` Before: ${beforeContext.join(" | ")}`); if (afterContext.length > 0) lines.push(` After: ${afterContext.join(" | ")}`); } } } } if (kinds.includes("entry") || kinds.includes("all")) { const entries = tapeService.scanEntriesWithFallback({ types, limit, sinceAnchor, lastAnchor, betweenAnchors, betweenDates, entryScope: entryScopeFallback, anchorScope, scan, }); entryCount = entries.length; if (entryCount > 0) { parts.push(`${entryCount} entries`); lines.push("Entries:"); for (const entry of entries) { lines.push(formatEntryLine(entry, DEFAULT_FORMATTED_ENTRY_CONTENT_CHARS) ?? entry.type); } } } const header = parts.length > 0 ? `Found ${parts.join(", ")}` : "No results"; return { content: [{ type: "text", text: `${header}\n\n${lines.join("\n") || "(no results)"}` }], details: { kinds, scan, count: anchorCount + entryCount, anchorCount, entryCount, anchorName, anchorType, anchorSummary, anchorPurpose, anchorKeywords, contextLines, }, }; }, renderCall(args, theme) { const parts = [theme.fg("toolTitle", theme.bold("tape_search"))]; if (args.kinds?.length) parts.push(theme.fg("muted", args.kinds.join(","))); if (args.anchorName) parts.push(theme.fg("accent", `name:${args.anchorName}`)); if (args.anchorType) parts.push(theme.fg("accent", `type:${args.anchorType}`)); if (args.anchorKeywords?.length) parts.push(theme.fg("accent", `keywords:${args.anchorKeywords.join(",")}`)); if (args.anchorSummary) parts.push(theme.fg("accent", `summary:${args.anchorSummary}`)); if (args.anchorPurpose) parts.push(theme.fg("accent", `purpose:${args.anchorPurpose}`)); if (args.scan) parts.push(theme.fg("accent", `"${args.scan}"`)); if (args.contextLines) parts.push(theme.fg("muted", `context=${args.contextLines}`)); if (args.sinceAnchor) parts.push(theme.fg("muted", `@${args.sinceAnchor}`)); return renderText(parts.join(" ")); }, renderResult(result, state: RenderState, theme) { const details = result.details as | { count?: number; anchorCount?: number; entryCount?: number; } | undefined; if (state.isPartial) return renderText(theme.fg("warning", "Loading...")); const anchorCount = details?.anchorCount ?? 0; const entryCount = details?.entryCount ?? 0; const total = details?.count ?? 0; const parts: string[] = []; if (anchorCount > 0) parts.push(`${anchorCount} anchors`); if (entryCount > 0) parts.push(`${entryCount} entries`); const collapsedSummary = parts.length > 0 ? `Found ${parts.join(", ")}` : `${total} found`; const contentText = (result.content[0] as { text?: string })?.text ?? ""; const totalLines = contentText.split("\n").length; if (!state.expanded) { return renderWithExpandHint(theme.fg("success", collapsedSummary), theme, totalLines); } return renderText(theme.fg("toolOutput", contentText)); }, }); } export function registerTapeRead(pi: ExtensionAPI, getTapeService: TapeServiceGetter): void { pi.registerTool({ name: "tape_read", label: "Tape Read", description: "Read tape entries from pi session with anchor, date, or scan filters.", parameters: Type.Object({ afterAnchor: Type.Optional(Type.String({ description: "Read entries after this anchor" })), lastAnchor: Type.Optional(Type.Boolean({ description: "Read entries after last anchor" })), betweenAnchors: Type.Optional( Type.Object({ start: Type.String(), end: Type.String() }, { description: "Between two anchors" }), ), betweenDates: Type.Optional( Type.Object({ start: Type.String(), end: Type.String() }, { description: "Between dates (ISO)" }), ), scan: Type.Optional(Type.String({ description: "Text scan" })), types: Type.Optional(Type.Array(EntryTypeUnion, { description: "Filter entries by type" })), entryScope: Type.Optional( Type.Unsafe({ ...QueryScopeUnion, description: "Entry scope: 'session' or 'project'" }), ), anchorScope: Type.Optional( Type.Unsafe({ ...AnchorScopeUnion, description: "Anchor resolution: 'session' or 'project'" }), ), limit: Type.Optional(Type.Integer({ description: "Max entries (default: 20)", minimum: 1, maximum: 100 })), maxContentChars: Type.Optional( Type.Union([ Type.Integer({ minimum: 80, description: "Max content chars per formatted entry. Omit for default 300; use null for full content.", }), Type.Null({ description: "Return full formatted entry content" }), ]), ), }), async execute(_toolCallId, params) { const tapeService = getTapeService(); if (!tapeService) return getTapeUnavailableResult(); const { afterAnchor, betweenAnchors, betweenDates, types, lastAnchor = false, entryScope = "project", anchorScope = "session", limit = 20, scan, maxContentChars, } = params as { afterAnchor?: string; betweenAnchors?: { start: string; end: string }; betweenDates?: { start: string; end: string }; types?: SessionEntry["type"][]; lastAnchor?: boolean; entryScope?: "session" | "project"; anchorScope?: "session" | "project"; limit?: number; scan?: string; maxContentChars?: number | null; }; const entries = tapeService.scan( buildEntryScanOptions({ types, limit, entryScope, anchorScope, scan, sinceAnchor: afterAnchor, lastAnchor, betweenAnchors, betweenDates, }), ); const contentCharLimit = maxContentChars === undefined ? DEFAULT_FORMATTED_ENTRY_CONTENT_CHARS : maxContentChars; const formatted = entries .map((entry) => formatEntryLine(entry, contentCharLimit ?? undefined) ?? entry.type) .join("\n"); return { content: [{ type: "text", text: `Retrieved ${entries.length} entries:\n\n${formatted || "(no entries)"}` }], details: { entries, count: entries.length, maxContentChars: contentCharLimit }, }; }, renderCall(args, theme) { const parts = [theme.fg("toolTitle", theme.bold("tape_read"))]; if (args.afterAnchor) parts.push(theme.fg("muted", `after=${args.afterAnchor}`)); if (args.lastAnchor) parts.push(theme.fg("muted", "@last")); if (args.scan) parts.push(theme.fg("muted", `"${args.scan}"`)); if (args.limit) parts.push(theme.fg("muted", `limit=${args.limit}`)); if (args.maxContentChars !== undefined) parts.push(theme.fg("muted", `maxContentChars=${args.maxContentChars}`)); return renderText(parts.join(" ")); }, renderResult(result, state: RenderState, theme) { const details = result.details as { count?: number } | undefined; return renderDefaultResult(result, state, theme, `${details?.count ?? 0} entries`); }, }); } export function registerTapeReset(pi: ExtensionAPI, getTapeService: TapeServiceGetter): void { pi.registerTool({ name: "tape_reset", label: "Tape Reset", description: "Clear tape anchors and create a fresh session anchor", parameters: Type.Object({ archive: Type.Optional(Type.Boolean({ description: "Archive old tape first (not implemented)" })), }), async execute(_toolCallId, params) { const tapeService = getTapeService(); if (!tapeService) return getTapeUnavailableResult(); const { archive = false } = params as { archive?: boolean }; tapeService.clear(); tapeService.recordSessionStart(); const text = archive ? "Tape archived and reset" : "Anchor index cleared"; return { content: [{ type: "text", text }], details: { archived: archive } }; }, renderCall(args, theme) { return renderText( theme.fg("toolTitle", theme.bold("tape_reset")) + (args.archive ? ` ${theme.fg("warning", "--archive")}` : ""), ); }, renderResult(result, state: RenderState, theme) { if (state.isPartial) return renderText(theme.fg("warning", "Resetting...")); return renderText(theme.fg("success", (result.content[0] as { text?: string })?.text ?? "")); }, }); } export function registerAllTapeTools( pi: ExtensionAPI, getTapeService: TapeServiceGetter, getSettings: TapeSettingsGetter, consumeHandoffMatch?: ConsumeHandoffMatch, ): void { registerTapeHandoff(pi, getTapeService, getSettings, consumeHandoffMatch); // Deprecated: tape_list is replaced by tape_search({ kinds: ["anchor"], contextLines }). // registerTapeAnchors(pi, getTapeService); registerTapeAnchorDelete(pi, getTapeService); registerTapeInfo(pi, getTapeService); registerTapeSearch(pi, getTapeService); registerTapeRead(pi, getTapeService); registerTapeReset(pi, getTapeService); }