hololake-system-architecture/product-source/hololake-platform/src/utils/ai-context.ts

364 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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
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<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, 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<string, unknown> {
const bodySnapshot = activeNoteBody(activeEntry, activeNoteContent, canReadVault)
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 referencedNoteSnapshot(ref: NoteReference, canReadVault = true): Record<string, unknown> {
const note: Record<string, unknown> = {
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<string, unknown>[] {
return references?.map(ref => referencedNoteSnapshot(ref, canReadVault)) ?? []
}
function appendReferencedNotes(snapshot: Record<string, unknown>, references: NoteReference[] | undefined, canReadVault: boolean): void {
const referencedNotes = referencedNotesSnapshot(references, canReadVault)
if (!referencedNotes.length) return
snapshot.referencedNotes = referencedNotes
}
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 canReadVault = params.capabilities !== 'embedded-context-only'
const snapshot: Record<string, unknown> = {
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')
}