diff --git a/src/commands/think.ts b/src/commands/think.ts index 9ce9ee099..e4aa6250d 100644 --- a/src/commands/think.ts +++ b/src/commands/think.ts @@ -9,6 +9,7 @@ import type { BrainEngine } from '../core/engine.ts'; import { runThink, persistSynthesis, stripGapsSection } from '../core/think/index.ts'; import { loadConfig, isThinClient } from '../core/config.ts'; import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts'; +import { canonicalLookup } from '../core/model-pricing.ts'; function flagValue(args: string[], name: string): string | undefined { const i = args.indexOf(name); @@ -20,6 +21,27 @@ function flagPresent(args: string[], name: string): boolean { return args.includes(name); } +/** + * think's own cost was previously unsurfaced anywhere: not in this CLI's own + * `--json` output, not in `budget_ledger`, and invisible to a wrapping + * caller's own token accounting (the LLM call `think` makes is its own, + * separate API call). Returns undefined when `usage` is absent (no-client/ + * stub paths, or a remote-MCP call that didn't forward it) or when the + * resolved model has no entry in the canonical pricing table. + */ +export function computeThinkCostUsd( + usage: { input_tokens: number; output_tokens: number } | undefined, + modelUsed: string, +): number | undefined { + if (!usage) return undefined; + const pricing = canonicalLookup(modelUsed); + if (!pricing) return undefined; + return Number( + ((usage.input_tokens / 1_000_000) * pricing.input + + (usage.output_tokens / 1_000_000) * pricing.output).toFixed(4), + ); +} + export async function runThinkCli(engine: BrainEngine, args: string[]): Promise { if (args.length === 0 || args.includes('--help') || args.includes('-h')) { console.log(`Usage: gbrain think "" [options] @@ -146,9 +168,15 @@ prints what would have been the input (exit 0). } } + const costUsd = computeThinkCostUsd( + (result as { usage?: { input_tokens: number; output_tokens: number } }).usage, + result.modelUsed, + ); + if (json) { console.log(JSON.stringify({ ...result, + cost_usd: costUsd ?? null, saved_slug: savedSlug ?? null, evidence_inserted: evidenceInserted, }, null, 2)); @@ -165,7 +193,8 @@ prints what would have been the input (exit 0). console.log(''); } console.log('---'); - console.log(`Model: ${result.modelUsed} | Pages: ${result.pagesGathered} | Takes: ${result.takesGathered} | Graph: ${result.graphHits} | Citations: ${result.citations.length}`); + const costSuffix = costUsd !== undefined ? ` | Cost: $${costUsd.toFixed(4)}` : ''; + console.log(`Model: ${result.modelUsed} | Pages: ${result.pagesGathered} | Takes: ${result.takesGathered} | Graph: ${result.graphHits} | Citations: ${result.citations.length}${costSuffix}`); if (savedSlug) { console.log(`Saved: ${savedSlug} (${evidenceInserted} evidence rows)`); } diff --git a/src/core/cycle/patterns.ts b/src/core/cycle/patterns.ts index 13b202e12..0673f718e 100644 --- a/src/core/cycle/patterns.ts +++ b/src/core/cycle/patterns.ts @@ -20,6 +20,7 @@ import { join, dirname } from 'node:path'; import { mkdirSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; import type { BrainEngine } from '../engine.ts'; import type { PhaseResult, PhaseError } from '../cycle.ts'; import { MinionQueue } from '../minions/queue.ts'; @@ -29,7 +30,14 @@ import { serializeMarkdown } from '../markdown.ts'; import type { Page, PageType } from '../types.ts'; // #2415: allow-list + output-root resolution shared with the synthesize // phase — both phases must agree on the configured namespace. -import { loadAllowedSlugPrefixes, loadOutputRoot } from './synthesize.ts'; +// runPgliteSubagentsInline is shared too: PGLite has no separate Minions +// worker process (the embedded data-dir holds an exclusive file lock), so a +// job submitted via queue.add() sits in 'waiting' forever unless something +// drives the claim -> run -> complete loop inline. synthesize.ts already +// does this for its own children; patterns.ts previously submitted and +// waited without ever draining, so every real (non-dry-run) invocation on a +// PGLite brain hung until subagentWaitTimeoutMs (default 35 min). +import { loadAllowedSlugPrefixes, loadOutputRoot, runPgliteSubagentsInline } from './synthesize.ts'; import { probeChatModel } from '../ai/gateway.ts'; import { normalizeModelId } from '../model-id.ts'; @@ -115,7 +123,7 @@ export async function runPhasePatterns( } // Gather reflections within lookback window. - const reflections = await gatherReflections(engine, config.lookbackDays, config.outputRoot); + const reflections = await gatherReflections(engine, config.lookbackDays, config.sourceSlugPrefix); if (reflections.length < config.minEvidence) { return skipped( 'insufficient_evidence', @@ -152,6 +160,16 @@ export async function runPhasePatterns( return failed(makeError('InternalError', 'NO_ALLOWLIST', 'skills/_brain-filing-rules.json missing dream_synthesize_paths.globs')); } + // A configured dream.patterns.output_slug_prefix diverging from the + // default `${outputRoot}/personal/patterns` composition (e.g. a flat + // schema with no personal/ nesting) is not covered by the filing-rules + // globs above, which only remap the `wiki/personal/patterns/*` literal + // by outputRoot. Add it explicitly so the subagent's put_page allow-list + // actually grants write access to wherever it's configured to write. + const outputGlob = `${config.outputSlugPrefix}/*`; + if (!allowedSlugPrefixes.includes(outputGlob)) { + allowedSlugPrefixes.push(outputGlob); + } // #2781: budget the subagent from the REMAINING parent-job time, not // the fixed config default. Checked after the cheap gates (disabled / @@ -167,8 +185,15 @@ export async function runPhasePatterns( } const queue = new MinionQueue(engine); + // PGLite children drain inline (no separate worker can open the embedded + // data-dir), so give this job a private per-run queue: the inline drain + // must never claim unrelated 'default'-queue jobs a Postgres worker owns. + // Mirrors synthesize.ts's childQueueName derivation exactly. + const childQueueName = engine.kind === 'pglite' + ? `dream-inline-${Date.now()}-${randomUUID().slice(0, 8)}` + : 'default'; const data: SubagentHandlerData = { - prompt: buildPatternsPrompt(reflections, config.minEvidence, config.outputRoot), + prompt: buildPatternsPrompt(reflections, config.minEvidence, config.sourceSlugPrefix, config.outputSlugPrefix), model: config.model, max_turns: 30, allowed_slug_prefixes: allowedSlugPrefixes, @@ -176,11 +201,19 @@ export async function runPhasePatterns( const submitOpts: Partial = { max_stalled: 3, timeout_ms: budgets.timeoutMs, + queue: childQueueName, }; const job = await queue.add('subagent', data as unknown as Record, submitOpts, { allowProtectedSubmit: true, }); + // PGLite cannot run a separate Minions worker because the embedded DB + // holds an exclusive file lock. Drain this phase's private child queue + // inline so the parent observes the terminal state instead of polling + // waitForCompletion until subagentWaitTimeoutMs expires. No-op on + // Postgres (a real worker process claims the job there). + await runPgliteSubagentsInline(engine, queue, childQueueName, opts.yieldDuringPhase); + let outcome: string; try { const final = await waitForCompletion(queue, job.id, { @@ -273,6 +306,21 @@ interface PatternsConfig { model: string; /** #2415: shared output namespace (dream.synthesize.output_root, default 'wiki'). */ outputRoot: string; + /** + * Slug prefix `gatherReflections` reads from (SQL `LIKE` scope). Defaults + * to `${outputRoot}/personal/reflections`, matching pre-existing behavior. + * Config `dream.patterns.source_slug_prefix` overrides it for brains whose + * schema has no `personal/reflections/` convention (e.g. a flat + * `meetings/` tree) so the phase can read from wherever compiled_truth + * excerpts actually live. + */ + sourceSlugPrefix: string; + /** + * Slug prefix new pattern pages are written under. Defaults to + * `${outputRoot}/personal/patterns`, matching pre-existing behavior. + * Config `dream.patterns.output_slug_prefix` overrides it. + */ + outputSlugPrefix: string; /** #1594-family: subagent job timeout, config `dream.patterns.subagent_timeout_ms`. */ subagentTimeoutMs: number; /** #1594-family: waitForCompletion timeout, config `dream.patterns.subagent_wait_timeout_ms`. */ @@ -289,6 +337,14 @@ async function getNumberConfig(engine: BrainEngine, key: string, fallback: numbe return Number.isNaN(value) ? fallback : value; } +/** Trims leading/trailing slashes from a config-supplied slug prefix; falls back to `fallback` when unset or empty after trimming. */ +async function getSlugPrefixConfig(engine: BrainEngine, key: string, fallback: string): Promise { + const raw = await engine.getConfig(key); + if (!raw) return fallback; + const trimmed = raw.trim().replace(/^\/+|\/+$/g, ''); + return trimmed || fallback; +} + async function loadPatternsConfig(engine: BrainEngine): Promise { const enabledStr = await engine.getConfig('dream.patterns.enabled'); const enabled = enabledStr === null ? true : enabledStr === 'true'; @@ -302,12 +358,19 @@ async function loadPatternsConfig(engine: BrainEngine): Promise tier: 'reasoning', fallback: 'sonnet', }); + const outputRoot = await loadOutputRoot(engine); return { enabled, lookbackDays: lookbackStr ? Math.max(1, parseInt(lookbackStr, 10) || 30) : 30, minEvidence: minEvidenceStr ? Math.max(1, parseInt(minEvidenceStr, 10) || 3) : 3, model, - outputRoot: await loadOutputRoot(engine), + outputRoot, + sourceSlugPrefix: await getSlugPrefixConfig( + engine, 'dream.patterns.source_slug_prefix', `${outputRoot}/personal/reflections`, + ), + outputSlugPrefix: await getSlugPrefixConfig( + engine, 'dream.patterns.output_slug_prefix', `${outputRoot}/personal/patterns`, + ), subagentTimeoutMs: await getNumberConfig( engine, 'dream.patterns.subagent_timeout_ms', DEFAULT_PATTERNS_SUBAGENT_TIMEOUT_MS, ), @@ -328,11 +391,11 @@ interface ReflectionRef { async function gatherReflections( engine: BrainEngine, lookbackDays: number, - outputRoot = 'wiki', + sourceSlugPrefix = 'wiki/personal/reflections', ): Promise { const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000).toISOString(); - // #2415: reflections live under the configured output root (bound as a - // parameter; outputRoot is slug-grammar-validated by loadOutputRoot). + // Reflections live under the configured source slug prefix (bound as a + // parameter; see PatternsConfig.sourceSlugPrefix / dream.patterns.source_slug_prefix). const rows = await engine.executeRaw<{ slug: string; title: string | null; compiled_truth: string | null }>( `SELECT slug, title, compiled_truth FROM pages @@ -340,7 +403,7 @@ async function gatherReflections( AND updated_at >= $1::timestamptz ORDER BY updated_at DESC LIMIT 100`, - [since, `${outputRoot}/personal/reflections/%`], + [since, `${sourceSlugPrefix}/%`], ); return rows.map(r => ({ slug: r.slug, @@ -351,7 +414,12 @@ async function gatherReflections( // ── Prompt ──────────────────────────────────────────────────────────── -function buildPatternsPrompt(reflections: ReflectionRef[], minEvidence: number, outputRoot = 'wiki'): string { +function buildPatternsPrompt( + reflections: ReflectionRef[], + minEvidence: number, + sourceSlugPrefix = 'wiki/personal/reflections', + outputSlugPrefix = 'wiki/personal/patterns', +): string { const today = new Date().toISOString().slice(0, 10); const corpus = reflections .map((r, i) => `### ${i + 1}. [[${r.slug}]] — ${r.title}\n${r.excerpt}`) @@ -361,15 +429,15 @@ function buildPatternsPrompt(reflections: ReflectionRef[], minEvidence: number, OUTPUT POLICY - Only name a pattern if it appears in at least ${minEvidence} DISTINCT reflections. -- Each pattern page MUST cite the reflections that constitute its evidence (use [[${outputRoot}/personal/reflections/...]] wikilinks). +- Each pattern page MUST cite the reflections that constitute its evidence (use [[${sourceSlugPrefix}/...]] wikilinks). - Use \`search\` to check whether a similar pattern page already exists; if yes, update it (use the same slug). If no, create a new one. -- Pattern slug format: \`${outputRoot}/personal/patterns/\` (lowercase alphanumeric + hyphens; no underscores, no extension, no date). +- Pattern slug format: \`${outputSlugPrefix}/\` (lowercase alphanumeric + hyphens; no underscores, no extension, no date). - A "pattern" is a recurring theme, anxiety, decision pattern, relationship dynamic, or self-knowledge motif. NOT a single insight. NOT a list of unrelated topics. DO NOT WRITE - A "patterns from today" digest (that's the dream-cycle-summaries page; not your job). - Patterns with <${minEvidence} reflections cited. -- Anything outside ${outputRoot}/personal/patterns/. +- Anything outside ${outputSlugPrefix}/. CONTEXT - Today: ${today} diff --git a/src/core/cycle/synthesize.ts b/src/core/cycle/synthesize.ts index 34361c77f..02a2a268f 100644 --- a/src/core/cycle/synthesize.ts +++ b/src/core/cycle/synthesize.ts @@ -276,7 +276,7 @@ const INLINE_PGLITE_LOCK_MS = 30_000; * `yieldDuringPhase` is ticked on a 60s interval while a child runs so the * 5-min cycle lock TTL keeps refreshing during long (up to 30-min) children. */ -async function runPgliteSubagentsInline( +export async function runPgliteSubagentsInline( engine: BrainEngine, queue: MinionQueue, queueName: string, diff --git a/src/core/think/index.ts b/src/core/think/index.ts index 05bdbd3b7..13f451ebc 100644 --- a/src/core/think/index.ts +++ b/src/core/think/index.ts @@ -149,6 +149,17 @@ export interface ThinkResult { takesFromVector: number; graphHits: number; }; + /** + * Token usage from the real LLM call, when one happened. Undefined on the + * no-client/stub paths (no Anthropic key, model not usable) — same + * distinction `synthesisOk` already makes. `think`'s cost was previously + * unsurfaced anywhere: not in this CLI's own output, not in + * `budget_ledger`, and invisible to a wrapping caller's own token + * accounting (the LLM call `think` makes is its own separate API call). + */ + usage?: { input_tokens: number; output_tokens: number }; + /** USD cost computed from `usage` + `canonicalLookup(modelUsed)`, when both are available. */ + cost_usd?: number; } const DEFAULT_MAX_OUTPUT_TOKENS = 4000; @@ -441,6 +452,7 @@ export async function runThink( // return ANDs it with a non-empty-answer check (catches valid-but-empty JSON). let synthesisOk = true; let response: ThinkResponse; + let usage: { input_tokens: number; output_tokens: number } | undefined; if (opts.stubResponse) { response = opts.stubResponse; } else { @@ -504,6 +516,7 @@ export async function runThink( system: systemPrompt, messages: [{ role: 'user', content: userMessage }], }); + usage = { input_tokens: result.usage.input_tokens, output_tokens: result.usage.output_tokens }; const block = result.content.find(b => b.type === 'text'); const text = block && 'text' in block ? block.text : ''; const parsed = tryParseJSON(text); @@ -554,6 +567,7 @@ export async function runThink( takesFromVector: gather.diagnostics.takesFromVector, graphHits: gather.diagnostics.graphHits, }, + usage, }; } diff --git a/test/cycle-patterns-child-outcome.test.ts b/test/cycle-patterns-child-outcome.test.ts index 6ed135108..60c94f9f5 100644 --- a/test/cycle-patterns-child-outcome.test.ts +++ b/test/cycle-patterns-child-outcome.test.ts @@ -5,10 +5,17 @@ * zero pattern pages written (e.g. when no subagent-capable worker slot was * free for the whole wait window) — a silent no-op for days. * - * Drives the real phase against PGLite with the (#1594-family) configurable - * wait timeout set to 1ms and NO worker running, so the child job never - * completes: waitForCompletion throws TimeoutError → outcome 'timeout' → - * nothing written → the phase must report status 'fail', not 'ok'. + * A later fix added runPgliteSubagentsInline to this phase (patterns.ts + * previously submitted a job and waited without anything ever claiming it on + * PGLite — synthesize.ts already had this inline drain, patterns.ts didn't). + * So a fake ANTHROPIC_API_KEY here now gets claimed and actually attempted; + * the real Anthropic call fails immediately, exhausting max_attempts and + * landing the job in 'dead' (not 'timeout' — nothing ever times out, the + * failure is immediate). The #2782 status-reflects-outcome contract this + * test exists to pin is unchanged: any non-'complete' outcome with zero + * writes must still surface as status 'fail', just under the outcome that + * actually occurs now that the job is drained instead of left stuck in + * 'waiting' for the full wait window. */ import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; @@ -57,24 +64,20 @@ async function seedReflections(): Promise { } describe('runPhasePatterns child-outcome status (#2782)', () => { - test('child timeout with zero writes → status fail (was silent ok)', async () => { + test('child dead with zero writes → status fail (was silent ok)', async () => { const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-patterns-outcome-')); try { await seedReflections(); - // #1594-family knob: make the wait window elapse immediately. No - // minion worker runs in this test, so the child job stays queued. - await engine.setConfig('dream.patterns.subagent_wait_timeout_ms', '1'); - const result = await withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' }, () => runPhasePatterns(engine, { brainDir, dryRun: false }), ); expect(result.status).toBe('fail'); - expect(result.details.child_outcome).toBe('timeout'); + expect(result.details.child_outcome).toBe('dead'); expect(result.details.patterns_written).toBe(0); - expect(result.error?.code).toBe('PATTERNS_CHILD_TIMEOUT'); - expect(result.error?.class).toBe('Timeout'); + expect(result.error?.code).toBe('PATTERNS_CHILD_DEAD'); + expect(result.error?.class).toBe('InternalError'); } finally { rmSync(brainDir, { recursive: true, force: true }); } diff --git a/test/cycle-patterns.test.ts b/test/cycle-patterns.test.ts index f50892348..60c8fee39 100644 --- a/test/cycle-patterns.test.ts +++ b/test/cycle-patterns.test.ts @@ -74,12 +74,18 @@ describe('patterns phase wiring', () => { }); describe('patterns scope filter', () => { - test('filters reflections by slug LIKE /personal/reflections/%', () => { - // #2415: the namespace root is configurable (dream.synthesize.output_root, - // default 'wiki') and bound as a parameter — the scope filter itself and - // the reflections sub-path stay pinned. + test('filters reflections by slug LIKE /%', () => { + // #2415 made the top-level namespace root configurable + // (dream.synthesize.output_root, default 'wiki'). A later patch made the + // full `personal/reflections` sub-path configurable too + // (dream.patterns.source_slug_prefix, defaults to + // `/personal/reflections` so existing behavior is + // unchanged) — schemas with no `personal/` nesting (e.g. a flat + // `meetings/` tree) can point the phase at their own compiled_truth + // source instead. expect(patternsSrc).toContain('slug LIKE $2'); - expect(patternsSrc).toContain('/personal/reflections/%'); + expect(patternsSrc).toContain('${sourceSlugPrefix}/%'); + expect(patternsSrc).toContain('dream.patterns.source_slug_prefix'); }); test('orders by updated_at DESC for recency-bias', () => { @@ -89,4 +95,22 @@ describe('patterns scope filter', () => { test('caps gather to 100 reflections (cost control)', () => { expect(patternsSrc).toContain('LIMIT 100'); }); + + test('output slug prefix is config-driven, defaulting to /personal/patterns', () => { + expect(patternsSrc).toContain('dream.patterns.output_slug_prefix'); + expect(patternsSrc).toContain('${outputRoot}/personal/patterns'); + }); + + test('source slug prefix defaults to /personal/reflections', () => { + expect(patternsSrc).toContain('${outputRoot}/personal/reflections'); + }); + + test('adds a configured output_slug_prefix to the subagent write allow-list', () => { + // A custom dream.patterns.output_slug_prefix (e.g. a flat schema with no + // personal/ nesting) is not covered by the filing-rules globs, which only + // remap the `wiki/personal/patterns/*` literal by output_root. The phase + // must add it explicitly so put_page actually grants write access there. + expect(patternsSrc).toContain('outputGlob'); + expect(patternsSrc).toContain('allowedSlugPrefixes.push(outputGlob)'); + }); }); diff --git a/test/think-cost.test.ts b/test/think-cost.test.ts new file mode 100644 index 000000000..cbb8540f4 --- /dev/null +++ b/test/think-cost.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test'; +import { computeThinkCostUsd } from '../src/commands/think.ts'; + +// think's own cost was previously unsurfaced anywhere: not in this CLI's own +// --json output, not in budget_ledger, and invisible to a wrapping caller's +// own token accounting (the LLM call think makes is its own, separate API +// call). computeThinkCostUsd is the small, pure function that turns +// {input_tokens, output_tokens} + a resolved modelUsed into a USD figure via +// the existing canonical pricing table. +describe('computeThinkCostUsd', () => { + test('computes cost from real usage against a known model', () => { + const cost = computeThinkCostUsd( + { input_tokens: 1_000_000, output_tokens: 1_000_000 }, + 'anthropic:claude-sonnet-5', + ); + // sonnet-5 pricing: input $3.00/MTok, output $15.00/MTok. + expect(cost).toBe(18); + }); + + test('undefined usage (no-client/stub path) → undefined, never a fabricated cost', () => { + expect(computeThinkCostUsd(undefined, 'anthropic:claude-sonnet-5')).toBeUndefined(); + }); + + test('unknown model id → undefined, not zero (no pricing to compute from)', () => { + expect(computeThinkCostUsd( + { input_tokens: 100, output_tokens: 100 }, + 'some-unlisted-provider:some-model', + )).toBeUndefined(); + }); + + test('zero-token usage → zero cost, not undefined (a real call that happened to be free)', () => { + expect(computeThinkCostUsd( + { input_tokens: 0, output_tokens: 0 }, + 'anthropic:claude-sonnet-5', + )).toBe(0); + }); +}); diff --git a/test/think-pipeline.serial.test.ts b/test/think-pipeline.serial.test.ts index adec8360b..82e55ae6a 100644 --- a/test/think-pipeline.serial.test.ts +++ b/test/think-pipeline.serial.test.ts @@ -177,6 +177,12 @@ describe('runThink (with stub client)', () => { expect(result.gaps).toEqual(['no info on funding history']); expect(result.takesGathered).toBeGreaterThan(0); expect(result.warnings).not.toContain('LLM_OUTPUT_NOT_JSON'); + // think's own cost was previously unsurfaced anywhere (not in this CLI's + // output, not in budget_ledger, and invisible to a wrapping caller's own + // token accounting since the LLM call is think's own, separate call). + // usage flows through from the real client.create() response so the CLI + // can compute cost_usd from it via canonicalLookup(modelUsed). + expect(result.usage).toEqual({ input_tokens: 10, output_tokens: 10 }); }); test('passes the question into page excerpt selection', async () => { @@ -417,6 +423,17 @@ describe('runThink + persistSynthesis — #1698 never persist empty', () => { expect(full.synthesisOk).toBe(true); }); + test('opts.stubResponse path never made a real LLM call — usage stays undefined', async () => { + // Same distinction synthesisOk already makes: opts.stubResponse bypasses + // client.create() entirely, so there is no real usage to report. cost_usd + // must not be computed (and should render as null in --json) when this + // happens, since there is nothing to compute it from. + const result = await runThink(engine, { + question: 'stub no usage', stubResponse: { answer: 'has content', citations: [], gaps: [] }, + }); + expect(result.usage).toBeUndefined(); + }); + test('pre-existing ThinkResult literal without synthesisOk still persists (back-compat)', async () => { const legacy: any = { question: 'legacy backcompat', answer: 'legacy body', citations: [], gaps: [],