/** * Persisted side-thread state for the /btw extension. * * A side thread belongs to exactly one session. It is stored in the session * file as `btw-thread` custom entries (an append-only ledger), which pi keeps * out of the main agent's LLM context. Each entry is one small record: * * { kind: "exchange", exchange } append one finished question/answer pair * { kind: "promoted", id } mark one exchange as promoted into main * { kind: "delete", id } drop one exchange from the thread * * Replaying records in branch order reconstructs the thread, so a promotion or * a deletion survives restarts without rewriting earlier entries. * * This module intentionally has no imports: the ledger rules are unit tested * outside the pi runtime. */ /** One finished side question/answer pair. */ export interface BtwExchange { /** Stable id, referenced by promoted and delete records. */ id: string; /** The user's side question, verbatim. */ question: string; /** Answer text returned by the no-tools side request. */ answer: string; /** Epoch ms when the answer was produced. */ answeredAt: number; /** `provider/modelId` of the model that answered, for display and provenance. */ model: string; /** Number of earlier exchanges replayed into this request. */ replayed: number; /** * Set once the exchange was promoted into the main conversation with `f`. * Promoted exchanges stay listed in the overlay but leave the replay * window: the main session context already carries them, so replaying them * again would duplicate the Q&A inside the side request. */ promoted?: boolean; } /** Custom entry type used for every thread ledger record. */ export const BTW_ENTRY_TYPE = "btw-thread"; /** Custom message type used when an exchange is promoted into the main session. */ export const BTW_MESSAGE_TYPE = "btw"; /** Oldest exchanges are dropped once the thread exceeds this length. */ export const MAX_STORED_EXCHANGES = 50; /** Replay window for a new side request; Claude Code replays its newest 20. */ export const MAX_REPLAY_EXCHANGES = 20; /** Structural view of a session entry, so this module stays dependency free. */ export interface ThreadEntrySource { type: string; customType?: string; data?: unknown; } export type BtwThreadRecord = | { kind: "exchange"; exchange: BtwExchange } | { kind: "promoted"; id: string } | { kind: "delete"; id: string }; /** Append one exchange, dropping the oldest ones beyond the storage cap. */ export function appendExchange(thread: readonly BtwExchange[], exchange: BtwExchange): BtwExchange[] { const next = [...thread, exchange]; return next.length <= MAX_STORED_EXCHANGES ? next : next.slice(next.length - MAX_STORED_EXCHANGES); } /** * Exchanges that must be replayed into the next side request, oldest first. * * Promotion is recorded before the promoted copy reaches the main context: * while the main agent streams, pi defers that append to the end of the turn. * A side question asked inside that window therefore does not see the * exchange, because it already left this window and is not in the main * context yet. */ export function replayWindow(thread: readonly BtwExchange[]): BtwExchange[] { return thread.filter((exchange) => exchange.promoted !== true).slice(-MAX_REPLAY_EXCHANGES); } /** Mark one exchange as promoted without touching the others. */ export function markPromoted(thread: readonly BtwExchange[], id: string): BtwExchange[] { return thread.map((exchange) => (exchange.id === id ? { ...exchange, promoted: true } : exchange)); } /** * Drop one exchange. * * An id that is no longer present is not an error: the storage cap in * `appendExchange` evicts the oldest exchanges, so a delete record can refer to * an exchange that a later replay has already dropped. */ export function removeExchange(thread: readonly BtwExchange[], id: string): BtwExchange[] { return thread.filter((exchange) => exchange.id !== id); } /** * Reconstruct the side thread of a branch by replaying its ledger in order. * * Replaying applies the storage cap through `appendExchange`, so the result can * never exceed MAX_STORED_EXCHANGES without a second clamp here. */ export function findThread(entries: readonly ThreadEntrySource[]): BtwExchange[] { let thread: BtwExchange[] = []; for (const entry of entries) { if (entry.type !== "custom" || entry.customType !== BTW_ENTRY_TYPE) continue; thread = applyRecord(thread, parseRecord(entry.data)); } return thread; } function applyRecord(thread: BtwExchange[], record: BtwThreadRecord): BtwExchange[] { switch (record.kind) { case "exchange": return appendExchange(thread, record.exchange); case "promoted": return markPromoted(thread, record.id); case "delete": return removeExchange(thread, record.id); } } /** * Parse one ledger record. * * Records are written by this extension, so a shape mismatch means the session * file was edited or corrupted. Throw instead of silently resetting the * thread: a dropped side thread is worse than a visible error. */ function parseRecord(data: unknown): BtwThreadRecord { if (typeof data !== "object" || data === null) { throw new Error(`Invalid ${BTW_ENTRY_TYPE} entry: expected an object, got ${JSON.stringify(data)}`); } const record = data as { kind?: unknown; exchange?: unknown; id?: unknown }; switch (record.kind) { case "exchange": return { kind: "exchange", exchange: parseExchange(record.exchange) }; case "promoted": case "delete": { if (typeof record.id !== "string") { throw new Error(`Invalid ${BTW_ENTRY_TYPE} ${record.kind} record: id must be a string`); } return { kind: record.kind, id: record.id }; } default: throw new Error(`Invalid ${BTW_ENTRY_TYPE} entry: unknown kind ${JSON.stringify(record.kind)}`); } } function parseExchange(value: unknown): BtwExchange { if (typeof value !== "object" || value === null) { throw new Error(`Invalid ${BTW_ENTRY_TYPE} exchange: expected an object`); } const exchange = value as Partial; if (typeof exchange.id !== "string" || exchange.id === "") { throw new Error(`Invalid ${BTW_ENTRY_TYPE} exchange: id must be a non-empty string`); } if (typeof exchange.question !== "string" || typeof exchange.answer !== "string") { throw new Error(`Invalid ${BTW_ENTRY_TYPE} exchange ${exchange.id}: question and answer must be strings`); } if (typeof exchange.answeredAt !== "number" || !Number.isFinite(exchange.answeredAt)) { throw new Error(`Invalid ${BTW_ENTRY_TYPE} exchange ${exchange.id}: answeredAt must be a number`); } if (typeof exchange.model !== "string" || typeof exchange.replayed !== "number") { throw new Error(`Invalid ${BTW_ENTRY_TYPE} exchange ${exchange.id}: model and replayed are required`); } return { id: exchange.id, question: exchange.question, answer: exchange.answer, answeredAt: exchange.answeredAt, model: exchange.model, replayed: exchange.replayed, ...(exchange.promoted === true ? { promoted: true } : {}), }; }