Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/server/infra/dream/dream-response-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,21 @@ export type ConsolidateResponse = z.infer<typeof ConsolidateResponseSchema>

// ── Synthesize ───────────────────────────────────────────────────────────────

// Bounds are slightly above the prompt's soft targets (200 chars / 3-5 tags /
// 5-10 keywords) so a model that goes a little over still produces a usable
// synthesis instead of being rejected outright; the caps still prevent a
// runaway model from landing oversized text directly in card-mode YAML.
export const SynthesisCandidateSchema = z.object({
claim: z.string(),
confidence: z.number().min(0).max(1),
evidence: z.array(z.object({
domain: z.string(),
fact: z.string(),
})),
keywords: z.array(z.string()).max(15),
placement: z.string(),
summary: z.string().max(500),
tags: z.array(z.string()).max(8),
title: z.string(),
Comment thread
RyanNg1403 marked this conversation as resolved.
})

Expand Down
4 changes: 2 additions & 2 deletions src/server/infra/dream/operations/consolidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ function addFrontmatterFields(content: string, fields: Record<string, unknown>):
if (parsed && typeof parsed === 'object') {
// Spread preserves existing key order; new fields are appended at end.
const merged = {...parsed, ...fields}
const newYaml = yamlDump(merged, {flowLevel: 2, lineWidth: -1, sortKeys: false}).trimEnd()
const newYaml = yamlDump(merged, {flowLevel: 1, lineWidth: -1, sortKeys: false}).trimEnd()
Comment thread
RyanNg1403 marked this conversation as resolved.
return `---\n${newYaml}\n---\n${body}`
}
} catch {
Expand All @@ -305,7 +305,7 @@ function addFrontmatterFields(content: string, fields: Record<string, unknown>):
}

// No valid frontmatter — prepend
const yaml = yamlDump(fields, {flowLevel: 2, lineWidth: -1, sortKeys: false}).trimEnd()
const yaml = yamlDump(fields, {flowLevel: 1, lineWidth: -1, sortKeys: false}).trimEnd()
return `---\n${yaml}\n---\n${content}`
}

Expand Down
43 changes: 35 additions & 8 deletions src/server/infra/dream/operations/synthesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,15 +257,36 @@ async function writeSynthesisFile(
}

const sources = candidate.evidence.map((e) => `${e.domain}/_index.md`)
// Normalize tags to lowercase kebab-case so card chips and BM25 search see
// a consistent label regardless of whether the model honored the prompt's
// formatting rule. Empty entries (post-trim) are dropped.
const normalizedTags = candidate.tags
.map((t) => t.toLowerCase().trim().replaceAll(/\s+/g, '-'))
.filter((t) => t.length > 0)
Comment thread
RyanNg1403 marked this conversation as resolved.
const now = new Date().toISOString()
// Field order is enforced by insertion order (yamlDump uses sortKeys:false).
// Synthesis markers (confidence, sources, synthesized_at, type) come first
// in the order pre-existing synthesized files use on disk, so re-generating
// an old file does not produce a mechanical reorder diff. The seven
// semantic fields below mirror the order in markdown-writer.ts's
// generateFrontmatter so the on-disk shape matches regular `brv save`
// files; cogit then exposes them in DtoV3MemoryCardResource for card-mode
// display in the web UI.
/* eslint-disable camelcase */
const frontmatter = {
confidence: candidate.confidence,
sources,
synthesized_at: new Date().toISOString(),
type: 'synthesis',
}
const frontmatter: Record<string, number | string | string[]> = {}
frontmatter.confidence = candidate.confidence
frontmatter.sources = sources
frontmatter.synthesized_at = now
frontmatter.type = 'synthesis'
frontmatter.title = candidate.title
frontmatter.summary = candidate.summary
frontmatter.tags = normalizedTags
frontmatter.related = []
frontmatter.keywords = candidate.keywords
frontmatter.createdAt = now
frontmatter.updatedAt = now
/* eslint-enable camelcase */
Comment thread
RyanNg1403 marked this conversation as resolved.
const yaml = yamlDump(frontmatter, {lineWidth: -1, sortKeys: false}).trimEnd()
const yaml = yamlDump(frontmatter, {flowLevel: 1, lineWidth: -1, sortKeys: false}).trimEnd()
const body = [
`# ${candidate.title}`,
'',
Expand Down Expand Up @@ -344,11 +365,17 @@ function buildPrompt(domains: DomainSummary[], existingSyntheses: string[]): str
'- Do NOT report trivial or obvious connections (e.g., "both domains use TypeScript").',
'- Each synthesis must reference at least 2 domains with specific evidence.',
'- For "placement", choose the domain where this insight is MOST actionable.',
'- "summary" is one sentence (≤ 200 chars) describing the insight; this is what the UI shows as a card preview.',
'- "tags" are 3-5 short topical labels drawn from the source domains (e.g., "auth", "caching"). Lowercase, kebab-case.',
'- "keywords" are 5-10 single words a developer would search for to surface this synthesis.',
'- If nothing meaningful is found, return an empty array. That is fine — but missing a clear cross-domain pattern is a failure.',
'',
// Keep the JSON shape below in sync with SynthesisCandidateSchema in
// dream-response-schemas.ts; the schema rejects responses that omit any
// listed field, so adding a field there requires updating this example.
'Respond with JSON:',
'```',
'{ "syntheses": [{ "title": "...", "claim": "...", "evidence": [{"domain": "...", "fact": "..."}], "confidence": 0.0-1.0, "placement": "..." }] }',
'{ "syntheses": [{ "title": "...", "summary": "...", "claim": "...", "evidence": [{"domain": "...", "fact": "..."}], "tags": ["..."], "keywords": ["..."], "confidence": 0.0-1.0, "placement": "..." }] }',
Comment thread
RyanNg1403 marked this conversation as resolved.
'```',
].join('\n')
}
63 changes: 63 additions & 0 deletions test/unit/infra/dream/dream-response-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ describe('dream-response-schemas', () => {
{domain: 'auth', fact: 'uses JWT for session management'},
{domain: 'api', fact: 'validates JWT in middleware'},
],
keywords: ['jwt', 'auth'],
placement: 'api',
summary: 'Shared JWT validation across auth and api.',
tags: ['auth', 'api'],
title: 'Shared auth pattern',
}],
}
Expand All @@ -107,7 +110,10 @@ describe('dream-response-schemas', () => {
claim: 'test',
confidence: -0.1,
evidence: [{domain: 'a', fact: 'f'}],
keywords: [],
placement: 'a',
summary: '',
tags: [],
title: 'test',
}],
}
Expand All @@ -120,7 +126,10 @@ describe('dream-response-schemas', () => {
claim: 'test',
confidence: 1.1,
evidence: [{domain: 'a', fact: 'f'}],
keywords: [],
placement: 'a',
summary: '',
tags: [],
title: 'test',
}],
}
Expand All @@ -133,7 +142,10 @@ describe('dream-response-schemas', () => {
claim: 'test',
confidence: 0,
evidence: [{domain: 'a', fact: 'f'}],
keywords: [],
placement: 'a',
summary: '',
tags: [],
title: 'test',
}],
}
Expand All @@ -146,12 +158,63 @@ describe('dream-response-schemas', () => {
claim: 'test',
confidence: 1,
evidence: [{domain: 'a', fact: 'f'}],
keywords: [],
placement: 'a',
summary: '',
tags: [],
title: 'test',
}],
}
expect(() => SynthesizeResponseSchema.parse(input)).to.not.throw()
})

