diff --git a/CHANGELOG.md b/CHANGELOG.md index d815dd4be..11abf7827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ All notable changes to GBrain will be documented in this file. +## [0.23.1] - 2026-04-30 + +**Dream cycle can't eat its own output. Verdict model is configurable.** + +Two fixes for the dream-cycle synthesize phase shipped in v0.23.0. + +### Self-consumption guard (built-in, zero config) + +The transcript discovery layer now auto-skips any file whose first 2000 characters contain dream output slug prefixes (`wiki/personal/reflections/`, `wiki/originals/ideas/`, `wiki/personal/patterns/`, `dream-cycle-summaries/`). This prevents the dream cycle from synthesizing its own output if transcripts ever include dream-generated pages — an infinite recursion bug that would compound with each overnight cycle. + +The guard is structural and always-on. User-configured `exclude_patterns` are additive on top. The 2000-char head scan keeps the cost negligible (no full-content scan). + +### Configurable verdict model + +The significance verdict model (the cheap "is this worth processing?" filter) is now configurable via `dream.synthesize.verdict_model`. Default remains `claude-haiku-4-5-20251001`. Set it if you want a stronger model for the triage pass: + +```bash +gbrain config set dream.synthesize.verdict_model claude-sonnet-4-6 +``` + +### Itemized changes + +#### Fixed +- `src/core/cycle/transcript-discovery.ts`: built-in `isDreamOutput()` guard checks first 2000 chars for dream output slug prefixes. Applied in both `discoverTranscripts()` and `readSingleTranscript()`. Runs before user-configured exclude patterns. +- `src/core/cycle/synthesize.ts`: `judgeSignificance()` now accepts a `verdictModel` parameter (default `claude-haiku-4-5-20251001`). Loaded from `dream.synthesize.verdict_model` config key via `loadSynthConfig()`. + +#### Tests +- 3 new test cases in `test/cycle-synthesize.test.ts` under `self-consumption guard`: discovery skips dream output files, single-transcript returns null for dream output, deep-buried slugs don't false-positive (performance guard checks head only). + ## [0.23.0] - 2026-04-26 **`gbrain dream` now actually dreams. Conversation transcripts become reflections, originals, and 25-year patterns ... overnight.** diff --git a/VERSION b/VERSION index ca222b7cf..610e28725 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.0 +0.23.1 diff --git a/package.json b/package.json index 19c0c3492..bcfbb0443 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gbrain", - "version": "0.23.0", + "version": "0.23.1", "description": "Postgres-native personal knowledge brain with hybrid RAG search", "type": "module", "main": "src/core/index.ts", diff --git a/src/core/cycle/synthesize.ts b/src/core/cycle/synthesize.ts index 96cd40e21..c6c5e8feb 100644 --- a/src/core/cycle/synthesize.ts +++ b/src/core/cycle/synthesize.ts @@ -120,7 +120,7 @@ export async function runPhaseSynthesize( verdicts.push({ filePath: t.filePath, worth: false, reasons: ['no ANTHROPIC_API_KEY for significance judge'], cached: false }); continue; } - const verdict = await judgeSignificance(haiku, t); + const verdict = await judgeSignificance(haiku, t, config.verdictModel); await engine.putDreamVerdict(t.filePath, t.contentHash, verdict); verdicts.push({ filePath: t.filePath, worth: verdict.worth_processing, reasons: verdict.reasons, cached: false }); if (verdict.worth_processing) worthProcessing.push(t); @@ -248,6 +248,7 @@ interface SynthConfig { minChars: number; excludePatterns: string[]; model: string; + verdictModel: string; cooldownHours: number; } @@ -258,6 +259,7 @@ async function loadSynthConfig(engine: BrainEngine): Promise { const minCharsStr = await engine.getConfig('dream.synthesize.min_chars'); const excludeStr = await engine.getConfig('dream.synthesize.exclude_patterns'); const model = (await engine.getConfig('dream.synthesize.model')) || 'claude-sonnet-4-6'; + const verdictModel = (await engine.getConfig('dream.synthesize.verdict_model')) || 'claude-haiku-4-5-20251001'; const cooldownHoursStr = await engine.getConfig('dream.synthesize.cooldown_hours'); let excludePatterns: string[] = ['medical', 'therapy']; @@ -275,6 +277,7 @@ async function loadSynthConfig(engine: BrainEngine): Promise { minChars: minCharsStr ? Math.max(0, parseInt(minCharsStr, 10) || 2000) : 2000, excludePatterns, model, + verdictModel, cooldownHours: cooldownHoursStr ? Math.max(0, parseInt(cooldownHoursStr, 10) || 12) : 12, }; } @@ -336,6 +339,7 @@ interface VerdictResult { async function judgeSignificance( client: JudgeClient, t: DiscoveredTranscript, + verdictModel = 'claude-haiku-4-5-20251001', ): Promise { // Truncate the transcript at 8K chars for cost control. Haiku's verdict // doesn't need the full body; the opening + closing sections are usually @@ -362,7 +366,7 @@ Respond as JSON: {"worth_processing": , "reasons": ["", ""]} Two reasons max, one phrase each.`; const msg = await client.create({ - model: 'claude-haiku-4-5-20251001', + model: verdictModel, max_tokens: 200, system: sys, messages: [{ role: 'user', content: `Transcript ${t.basename}:\n\n${trimmed}` }], diff --git a/src/core/cycle/transcript-discovery.ts b/src/core/cycle/transcript-discovery.ts index 31ece6264..d1ee0cd81 100644 --- a/src/core/cycle/transcript-discovery.ts +++ b/src/core/cycle/transcript-discovery.ts @@ -46,6 +46,27 @@ export interface DiscoverOpts { const DATE_RE = /^(\d{4}-\d{2}-\d{2})/; const WORD_BOUNDARY_HEURISTIC = /^[a-zA-Z][a-zA-Z0-9_-]*$/; +/** + * Built-in self-consumption guard: slug prefixes that indicate dream output. + * If a transcript contains any of these paths, it's the dream cycle's own + * output being fed back in — skip it to prevent infinite recursion. + * This is a hard-coded safety net; user-configured exclude_patterns are + * additive on top of this. + */ +const DREAM_OUTPUT_SLUGS = [ + 'wiki/personal/reflections/', + 'wiki/originals/ideas/', + 'wiki/personal/patterns/', + 'dream-cycle-summaries/', +]; + +function isDreamOutput(content: string): boolean { + // Check the first 2000 chars (frontmatter + opening) for dream output markers. + // Full-content scan is wasteful; dream output always self-identifies early. + const head = content.slice(0, 2000); + return DREAM_OUTPUT_SLUGS.some(slug => head.includes(slug)); +} + /** * Auto-wrap bare-word patterns in `\b\b`. Power users can pass full * regex (e.g. `^therapy:`) which we honor verbatim. Heuristic: any input @@ -141,6 +162,7 @@ export function discoverTranscripts(opts: DiscoverOpts): DiscoveredTranscript[] continue; } if (content.length < minChars) continue; + if (isDreamOutput(content)) continue; if (matchesAnyExclude(content, excludeRes)) continue; results.push({ @@ -175,6 +197,7 @@ export function readSingleTranscript( throw new Error(`could not read transcript at ${filePath}: ${msg}`); } if (content.length < minChars) return null; + if (isDreamOutput(content)) return null; if (matchesAnyExclude(content, excludeRes)) return null; const baseName = basename(filePath, '.txt'); const dateMatch = DATE_RE.exec(baseName); diff --git a/test/cycle-synthesize.test.ts b/test/cycle-synthesize.test.ts index ed1ba294c..0aa7c1ded 100644 --- a/test/cycle-synthesize.test.ts +++ b/test/cycle-synthesize.test.ts @@ -189,3 +189,36 @@ describe('readSingleTranscript', () => { expect(t!.inferredDate).toBeNull(); }); }); + +describe('self-consumption guard', () => { + test('discoverTranscripts skips files containing dream output slug prefixes', () => { + makeTranscript('2026-04-25-real.txt', 'This is a real conversation transcript. ' + 'x'.repeat(3000)); + makeTranscript('2026-04-25-dream.txt', + '---\ntitle: Reflection\n---\n# Some reflection\nwiki/personal/reflections/2026-04-25-foo-abc123\n' + 'x'.repeat(3000)); + makeTranscript('2026-04-25-original.txt', + '---\ntitle: Idea\n---\n# Some idea\nwiki/originals/ideas/2026-04-25-bar-def456\n' + 'x'.repeat(3000)); + makeTranscript('2026-04-25-pattern.txt', + '---\ntype: pattern\n---\nwiki/personal/patterns/shame-cycle\n' + 'x'.repeat(3000)); + makeTranscript('2026-04-25-summary.txt', + 'dream-cycle-summaries/2026-04-25 generated this page\n' + 'x'.repeat(3000)); + + const results = discoverTranscripts({ corpusDir: tmpDir, minChars: 1000 }); + expect(results).toHaveLength(1); + expect(results[0].basename).toBe('2026-04-25-real'); + }); + + test('readSingleTranscript returns null for dream output', () => { + const path = makeTranscript('2026-04-25-dream.txt', + 'wiki/personal/reflections/2026-04-25-thing-abc123\n' + 'x'.repeat(3000)); + const result = readSingleTranscript(path, { minChars: 1000 }); + expect(result).toBeNull(); + }); + + test('self-consumption guard checks only first 2000 chars (performance)', () => { + // Dream slug buried deep in the file should NOT trigger the guard + const content = 'x'.repeat(3000) + 'wiki/personal/reflections/2026-04-25-buried-abc123'; + const path = makeTranscript('2026-04-25-deep.txt', content); + const result = readSingleTranscript(path, { minChars: 1000 }); + expect(result).not.toBeNull(); + }); +});