/** * AI contextual chat — builds a structured context snapshot from the active note, * open tabs, vault metadata, and optional explicit note references. */ import type { VaultEntry } from '../types' import { wikilinkTarget, resolveEntry } from './wikilink' import { splitFrontmatter } from './wikilinks' /** Extract only the body text from raw file content (strips YAML frontmatter). */ function extractBody(rawContent: string): string { const [, body] = splitFrontmatter(rawContent) return body.trim() } /** Resolve a link target string to a VaultEntry by matching title, aliases, or filename stem. * Delegates to the unified resolveEntry for consistent matching. */ export function resolveTarget(target: string, entries: VaultEntry[]): VaultEntry | undefined { return resolveEntry(entries, target) } /** Collect first-degree linked notes from the active entry. */ export function collectLinkedEntries( active: VaultEntry, entries: VaultEntry[], ): VaultEntry[] { const seen = new Set([active.path]) const linked: VaultEntry[] = [] const addTarget = (target: string) => { const entry = resolveTarget(target, entries) if (entry && !seen.has(entry.path)) { seen.add(entry.path) linked.push(entry) } } for (const target of active.outgoingLinks) { addTarget(target) } for (const refs of Object.values(active.relationships)) { for (const ref of refs) { addTarget(wikilinkTarget(ref)) } } for (const ref of active.belongsTo) { addTarget(wikilinkTarget(ref)) } for (const ref of active.relatedTo) { addTarget(wikilinkTarget(ref)) } return linked } /** A note reference from the user's [[wikilink]] selection in the chat input. */ export interface NoteReference { title: string path: string type?: string | null content?: string } /** Lightweight note summary for the context snapshot. */ export interface NoteListItem { path: string title: string type: string } /** Parameters for building the structured context snapshot. */ export interface ContextSnapshotParams { activeEntry: VaultEntry /** Direct content of the active note from the editor tab (most reliable source). */ activeNoteContent?: string openTabs?: VaultEntry[] noteList?: NoteListItem[] noteListFilter?: { type: string | null; query: string } entries: VaultEntry[] references?: NoteReference[] capabilities?: 'vault-tools' | 'embedded-context-only' } const MAX_ACTIVE_NOTE_BODY_CHARS = 24_000 const ACTIVE_NOTE_BODY_HEAD_CHARS = 16_000 const ACTIVE_NOTE_BODY_TAIL_CHARS = 4_000 const MAX_REFERENCED_NOTE_BODY_CHARS = 12_000 const REFERENCED_NOTE_BODY_HEAD_CHARS = 8_000 const REFERENCED_NOTE_BODY_TAIL_CHARS = 2_000 const MAX_NOTE_LIST_ITEMS = 100 interface ActiveNoteBody { body: string bodyTruncated?: { shownChars: number totalChars: number strategy: 'head-tail' } } function isPresentValue(value: unknown): boolean { if (value === null) return false if (value === undefined) return false if (value === '') return false return true } function assignIfPresent(target: Record, key: string, value: unknown): void { if (isPresentValue(value)) Reflect.set(target, key, value) } function assignIfNonEmpty(target: Record, key: string, values: unknown[]): void { if (values.length > 0) { Reflect.set(target, key, values) } } function propertyString(value: unknown): string | undefined { if (!isPresentValue(value)) return undefined return typeof value === 'string' ? value : String(value) } function entryFrontmatter(e: VaultEntry): Record { const fm: Record = {} assignIfPresent(fm, 'type', e.isA) assignIfPresent(fm, 'status', e.status) assignIfPresent(fm, 'owner', propertyString(e.properties?.Owner ?? e.properties?.owner)) assignIfPresent(fm, 'cadence', propertyString(e.properties?.Cadence ?? e.properties?.cadence)) assignIfNonEmpty(fm, 'belongsTo', e.belongsTo) assignIfNonEmpty(fm, 'relatedTo', e.relatedTo) assignIfNonEmpty(fm, 'relationships', Object.keys(e.relationships)) if (fm.relationships) fm.relationships = e.relationships return fm } function unavailableBodyInstruction(activeEntry: VaultEntry, canReadVault: boolean): string { if (!canReadVault) return `[Content not available in the embedded editor context (${activeEntry.wordCount} words). Ask the user to open or explicitly reference this note if its full content is required.]` return `[Content not available in editor context — use get_note("${activeEntry.path}") to read the full note (${activeEntry.wordCount} words)]` } function truncatedBodyInstruction(path: string, omittedChars: number, canReadVault: boolean): string { return [ '[Active note body truncated by HoloLake Era to keep CLI agent context within provider limits.', `Omitted approximately ${omittedChars} characters from the middle.`, canReadVault ? `Use get_note("${path}") to read the full note before making content-sensitive edits or summaries.]` : 'Ask the user to open or explicitly reference the full note before content-sensitive edits or summaries.]', ].join(' ') } function truncatedReferencedBodyInstruction(path: string, omittedChars: number, canReadVault: boolean): string { return [ '[Referenced note body truncated by HoloLake Era to keep CLI agent context within provider limits.', `Omitted approximately ${omittedChars} characters from the middle.`, canReadVault ? `Use get_note("${path}") to read the full note before making content-sensitive edits or summaries.]` : 'Ask the user to explicitly include the full note before content-sensitive edits or summaries.]', ].join(' ') } function compactActiveNoteBody(body: string, path: string, canReadVault: boolean): ActiveNoteBody { if (body.length <= MAX_ACTIVE_NOTE_BODY_CHARS) { return { body } } const head = body.slice(0, ACTIVE_NOTE_BODY_HEAD_CHARS).trimEnd() const tail = body.slice(-ACTIVE_NOTE_BODY_TAIL_CHARS).trimStart() const omittedChars = Math.max(0, body.length - ACTIVE_NOTE_BODY_HEAD_CHARS - ACTIVE_NOTE_BODY_TAIL_CHARS) return { body: `${head}\n\n${truncatedBodyInstruction(path, omittedChars, canReadVault)}\n\n${tail}`, bodyTruncated: { shownChars: ACTIVE_NOTE_BODY_HEAD_CHARS + ACTIVE_NOTE_BODY_TAIL_CHARS, totalChars: body.length, strategy: 'head-tail', }, } } function compactReferencedNoteBody(body: string, path: string, canReadVault: boolean): ActiveNoteBody { if (body.length <= MAX_REFERENCED_NOTE_BODY_CHARS) { return { body } } const head = body.slice(0, REFERENCED_NOTE_BODY_HEAD_CHARS).trimEnd() const tail = body.slice(-REFERENCED_NOTE_BODY_TAIL_CHARS).trimStart() const omittedChars = Math.max(0, body.length - REFERENCED_NOTE_BODY_HEAD_CHARS - REFERENCED_NOTE_BODY_TAIL_CHARS) return { body: `${head}\n\n${truncatedReferencedBodyInstruction(path, omittedChars, canReadVault)}\n\n${tail}`, bodyTruncated: { shownChars: REFERENCED_NOTE_BODY_HEAD_CHARS + REFERENCED_NOTE_BODY_TAIL_CHARS, totalChars: body.length, strategy: 'head-tail', }, } } function activeNoteBody(activeEntry: VaultEntry, activeNoteContent: string | undefined, canReadVault: boolean): ActiveNoteBody { const body = extractBody(activeNoteContent || '') if (!body && activeEntry.wordCount > 0) { return { body: unavailableBodyInstruction(activeEntry, canReadVault) } } return compactActiveNoteBody(body, activeEntry.path, canReadVault) } function activeNoteSnapshot(activeEntry: VaultEntry, activeNoteContent: string | undefined, canReadVault: boolean): Record { const bodySnapshot = activeNoteBody(activeEntry, activeNoteContent, canReadVault) const note: Record = { path: activeEntry.path, title: activeEntry.title, type: activeEntry.isA ?? 'Note', frontmatter: entryFrontmatter(activeEntry), body: bodySnapshot.body, wordCount: activeEntry.wordCount, } assignIfPresent(note, 'bodyTruncated', bodySnapshot.bodyTruncated) return note } function appendOpenTabs(snapshot: Record, activeEntry: VaultEntry, openTabs?: VaultEntry[]): void { const otherTabs = openTabs?.filter(t => t.path !== activeEntry.path) if (!otherTabs?.length) return snapshot.openTabs = otherTabs.map(t => ({ path: t.path, title: t.title, type: t.isA ?? 'Note', frontmatter: entryFrontmatter(t), })) } function appendNoteList(snapshot: Record, noteList?: NoteListItem[]): void { if (!noteList?.length) return snapshot.noteList = noteList.slice(0, MAX_NOTE_LIST_ITEMS) if (noteList.length > MAX_NOTE_LIST_ITEMS) { snapshot.noteListTruncated = { shown: MAX_NOTE_LIST_ITEMS, total: noteList.length } } } function hasNoteListFilter(noteListFilter?: { type: string | null; query: string }): boolean { return Boolean(noteListFilter?.type || noteListFilter?.query) } function referencedNoteSnapshot(ref: NoteReference, canReadVault = true): Record { const note: Record = { path: ref.path, title: ref.title, type: ref.type ?? 'Note', } if (ref.content === undefined) { note.body = canReadVault ? `[Referenced note content not embedded — use get_note("${ref.path}") to read the full note before answering about it.]` : '[Referenced note content not embedded. Ask the user to explicitly include it before answering about its contents.]' return note } const bodySnapshot = compactReferencedNoteBody(extractBody(ref.content), ref.path, canReadVault) note.body = bodySnapshot.body assignIfPresent(note, 'bodyTruncated', bodySnapshot.bodyTruncated) return note } function referencedNotesSnapshot(references?: NoteReference[], canReadVault = true): Record[] { return references?.map(ref => referencedNoteSnapshot(ref, canReadVault)) ?? [] } function appendReferencedNotes(snapshot: Record, references: NoteReference[] | undefined, canReadVault: boolean): void { const referencedNotes = referencedNotesSnapshot(references, canReadVault) if (!referencedNotes.length) return snapshot.referencedNotes = referencedNotes } function vaultSummary(entries: VaultEntry[]): Record { const types = new Set() for (const e of entries) { if (e.isA) types.add(e.isA) } return { types: [...types].sort(), totalNotes: entries.length, } } function contextSnapshot(params: ContextSnapshotParams): Record { const { activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params const canReadVault = params.capabilities !== 'embedded-context-only' const snapshot: Record = { activeNote: activeNoteSnapshot(activeEntry, activeNoteContent, canReadVault), } appendOpenTabs(snapshot, activeEntry, openTabs) appendNoteList(snapshot, noteList) if (hasNoteListFilter(noteListFilter)) snapshot.noteListFilter = noteListFilter snapshot.vault = vaultSummary(entries) appendReferencedNotes(snapshot, references, canReadVault) return snapshot } /** Build a structured context snapshot as a system prompt for Claude. */ export function buildContextSnapshot(params: ContextSnapshotParams): string { const snapshot = contextSnapshot(params) const preamble = [ 'You are the current AI instance operating the Guanghu language-personality system inside HoloLake Era.', 'The user is viewing a specific note. Use the structured context below to answer questions accurately.', params.capabilities === 'embedded-context-only' ? 'Use only the embedded context below. Only call tools explicitly provided by the API; never invent unavailable file tools.' : 'You can also use MCP tools to search, read, create, or edit notes in the vault.', params.capabilities === 'embedded-context-only' ? 'If content is missing or truncated, say so and ask the user to open or explicitly reference it.' : 'If the body field is empty or truncated, use get_note to read the full note from disk before content-sensitive edits or summaries.', 'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.', ].join('\n') return `${preamble}\n\n## Context Snapshot\n\`\`\`json\n${JSON.stringify(snapshot, null, 2)}\n\`\`\`` } export function formatPromptWithReferences(text: string, references?: NoteReference[]): string { const referencedNotes = referencedNotesSnapshot(references) if (!referencedNotes.length) return text return [ text, '', '## Referenced Notes', 'The user explicitly referenced these notes in the prompt. Use their bodies as first-class context.', '```json', JSON.stringify(referencedNotes, null, 2), '```', ].join('\n') } /** Legacy: Build a contextual system prompt (text-based). */ export function buildContextualPrompt( active: VaultEntry, linkedEntries: VaultEntry[], ): string { const parts: string[] = [ 'You are the current AI instance operating the Guanghu language-personality system inside HoloLake Era.', 'The user is viewing a specific note. Use the note and its linked context to answer questions accurately.', 'You can also use MCP tools to search, read, create, or edit notes in the vault.', '', `## Active Note: ${active.title}`, `Type: ${active.isA ?? 'Note'} | Path: ${active.path}`, ] if (linkedEntries.length > 0) { parts.push('', '## Linked Notes') for (const entry of linkedEntries) { parts.push( '', `### ${entry.title} (${entry.isA ?? 'Note'})`, ) } } return parts.join('\n') }