it('should reject summary longer than 500 characters', () => {
const input = {
syntheses: [{
claim: 'test',
confidence: 0.5,
evidence: [{domain: 'a', fact: 'f'}],
keywords: [],
placement: 'a',
summary: 'x'.repeat(501),
tags: [],
title: 'test',
}],
}
expect(() => SynthesizeResponseSchema.parse(input)).to.throw()
})

it('should reject tags array longer than 8 entries', () => {
const input = {
syntheses: [{
claim: 'test',
confidence: 0.5,
evidence: [{domain: 'a', fact: 'f'}],
keywords: [],
placement: 'a',
summary: '',
tags: Array.from({length: 9}, (_, i) => `tag-${i}`),
title: 'test',
}],
}
expect(() => SynthesizeResponseSchema.parse(input)).to.throw()
})

it('should reject keywords array longer than 15 entries', () => {
const input = {
syntheses: [{
claim: 'test',
confidence: 0.5,
evidence: [{domain: 'a', fact: 'f'}],
keywords: Array.from({length: 16}, (_, i) => `kw-${i}`),
placement: 'a',
summary: '',
tags: [],
title: 'test',
}],
}
expect(() => SynthesizeResponseSchema.parse(input)).to.throw()
})
})

describe('PruneResponseSchema', () => {
Expand Down
40 changes: 40 additions & 0 deletions test/unit/infra/dream/operations/consolidate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,46 @@ describe('consolidate', () => {
expect(titleIdx, 'title should appear before createdAt (canonical order)').to.be.lessThan(createdAtIdx)
})

it('TEMPORAL_UPDATE preserves flow-style arrays (no block-style reflow)', async () => {
await createCanonicalFile(ctxDir, 'auth/session.md', '# Old session info')

// Input frontmatter uses flow-style arrays (the canonical CLI format
// emitted by markdown-writer with flowLevel: 1). After consolidate
// appends consolidated_at, the rewritten file must keep the SAME
// flow style — block-style reflow (`- a\n - b`) silently diverges
// from regular brv curate output and recreates the synthesis-vs-regular
// inconsistency this work eliminates.
const updatedWithFm = [
'---',
'title: Auth Session',
"summary: Updated session handling",
'tags: [auth, session, security]',
'related: []',
'keywords: [session, cookie, jwt]',
"createdAt: '2026-04-01T00:00:00.000Z'",
"updatedAt: '2026-04-10T00:00:00.000Z'",
'---',
'# Updated session info',
].join('\n')

agent.executeOnSession.resolves(llmResponse([{
files: ['auth/session.md'],
reason: 'Outdated info',
type: 'TEMPORAL_UPDATE',
updatedContent: updatedWithFm,
}]))

await consolidate(['auth/session.md'], deps)

const updated = await readFile(join(ctxDir, 'auth/session.md'), 'utf8')
expect(updated).to.include('tags: [auth, session, security]')
expect(updated).to.include('keywords: [session, cookie, jwt]')
expect(updated).to.include('related: []')
// Reject block-style reflow
expect(updated).to.not.match(/^tags:\s*\n\s+- /m)
expect(updated).to.not.match(/^keywords:\s*\n\s+- /m)
})

it('CROSS_REFERENCE preserves existing frontmatter field order', async () => {
await createCanonicalFile(ctxDir, 'auth/session.md', '# Session')
await createCanonicalFile(ctxDir, 'auth/tokens.md', '# Tokens')
Expand Down
Loading
Loading