fix: strip frontmatter from AI context body field — fixes empty body bug

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 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-08 16:15:38 +01:00
parent a53819e56a
commit 97112b9c84
2 changed files with 48 additions and 5 deletions

View File

@@ -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<string, string> = {}
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]]')

View File

@@ -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<string, unknown> = {
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')