@shadowforge0/aquifer-memory 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.env.example +8 -0
  2. package/README.md +72 -0
  3. package/README_CN.md +17 -0
  4. package/README_TW.md +4 -0
  5. package/aquifer.config.example.json +19 -0
  6. package/consumers/cli.js +259 -12
  7. package/consumers/codex-active-checkpoint.js +186 -0
  8. package/consumers/codex-current-memory.js +106 -0
  9. package/consumers/codex-handoff.js +551 -6
  10. package/consumers/codex.js +209 -25
  11. package/consumers/mcp.js +144 -6
  12. package/consumers/shared/config.js +60 -1
  13. package/consumers/shared/factory.js +10 -3
  14. package/core/aquifer.js +357 -838
  15. package/core/backends/capabilities.js +89 -0
  16. package/core/backends/local.js +430 -0
  17. package/core/legacy-bootstrap.js +140 -0
  18. package/core/mcp-manifest.js +66 -2
  19. package/core/memory-bootstrap.js +20 -8
  20. package/core/memory-consolidation.js +365 -11
  21. package/core/memory-promotion.js +157 -26
  22. package/core/memory-recall.js +341 -22
  23. package/core/memory-records.js +347 -11
  24. package/core/memory-serving.js +132 -0
  25. package/core/postgres-migrations.js +533 -0
  26. package/core/public-session-filter.js +40 -0
  27. package/core/recall-runtime.js +115 -0
  28. package/core/scope-attribution.js +279 -0
  29. package/core/session-checkpoint-producer.js +412 -0
  30. package/core/session-checkpoints.js +432 -0
  31. package/core/session-finalization.js +98 -2
  32. package/core/storage-checkpoints.js +546 -0
  33. package/core/storage.js +121 -8
  34. package/docs/getting-started.md +6 -0
  35. package/docs/setup.md +66 -3
  36. package/package.json +8 -4
  37. package/schema/014-v1-checkpoint-runs.sql +349 -0
  38. package/schema/015-v1-evidence-items.sql +92 -0
  39. package/schema/016-v1-evidence-ref-multi-item.sql +19 -0
  40. package/schema/017-v1-memory-record-embeddings.sql +25 -0
  41. package/schema/018-v1-finalization-candidate-envelope.sql +39 -0
  42. package/scripts/codex-checkpoint-commands.js +464 -0
  43. package/scripts/codex-checkpoint-runtime.js +520 -0
  44. package/scripts/codex-recovery.js +246 -1
@@ -18,9 +18,73 @@ const path = require('path');
18
18
  const MCP_SERVER_NAME = 'aquifer-memory';
19
19
 
