/** * Citation decoration — Phase 2. * * Ported from upstream `extensions/memory-core/src/tools.citations.ts`. * Decorates memory_search hits with `[path#L{start}-L{end}]` markers so * the model can attribute claims back to specific memory files when it * uses them in its answer. * * Since our backend stores files as single rows (not line-chunked), we * compute line ranges from the returned content block on the fly: * - `startLine` = 1 (line 1 of the file) * - `endLine` = total number of lines in the block * This matches upstream's output format exactly while keeping the pg * schema chunk-free. */ export type MemoryCitationsMode = 'on' | 'off' | 'auto'; export const DEFAULT_CITATIONS_MODE: MemoryCitationsMode = 'auto'; export function resolveMemoryCitationsMode( raw: string | undefined | null ): MemoryCitationsMode { if (raw === 'on' || raw === 'off' || raw === 'auto') return raw; return DEFAULT_CITATIONS_MODE; } export interface CitationCandidate { path: string; content: string; startLine?: number; endLine?: number; citation?: string; } function countLines(text: string): number { if (!text) return 1; return Math.max(1, text.split('\n').length); } function formatCitation( path: string, startLine: number, endLine: number ): string { if (startLine === endLine) return `${path}#L${startLine}`; return `${path}#L${startLine}-L${endLine}`; } /** * Decorate each hit with a citation marker. Mirrors upstream's behavior: * appends `\n\nSource: ` to the content and sets `citation`. * When `include=false`, clears any existing citation field. */ export function decorateCitations( hits: T[], include: boolean ): T[] { if (!include) return hits.map((h) => ({ ...h, citation: undefined })); return hits.map((h) => { const start = Math.max(1, Math.floor(h.startLine ?? 1)); const end = Math.max(start, Math.floor(h.endLine ?? countLines(h.content))); const citation = formatCitation(h.path, start, end); const content = `${(h.content ?? '').trimEnd()}\n\nSource: ${citation}`; return { ...h, citation, content, startLine: start, endLine: end }; }); } /** * Whether citations should be emitted for this call. * * Upstream keys `auto` off the session type (direct/group/channel). In * Phase 1 we only have direct chat, so `auto` => `on`. Callers that * later distinguish session types can pass `mode` explicitly. */ export function shouldIncludeCitations(mode: MemoryCitationsMode): boolean { if (mode === 'on') return true; if (mode === 'off') return false; return true; }