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:
@@ -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]]')
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user