mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}` }],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user