diff --git a/src/server/infra/dream/operations/synthesize.ts b/src/server/infra/dream/operations/synthesize.ts index 8b67ab66e..dca8bce39 100644 --- a/src/server/infra/dream/operations/synthesize.ts +++ b/src/server/infra/dream/operations/synthesize.ts @@ -17,9 +17,13 @@ import {access, mkdir, readdir, readFile, rename, writeFile} from 'node:fs/promi import {dirname, join, resolve} from 'node:path' import type {ICipherAgent} from '../../../../agent/core/interfaces/i-cipher-agent.js' +import type {ILogger} from '../../../../agent/core/interfaces/i-logger.js' +import type {IRuntimeSignalStore} from '../../../core/interfaces/storage/i-runtime-signal-store.js' import type {DreamOperation} from '../dream-log-schema.js' import type {SynthesisCandidate} from '../dream-response-schemas.js' +import {createDefaultRuntimeSignals} from '../../../core/domain/knowledge/runtime-signals-schema.js' +import {warnSidecarFailure} from '../../../core/domain/knowledge/sidecar-logging.js' import {isDescendantOf} from '../../../utils/path-utils.js' import {SynthesizeResponseSchema} from '../dream-response-schemas.js' import {parseDreamResponse} from '../parse-dream-response.js' @@ -27,6 +31,17 @@ import {parseDreamResponse} from '../parse-dream-response.js' export type SynthesizeDeps = { agent: ICipherAgent contextTreeDir: string + /** + * Optional logger. When provided, sidecar seed failures emit a warn + * so the fail-open degradation is observable rather than silent. + */ + logger?: ILogger + /** + * Optional sidecar store for runtime ranking signals. When provided, + * newly created synthesis files are seeded with default signals so + * ranking data lives in the sidecar rather than in markdown frontmatter. + */ + runtimeSignalStore?: IRuntimeSignalStore searchService: { search(query: string, options?: {limit?: number; scope?: string}): Promise<{results: Array<{path: string; score: number; title: string}>}> } @@ -92,7 +107,7 @@ export async function synthesize(deps: SynthesizeDeps): Promise { const slug = slugify(candidate.title) const relativePath = `${candidate.placement}/${slug}.md` @@ -243,7 +260,6 @@ async function writeSynthesisFile( /* eslint-disable camelcase */ const frontmatter = { confidence: candidate.confidence, - maturity: 'draft', sources, synthesized_at: new Date().toISOString(), type: 'synthesis', @@ -264,6 +280,17 @@ async function writeSynthesisFile( await atomicWrite(absPath, content) + // Seed the sidecar with default signals so ranking data lives in the + // sidecar rather than in markdown frontmatter. Best-effort — a sidecar + // failure must never prevent the synthesis file from being created. + if (runtimeSignalStore) { + try { + await runtimeSignalStore.set(relativePath, createDefaultRuntimeSignals()) + } catch (error) { + warnSidecarFailure(logger, 'synthesize', 'seed', relativePath, error) + } + } + return { action: 'CREATE', confidence: candidate.confidence, diff --git a/src/server/infra/executor/dream-executor.ts b/src/server/infra/executor/dream-executor.ts index d823303a0..80c9438e0 100644 --- a/src/server/infra/executor/dream-executor.ts +++ b/src/server/infra/executor/dream-executor.ts @@ -288,6 +288,7 @@ export class DreamExecutor { ...(await synthesize({ agent, contextTreeDir, + runtimeSignalStore: this.deps.runtimeSignalStore, searchService: this.deps.searchService, signal, taskId, diff --git a/test/unit/infra/dream/operations/synthesize.test.ts b/test/unit/infra/dream/operations/synthesize.test.ts index 6dd7b2b53..ff1f3ad06 100644 --- a/test/unit/infra/dream/operations/synthesize.test.ts +++ b/test/unit/infra/dream/operations/synthesize.test.ts @@ -5,9 +5,11 @@ import {join} from 'node:path' import {restore, type SinonStub, stub} from 'sinon' import type {ICipherAgent} from '../../../../../src/agent/core/interfaces/i-cipher-agent.js' +import type {IRuntimeSignalStore} from '../../../../../src/server/core/interfaces/storage/i-runtime-signal-store.js' import type {DreamOperation} from '../../../../../src/server/infra/dream/dream-log-schema.js' import {synthesize, type SynthesizeDeps} from '../../../../../src/server/infra/dream/operations/synthesize.js' +import {createMockRuntimeSignalStore} from '../../../../helpers/mock-factories.js' /** Helper: create a markdown file with optional frontmatter */ async function createMdFile(dir: string, relativePath: string, body: string, frontmatter?: Record): Promise { @@ -159,7 +161,7 @@ describe('synthesize', () => { const content = await readFile(join(ctxDir, 'auth/shared-token-validation.md'), 'utf8') expect(content).to.include('type: synthesis') - expect(content).to.include('maturity: draft') + expect(content).to.not.include('maturity:') expect(content).to.include('Shared Token Validation') expect(content).to.include('Both auth and API share token validation logic.') }) @@ -478,4 +480,133 @@ describe('synthesize', () => { const options = agent.executeOnSession.firstCall.args[2] expect(options).to.have.property('signal', controller.signal) }) + + // ── Runtime-signal sidecar ────────────────────────────────────────────── + + describe('runtime-signal sidecar', () => { + let signalStore: IRuntimeSignalStore + + beforeEach(() => { + signalStore = createMockRuntimeSignalStore() + }) + + it('does not write maturity to markdown frontmatter', async () => { + await createMdFile(ctxDir, 'auth/_index.md', '# Auth', {type: 'summary'}) + await createMdFile(ctxDir, 'api/_index.md', '# API', {type: 'summary'}) + + agent.executeOnSession.resolves(llmResponse([{ + claim: 'Cross-domain pattern.', + confidence: 0.9, + evidence: [{domain: 'auth', fact: 'A'}, {domain: 'api', fact: 'B'}], + placement: 'auth', + title: 'Sidecar Test', + }])) + + await synthesize({...deps, runtimeSignalStore: signalStore}) + + const content = await readFile(join(ctxDir, 'auth/sidecar-test.md'), 'utf8') + expect(content).to.not.include('maturity:') + expect(content).to.not.include('importance:') + expect(content).to.not.include('recency:') + expect(content).to.not.include('accessCount:') + expect(content).to.not.include('updateCount:') + }) + + it('seeds sidecar with default signals after writing synthesis file', async () => { + await createMdFile(ctxDir, 'auth/_index.md', '# Auth', {type: 'summary'}) + await createMdFile(ctxDir, 'api/_index.md', '# API', {type: 'summary'}) + + agent.executeOnSession.resolves(llmResponse([{ + claim: 'Pattern.', + confidence: 0.85, + evidence: [{domain: 'auth', fact: 'A'}, {domain: 'api', fact: 'B'}], + placement: 'auth', + title: 'Seeded Pattern', + }])) + + const setSpy = stub(signalStore, 'set').callThrough() + + await synthesize({...deps, runtimeSignalStore: signalStore}) + + expect(setSpy.calledOnce).to.be.true + expect(setSpy.firstCall.args[0]).to.equal('auth/seeded-pattern.md') + const signals = await signalStore.get('auth/seeded-pattern.md') + expect(signals.importance).to.equal(50) + expect(signals.maturity).to.equal('draft') + expect(signals.accessCount).to.equal(0) + expect(signals.updateCount).to.equal(0) + }) + + it('seeds sidecar for each created file in multi-candidate run', async () => { + await createMdFile(ctxDir, 'auth/_index.md', '# Auth', {type: 'summary'}) + await createMdFile(ctxDir, 'api/_index.md', '# API', {type: 'summary'}) + + agent.executeOnSession.resolves(llmResponse([ + { + claim: 'First.', + confidence: 0.9, + evidence: [{domain: 'auth', fact: 'A'}, {domain: 'api', fact: 'B'}], + placement: 'auth', + title: 'Multi One', + }, + { + claim: 'Second.', + confidence: 0.8, + evidence: [{domain: 'auth', fact: 'C'}, {domain: 'api', fact: 'D'}], + placement: 'api', + title: 'Multi Two', + }, + ])) + + const setSpy = stub(signalStore, 'set').callThrough() + + await synthesize({...deps, runtimeSignalStore: signalStore}) + + expect(setSpy.calledTwice).to.be.true + expect(setSpy.firstCall.args[0]).to.equal('auth/multi-one.md') + expect(setSpy.secondCall.args[0]).to.equal('api/multi-two.md') + }) + + it('creates file even when sidecar store.set throws (fail-open)', async () => { + const brokenStore = createMockRuntimeSignalStore() + stub(brokenStore, 'set').rejects(new Error('disk full')) + + await createMdFile(ctxDir, 'auth/_index.md', '# Auth', {type: 'summary'}) + await createMdFile(ctxDir, 'api/_index.md', '# API', {type: 'summary'}) + + agent.executeOnSession.resolves(llmResponse([{ + claim: 'Fail open.', + confidence: 0.9, + evidence: [{domain: 'auth', fact: 'A'}, {domain: 'api', fact: 'B'}], + placement: 'auth', + title: 'Fail Open Pattern', + }])) + + const results = await synthesize({...deps, runtimeSignalStore: brokenStore}) + expect(results).to.have.lengthOf(1) + + const content = await readFile(join(ctxDir, 'auth/fail-open-pattern.md'), 'utf8') + expect(content).to.include('type: synthesis') + }) + + it('succeeds even when sidecar store is not provided', async () => { + await createMdFile(ctxDir, 'auth/_index.md', '# Auth', {type: 'summary'}) + await createMdFile(ctxDir, 'api/_index.md', '# API', {type: 'summary'}) + + agent.executeOnSession.resolves(llmResponse([{ + claim: 'No store.', + confidence: 0.9, + evidence: [{domain: 'auth', fact: 'A'}, {domain: 'api', fact: 'B'}], + placement: 'auth', + title: 'No Store Pattern', + }])) + + // No runtimeSignalStore in deps — should still create the file + const results = await synthesize(deps) + expect(results).to.have.lengthOf(1) + + const content = await readFile(join(ctxDir, 'auth/no-store-pattern.md'), 'utf8') + expect(content).to.include('type: synthesis') + }) + }) })