From 97112b9c843de7c7bf5ffda30d5e6693f0e55375 Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 8 Mar 2026 16:15:38 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20strip=20frontmatter=20from=20AI=20contex?= =?UTF-8?q?t=20body=20field=20=E2=80=94=20fixes=20empty=20body=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The body field in buildContextSnapshot was passing the full raw file content (including YAML frontmatter delimiters) instead of just the body text. When handleEditorChange reconstructed tab content with empty blocksToMarkdownLossy output, the body became frontmatter-only — causing the AI to report "has frontmatter but no body content." Three changes: 1. Strip frontmatter from body using splitFrontmatter before setting the body field (frontmatter is already a separate parsed field) 2. Add wordCount to the context snapshot so the AI can detect when body is stale vs genuinely empty 3. Instruct the AI to call get_note MCP tool when body is empty but wordCount > 0, providing a safety net for any content staleness Co-Authored-By: Claude Opus 4.6 --- src/utils/ai-context.test.ts | 37 +++++++++++++++++++++++++++++++++--- src/utils/ai-context.ts | 16 ++++++++++++++-- 2 files changed, 48 insertions(+), 5 deletions(-) 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')