20
20
  const MCP_TOOL_MANIFEST = Object.freeze([
21
+ {
22
+ name: 'memory_recall',
23
+ description: 'Explicit current-memory recall on the active curated memory corpus. Use this for current state and next-step lookup; use historical_recall for older session detail.',
24
+ inputSchema: {
25
+ type: 'object',
26
+ additionalProperties: false,
27
+ properties: {
28
+ query: { type: 'string', minLength: 1, description: 'Search query (keyword or natural language)' },
29
+ limit: { type: 'integer', minimum: 1, maximum: 20, description: 'Max results (default 5)' },
30
+ mode: {
31
+ type: 'string',
32
+ enum: ['fts', 'hybrid', 'vector'],
33
+ description: 'Recall mode: "fts" (keyword only), "hybrid" (default, FTS + vector), "vector" (vector only)',
34
+ },
35
+ explain: {
36
+ type: 'boolean',
37
+ description: 'Include per-result score breakdown (diagnostic use only).',
38
+ },
39
+ activeScopeKey: { type: 'string', description: 'Active curated memory scope key, e.g. project:aquifer' },
40
+ activeScopePath: {
41
+ type: 'array',
42
+ items: { type: 'string' },
43
+ description: 'Ordered curated scope path from global to active scope',
44
+ },
45
+ },
46
+ required: ['query'],
47
+ },
48
+ },
49
+ {
50
+ name: 'historical_recall',
51
+ description: 'Explicit historical/session recall over stored sessions and summaries. Use this for timeline, detail, and session context lookup; use evidence_recall only for audit/debug/provenance.',
52
+ inputSchema: {
53
+ type: 'object',
54
+ additionalProperties: false,
55
+ properties: {
56
+ query: { type: 'string', minLength: 1, description: 'Search query (keyword or natural language)' },
57
+ limit: { type: 'integer', minimum: 1, maximum: 20, description: 'Max results (default 5)' },
58
+ agentId: { type: 'string', description: 'Filter by agent ID' },
59
+ source: { type: 'string', description: 'Filter by source (e.g., gateway, cc)' },
60
+ dateFrom: { type: 'string', description: 'Start date YYYY-MM-DD' },
61
+ dateTo: { type: 'string', description: 'End date YYYY-MM-DD' },
62
+ entities: {
63
+ type: 'array',
64
+ items: { type: 'string' },
65
+ description: 'Entity names to match',
66
+ },
67
+ entityMode: {
68
+ type: 'string',
69
+ enum: ['any', 'all'],
70
+ description: '"any" (default, boost) or "all" (only sessions with every entity)',
71
+ },
72
+ mode: {
73
+ type: 'string',
74
+ enum: ['fts', 'hybrid', 'vector'],
75
+ description: 'Recall mode: "fts" (keyword only, no embed needed), "hybrid" (default, FTS + vector), "vector" (vector only)',
76
+ },
77
+ explain: {
78
+ type: 'boolean',
79
+ description: 'Include per-result score breakdown (rrf, timeDecay, entity, trust, rerank). Diagnostic use only.',
80
+ },
81
+ },
82
+ required: ['query'],
83
+ },
84
+ },
21
85
  {
22
86
  name: 'session_recall',
23
- description: 'Search Aquifer memory. When memory serving mode is curated, this searches active curated memory only; use evidence_recall for legacy session/evidence lookup. Use entities/date filters where supported by the active serving mode.',
87
+ description: 'Compatibility recall surface. In curated serving mode this routes to current memory; in legacy serving mode it routes to historical/session recall. Prefer memory_recall for current state and historical_recall for timeline/detail lookup.',
24
88
  inputSchema: {
25
89
  type: 'object',
26
90
  additionalProperties: false,
@@ -115,7 +179,7 @@ const MCP_TOOL_MANIFEST = Object.freeze([
115
179
  },
116
180
  {
117
181
  name: 'memory_stats',
118
- description: 'Return storage statistics for the Aquifer memory store (session counts by status, summaries, turn embeddings, entities, date range).',
182
+ description: 'Return storage statistics for the Aquifer memory store, serving mode, current-memory record coverage, and session date range.',
119
183
  inputSchema: {
120
184
  type: 'object',
121
185
  additionalProperties: false,
@@ -2,10 +2,10 @@
2
2
 
3
3
  const TYPE_PRIORITY = {
4
4
  constraint: 0,
5
- preference: 1,
6
- state: 2,
7
- open_loop: 3,
8
- decision: 4,
5
+ state: 1,
6
+ open_loop: 2,
7
+ decision: 3,
8
+ preference: 4,
9
9
  fact: 5,
10
10
  conclusion: 6,
11
11
  entity_note: 7,
@@ -96,7 +96,13 @@ function resolveApplicableRecords(records = [], opts = {}) {
96
96
  return [...winners.values(), ...additive];
97
97
  }
98
98
 
99
- function sortForBootstrap(a, b) {
99
+ function sortForBootstrap(a, b, opts = {}) {
100
+ const activeScopePath = opts.activeScopePath || (opts.activeScopeKey ? [opts.activeScopeKey] : ['global']);
101
+ const position = new Map(activeScopePath.map((key, idx) => [key, idx]));
102
+ const aScope = position.get(scopeKey(a)) ?? -1;
103
+ const bScope = position.get(scopeKey(b)) ?? -1;
104
+ if (bScope !== aScope) return bScope - aScope;
105
+
100
106
  const aType = TYPE_PRIORITY[a.memoryType || a.memory_type] ?? 99;
101
107
  const bType = TYPE_PRIORITY[b.memoryType || b.memory_type] ?? 99;
102
108
  if (aType !== bType) return aType - bType;
@@ -129,10 +135,11 @@ function buildText(records, meta) {
129
135
 
130
136
  function buildMemoryBootstrap(records = [], opts = {}) {
131
137
  const maxChars = Math.max(120, opts.maxChars || 4000);
138
+ const limit = Number.isFinite(opts.limit) ? Math.max(1, Math.min(100, Math.floor(opts.limit))) : null;
132
139
  const active = resolveApplicableRecords(
133
140
  records.filter(record => isActiveBootstrap(record, opts)),
134
141
  opts,
135
- ).sort(sortForBootstrap);
142
+ ).sort((a, b) => sortForBootstrap(a, b, opts));
136
143
 
137
144
  const meta = {
138
145
  overflow: false,
@@ -141,7 +148,11 @@ function buildMemoryBootstrap(records = [], opts = {}) {
141
148
  count: active.length,
142
149
  };
143
150
 
144
- let selected = active.slice();
151
+ let selected = limit ? active.slice(0, limit) : active.slice();
152
+ if (limit && active.length > limit) {
153
+ meta.overflow = true;
154
+ meta.degraded = true;
155
+ }
145
156
  let text = buildText(selected, meta);
146
157
  while (text.length > maxChars && selected.length > 1) {
147
158
  selected = selected.slice(0, -1);
@@ -167,13 +178,14 @@ function buildMemoryBootstrap(records = [], opts = {}) {
167
178
 
168
179
  function createMemoryBootstrap({ records }) {
169
180
  async function bootstrap(opts = {}) {
181
+ const requestedLimit = Number.isFinite(opts.limit) ? Math.max(1, Math.floor(opts.limit)) : 50;
170
182
  const rows = await records.listActive({
171
183
  tenantId: opts.tenantId,
172
184
  scopeId: opts.scopeId,
173
185
  scopeKeys: opts.activeScopePath || (opts.activeScopeKey ? [opts.activeScopeKey] : undefined),
174
186
  visibleInBootstrap: true,
175
187
  asOf: opts.asOf,
176
- limit: opts.limit || 50,
188
+ limit: Math.max(50, Math.min(200, requestedLimit * 4)),
177
189
  });
178
190
  return buildMemoryBootstrap(rows, opts);
179
191
  }
@@ -3,6 +3,7 @@
3
3
  const crypto = require('crypto');
4
4
  const { extractCandidatesFromStructuredSummary, createMemoryPromotion } = require('./memory-promotion');
5
5
  const { createMemoryRecords } = require('./memory-records');
6
+ const { sanitizeSummaryResult } = require('./memory-safety-gate');
6
7
 
7
8
  const ALLOWED_CADENCES = new Set(['session', 'daily', 'weekly', 'monthly', 'manual']);
8
9
  const OPERATOR_CADENCES = new Set(['manual', 'daily', 'weekly', 'monthly']);
@@ -296,6 +297,9 @@ function buildAggregateCandidates(normalized, statusUpdates, opts) {
296
297
  candidateHash,
297
298
  payload: {
298
299
  kind: 'compaction_rollup',
300
+ synthesisKind: 'timer_current_memory_synthesis_v1',
301
+ currentMemoryRole: `${cadence}_timer_synthesis_candidate`,
302
+ promotionGate: 'operator_required',
299
303
  cadence,
300
304
  policyVersion,
301
305
  periodStart: windowStart,
@@ -328,6 +332,326 @@ function buildAggregateCandidates(normalized, statusUpdates, opts) {
328
332
  return candidates;
329
333
  }
330
334
 
335
+ function buildTimerSynthesisInput(normalized, statusUpdates, candidates, opts) {
336
+ const { cadence, periodStart, periodEnd } = opts;
337
+ if (!aggregateCandidateCadence(cadence)) return null;
338
+
339
+ const staleIds = new Set(statusUpdates.map(update => String(update.memoryId)));
340
+ const staleKeys = new Set(statusUpdates.map(update => String(update.canonicalKey || '')));
341
+ const sourceCurrentMemory = normalized
342
+ .filter(record => record.status === 'active')
343
+ .filter(record => record.id === null || !staleIds.has(String(record.id)))
344
+ .filter(record => !staleKeys.has(record.canonicalKey))
345
+ .filter(record => Boolean(record.canonicalKey))
346
+ .map(record => ({
347
+ memoryId: record.id,
348
+ memoryType: record.memoryType,
349
+ canonicalKey: record.canonicalKey,
350
+ scopeKind: record.scopeKind,
351
+ scopeKey: record.scopeKey,
352
+ contextKey: record.contextKey,
353
+ topicKey: record.topicKey,
354
+ summary: compactSummary(record),
355
+ acceptedAt: record.acceptedAt,
356
+ validFrom: record.validFrom,
357
+ validTo: record.validTo,
358
+ staleAfter: record.staleAfter,
359
+ }))
360
+ .sort((a, b) => {
361
+ if (a.canonicalKey !== b.canonicalKey) return a.canonicalKey.localeCompare(b.canonicalKey);
362
+ return String(a.memoryId).localeCompare(String(b.memoryId));
363
+ });
364
+
365
+ const windowStart = canonicalInstant(periodStart);
366
+ const windowEnd = canonicalInstant(periodEnd);
367
+ return {
368
+ kind: 'timer_current_memory_synthesis_v1',
369
+ sourceOfTruth: 'memory_records',
370
+ cadence,
371
+ policyVersion: opts.policyVersion || 'v1',
372
+ periodStart: windowStart,
373
+ periodEnd: windowEnd,
374
+ promotion: {
375
+ default: 'candidate_only',
376
+ requires: 'apply=true and promoteCandidates=true',
377
+ },
378
+ guards: {
379
+ rawTranscriptExcluded: true,
380
+ sessionSummariesExcluded: true,
381
+ nonActiveMemoryExcluded: true,
382
+ stalePlannedMemoryExcluded: true,
383
+ },
384
+ sourceCurrentMemory,
385
+ statusUpdates: statusUpdates
386
+ .map(update => ({
387
+ memoryId: update.memoryId,
388
+ canonicalKey: update.canonicalKey,
389
+ status: update.status,
390
+ reason: update.reason,
391
+ }))
392
+ .sort((a, b) => {
393
+ if (a.canonicalKey !== b.canonicalKey) return String(a.canonicalKey).localeCompare(String(b.canonicalKey));
394
+ return String(a.memoryId).localeCompare(String(b.memoryId));
395
+ }),
396
+ candidateProposals: candidates
397
+ .map(candidate => ({
398
+ candidateHash: candidate.candidateHash,
399
+ memoryType: candidate.memoryType,
400
+ canonicalKey: candidate.canonicalKey,
401
+ scopeKind: candidate.scopeKind,
402
+ scopeKey: candidate.scopeKey,
403
+ contextKey: candidate.contextKey,
404
+ topicKey: candidate.topicKey,
405
+ summary: compactSummary(candidate),
406
+ sourceMemoryIds: candidate.payload?.sourceMemoryIds || [],
407
+ sourceCanonicalKeys: candidate.payload?.sourceCanonicalKeys || [],
408
+ }))
409
+ .sort((a, b) => {
410
+ if (a.canonicalKey !== b.canonicalKey) return String(a.canonicalKey).localeCompare(String(b.canonicalKey));
411
+ return String(a.candidateHash).localeCompare(String(b.candidateHash));
412
+ }),
413
+ };
414
+ }
415
+
416
+ function buildTimerSynthesisPrompt(plan = {}, opts = {}) {
417
+ const synthesisInput = plan.synthesisInput || plan.meta?.synthesisInput || null;
418
+ if (!synthesisInput) {
419
+ throw new Error('memory.consolidation.timer_synthesis_prompt requires a timer synthesisInput');
420
+ }
421
+ const maxFacts = opts.maxFacts || 12;
422
+ return [
423
+ 'You are producing an Aquifer timer current-memory synthesis proposal.',
424
+ 'Use only the <timer_synthesis_input> block. Do not read raw transcripts, session summaries, tool output, or debug material.',
425
+ 'This is a producer proposal, not an active memory commit. Promotion still requires the normal operator promotion gate.',
426
+ 'Return compact JSON with this shape:',
427
+ '{"summaryText":"...","structuredSummary":{"facts":[],"decisions":[],"open_loops":[],"preferences":[],"constraints":[],"conclusions":[],"entity_notes":[],"states":[]}}',
428
+ `Keep facts/states/open_loops concrete and scoped. Use at most ${maxFacts} facts.`,
429
+ 'Mark uncertain, resolved, superseded, revoked, or stale items explicitly in payload fields when applicable.',
430
+ 'Do not copy sourceCurrentMemory unchanged unless the timer window confirms it still carries forward.',
431
+ '',
432
+ '<timer_synthesis_input>',
433
+ stableJson(synthesisInput),
434
+ '</timer_synthesis_input>',
435
+ ].join('\n');
436
+ }
437
+
438
+ function normalizeTimerSynthesisSummary(input = {}) {
439
+ const raw = input && typeof input === 'object'
440
+ ? {
441
+ summaryText: input.summaryText || input.summary || '',
442
+ structuredSummary: input.structuredSummary || input.structured_summary || {},
443
+ }
444
+ : {
445
+ summaryText: '',
446
+ structuredSummary: {},
447
+ };
448
+ const sanitized = sanitizeSummaryResult(raw);
449
+ return {
450
+ summary: sanitized.summaryResult || raw,
451
+ safetyGate: sanitized.meta || {},
452
+ };
453
+ }
454
+
455
+ function inferTimerSynthesisScope(synthesisInput = {}, opts = {}) {
456
+ if (opts.scopeKind && opts.scopeKey) {
457
+ return {
458
+ scopeKind: opts.scopeKind,
459
+ scopeKey: opts.scopeKey,
460
+ contextKey: opts.contextKey || null,
461
+ topicKey: opts.topicKey || null,
462
+ };
463
+ }
464
+ const scoped = new Map();
465
+ for (const row of synthesisInput.sourceCurrentMemory || []) {
466
+ const scopeKind = row.scopeKind || 'unspecified';
467
+ const scopeKey = row.scopeKey || 'unspecified';
468
+ const contextKey = row.contextKey || null;
469
+ const topicKey = row.topicKey || null;
470
+ scoped.set(stableJson({ scopeKind, scopeKey, contextKey, topicKey }), {
471
+ scopeKind,
472
+ scopeKey,
473
+ contextKey,
474
+ topicKey,
475
+ });
476
+ }
477
+ if (scoped.size === 1) return [...scoped.values()][0];
478
+ if (scoped.size === 0 && opts.activeScopeKey) {
479
+ return {
480
+ scopeKind: opts.activeScopeKind || 'project',
481
+ scopeKey: opts.activeScopeKey,
482
+ contextKey: opts.contextKey || null,
483
+ topicKey: opts.topicKey || null,
484
+ };
485
+ }
486
+ throw new Error('memory.consolidation.timer_synthesis requires scopeKind/scopeKey for multi-scope synthesis');
487
+ }
488
+
489
+ function sourceLineageFromSynthesisInput(synthesisInput = {}) {
490
+ const rows = Array.isArray(synthesisInput.sourceCurrentMemory) ? synthesisInput.sourceCurrentMemory : [];
491
+ const pairs = rows
492
+ .map(row => ({
493
+ id: Number(row.memoryId),
494
+ key: String(row.canonicalKey || '').trim(),
495
+ }))
496
+ .filter(row => Number.isSafeInteger(row.id) && row.id > 0 && row.key);
497
+ pairs.sort((a, b) => {
498
+ if (a.key !== b.key) return a.key.localeCompare(b.key);
499
+ return a.id - b.id;
500
+ });
501
+ return {
502
+ sourceMemoryIds: pairs.map(row => row.id),
503
+ sourceCanonicalKeys: pairs.map(row => row.key),
504
+ };
505
+ }
506
+
507
+ function buildTimerSynthesisCandidates(plan = {}, synthesisSummary = {}, opts = {}) {
508
+ const synthesisInput = plan.synthesisInput || plan.meta?.synthesisInput || null;
509
+ if (!synthesisInput) {
510
+ throw new Error('memory.consolidation.timer_synthesis requires a timer synthesisInput');
511
+ }
512
+ const { summary, safetyGate } = normalizeTimerSynthesisSummary(synthesisSummary);
513
+ const structuredSummary = summary.structuredSummary || {};
514
+ const scope = inferTimerSynthesisScope(synthesisInput, opts);
515
+ const lineage = sourceLineageFromSynthesisInput(synthesisInput);
516
+ const synthesisHash = hashSnapshot({
517
+ summary,
518
+ lineage,
519
+ cadence: plan.cadence,
520
+ periodStart: canonicalInstant(plan.periodStart),
521
+ periodEnd: canonicalInstant(plan.periodEnd),
522
+ policyVersion: plan.policyVersion || 'v1',
523
+ });
524
+ const evidenceRefs = [{
525
+ sourceKind: 'external',
526
+ sourceRef: `timer_synthesis:${synthesisHash}`,
527
+ relationKind: 'derived_from',
528
+ metadata: {
529
+ cadence: plan.cadence,
530
+ periodStart: canonicalInstant(plan.periodStart),
531
+ periodEnd: canonicalInstant(plan.periodEnd),
532
+ policyVersion: plan.policyVersion || 'v1',
533
+ sourceCanonicalKeys: lineage.sourceCanonicalKeys,
534
+ },
535
+ }];
536
+ const extracted = extractCandidatesFromStructuredSummary({
537
+ structuredSummary,
538
+ scopeKind: scope.scopeKind,
539
+ scopeKey: scope.scopeKey,
540
+ contextKey: scope.contextKey,
541
+ topicKey: scope.topicKey,
542
+ subject: opts.subject || `timer:${plan.cadence || 'manual'}`,
543
+ authority: opts.authority || 'verified_summary',
544
+ evidenceRefs,
545
+ });
546
+ const candidates = extracted.map((candidate, index) => ({
547
+ ...candidate,
548
+ candidateHash: hashSnapshot({
549
+ synthesisHash,
550
+ index,
551
+ canonicalKey: candidate.canonicalKey,
552
+ summary: candidate.summary,
553
+ }),
554
+ payload: {
555
+ ...(candidate.payload || {}),
556
+ kind: 'timer_synthesis',
557
+ synthesisKind: synthesisInput.kind || 'timer_current_memory_synthesis_v1',
558
+ currentMemoryRole: `${plan.cadence || 'manual'}_timer_synthesis_candidate`,
559
+ promotionGate: 'operator_required',
560
+ cadence: plan.cadence,
561
+ policyVersion: plan.policyVersion || 'v1',
562
+ periodStart: canonicalInstant(plan.periodStart),
563
+ periodEnd: canonicalInstant(plan.periodEnd),
564
+ synthesisHash,
565
+ sourceMemoryIds: lineage.sourceMemoryIds,
566
+ sourceCanonicalKeys: lineage.sourceCanonicalKeys,
567
+ safetyGate,
568
+ },
569
+ }));
570
+
571
+ return {
572
+ summary,
573
+ safetyGate,
574
+ synthesisHash,
575
+ candidates,
576
+ sourceMemoryIds: lineage.sourceMemoryIds,
577
+ sourceCanonicalKeys: lineage.sourceCanonicalKeys,
578
+ };
579
+ }
580
+
581
+ function attachTimerSynthesis(plan = {}, synthesisSummary = {}, opts = {}) {
582
+ const synthesis = buildTimerSynthesisCandidates(plan, synthesisSummary, opts);
583
+ const includeAggregateCandidates = opts.includeAggregateCandidates === true;
584
+ const candidates = includeAggregateCandidates
585
+ ? [...(Array.isArray(plan.candidates) ? plan.candidates : []), ...synthesis.candidates]
586
+ : synthesis.candidates;
587
+ const outputCoverage = {
588
+ ...(plan.outputCoverage || plan.meta?.outputCoverage || {}),
589
+ candidateCount: candidates.length,
590
+ synthesizedCandidateCount: synthesis.candidates.length,
591
+ };
592
+ const inputHash = hashSnapshot({
593
+ baseInputHash: plan.inputHash,
594
+ synthesisHash: synthesis.synthesisHash,
595
+ candidateHashes: candidates.map(candidate => candidate.candidateHash || hashSnapshot(candidate)),
596
+ });
597
+ return {
598
+ ...plan,
599
+ inputHash,
600
+ candidates,
601
+ synthesisResult: {
602
+ summary: synthesis.summary,
603
+ safetyGate: synthesis.safetyGate,
604
+ synthesisHash: synthesis.synthesisHash,
605
+ candidateCount: synthesis.candidates.length,
606
+ sourceMemoryIds: synthesis.sourceMemoryIds,
607
+ sourceCanonicalKeys: synthesis.sourceCanonicalKeys,
608
+ },
609
+ outputCoverage,
610
+ meta: {
611
+ ...(plan.meta || {}),
612
+ outputCoverage,
613
+ synthesisResult: {
614
+ synthesisHash: synthesis.synthesisHash,
615
+ candidateCount: synthesis.candidates.length,
616
+ safetyGate: synthesis.safetyGate,
617
+ },
618
+ },
619
+ };
620
+ }
621
+
622
+ function buildPromotionReview(input = {}, opts = {}) {
623
+ const plan = input.plan || input;
624
+ const promotionResult = input.promotionResult || {};
625
+ const applyResult = input.applyResult || {};
626
+ const candidates = Array.isArray(plan.candidates) ? plan.candidates : [];
627
+ const statusUpdates = Array.isArray(plan.statusUpdates) ? plan.statusUpdates : [];
628
+ const candidateLines = candidates.slice(0, Math.max(0, Math.min(20, opts.limit || 8))).map(candidate => {
629
+ const type = candidate.memoryType || candidate.memory_type || 'memory';
630
+ const scope = candidate.scopeKey || candidate.scope_key || 'unspecified';
631
+ const payload = candidate.payload && typeof candidate.payload === 'object' ? candidate.payload : {};
632
+ const sourceKeys = Array.isArray(payload.sourceCanonicalKeys) && payload.sourceCanonicalKeys.length > 0
633
+ ? ` | sources=${payload.sourceCanonicalKeys.join(',')}`
634
+ : '';
635
+ return `- candidate ${type} ${scope}: ${compactSummary(candidate)}${sourceKeys}`;
636
+ });
637
+ const staleLines = statusUpdates.slice(0, Math.max(0, Math.min(20, opts.limit || 8))).map(update => (
638
+ `- ${update.status}: ${update.canonicalKey || update.memoryId || 'memory'}${update.reason ? ` (${update.reason})` : ''}`
639
+ ));
640
+ const linesOrNone = lines => (lines.length > 0 ? lines.join('\n') : '- none');
641
+ return [
642
+ 'Promotion review:',
643
+ `window: ${plan.cadence || 'manual'} ${canonicalInstant(plan.periodStart)} -> ${canonicalInstant(plan.periodEnd)}`,
644
+ `source: ${plan.synthesisInput?.sourceOfTruth || plan.meta?.synthesisInput?.sourceOfTruth || 'memory_records'}`,
645
+ `gate: ${input.promoteCandidates === true ? 'operator promotion requested' : 'candidate-only unless promoteCandidates=true'}`,
646
+ `status updates: planned=${statusUpdates.length} applied=${applyResult.applied || 0} skipped=${applyResult.skipped || 0}`,
647
+ `candidates: planned=${candidates.length} promoted=${promotionResult.promoted || 0} quarantined=${promotionResult.quarantined || 0} errored=${promotionResult.errored || 0}`,
648
+ 'candidate proposals:',
649
+ linesOrNone(candidateLines),
650
+ 'status update proposals:',
651
+ linesOrNone(staleLines),
652
+ ].join('\n');
653
+ }
654
+
331
655
  function buildCoverage(normalized, statusUpdates, candidates) {
332
656
  const active = normalized.filter(record => record.status === 'active');
333
657
  const activeOpenLoops = active.filter(record => record.memoryType === 'open_loop');
@@ -460,6 +784,12 @@ function planCompaction(records = [], opts = {}) {
460
784
  periodStart,
461
785
  periodEnd,
462
786
  });
787
+ const synthesisInput = buildTimerSynthesisInput(normalized, statusUpdates, candidates, {
788
+ ...opts,
789
+ cadence,
790
+ periodStart,
791
+ periodEnd,
792
+ });
463
793
  const coverage = buildCoverage(normalized, statusUpdates, candidates);
464
794
 
465
795
  return {
@@ -469,6 +799,7 @@ function planCompaction(records = [], opts = {}) {
469
799
  policyVersion: opts.policyVersion || 'v1',
470
800
  inputHash,
471
801
  candidates,
802
+ synthesisInput,
472
803
  statusUpdates,
473
804
  sourceCoverage: coverage.sourceCoverage,
474
805
  outputCoverage: coverage.outputCoverage,
@@ -476,6 +807,7 @@ function planCompaction(records = [], opts = {}) {
476
807
  activeConflictRate: 0,
477
808
  deterministic: true,
478
809
  recordCount: normalized.length,
810
+ synthesisInput,
479
811
  sourceCoverage: coverage.sourceCoverage,
480
812
  outputCoverage: coverage.outputCoverage,
481
813
  },
@@ -1156,16 +1488,27 @@ function createMemoryConsolidation({ pool, schema, defaultTenantId, records = nu
1156
1488
  periodEnd: window.periodEnd,
1157
1489
  policyVersion: input.policyVersion || 'v1',
1158
1490
  });
1491
+ const synthesisSummary = input.synthesisSummary || input.timerSynthesisSummary || null;
1492
+ const effectivePlan = synthesisSummary
1493
+ ? attachTimerSynthesis(plan, synthesisSummary, {
1494
+ ...input,
1495
+ tenantId,
1496
+ })
1497
+ : plan;
1159
1498
 
1160
1499
  if (input.apply !== true) {
1161
1500
  return {
1162
1501
  job,
1163
1502
  status: 'planned',
1164
1503
  dryRun: true,
1165
- plan,
1166
- cadence: plan.cadence,
1167
- periodStart: plan.periodStart,
1168
- periodEnd: plan.periodEnd,
1504
+ plan: effectivePlan,
1505
+ synthesisPrompt: input.includeSynthesisPrompt === true && effectivePlan.synthesisInput
1506
+ ? buildTimerSynthesisPrompt(effectivePlan, input)
1507
+ : undefined,
1508
+ promotionReview: buildPromotionReview({ plan: effectivePlan }),
1509
+ cadence: effectivePlan.cadence,
1510
+ periodStart: effectivePlan.periodStart,
1511
+ periodEnd: effectivePlan.periodEnd,
1169
1512
  snapshotCount: snapshot.rows.length,
1170
1513
  snapshotLimit: snapshot.snapshotLimit,
1171
1514
  snapshotTruncated: snapshot.snapshotTruncated,
@@ -1176,7 +1519,7 @@ function createMemoryConsolidation({ pool, schema, defaultTenantId, records = nu
1176
1519
 
1177
1520
  const result = input.promoteCandidates === true
1178
1521
  ? await executePlan({
1179
- plan,
1522
+ plan: effectivePlan,
1180
1523
  tenantId,
1181
1524
  workerId: input.workerId,
1182
1525
  applyToken: input.applyToken,
@@ -1186,7 +1529,7 @@ function createMemoryConsolidation({ pool, schema, defaultTenantId, records = nu
1186
1529
  reclaimStaleClaims: input.reclaimStaleClaims,
1187
1530
  })
1188
1531
  : await applyPlan({
1189
- plan,
1532
+ plan: effectivePlan,
1190
1533
  tenantId,
1191
1534
  workerId: input.workerId,
1192
1535
  applyToken: input.applyToken,
@@ -1194,21 +1537,27 @@ function createMemoryConsolidation({ pool, schema, defaultTenantId, records = nu
1194
1537
  claimLeaseSeconds: input.claimLeaseSeconds,
1195
1538
  reclaimStaleClaims: input.reclaimStaleClaims,
1196
1539
  });
1197
- const existingRun = result.run || await findExistingRun({ tenantId, plan });
1540
+ const existingRun = result.run || await findExistingRun({ tenantId, plan: effectivePlan });
1198
1541
  return {
1199
1542
  ...result,
1200
1543
  job,
1201
1544
  dryRun: false,
1202
- cadence: plan.cadence,
1203
- periodStart: plan.periodStart,
1204
- periodEnd: plan.periodEnd,
1545
+ promotionReview: buildPromotionReview({
1546
+ plan: effectivePlan,
1547
+ promotionResult: result.promotionResult,
1548
+ applyResult: result.applyResult,
1549
+ promoteCandidates: input.promoteCandidates === true,
1550
+ }),
1551
+ cadence: effectivePlan.cadence,
1552
+ periodStart: effectivePlan.periodStart,
1553
+ periodEnd: effectivePlan.periodEnd,
1205
1554
  snapshotCount: snapshot.rows.length,
1206
1555
  snapshotLimit: snapshot.snapshotLimit,
1207
1556
  snapshotTruncated: snapshot.snapshotTruncated,
1208
1557
  snapshotAsOf: snapshot.snapshotAsOf,
1209
1558
  scopeKeys: snapshot.scopeKeys,
1210
1559
  existingRun,
1211
- skipReason: result.run ? null : classifySkippedRun(existingRun, plan),
1560
+ skipReason: result.run ? null : classifySkippedRun(existingRun, effectivePlan),
1212
1561
  };
1213
1562
  }
1214
1563
 
@@ -1231,6 +1580,11 @@ module.exports = {
1231
1580
  normalizeClaimLeaseSeconds,
1232
1581
  resolveOperatorWindow,
1233
1582
  planCompaction,
1583
+ buildTimerSynthesisInput,
1584
+ buildTimerSynthesisPrompt,
1585
+ buildTimerSynthesisCandidates,
1586
+ attachTimerSynthesis,
1587
+ buildPromotionReview,
1234
1588
  distillArchiveSnapshot,
1235
1589
  createMemoryConsolidation,
1236
1590
  };