v0.23.1 fix: dream self-consumption guard + configurable verdict model

Built-in isDreamOutput() guard in transcript-discovery.ts auto-skips
any transcript whose first 2000 chars contain dream output slug prefixes
(wiki/personal/reflections/, wiki/originals/ideas/, wiki/personal/patterns/,
dream-cycle-summaries/). Prevents infinite recursion if dream output is
ever fed back into the corpus.

judgeSignificance() now accepts a verdictModel parameter, loaded from
dream.synthesize.verdict_model config key. Default: claude-haiku-4-5.

3 new test cases covering the guard.
This commit is contained in:
Wintermute
2026-04-30 09:07:36 +00:00
parent 527b87bd1e
commit 8cdb0417bc
6 changed files with 93 additions and 4 deletions
+29
View File
@@ -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.**
+1 -1
View File
@@ -1 +1 @@
0.23.0
0.23.1
+1 -1
View File
@@ -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",
+6 -2
View File
@@ -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<SynthConfig> {
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<SynthConfig> {
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<VerdictResult> {
// 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": <bool>, "reasons": ["<short>", "<short>"]}
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}` }],
+23
View File
@@ -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<word>\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);
+33
View File
@@ -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();
});
});