diff --git a/src/app/runtime-container.ts b/src/app/runtime-container.ts index f332af5..d10fcc4 100644 --- a/src/app/runtime-container.ts +++ b/src/app/runtime-container.ts @@ -123,6 +123,8 @@ export interface CoreRuntimeConfig { retrievalConfidenceMarginNormalizer: number; retrievalConfidenceSimilarityNormalizer: number; retrievalConfidenceFloor: number; + /** EXP-SUM: see RuntimeConfig.summaryDownweightFactor. */ + summaryDownweightFactor: number; } /** Repositories constructed by the runtime container. */ diff --git a/src/config.ts b/src/config.ts index 6655f20..0ded8e8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -158,6 +158,25 @@ export interface RuntimeConfig { costLogDir: string; costRunId: string; conflictAutoResolveMs: number; + /** + * EXP-SUM: synthesize-only periodic consolidation. When enabled, every + * `summarySynthesisTurnInterval` ingested turns the system clusters + * active memories and stores LLM-synthesized summaries WITHOUT archiving + * the cluster members. Summaries are tagged `metadata.fact_role: + * 'summary'` and `metadata.summary_of: [ids]`. Defaults-off — preserves + * the existing archive-style consolidation as the only consolidation + * path until operators opt in. + */ + summarySynthesisEnabled: boolean; + /** EXP-SUM: interval (in ingest turns) between synthesize-only runs. */ + summarySynthesisTurnInterval: number; + /** + * EXP-SUM: multiplicative down-weight applied to summary-tagged results + * for non-summary-style queries. 0.5 halves the score; 1.0 disables the + * down-weight entirely. Summary-style queries (e.g. "summarize", "give + * me an overview") skip the down-weight so summaries surface naturally. + */ + summaryDownweightFactor: number; /** * Dev/test-only: when true, PUT /v1/memories/config mutates the runtime * singleton. Production deploys leave this unset (false) — the route @@ -414,6 +433,9 @@ export const config: RuntimeConfig = { costLogDir: optionalEnv('COST_LOG_DIR') ?? 'data/cost-logs', costRunId: optionalEnv('COST_RUN_ID') ?? '', conflictAutoResolveMs: parseInt(optionalEnv('CONFLICT_AUTO_RESOLVE_MS') ?? '86400000', 10), + summarySynthesisEnabled: (optionalEnv('SUMMARY_SYNTHESIS_ENABLED') ?? 'false') === 'true', + summarySynthesisTurnInterval: parsePositiveIntEnv('SUMMARY_SYNTHESIS_TURN_INTERVAL', 30), + summaryDownweightFactor: parseFloat(optionalEnv('SUMMARY_DOWNWEIGHT_FACTOR') ?? '0.5'), runtimeConfigMutationEnabled: (process.env.CORE_RUNTIME_CONFIG_MUTATION_ENABLED ?? 'false') === 'true', }; @@ -563,6 +585,9 @@ export const INTERNAL_POLICY_CONFIG_FIELDS = [ 'compositeMaxClusterSize', 'compositeSimilarityThreshold', // Conflict handling 'conflictAutoResolveMs', + // Synthesize-only periodic consolidation (EXP-SUM) + 'summarySynthesisEnabled', 'summarySynthesisTurnInterval', + 'summaryDownweightFactor', ] as const; export type SupportedRuntimeConfigField = typeof SUPPORTED_RUNTIME_CONFIG_FIELDS[number]; diff --git a/src/db/repository-types.ts b/src/db/repository-types.ts index 529a251..073987f 100644 --- a/src/db/repository-types.ts +++ b/src/db/repository-types.ts @@ -56,6 +56,8 @@ export const RESERVED_METADATA_KEYS = new Set([ // Event boundary — `src/services/memory-storage.ts` (EXP-13) 'event_boundary', 'boundary_strength', + // Synthesize-only consolidation — `src/services/summary-synthesis.ts` (EXP-SUM) + 'summary_of', ]); /** diff --git a/src/services/__tests__/summary-synthesis.test.ts b/src/services/__tests__/summary-synthesis.test.ts new file mode 100644 index 0000000..53cb524 --- /dev/null +++ b/src/services/__tests__/summary-synthesis.test.ts @@ -0,0 +1,305 @@ +/** + * Unit tests for EXP-SUM synthesize-only periodic consolidation. + * + * Critical assertions: + * - flag-off → no-op (no synthesis, no soft-delete) + * - triggers exactly at multiples of summarySynthesisTurnInterval + * - originals are NEVER soft-deleted (this is the whole point vs EXP-08) + * - summary memory carries fact_role: 'summary' AND summary_of: [ids] + * - retrieval down-weight: summary score < non-summary for non-SUM queries + * - retrieval no down-weight: summary unchanged for "summarize" queries + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + applySummaryDownweight, + isSummarizationStyleQuery, +} from '../summary-downweight.js'; +import { createSearchResult } from './test-fixtures.js'; + +const { + mockFindConsolidationCandidates, + mockSynthesizeCluster, + mockEmbedText, +} = vi.hoisted(() => ({ + mockFindConsolidationCandidates: vi.fn(), + mockSynthesizeCluster: vi.fn(), + mockEmbedText: vi.fn(), +})); + +vi.mock('../consolidation-service.js', () => ({ + findConsolidationCandidates: mockFindConsolidationCandidates, + synthesizeCluster: mockSynthesizeCluster, +})); +vi.mock('../embedding.js', () => ({ + embedText: mockEmbedText, +})); + +const { + synthesizeSummariesForUser, + __resetSummaryTurnCountsForTest, + __peekSummaryTurnCountForTest, +} = await import('../summary-synthesis.js'); + +type DepsLike = Parameters[0]; + +interface FakeMemoryStore { + storeMemory: ReturnType; + getMemory: ReturnType; + softDeleteMemory: ReturnType; +} + +function makeFakeMemoryStore(): FakeMemoryStore { + return { + storeMemory: vi.fn().mockResolvedValue('summary-id'), + getMemory: vi.fn(), + softDeleteMemory: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeDeps( + memory: FakeMemoryStore, + overrides: Partial<{ enabled: boolean; interval: number }> = {}, +): DepsLike { + return { + config: { + summarySynthesisEnabled: overrides.enabled ?? true, + summarySynthesisTurnInterval: overrides.interval ?? 30, + llmModel: 'gpt-test', + }, + stores: { memory }, + } as unknown as DepsLike; +} + +function makeMember(id: string, importance = 0.5) { + return { + id, + user_id: 'u1', + content: `fact ${id}`, + embedding: [0.1, 0.2], + memory_type: 'semantic', + importance, + source_site: 'site', + source_url: '', + episode_id: null, + status: 'active', + metadata: {}, + keywords: '', + namespace: null, + summary: '', + overview: '', + trust_score: 1, + observed_at: new Date(), + created_at: new Date(), + last_accessed_at: new Date(), + access_count: 0, + expired_at: null, + deleted_at: null, + network: 'episodic', + opinion_confidence: null, + observation_subject: null, + }; +} + +describe('synthesizeSummariesForUser', () => { + beforeEach(() => { + __resetSummaryTurnCountsForTest(); + mockFindConsolidationCandidates.mockReset(); + mockSynthesizeCluster.mockReset(); + mockEmbedText.mockReset(); + mockEmbedText.mockResolvedValue([0.9, 0.1]); + mockFindConsolidationCandidates.mockResolvedValue({ + memoriesScanned: 3, + clustersFound: 1, + memoriesInClusters: 3, + clusters: [ + { + memberIds: ['m1', 'm2', 'm3'], + memberContents: ['fact 1', 'fact 2', 'fact 3'], + avgAffinity: 0.92, + memberCount: 3, + }, + ], + }); + mockSynthesizeCluster.mockResolvedValue('Synthesized summary text.'); + }); + + afterEach(() => { + __resetSummaryTurnCountsForTest(); + }); + + it('flag-off → strict no-op: no clustering, no synthesis, no store, no soft-delete', async () => { + const memory = makeFakeMemoryStore(); + memory.getMemory.mockResolvedValue(makeMember('m1')); + const deps = makeDeps(memory, { enabled: false }); + + for (let i = 0; i < 100; i++) { + const ids = await synthesizeSummariesForUser(deps, 'u1'); + expect(ids).toEqual([]); + } + + expect(mockFindConsolidationCandidates).not.toHaveBeenCalled(); + expect(mockSynthesizeCluster).not.toHaveBeenCalled(); + expect(memory.storeMemory).not.toHaveBeenCalled(); + expect(memory.softDeleteMemory).not.toHaveBeenCalled(); + expect(__peekSummaryTurnCountForTest('u1')).toBe(0); + }); + + it('triggers exactly twice across 60 turns at interval 30', async () => { + const memory = makeFakeMemoryStore(); + memory.getMemory.mockImplementation(async (id: string) => makeMember(id)); + const deps = makeDeps(memory, { enabled: true, interval: 30 }); + + let triggered = 0; + for (let i = 0; i < 60; i++) { + const ids = await synthesizeSummariesForUser(deps, 'u1'); + if (ids.length > 0) triggered++; + } + + expect(mockFindConsolidationCandidates).toHaveBeenCalledTimes(2); + expect(triggered).toBe(2); + expect(__peekSummaryTurnCountForTest('u1')).toBe(60); + }); + + it('does NOT soft-delete cluster members (the whole point vs EXP-08)', async () => { + const memory = makeFakeMemoryStore(); + memory.getMemory.mockImplementation(async (id: string) => makeMember(id, 0.7)); + const deps = makeDeps(memory, { enabled: true, interval: 1 }); + + const ids = await synthesizeSummariesForUser(deps, 'u1'); + expect(ids).toHaveLength(1); + + // Members were looked up — confirms we DID see them — but never deleted. + expect(memory.getMemory).toHaveBeenCalledTimes(3); + expect(memory.softDeleteMemory).not.toHaveBeenCalled(); + }); + + it('summary memory carries fact_role: "summary" and summary_of: [member ids]', async () => { + const memory = makeFakeMemoryStore(); + memory.getMemory.mockImplementation(async (id: string) => makeMember(id, 0.6)); + const deps = makeDeps(memory, { enabled: true, interval: 1 }); + + await synthesizeSummariesForUser(deps, 'u1'); + + expect(memory.storeMemory).toHaveBeenCalledOnce(); + const writeInput = memory.storeMemory.mock.calls[0][0]; + expect(writeInput.metadata.fact_role).toBe('summary'); + expect(writeInput.metadata.summary_of).toEqual(['m1', 'm2', 'm3']); + expect(writeInput.content).toBe('Synthesized summary text.'); + expect(writeInput.userId).toBe('u1'); + }); + + it('per-user counter is independent', async () => { + const memory = makeFakeMemoryStore(); + memory.getMemory.mockImplementation(async (id: string) => makeMember(id)); + const deps = makeDeps(memory, { enabled: true, interval: 5 }); + + for (let i = 0; i < 5; i++) await synthesizeSummariesForUser(deps, 'user-a'); + for (let i = 0; i < 4; i++) await synthesizeSummariesForUser(deps, 'user-b'); + + expect(mockFindConsolidationCandidates).toHaveBeenCalledTimes(1); + }); + + it('logs and swallows errors thrown by synthesis (no propagation)', async () => { + const memory = makeFakeMemoryStore(); + memory.getMemory.mockImplementation(async (id: string) => makeMember(id)); + mockFindConsolidationCandidates.mockRejectedValueOnce(new Error('boom')); + + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const deps = makeDeps(memory, { enabled: true, interval: 1 }); + + await expect(synthesizeSummariesForUser(deps, 'u1')).resolves.toEqual([]); + expect(errSpy).toHaveBeenCalledOnce(); + expect(errSpy.mock.calls[0]?.[0]).toContain('summary-synthesis'); + expect(errSpy.mock.calls[0]?.[0]).toContain('boom'); + expect(memory.softDeleteMemory).not.toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + it('skips clusters whose synthesized text is null (LLM error or too short)', async () => { + const memory = makeFakeMemoryStore(); + memory.getMemory.mockImplementation(async (id: string) => makeMember(id)); + mockSynthesizeCluster.mockResolvedValueOnce(null); + const deps = makeDeps(memory, { enabled: true, interval: 1 }); + + const ids = await synthesizeSummariesForUser(deps, 'u1'); + expect(ids).toEqual([]); + expect(memory.storeMemory).not.toHaveBeenCalled(); + expect(memory.softDeleteMemory).not.toHaveBeenCalled(); + }); + + it('does nothing for non-positive intervals', async () => { + const memory = makeFakeMemoryStore(); + const deps = makeDeps(memory, { enabled: true, interval: 0 }); + for (let i = 0; i < 10; i++) await synthesizeSummariesForUser(deps, 'u1'); + expect(mockFindConsolidationCandidates).not.toHaveBeenCalled(); + }); +}); + +describe('isSummarizationStyleQuery', () => { + it.each([ + ['summarize my recent notes', true], + ['give me a summary of last week', true], + ['what did we discuss yesterday?', true], + ['give me an overview of project X', true], + ['recap the design meeting', true], + ['tl;dr of the doc', true], + ['where did I park my car?', false], + ['what is the capital of France?', false], + ['who won the game?', false], + ])('classifies %s → %s', (query, expected) => { + expect(isSummarizationStyleQuery(query)).toBe(expected); + }); +}); + +describe('applySummaryDownweight', () => { + function summary(id: string, score: number) { + return createSearchResult({ + id, + score, + similarity: score, + metadata: { fact_role: 'summary' }, + }); + } + function regular(id: string, score: number) { + return createSearchResult({ id, score, similarity: score, metadata: {} }); + } + + it('summary score is reduced below a non-summary peer for non-SUM queries', () => { + const results = [summary('s1', 0.9), regular('r1', 0.5)]; + const out = applySummaryDownweight(results, 'where did I park?', { + summaryDownweightFactor: 0.5, + }); + const s = out.find((r) => r.id === 's1'); + const r = out.find((r) => r.id === 'r1'); + expect(s?.score).toBeCloseTo(0.45, 10); + expect(r?.score).toBe(0.5); + // After down-weight, regular outranks summary. + expect(out[0].id).toBe('r1'); + }); + + it('summary score is NOT penalized for summarize-style queries', () => { + const results = [summary('s1', 0.9), regular('r1', 0.5)]; + const out = applySummaryDownweight(results, 'summarize my notes', { + summaryDownweightFactor: 0.5, + }); + expect(out).toBe(results); + expect(out.find((r) => r.id === 's1')?.score).toBe(0.9); + }); + + it('returns the input reference when factor >= 1 (effective off)', () => { + const results = [summary('s1', 0.9), regular('r1', 0.5)]; + const out = applySummaryDownweight(results, 'unrelated query', { + summaryDownweightFactor: 1, + }); + expect(out).toBe(results); + }); + + it('returns the input reference when no summary results are present', () => { + const results = [regular('a', 0.6), regular('b', 0.5)]; + const out = applySummaryDownweight(results, 'unrelated query', { + summaryDownweightFactor: 0.5, + }); + expect(out).toBe(results); + }); +}); diff --git a/src/services/memory-ingest.ts b/src/services/memory-ingest.ts index e286e80..622ed23 100644 --- a/src/services/memory-ingest.ts +++ b/src/services/memory-ingest.ts @@ -12,6 +12,7 @@ import { timed } from './timing.js'; import { runPostWriteProcessors } from './ingest-post-write.js'; import { processFactThroughPipeline } from './ingest-fact-pipeline.js'; import { resolveSessionDate } from './session-date.js'; +import { synthesizeSummariesForUser } from './summary-synthesis.js'; import type { WorkspaceContext } from '../db/repository-types.js'; import type { IngestResult, @@ -112,6 +113,7 @@ export async function performIngest( }); console.log(`[timing] ingest.total: ${(performance.now() - ingestStart).toFixed(1)}ms (${facts.length} facts, ${postWrite.compositesCreated} composites)`); + await synthesizeSummariesForUser(deps, userId); return finalizeIngestResult( episodeId, facts.length, @@ -161,6 +163,7 @@ export async function performQuickIngest( }); console.log(`[timing] quick-ingest.total: ${(performance.now() - ingestStart).toFixed(1)}ms (${extractedFacts.length} facts, ${acc.counters.stored} stored, ${acc.counters.skipped} skipped)`); + await synthesizeSummariesForUser(deps, userId); return finalizeIngestResult( episodeId, extractedFacts.length, @@ -290,6 +293,7 @@ export async function performWorkspaceIngest( }); console.log(`[timing] ws-ingest.total: ${(performance.now() - ingestStart).toFixed(1)}ms (${facts.length} facts, workspace=${workspace.workspaceId})`); + await synthesizeSummariesForUser(deps, userId); return finalizeIngestResult( episodeId, facts.length, diff --git a/src/services/memory-service-types.ts b/src/services/memory-service-types.ts index 90aeb49..2ae921a 100644 --- a/src/services/memory-service-types.ts +++ b/src/services/memory-service-types.ts @@ -294,4 +294,8 @@ export interface IngestRuntimeConfig { llmModel: string; trustScoringEnabled: boolean; trustScoreMinThreshold: number; + /** EXP-SUM: see RuntimeConfig.summarySynthesisEnabled. */ + summarySynthesisEnabled: boolean; + /** EXP-SUM: see RuntimeConfig.summarySynthesisTurnInterval. */ + summarySynthesisTurnInterval: number; } diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 4769c7c..fb3e66a 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -39,6 +39,7 @@ import { applyConcisenessPenalty } from './conciseness-preference.js'; import { applyInstructionBoost } from './instruction-boost.js'; import { applyRecencyBinBoost } from './recency-bin-ranking.js'; import { applyEventBoundaryBoost } from './event-boundary-ranking.js'; +import { applySummaryDownweight } from './summary-downweight.js'; import { protectLiteralListAnswerCandidates } from './literal-list-protection.js'; import { applyTemporalQueryConstraints } from './temporal-query-constraints.js'; import { computeRetrievalConfidence, type RetrievalConfidence } from './retrieval-confidence-gate.js'; @@ -99,6 +100,7 @@ export type SearchPipelineRuntimeConfig = Pick< | 'retrievalConfidenceMarginNormalizer' | 'retrievalConfidenceSimilarityNormalizer' | 'retrievalConfidenceFloor' + | 'summaryDownweightFactor' >; /** * Decide whether to auto-skip cross-encoder reranking. @@ -783,6 +785,7 @@ function applyRankingProtectionStages( state = { ...state, candidates: currentStateRanked.results }; } + state = applySummaryDownweightStage(query, state, trace, policyConfig); state = applyInstructionBoostStage(query, state, trace, policyConfig); state = applyRecencyBinStage( query, @@ -797,6 +800,27 @@ function applyRankingProtectionStages( return { ...state, candidates: applyConcisenessPenalty(state.candidates) }; } +/** + * Wraps `applySummaryDownweight` with trace emission. No-op when the + * factor is >= 1 (default 0.5), when the query is summarization-style, + * or when no summary-tagged results are present. EXP-SUM. + */ +function applySummaryDownweightStage( + query: string, + state: RankedCandidateState, + trace: TraceCollector, + policyConfig: SearchPipelineRuntimeConfig, +): RankedCandidateState { + const factor = policyConfig.summaryDownweightFactor; + if (!Number.isFinite(factor) || factor >= 1) return state; + const adjusted = applySummaryDownweight(state.candidates, query, { + summaryDownweightFactor: factor, + }); + if (adjusted === state.candidates) return state; + trace.stage('summary-downweight', adjusted, { factor }); + return { ...state, candidates: adjusted }; +} + /** * Wraps `applyInstructionBoost` with trace emission. No-op when the feature * flag is off (the boost function itself short-circuits). EXP-05. diff --git a/src/services/summary-downweight.ts b/src/services/summary-downweight.ts new file mode 100644 index 0000000..ded652d --- /dev/null +++ b/src/services/summary-downweight.ts @@ -0,0 +1,79 @@ +/** + * EXP-SUM: retrieval-time down-weight for summary memories. + * + * Summary memories produced by `summary-synthesis.ts` carry + * `metadata.fact_role: 'summary'`. They are useful for BEAM SUM-style + * questions ("summarize", "what did we discuss"), but for the rest of + * the BEAM abilities (TR, IE, etc.) summaries are noisier than the + * underlying canonical facts they were synthesized from. + * + * To avoid summaries diluting non-SUM retrieval, this stage multiplies + * summary-tagged results' `score` by `summaryDownweightFactor` (default + * 0.5) — but ONLY when the query is NOT itself summarization-style. For + * summarization-style queries, summaries surface naturally with no + * adjustment. + * + * The keyword detector lives here (kept tight; expanded literals are + * intentional). Defaults preserve current behavior: with the default + * factor, the stage is a no-op until at least one summary memory exists. + */ + +import type { SearchResult } from '../db/repository-types.js'; + +export interface SummaryDownweightConfig { + /** Multiplicative factor applied to summary-tagged results. */ + summaryDownweightFactor: number; +} + +/** + * Detect summarization-style queries (rough keyword match). Returns true + * for queries that *want* a summary memory. Conservative on purpose — + * a false negative just causes a small score nudge; a false positive + * skips the down-weight, which is fine. + */ +export function isSummarizationStyleQuery(query: string): boolean { + const q = query.toLowerCase(); + return ( + q.includes('summarize') + || q.includes('summary of') + || q.includes('summary about') + || q.includes('what did we discuss') + || q.includes('what have we discussed') + || q.includes('give me an overview') + || q.includes('overview of') + || q.includes('recap') + || q.includes('tl;dr') + ); +} + +/** + * Apply the summary down-weight stage. + * + * - Returns the input reference unchanged when the factor is >= 1 + * (no-op), when the query is summarization-style, or when no + * summary-tagged results are present. + * - Otherwise returns a new array with summary results' scores scaled + * by `summaryDownweightFactor`, re-sorted by the new score. + */ +export function applySummaryDownweight( + results: SearchResult[], + query: string, + config: SummaryDownweightConfig, +): SearchResult[] { + if (results.length === 0) return results; + if (config.summaryDownweightFactor >= 1) return results; + if (isSummarizationStyleQuery(query)) return results; + if (!results.some(isSummaryResult)) return results; + + const adjusted = results.map((r) => + isSummaryResult(r) + ? { ...r, score: r.score * config.summaryDownweightFactor } + : r, + ); + adjusted.sort((a, b) => b.score - a.score); + return adjusted; +} + +function isSummaryResult(result: SearchResult): boolean { + return result.metadata?.fact_role === 'summary'; +} diff --git a/src/services/summary-synthesis.ts b/src/services/summary-synthesis.ts new file mode 100644 index 0000000..6af3ab2 --- /dev/null +++ b/src/services/summary-synthesis.ts @@ -0,0 +1,136 @@ +/** + * EXP-SUM: synthesize-only periodic consolidation. + * + * Re-uses the existing consolidation primitives (`findConsolidationCandidates` + * + `synthesizeCluster`) but DOES NOT call `softDeleteMemory` on cluster + * members. Original facts stay live; the LLM-synthesized summary is stored + * alongside them, tagged via `metadata.fact_role: 'summary'` and + * `metadata.summary_of: [original_fact_ids]`. + * + * Targets BEAM SUM (1/6 across sprint-2 with EXP-08 archive-style + * consolidation; 2/6 without). EXP-08 hurt SUM because archiving cluster + * members removes facts BEAM SUM questions need to retrieve. Synthesizing + * alongside originals preserves the underlying facts other BEAM abilities + * (TR, IE, etc.) need while still letting summary-style queries surface a + * compact, retrievable answer. + * + * Per-user_id turn counter mirrors the EXP-08 pattern; counts live in a + * process-local map (intentionally not persisted across restarts — + * synthesis is a tuning heuristic, not a correctness mechanism). + * + * Failure mode: synthesis errors are logged via `console.error` and the + * caller (memory-ingest) continues. Defaults-off behind + * `summarySynthesisEnabled`. + */ + +import { + findConsolidationCandidates, + synthesizeCluster, +} from './consolidation-service.js'; +import { embedText } from './embedding.js'; +import type { MemoryServiceDeps } from './memory-service-types.js'; +import type { MemoryRow } from '../db/repository-types.js'; +import type { MemoryStore } from '../db/stores.js'; + +const turnCounts = new Map(); + +/** + * Increment the per-user_id turn counter and synthesize-only when the + * counter crosses a multiple of `summarySynthesisTurnInterval`. + * + * Returns the IDs of any newly stored summary memories (empty when the + * trigger does not fire, when synthesis is disabled, or when no clusters + * meet the affinity threshold). + */ +export async function synthesizeSummariesForUser( + deps: MemoryServiceDeps, + userId: string, +): Promise { + if (!deps.config.summarySynthesisEnabled) return []; + + const interval = deps.config.summarySynthesisTurnInterval; + if (!Number.isFinite(interval) || interval < 1) return []; + + const next = (turnCounts.get(userId) ?? 0) + 1; + turnCounts.set(userId, next); + if (next % interval !== 0) return []; + + try { + return await runSynthesisCycle(deps.stores.memory, userId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error( + `[summary-synthesis] failed for user=${userId} at turn=${next}: ${msg}`, + ); + return []; + } +} + +/** + * Cluster active memories, synthesize each cluster, and store the summary + * WITHOUT archiving the originals. Returns the new summary memory IDs. + */ +async function runSynthesisCycle( + memoryStore: MemoryStore, + userId: string, +): Promise { + const candidates = await findConsolidationCandidates(memoryStore, userId); + const summaryIds: string[] = []; + + for (const cluster of candidates.clusters) { + const id = await synthesizeAndStoreCluster(memoryStore, userId, cluster); + if (id) summaryIds.push(id); + } + + return summaryIds; +} + +/** + * Synthesize a single cluster and write the summary alongside originals. + * Returns the new memory ID, or null if synthesis or member lookup failed. + */ +async function synthesizeAndStoreCluster( + memoryStore: MemoryStore, + userId: string, + cluster: { memberIds: string[]; memberContents: string[]; avgAffinity: number; memberCount: number }, +): Promise { + const synthesized = await synthesizeCluster(cluster.memberContents); + if (!synthesized) return null; + + const memberMemories = await Promise.all( + cluster.memberIds.map((id) => memoryStore.getMemory(id, userId)), + ); + const validMembers = memberMemories.filter((m): m is MemoryRow => m !== null); + if (validMembers.length < 2) return null; + + const importance = Math.min( + 1.0, + Math.max(...validMembers.map((m) => m.importance)) + 0.05, + ); + const embedding = await embedText(synthesized); + + return memoryStore.storeMemory({ + userId, + content: synthesized, + embedding, + memoryType: 'semantic', + importance, + sourceSite: validMembers[0].source_site, + metadata: { + fact_role: 'summary', + summary_of: cluster.memberIds, + cluster_size: cluster.memberCount, + avg_affinity: cluster.avgAffinity, + }, + }); +} + +/** Test-only: clear the per-user turn counter. */ +export function __resetSummaryTurnCountsForTest(): void { + turnCounts.clear(); +} + +/** Test-only: peek the current counter for a user. */ +export function __peekSummaryTurnCountForTest(userId: string): number { + return turnCounts.get(userId) ?? 0; +}