287 lines
9.6 KiB
TypeScript
287 lines
9.6 KiB
TypeScript
/**
|
|
* 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<string>([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
|
|
}
|
|
|
|
/** 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[]
|
|
}
|
|
|
|
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_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<string, unknown>, key: string, value: unknown): void {
|
|
if (isPresentValue(value)) Reflect.set(target, key, value)
|
|
}
|
|
|
|
function assignIfNonEmpty(target: Record<string, unknown>, 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<string, unknown> {
|
|
const fm: Record<string, unknown> = {}
|
|
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): string {
|
|
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): string {
|
|
return [
|
|
'[Active note body truncated by Tolaria to keep CLI agent context within provider limits.',
|
|
`Omitted approximately ${omittedChars} characters from the middle.`,
|
|
`Use get_note("${path}") to read the full note before making content-sensitive edits or summaries.]`,
|
|
].join(' ')
|
|
}
|
|
|
|
function compactActiveNoteBody(body: string, path: string): 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)}\n\n${tail}`,
|
|
bodyTruncated: {
|
|
shownChars: ACTIVE_NOTE_BODY_HEAD_CHARS + ACTIVE_NOTE_BODY_TAIL_CHARS,
|
|
totalChars: body.length,
|
|
strategy: 'head-tail',
|
|
},
|
|
}
|
|
}
|
|
|
|
function activeNoteBody(activeEntry: VaultEntry, activeNoteContent?: string): ActiveNoteBody {
|
|
const body = extractBody(activeNoteContent || '')
|
|
if (!body && activeEntry.wordCount > 0) {
|
|
return { body: unavailableBodyInstruction(activeEntry) }
|
|
}
|
|
return compactActiveNoteBody(body, activeEntry.path)
|
|
}
|
|
|
|
function activeNoteSnapshot(activeEntry: VaultEntry, activeNoteContent?: string): Record<string, unknown> {
|
|
const bodySnapshot = activeNoteBody(activeEntry, activeNoteContent)
|
|
const note: Record<string, unknown> = {
|
|
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<string, unknown>, 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<string, unknown>, 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 appendReferencedNotes(snapshot: Record<string, unknown>, references?: NoteReference[]): void {
|
|
if (!references?.length) return
|
|
|
|
snapshot.referencedNotes = references.map(ref => ({
|
|
path: ref.path,
|
|
title: ref.title,
|
|
type: ref.type ?? 'Note',
|
|
}))
|
|
}
|
|
|
|
function vaultSummary(entries: VaultEntry[]): Record<string, unknown> {
|
|
const types = new Set<string>()
|
|
for (const e of entries) {
|
|
if (e.isA) types.add(e.isA)
|
|
}
|
|
return {
|
|
types: [...types].sort(),
|
|
totalNotes: entries.length,
|
|
}
|
|
}
|
|
|
|
function contextSnapshot(params: ContextSnapshotParams): Record<string, unknown> {
|
|
const { activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
|
|
const snapshot: Record<string, unknown> = {
|
|
activeNote: activeNoteSnapshot(activeEntry, activeNoteContent),
|
|
}
|
|
|
|
appendOpenTabs(snapshot, activeEntry, openTabs)
|
|
appendNoteList(snapshot, noteList)
|
|
if (hasNoteListFilter(noteListFilter)) snapshot.noteListFilter = noteListFilter
|
|
snapshot.vault = vaultSummary(entries)
|
|
appendReferencedNotes(snapshot, references)
|
|
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 an AI assistant integrated into Tolaria, a personal knowledge management app.',
|
|
'The user is viewing a specific note. Use the structured context below to answer questions accurately.',
|
|
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
|
|
'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\`\`\``
|
|
}
|
|
|
|
/** Legacy: Build a contextual system prompt (text-based). */
|
|
export function buildContextualPrompt(
|
|
active: VaultEntry,
|
|
linkedEntries: VaultEntry[],
|
|
): string {
|
|
const parts: string[] = [
|
|
'You are an AI assistant integrated into Tolaria, a personal knowledge management app.',
|
|
'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')
|
|
}
|