diff --git a/src/utils/ai-context.test.ts b/src/utils/ai-context.test.ts index a317fe3a..e27e5b8c 100644 --- a/src/utils/ai-context.test.ts +++ b/src/utils/ai-context.test.ts @@ -331,7 +331,7 @@ describe('buildContextSnapshot', () => { expect(json.noteList).toBeUndefined() }) - it('uses activeNoteContent for body when allContent is empty (Tauri mode)', () => { + it('strips frontmatter from activeNoteContent before setting body', () => { const emptyAllContent: Record = {} const result = buildContextSnapshot({ activeEntry: active, @@ -341,14 +341,27 @@ describe('buildContextSnapshot', () => { }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) expect(json.activeNote.body).toContain('Project content from tab.') + expect(json.activeNote.body).not.toContain('---') + expect(json.activeNote.body).not.toContain('title: Alpha') }) - it('prefers activeNoteContent over allContent when both present', () => { + it('returns empty body when raw content is frontmatter-only (bug case)', () => { + const result = buildContextSnapshot({ + activeEntry: active, + allContent: {}, + entries, + activeNoteContent: '---\ntitle: Alpha\nis_a: Project\nstatus: active\n---\n', + }) + const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) + expect(json.activeNote.body).toBe('') + }) + + it('prefers activeNoteContent body over allContent when both present', () => { const result = buildContextSnapshot({ activeEntry: active, allContent, entries, - activeNoteContent: 'Fresh editor content', + activeNoteContent: '---\ntitle: Alpha\n---\nFresh editor content', }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) expect(json.activeNote.body).toBe('Fresh editor content') @@ -360,6 +373,24 @@ describe('buildContextSnapshot', () => { expect(json.activeNote.body).toBe('# Alpha\nProject content.') }) + it('includes wordCount in activeNote', () => { + const entryWithWords = makeEntry({ path: '/vault/a.md', title: 'Alpha', wordCount: 206 }) + const result = buildContextSnapshot({ activeEntry: entryWithWords, allContent, entries }) + const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) + expect(json.activeNote.wordCount).toBe(206) + }) + + it('handles content with no frontmatter (plain markdown)', () => { + const result = buildContextSnapshot({ + activeEntry: active, + allContent: {}, + entries, + activeNoteContent: '# Just a heading\n\nSome plain content.', + }) + const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) + expect(json.activeNote.body).toBe('# Just a heading\n\nSome plain content.') + }) + it('includes wikilink instruction in preamble', () => { const result = buildContextSnapshot({ activeEntry: active, allContent, entries }) expect(result).toContain('[[Note Title]]') diff --git a/src/utils/ai-context.ts b/src/utils/ai-context.ts index f14ed22b..4b673a3e 100644 --- a/src/utils/ai-context.ts +++ b/src/utils/ai-context.ts @@ -5,6 +5,13 @@ import type { VaultEntry } from '../types' import { wikilinkTarget } 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. */ export function resolveTarget(target: string, entries: VaultEntry[]): VaultEntry | undefined { @@ -98,13 +105,17 @@ const MAX_NOTE_LIST_ITEMS = 100 export function buildContextSnapshot(params: ContextSnapshotParams): string { const { activeEntry, allContent, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params + const rawContent = activeNoteContent ?? allContent[activeEntry.path] ?? '' + const body = extractBody(rawContent) + const snapshot: Record = { activeNote: { path: activeEntry.path, title: activeEntry.title, type: activeEntry.isA ?? 'Note', frontmatter: entryFrontmatter(activeEntry), - body: activeNoteContent ?? allContent[activeEntry.path] ?? '', + body, + wordCount: activeEntry.wordCount, }, } @@ -146,7 +157,7 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string { path: ref.path, title: ref.title, type: ref.type ?? 'Note', - body: allContent[ref.path] ?? '', + body: extractBody(allContent[ref.path] ?? ''), })) } @@ -154,6 +165,7 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string { 'You are an AI assistant integrated into Laputa, 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 but wordCount is > 0, the content may be stale — use get_note to read the full note from disk.', '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')