@shadowforge0/aquifer-memory 1.5.12 → 1.7.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 (60) hide show
  1. package/.env.example +23 -0
  2. package/README.md +84 -73
  3. package/README_CN.md +676 -0
  4. package/README_TW.md +684 -0
  5. package/aquifer.config.example.json +34 -0
  6. package/consumers/claude-code.js +11 -11
  7. package/consumers/cli.js +421 -53
  8. package/consumers/codex-handoff.js +258 -0
  9. package/consumers/codex.js +1676 -0
  10. package/consumers/default/daily-entries.js +23 -4
  11. package/consumers/default/index.js +2 -2
  12. package/consumers/default/prompts/summary.js +6 -6
  13. package/consumers/mcp.js +96 -5
  14. package/consumers/openclaw-ext/index.js +0 -1
  15. package/consumers/openclaw-plugin.js +1 -1
  16. package/consumers/shared/config.js +8 -0
  17. package/consumers/shared/factory.js +1 -0
  18. package/consumers/shared/ingest.js +1 -1
  19. package/consumers/shared/normalize.js +14 -3
  20. package/consumers/shared/recall-format.js +27 -0
  21. package/consumers/shared/summary-parser.js +151 -0
  22. package/core/aquifer.js +380 -18
  23. package/core/finalization-review.js +319 -0
  24. package/core/mcp-manifest.js +52 -2
  25. package/core/memory-bootstrap.js +200 -0
  26. package/core/memory-consolidation.js +1590 -0
  27. package/core/memory-promotion.js +544 -0
  28. package/core/memory-recall.js +247 -0
  29. package/core/memory-records.js +797 -0
  30. package/core/memory-safety-gate.js +224 -0
  31. package/core/session-finalization.js +365 -0
  32. package/core/storage.js +385 -2
  33. package/docs/getting-started.md +105 -0
  34. package/docs/postprocess-contract.md +2 -2
  35. package/docs/setup.md +92 -2
  36. package/package.json +25 -11
  37. package/pipeline/normalize/adapters/codex.js +106 -0
  38. package/pipeline/normalize/detect.js +3 -2
  39. package/schema/001-base.sql +3 -0
  40. package/schema/007-v1-foundation.sql +273 -0
  41. package/schema/008-session-finalizations.sql +50 -0
  42. package/schema/009-v1-assertion-plane.sql +193 -0
  43. package/schema/010-v1-finalization-review.sql +160 -0
  44. package/schema/011-v1-compaction-claim.sql +46 -0
  45. package/schema/012-v1-compaction-lease.sql +39 -0
  46. package/schema/013-v1-compaction-lineage.sql +193 -0
  47. package/scripts/codex-recovery.js +672 -0
  48. package/consumers/miranda/context-inject.js +0 -120
  49. package/consumers/miranda/daily-entries.js +0 -224
  50. package/consumers/miranda/index.js +0 -364
  51. package/consumers/miranda/instance.js +0 -55
  52. package/consumers/miranda/llm.js +0 -99
  53. package/consumers/miranda/profile.json +0 -145
  54. package/consumers/miranda/prompts/summary.js +0 -303
  55. package/consumers/miranda/recall-format.js +0 -76
  56. package/consumers/miranda/render-daily-md.js +0 -186
  57. package/consumers/miranda/workspace-files.js +0 -91
  58. package/scripts/drop-entity-state-history.sql +0 -17
  59. package/scripts/drop-insights.sql +0 -12
  60. package/scripts/install-openclaw.sh +0 -59
@@ -0,0 +1,1590 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const { extractCandidatesFromStructuredSummary, createMemoryPromotion } = require('./memory-promotion');
5
+ const { createMemoryRecords } = require('./memory-records');
6
+ const { sanitizeSummaryResult } = require('./memory-safety-gate');
7
+
8
+ const ALLOWED_CADENCES = new Set(['session', 'daily', 'weekly', 'monthly', 'manual']);
9
+ const OPERATOR_CADENCES = new Set(['manual', 'daily', 'weekly', 'monthly']);
10
+ const DEFAULT_CLAIM_LEASE_SECONDS = 600;
11
+ const DEFAULT_OPERATOR_SNAPSHOT_LIMIT = 1000;
12
+ const MAX_OPERATOR_SNAPSHOT_LIMIT = 5000;
13
+
14
+ function stableJson(value) {
15
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
16
+ if (value && typeof value === 'object') {
17
+ return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stableJson(value[k])}`).join(',')}}`;
18
+ }
19
+ return JSON.stringify(value);
20
+ }
21
+
22
+ function hashSnapshot(value) {
23
+ return crypto.createHash('sha256').update(stableJson(value)).digest('hex');
24
+ }
25
+
26
+ function advisoryLockKeys(namespace, value) {
27
+ const digest = crypto.createHash('sha256').update(`${namespace}:${value}`).digest();
28
+ return [digest.readInt32BE(0), digest.readInt32BE(4)];
29
+ }
30
+
31
+ function canonicalInstant(value) {
32
+ const t = timeMs(value);
33
+ return t === null ? String(value || '') : new Date(t).toISOString();
34
+ }
35
+
36
+ function timeMs(value) {
37
+ const t = Date.parse(value || '');
38
+ return Number.isFinite(t) ? t : null;
39
+ }
40
+
41
+ function normalizeClaimLeaseSeconds(value) {
42
+ const n = Number(value);
43
+ if (!Number.isFinite(n)) return DEFAULT_CLAIM_LEASE_SECONDS;
44
+ return Math.max(10, Math.floor(n));
45
+ }
46
+
47
+ function requirePeriod(opts = {}) {
48
+ const cadence = opts.cadence || 'manual';
49
+ if (!ALLOWED_CADENCES.has(cadence)) {
50
+ throw new Error(`memory.consolidation.plan invalid cadence: ${cadence}`);
51
+ }
52
+ const periodStart = opts.periodStart || opts.from || null;
53
+ const periodEnd = opts.periodEnd || opts.to || null;
54
+ if (!periodStart || !periodEnd) {
55
+ throw new Error('memory.consolidation.plan requires periodStart and periodEnd');
56
+ }
57
+ const startMs = timeMs(periodStart);
58
+ const endMs = timeMs(periodEnd);
59
+ if (startMs === null || endMs === null) {
60
+ throw new Error('memory.consolidation.plan requires valid periodStart and periodEnd');
61
+ }
62
+ if (endMs <= startMs) {
63
+ throw new Error('memory.consolidation.plan requires periodEnd after periodStart');
64
+ }
65
+ return { cadence, periodStart, periodEnd, startMs, endMs };
66
+ }
67
+
68
+ function utcDayStart(ms) {
69
+ const d = new Date(ms);
70
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
71
+ }
72
+
73
+ function utcWeekStart(ms) {
74
+ const dayStart = utcDayStart(ms);
75
+ const d = new Date(dayStart);
76
+ const mondayOffset = (d.getUTCDay() + 6) % 7;
77
+ return dayStart - (mondayOffset * 86400000);
78
+ }
79
+
80
+ function utcMonthStart(ms) {
81
+ const d = new Date(ms);
82
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
83
+ }
84
+
85
+ function resolveOperatorCadence(value) {
86
+ const cadence = String(value || 'manual').trim().toLowerCase();
87
+ if (!OPERATOR_CADENCES.has(cadence)) {
88
+ throw new Error(`memory.consolidation.job invalid cadence: ${cadence}`);
89
+ }
90
+ return cadence;
91
+ }
92
+
93
+ function resolveOperatorAnchorMs(input = {}) {
94
+ const value = input.anchorTime || input.now || input.asOf || input.snapshotAsOf || Date.now();
95
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
96
+ const parsed = Date.parse(value);
97
+ if (Number.isFinite(parsed)) return parsed;
98
+ throw new Error('memory.consolidation.job requires a valid anchorTime');
99
+ }
100
+
101
+ function resolveOperatorWindow(input = {}) {
102
+ const cadence = resolveOperatorCadence(input.cadence);
103
+ const hasExplicitWindow = Boolean(input.periodStart || input.periodEnd || input.from || input.to);
104
+
105
+ if (cadence === 'manual' || hasExplicitWindow) {
106
+ const period = requirePeriod({
107
+ cadence,
108
+ periodStart: input.periodStart || input.from || null,
109
+ periodEnd: input.periodEnd || input.to || null,
110
+ });
111
+ return {
112
+ cadence,
113
+ periodStart: canonicalInstant(period.periodStart),
114
+ periodEnd: canonicalInstant(period.periodEnd),
115
+ };
116
+ }
117
+
118
+ const anchorMs = resolveOperatorAnchorMs(input);
119
+ let periodStartMs;
120
+ let periodEndMs;
121
+
122
+ if (cadence === 'daily') {
123
+ periodEndMs = utcDayStart(anchorMs);
124
+ periodStartMs = periodEndMs - 86400000;
125
+ } else if (cadence === 'weekly') {
126
+ periodEndMs = utcWeekStart(anchorMs);
127
+ periodStartMs = periodEndMs - (7 * 86400000);
128
+ } else if (cadence === 'monthly') {
129
+ periodEndMs = utcMonthStart(anchorMs);
130
+ const d = new Date(periodEndMs);
131
+ periodStartMs = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 1, 1);
132
+ } else {
133
+ throw new Error(`memory.consolidation.job invalid cadence: ${cadence}`);
134
+ }
135
+
136
+ return {
137
+ cadence,
138
+ periodStart: new Date(periodStartMs).toISOString(),
139
+ periodEnd: new Date(periodEndMs).toISOString(),
140
+ };
141
+ }
142
+
143
+ function normalizeOperatorSnapshotLimit(value) {
144
+ if (value === null || value === undefined || value === '') return DEFAULT_OPERATOR_SNAPSHOT_LIMIT;
145
+ const n = Number(value);
146
+ if (!Number.isFinite(n)) return DEFAULT_OPERATOR_SNAPSHOT_LIMIT;
147
+ return Math.max(1, Math.min(MAX_OPERATOR_SNAPSHOT_LIMIT, Math.floor(n)));
148
+ }
149
+
150
+ function normalizeStringList(value) {
151
+ if (Array.isArray(value)) return value.map(item => String(item || '').trim()).filter(Boolean);
152
+ if (value === null || value === undefined || value === '') return [];
153
+ return String(value).split(',').map(item => item.trim()).filter(Boolean);
154
+ }
155
+
156
+ function recordKey(record) {
157
+ return String(record.canonicalKey || record.canonical_key || record.id || record.memory_id || '');
158
+ }
159
+
160
+ function normalizeRecordId(value) {
161
+ if (value === null || value === undefined || value === '') return null;
162
+ const number = Number(value);
163
+ if (Number.isSafeInteger(number) && number > 0 && String(number) === String(value)) return number;
164
+ return value;
165
+ }
166
+
167
+ function normalizeRecord(record) {
168
+ return {
169
+ id: normalizeRecordId(record.id || record.memory_id),
170
+ memoryType: record.memoryType || record.memory_type || null,
171
+ canonicalKey: recordKey(record),
172
+ status: record.status || null,
173
+ scopeKind: record.scopeKind || record.scope_kind || null,
174
+ scopeKey: record.scopeKey || record.scope_key || null,
175
+ contextKey: record.contextKey || record.context_key || null,
176
+ topicKey: record.topicKey || record.topic_key || null,
177
+ summary: record.summary || '',
178
+ validFrom: record.validFrom || record.valid_from || null,
179
+ validTo: record.validTo || record.valid_to || null,
180
+ staleAfter: record.staleAfter || record.stale_after || null,
181
+ acceptedAt: record.acceptedAt || record.accepted_at || null,
182
+ };
183
+ }
184
+
185
+ function aggregateCandidateCadence(cadence) {
186
+ return cadence === 'daily' || cadence === 'weekly' || cadence === 'monthly';
187
+ }
188
+
189
+ function safeKeyPart(value, fallback) {
190
+ const text = String(value || fallback || '').trim().toLowerCase();
191
+ return text.replace(/\s+/g, '-').replace(/[^a-z0-9:._/-]/g, '-');
192
+ }
193
+
194
+ function compactSummary(record) {
195
+ const summary = String(record.summary || '').trim().replace(/\s+/g, ' ');
196
+ if (!summary) return record.canonicalKey;
197
+ return summary.length > 240 ? `${summary.slice(0, 237)}...` : summary;
198
+ }
199
+
200
+ function buildAggregateCandidates(normalized, statusUpdates, opts) {
201
+ const { cadence, periodStart, periodEnd } = opts;
202
+ if (!aggregateCandidateCadence(cadence)) return [];
203
+
204
+ const staleIds = new Set(statusUpdates.map(update => String(update.memoryId)));
205
+ const staleKeys = new Set(statusUpdates.map(update => String(update.canonicalKey || '')));
206
+ const active = normalized.filter(record => {
207
+ if (record.status !== 'active') return false;
208
+ if (record.id !== null && staleIds.has(String(record.id))) return false;
209
+ if (staleKeys.has(record.canonicalKey)) return false;
210
+ return Boolean(record.canonicalKey);
211
+ });
212
+ if (active.length === 0) return [];
213
+
214
+ const tenantId = opts.tenantId || 'default';
215
+ const policyVersion = opts.policyVersion || 'v1';
216
+ const windowStart = canonicalInstant(periodStart);
217
+ const windowEnd = canonicalInstant(periodEnd);
218
+ const groups = new Map();
219
+
220
+ for (const record of active) {
221
+ const scopeKind = record.scopeKind || 'unspecified';
222
+ const scopeKey = record.scopeKey || 'unspecified';
223
+ const contextKey = record.contextKey || null;
224
+ const topicKey = record.topicKey || null;
225
+ const groupKey = stableJson({ scopeKind, scopeKey, contextKey, topicKey });
226
+ if (!groups.has(groupKey)) {
227
+ groups.set(groupKey, {
228
+ scopeKind,
229
+ scopeKey,
230
+ contextKey,
231
+ topicKey,
232
+ records: [],
233
+ });
234
+ }
235
+ groups.get(groupKey).records.push(record);
236
+ }
237
+
238
+ const candidates = [];
239
+ const sortedGroups = [...groups.values()].sort((a, b) => {
240
+ const aKey = stableJson({ scopeKind: a.scopeKind, scopeKey: a.scopeKey, contextKey: a.contextKey, topicKey: a.topicKey });
241
+ const bKey = stableJson({ scopeKind: b.scopeKind, scopeKey: b.scopeKey, contextKey: b.contextKey, topicKey: b.topicKey });
242
+ return aKey.localeCompare(bKey);
243
+ });
244
+
245
+ for (const group of sortedGroups) {
246
+ const records = group.records.sort((a, b) => {
247
+ if (a.memoryType !== b.memoryType) return String(a.memoryType).localeCompare(String(b.memoryType));
248
+ if (a.canonicalKey !== b.canonicalKey) return a.canonicalKey.localeCompare(b.canonicalKey);
249
+ return String(a.id).localeCompare(String(b.id));
250
+ });
251
+ const sourceMemoryIds = records.map(record => record.id).filter(id => id !== null);
252
+ const sourceCanonicalKeys = records.map(record => record.canonicalKey);
253
+ const subject = [
254
+ `tenant:${safeKeyPart(tenantId, 'default')}`,
255
+ `scope:${safeKeyPart(group.scopeKey, 'unspecified')}`,
256
+ `context:${safeKeyPart(group.contextKey, 'none')}`,
257
+ `topic:${safeKeyPart(group.topicKey, 'none')}`,
258
+ `cadence:${cadence}`,
259
+ ].join('|');
260
+ const aspect = [
261
+ 'aggregate',
262
+ `policy:${safeKeyPart(policyVersion, 'v1')}`,
263
+ `window:${safeKeyPart(windowStart, 'start')}_${safeKeyPart(windowEnd, 'end')}`,
264
+ ].join('|');
265
+ const canonicalKey = [
266
+ 'conclusion',
267
+ safeKeyPart(group.scopeKey, 'unspecified'),
268
+ subject,
269
+ aspect,
270
+ ].join(':');
271
+ const candidateHash = hashSnapshot({
272
+ canonicalKey,
273
+ cadence,
274
+ policyVersion,
275
+ periodStart: windowStart,
276
+ periodEnd: windowEnd,
277
+ sourceMemoryIds,
278
+ sourceCanonicalKeys,
279
+ });
280
+ const title = `${cadence} memory rollup candidate for ${group.scopeKey}`;
281
+ const summary = [
282
+ `${cadence} rollup candidate for ${group.scopeKind}:${group.scopeKey} covering ${records.length} active curated memories from ${windowStart} to ${windowEnd}.`,
283
+ ...records.map(record => `- ${record.memoryType}:${record.canonicalKey}: ${compactSummary(record)}`),
284
+ ].join('\n');
285
+
286
+ candidates.push({
287
+ memoryType: 'conclusion',
288
+ status: 'candidate',
289
+ canonicalKey,
290
+ scopeKind: group.scopeKind,
291
+ scopeKey: group.scopeKey,
292
+ contextKey: group.contextKey,
293
+ topicKey: group.topicKey,
294
+ inheritanceMode: cadence === 'daily' ? 'defaultable' : 'additive',
295
+ title,
296
+ summary,
297
+ candidateHash,
298
+ payload: {
299
+ kind: 'compaction_rollup',
300
+ synthesisKind: 'timer_current_memory_synthesis_v1',
301
+ currentMemoryRole: `${cadence}_timer_synthesis_candidate`,
302
+ promotionGate: 'operator_required',
303
+ cadence,
304
+ policyVersion,
305
+ periodStart: windowStart,
306
+ periodEnd: windowEnd,
307
+ candidateHash,
308
+ sourceMemoryIds,
309
+ sourceCanonicalKeys,
310
+ sourceRecordCount: records.length,
311
+ },
312
+ authority: 'system',
313
+ evidenceRefs: records.map(record => ({
314
+ sourceKind: 'external',
315
+ sourceRef: record.id !== null
316
+ ? `memory_record:${record.id}`
317
+ : `memory_record:${record.canonicalKey}`,
318
+ relationKind: 'derived_from',
319
+ metadata: {
320
+ compaction: true,
321
+ cadence,
322
+ periodStart: windowStart,
323
+ periodEnd: windowEnd,
324
+ canonicalKey: record.canonicalKey,
325
+ },
326
+ })),
327
+ visibleInBootstrap: true,
328
+ visibleInRecall: true,
329
+ });
330
+ }
331
+
332
+ return candidates;
333
+ }
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
+
655
+ function buildCoverage(normalized, statusUpdates, candidates) {
656
+ const active = normalized.filter(record => record.status === 'active');
657
+ const activeOpenLoops = active.filter(record => record.memoryType === 'open_loop');
658
+ return {
659
+ sourceCoverage: {
660
+ recordCount: normalized.length,
661
+ activeCount: active.length,
662
+ activeOpenLoopCount: activeOpenLoops.length,
663
+ },
664
+ outputCoverage: {
665
+ candidateCount: candidates.length,
666
+ statusUpdateCount: statusUpdates.length,
667
+ },
668
+ };
669
+ }
670
+
671
+ function createApplySummary(statusUpdates) {
672
+ return {
673
+ applied: 0,
674
+ skipped: 0,
675
+ unsupported: 0,
676
+ statusUpdates: statusUpdates.length,
677
+ };
678
+ }
679
+
680
+ async function applyStatusUpdatesWithRecords(statusUpdates, targetRecords, tenantId, summary) {
681
+ for (const update of statusUpdates) {
682
+ if (update.status !== 'stale') {
683
+ summary.unsupported++;
684
+ summary.skipped++;
685
+ continue;
686
+ }
687
+ const row = await targetRecords.updateMemoryStatusIfCurrent({
688
+ tenantId,
689
+ memoryId: update.memoryId,
690
+ fromStatus: 'active',
691
+ status: 'stale',
692
+ validTo: update.validTo || null,
693
+ });
694
+ if (row) summary.applied++;
695
+ else summary.skipped++;
696
+ }
697
+ return summary;
698
+ }
699
+
700
+ function summarizePromotionResults(results = []) {
701
+ const summary = {
702
+ candidates: results.length,
703
+ planned: 0,
704
+ promoted: 0,
705
+ quarantined: 0,
706
+ skipped: 0,
707
+ errored: 0,
708
+ reasons: {},
709
+ };
710
+ for (const result of results) {
711
+ const action = result && result.action ? result.action : 'error';
712
+ const reason = result && result.reason ? result.reason : 'unknown';
713
+ if (action === 'planned') summary.planned++;
714
+ else if (action === 'promote') summary.promoted++;
715
+ else if (action === 'quarantine') summary.quarantined++;
716
+ else if (action === 'error') summary.errored++;
717
+ else summary.skipped++;
718
+ summary.reasons[reason] = (summary.reasons[reason] || 0) + 1;
719
+ }
720
+ return summary;
721
+ }
722
+
723
+ function normalizeCandidateLineage(candidate = {}) {
724
+ const payload = candidate.payload && typeof candidate.payload === 'object' ? candidate.payload : {};
725
+ const sourceCanonicalKeys = Array.isArray(payload.sourceCanonicalKeys)
726
+ ? payload.sourceCanonicalKeys.map(key => String(key || '')).filter(Boolean)
727
+ : [];
728
+ const rawSourceMemoryIds = Array.isArray(payload.sourceMemoryIds) ? payload.sourceMemoryIds : [];
729
+ const sourceMemoryIds = rawSourceMemoryIds
730
+ .filter(id => id !== null && id !== undefined)
731
+ .map(id => Number(id));
732
+
733
+ if (sourceMemoryIds.some(id => !Number.isSafeInteger(id) || id <= 0)) {
734
+ throw new Error('memory.consolidation.compaction_candidates requires positive integer sourceMemoryIds');
735
+ }
736
+ if (sourceMemoryIds.length !== sourceCanonicalKeys.length) {
737
+ throw new Error('memory.consolidation.compaction_candidates requires one sourceMemoryId for each sourceCanonicalKey');
738
+ }
739
+
740
+ return { payload, sourceMemoryIds, sourceCanonicalKeys };
741
+ }
742
+
743
+ function classifySkippedRun(existingRun, plan) {
744
+ if (!existingRun) return 'claim_not_acquired';
745
+ const sameSnapshot = existingRun.input_hash === plan.inputHash;
746
+ if (sameSnapshot && existingRun.status === 'applied') return 'already_applied';
747
+ if (sameSnapshot && existingRun.status === 'applying') return 'already_claimed';
748
+ if (existingRun.status === 'applied' || existingRun.status === 'applying') return 'window_winner_exists';
749
+ return 'claim_not_acquired';
750
+ }
751
+
752
+ function planCompaction(records = [], opts = {}) {
753
+ const { cadence, periodStart, periodEnd, endMs } = requirePeriod(opts);
754
+ const normalized = records.map(normalizeRecord).sort((a, b) => {
755
+ if (a.canonicalKey !== b.canonicalKey) return a.canonicalKey.localeCompare(b.canonicalKey);
756
+ return String(a.id).localeCompare(String(b.id));
757
+ });
758
+ const inputHash = hashSnapshot({
759
+ cadence,
760
+ periodStart,
761
+ periodEnd,
762
+ policyVersion: opts.policyVersion || 'v1',
763
+ records: normalized,
764
+ });
765
+
766
+ const statusUpdates = [];
767
+ for (const record of normalized) {
768
+ if (record.status !== 'active') continue;
769
+ if (record.memoryType !== 'open_loop') continue;
770
+ const validTo = timeMs(record.validTo);
771
+ const staleAfter = timeMs(record.staleAfter);
772
+ if ((validTo !== null && validTo <= endMs) || (staleAfter !== null && staleAfter <= endMs)) {
773
+ statusUpdates.push({
774
+ memoryId: record.id,
775
+ canonicalKey: record.canonicalKey,
776
+ status: 'stale',
777
+ reason: validTo !== null && validTo <= endMs ? 'valid_to_elapsed' : 'stale_after_elapsed',
778
+ });
779
+ }
780
+ }
781
+ const candidates = buildAggregateCandidates(normalized, statusUpdates, {
782
+ ...opts,
783
+ cadence,
784
+ periodStart,
785
+ periodEnd,
786
+ });
787
+ const synthesisInput = buildTimerSynthesisInput(normalized, statusUpdates, candidates, {
788
+ ...opts,
789
+ cadence,
790
+ periodStart,
791
+ periodEnd,
792
+ });
793
+ const coverage = buildCoverage(normalized, statusUpdates, candidates);
794
+
795
+ return {
796
+ cadence,
797
+ periodStart,
798
+ periodEnd,
799
+ policyVersion: opts.policyVersion || 'v1',
800
+ inputHash,
801
+ candidates,
802
+ synthesisInput,
803
+ statusUpdates,
804
+ sourceCoverage: coverage.sourceCoverage,
805
+ outputCoverage: coverage.outputCoverage,
806
+ meta: {
807
+ activeConflictRate: 0,
808
+ deterministic: true,
809
+ recordCount: normalized.length,
810
+ synthesisInput,
811
+ sourceCoverage: coverage.sourceCoverage,
812
+ outputCoverage: coverage.outputCoverage,
813
+ },
814
+ };
815
+ }
816
+
817
+ function distillArchiveSnapshot(snapshot = {}, opts = {}) {
818
+ const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
819
+ const candidates = [];
820
+ for (const session of sessions) {
821
+ const structuredSummary = session.structuredSummary || session.structured_summary || {};
822
+ const sessionId = session.sessionId || session.session_id || session.id || null;
823
+ const sourceRef = session.archiveRef || session.sourceRef || sessionId || 'archive';
824
+ const extracted = extractCandidatesFromStructuredSummary({
825
+ structuredSummary,
826
+ sessionId,
827
+ scopeKind: session.scopeKind || opts.scopeKind || 'session',
828
+ scopeKey: session.scopeKey || (sessionId ? `session:${sessionId}` : opts.scopeKey || 'archive'),
829
+ contextKey: session.contextKey || opts.contextKey || null,
830
+ topicKey: session.topicKey || opts.topicKey || null,
831
+ authority: opts.authority || 'raw_transcript',
832
+ evidenceRefs: [{
833
+ sourceKind: 'external',
834
+ sourceRef,
835
+ relationKind: 'imported_from',
836
+ metadata: { archive: true },
837
+ }],
838
+ });
839
+ for (const candidate of extracted) {
840
+ candidates.push({
841
+ ...candidate,
842
+ status: 'candidate',
843
+ visibleInBootstrap: false,
844
+ visibleInRecall: false,
845
+ });
846
+ }
847
+ }
848
+ return {
849
+ inputHash: hashSnapshot(snapshot),
850
+ candidates: candidates.sort((a, b) => a.canonicalKey.localeCompare(b.canonicalKey)),
851
+ meta: {
852
+ candidateCount: candidates.length,
853
+ bypassedPromotion: false,
854
+ },
855
+ };
856
+ }
857
+
858
+ function createMemoryConsolidation({ pool, schema, defaultTenantId, records = null }) {
859
+ function makeApplyToken() {
860
+ if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
861
+ return crypto.randomBytes(16).toString('hex');
862
+ }
863
+
864
+ async function recordRunWith(queryable, input = {}) {
865
+ const tenantId = input.tenantId || defaultTenantId;
866
+ const plan = input.plan || input;
867
+ const sourceCoverage = input.sourceCoverage || plan.sourceCoverage || plan.meta?.sourceCoverage || {};
868
+ const outputCoverage = input.outputCoverage || plan.outputCoverage || plan.meta?.outputCoverage || {};
869
+ const result = await queryable.query(
870
+ `INSERT INTO ${schema}.compaction_runs (
871
+ tenant_id, cadence, period_start, period_end, input_hash,
872
+ policy_version, status, output, error, applied_at,
873
+ source_coverage, output_coverage
874
+ )
875
+ VALUES ($1,$2,$3,$4,$5,COALESCE($6,'v1'),COALESCE($7,'planned'),COALESCE($8::jsonb,'{}'::jsonb),$9,$10,
876
+ COALESCE($11::jsonb,'{}'::jsonb),COALESCE($12::jsonb,'{}'::jsonb))
877
+ ON CONFLICT (tenant_id, cadence, period_start, period_end, input_hash, policy_version)
878
+ DO UPDATE SET
879
+ status = CASE
880
+ WHEN compaction_runs.status IN ('applying','applied')
881
+ AND EXCLUDED.status <> compaction_runs.status
882
+ THEN compaction_runs.status
883
+ ELSE EXCLUDED.status
884
+ END,
885
+ output = CASE
886
+ WHEN compaction_runs.status IN ('applying','applied')
887
+ AND EXCLUDED.status <> compaction_runs.status
888
+ THEN compaction_runs.output
889
+ ELSE EXCLUDED.output
890
+ END,
891
+ error = CASE
892
+ WHEN compaction_runs.status IN ('applying','applied')
893
+ AND EXCLUDED.status <> compaction_runs.status
894
+ THEN compaction_runs.error
895
+ ELSE EXCLUDED.error
896
+ END,
897
+ applied_at = CASE
898
+ WHEN compaction_runs.status IN ('applying','applied')
899
+ AND EXCLUDED.status <> compaction_runs.status
900
+ THEN compaction_runs.applied_at
901
+ ELSE EXCLUDED.applied_at
902
+ END,
903
+ source_coverage = CASE
904
+ WHEN compaction_runs.status IN ('applying','applied')
905
+ AND EXCLUDED.status <> compaction_runs.status
906
+ THEN compaction_runs.source_coverage
907
+ ELSE EXCLUDED.source_coverage
908
+ END,
909
+ output_coverage = CASE
910
+ WHEN compaction_runs.status IN ('applying','applied')
911
+ AND EXCLUDED.status <> compaction_runs.status
912
+ THEN compaction_runs.output_coverage
913
+ ELSE EXCLUDED.output_coverage
914
+ END
915
+ RETURNING *`,
916
+ [
917
+ tenantId,
918
+ plan.cadence,
919
+ plan.periodStart,
920
+ plan.periodEnd,
921
+ plan.inputHash,
922
+ plan.policyVersion || 'v1',
923
+ input.status || plan.status || 'planned',
924
+ JSON.stringify(input.output || plan),
925
+ input.error || null,
926
+ input.appliedAt || null,
927
+ JSON.stringify(sourceCoverage),
928
+ JSON.stringify(outputCoverage),
929
+ ]
930
+ );
931
+ return result.rows[0] || null;
932
+ }
933
+
934
+ async function recordRun(input = {}) {
935
+ return recordRunWith(pool, input);
936
+ }
937
+
938
+ async function claimRunWith(queryable, input = {}) {
939
+ const tenantId = input.tenantId || defaultTenantId;
940
+ const plan = input.plan || input;
941
+ const workerId = input.workerId || 'aquifer';
942
+ const applyToken = input.applyToken || makeApplyToken();
943
+ const claimLeaseSeconds = normalizeClaimLeaseSeconds(input.claimLeaseSeconds ?? input.staleAfterSeconds);
944
+ const [lockKey1, lockKey2] = advisoryLockKeys(
945
+ 'aquifer.compaction_runs.claim_window',
946
+ `${schema}:${tenantId}:${plan.cadence}:${canonicalInstant(plan.periodStart)}:${canonicalInstant(plan.periodEnd)}:${plan.policyVersion || 'v1'}`,
947
+ );
948
+
949
+ await queryable.query('SELECT pg_advisory_xact_lock($1, $2)', [lockKey1, lockKey2]);
950
+
951
+ if (input.reclaimStaleClaims !== false) {
952
+ await queryable.query(
953
+ `UPDATE ${schema}.compaction_runs
954
+ SET status = 'failed',
955
+ error = COALESCE(error, 'claim lease expired before finalize'),
956
+ reclaimed_at = transaction_timestamp(),
957
+ reclaimed_by_worker_id = $6
958
+ WHERE tenant_id = $1
959
+ AND cadence = $2
960
+ AND period_start = $3
961
+ AND period_end = $4
962
+ AND policy_version = $5
963
+ AND status = 'applying'
964
+ AND lease_expires_at < transaction_timestamp()
965
+ RETURNING *`,
966
+ [
967
+ tenantId,
968
+ plan.cadence,
969
+ plan.periodStart,
970
+ plan.periodEnd,
971
+ plan.policyVersion || 'v1',
972
+ workerId,
973
+ ]
974
+ );
975
+ }
976
+
977
+ await recordRunWith(queryable, {
978
+ tenantId,
979
+ plan,
980
+ status: 'planned',
981
+ output: input.output || plan,
982
+ sourceCoverage: input.sourceCoverage || plan.sourceCoverage || plan.meta?.sourceCoverage || {},
983
+ outputCoverage: input.outputCoverage || plan.outputCoverage || plan.meta?.outputCoverage || {},
984
+ });
985
+
986
+ const result = await queryable.query(
987
+ `UPDATE ${schema}.compaction_runs AS cr
988
+ SET status = 'applying',
989
+ claimed_at = transaction_timestamp(),
990
+ lease_expires_at = transaction_timestamp() + ($7::int * interval '1 second'),
991
+ worker_id = $8,
992
+ apply_token = $9
993
+ WHERE cr.tenant_id = $1
994
+ AND cr.cadence = $2
995
+ AND cr.period_start = $3
996
+ AND cr.period_end = $4
997
+ AND cr.input_hash = $5
998
+ AND cr.policy_version = $6
999
+ AND cr.status = 'planned'
1000
+ AND NOT EXISTS (
1001
+ SELECT 1
1002
+ FROM ${schema}.compaction_runs other
1003
+ WHERE other.tenant_id = cr.tenant_id
1004
+ AND other.cadence = cr.cadence
1005
+ AND other.period_start = cr.period_start
1006
+ AND other.period_end = cr.period_end
1007
+ AND other.policy_version = cr.policy_version
1008
+ AND other.status IN ('applying','applied')
1009
+ AND other.id <> cr.id
1010
+ )
1011
+ RETURNING *`,
1012
+ [
1013
+ tenantId,
1014
+ plan.cadence,
1015
+ plan.periodStart,
1016
+ plan.periodEnd,
1017
+ plan.inputHash,
1018
+ plan.policyVersion || 'v1',
1019
+ claimLeaseSeconds,
1020
+ workerId,
1021
+ applyToken,
1022
+ ]
1023
+ );
1024
+ return result.rows[0] || null;
1025
+ }
1026
+
1027
+ async function claimRun(input = {}) {
1028
+ if (pool && typeof pool.connect === 'function') {
1029
+ const client = await pool.connect();
1030
+ try {
1031
+ await client.query('BEGIN');
1032
+ const claim = await claimRunWith(client, input);
1033
+ await client.query('COMMIT');
1034
+ return claim;
1035
+ } catch (error) {
1036
+ await client.query('ROLLBACK').catch(() => {});
1037
+ throw error;
1038
+ } finally {
1039
+ client.release();
1040
+ }
1041
+ }
1042
+ return claimRunWith(pool, input);
1043
+ }
1044
+
1045
+ async function finalizeClaimWith(queryable, input = {}) {
1046
+ const tenantId = input.tenantId || defaultTenantId;
1047
+ const plan = input.plan || input;
1048
+ const claim = input.claim || {};
1049
+ const status = input.status || 'applied';
1050
+ const sourceCoverage = input.sourceCoverage || plan.sourceCoverage || plan.meta?.sourceCoverage || {};
1051
+ const outputCoverage = input.outputCoverage || plan.outputCoverage || plan.meta?.outputCoverage || {};
1052
+ const result = await queryable.query(
1053
+ `UPDATE ${schema}.compaction_runs
1054
+ SET status = $4,
1055
+ output = COALESCE($5::jsonb,'{}'::jsonb),
1056
+ error = $6,
1057
+ applied_at = $7,
1058
+ source_coverage = COALESCE($8::jsonb,'{}'::jsonb),
1059
+ output_coverage = COALESCE($9::jsonb,'{}'::jsonb)
1060
+ WHERE tenant_id = $1
1061
+ AND id = $2
1062
+ AND apply_token = $3
1063
+ AND status = 'applying'
1064
+ RETURNING *`,
1065
+ [
1066
+ tenantId,
1067
+ claim.id,
1068
+ claim.apply_token || claim.applyToken || input.applyToken,
1069
+ status,
1070
+ JSON.stringify(input.output || plan),
1071
+ input.error || null,
1072
+ status === 'applied' ? (input.appliedAt || new Date().toISOString()) : null,
1073
+ JSON.stringify(sourceCoverage),
1074
+ JSON.stringify(outputCoverage),
1075
+ ]
1076
+ );
1077
+ return result.rows[0] || null;
1078
+ }
1079
+
1080
+ async function recordCompactionCandidateResultsWith(queryable, input = {}) {
1081
+ const tenantId = input.tenantId || defaultTenantId;
1082
+ const run = input.run || input.claim || {};
1083
+ const candidates = input.candidates || [];
1084
+ const results = input.results || [];
1085
+ const rows = [];
1086
+ for (let i = 0; i < candidates.length; i++) {
1087
+ const candidate = candidates[i] || {};
1088
+ const result = results[i] || {};
1089
+ const { payload, sourceMemoryIds, sourceCanonicalKeys } = normalizeCandidateLineage(candidate);
1090
+ const candidateHash = candidate.candidateHash || payload.candidateHash || hashSnapshot(candidate);
1091
+ const inserted = await queryable.query(
1092
+ `INSERT INTO ${schema}.compaction_candidates (
1093
+ tenant_id, compaction_run_id, candidate_index, candidate_hash,
1094
+ action, reason, memory_type, canonical_key, scope_kind, scope_key,
1095
+ context_key, topic_key, summary, payload, source_memory_ids,
1096
+ source_canonical_keys, memory_record_id, fact_assertion_id
1097
+ )
1098
+ VALUES (
1099
+ $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,COALESCE($14::jsonb,'{}'::jsonb),
1100
+ COALESCE($15::bigint[], ARRAY[]::bigint[]), COALESCE($16::jsonb,'[]'::jsonb), $17,$18
1101
+ )
1102
+ ON CONFLICT (tenant_id, compaction_run_id, candidate_index)
1103
+ DO UPDATE SET
1104
+ candidate_hash = EXCLUDED.candidate_hash,
1105
+ action = EXCLUDED.action,
1106
+ reason = EXCLUDED.reason,
1107
+ memory_type = EXCLUDED.memory_type,
1108
+ canonical_key = EXCLUDED.canonical_key,
1109
+ scope_kind = EXCLUDED.scope_kind,
1110
+ scope_key = EXCLUDED.scope_key,
1111
+ context_key = EXCLUDED.context_key,
1112
+ topic_key = EXCLUDED.topic_key,
1113
+ summary = EXCLUDED.summary,
1114
+ payload = EXCLUDED.payload,
1115
+ source_memory_ids = EXCLUDED.source_memory_ids,
1116
+ source_canonical_keys = EXCLUDED.source_canonical_keys,
1117
+ memory_record_id = COALESCE(EXCLUDED.memory_record_id, ${schema}.compaction_candidates.memory_record_id),
1118
+ fact_assertion_id = COALESCE(EXCLUDED.fact_assertion_id, ${schema}.compaction_candidates.fact_assertion_id),
1119
+ updated_at = now()
1120
+ RETURNING *`,
1121
+ [
1122
+ tenantId,
1123
+ run.id,
1124
+ i,
1125
+ candidateHash,
1126
+ result.action || 'error',
1127
+ result.reason || null,
1128
+ candidate.memoryType || candidate.memory_type || null,
1129
+ candidate.canonicalKey || candidate.canonical_key || null,
1130
+ candidate.scopeKind || candidate.scope_kind || null,
1131
+ candidate.scopeKey || candidate.scope_key || null,
1132
+ candidate.contextKey || candidate.context_key || null,
1133
+ candidate.topicKey || candidate.topic_key || null,
1134
+ candidate.summary || null,
1135
+ JSON.stringify(payload),
1136
+ sourceMemoryIds,
1137
+ JSON.stringify(sourceCanonicalKeys),
1138
+ result.memory ? result.memory.id : null,
1139
+ result.backingFact ? result.backingFact.id : null,
1140
+ ]
1141
+ );
1142
+ rows.push(inserted.rows[0] || null);
1143
+ }
1144
+ return rows;
1145
+ }
1146
+
1147
+ async function applyPlan(input = {}) {
1148
+ const plan = input.plan || input;
1149
+ const tenantId = input.tenantId || defaultTenantId;
1150
+ const statusUpdates = Array.isArray(plan.statusUpdates) ? plan.statusUpdates : [];
1151
+ const candidates = Array.isArray(plan.candidates) ? plan.candidates : [];
1152
+ const appliedAt = input.appliedAt || new Date().toISOString();
1153
+ const summary = createApplySummary(statusUpdates);
1154
+
1155
+ const applyWithRecords = async targetRecords => {
1156
+ return applyStatusUpdatesWithRecords(statusUpdates, targetRecords, tenantId, summary);
1157
+ };
1158
+
1159
+ if (!records || typeof records.updateMemoryStatusIfCurrent !== 'function') {
1160
+ throw new Error('memory.consolidation.applyPlan requires records.updateMemoryStatusIfCurrent');
1161
+ }
1162
+
1163
+ const runInput = status => {
1164
+ const output = {
1165
+ ...plan,
1166
+ applyResult: summary,
1167
+ };
1168
+ const outputCoverage = {
1169
+ ...(plan.outputCoverage || plan.meta?.outputCoverage || {}),
1170
+ appliedStatusUpdateCount: summary.applied,
1171
+ skippedStatusUpdateCount: summary.skipped,
1172
+ unsupportedStatusUpdateCount: summary.unsupported,
1173
+ plannedCandidateCount: candidates.length,
1174
+ };
1175
+ return {
1176
+ tenantId,
1177
+ plan,
1178
+ status,
1179
+ output,
1180
+ sourceCoverage: plan.sourceCoverage || plan.meta?.sourceCoverage || {},
1181
+ outputCoverage,
1182
+ appliedAt: status === 'applied' ? appliedAt : null,
1183
+ };
1184
+ };
1185
+
1186
+ if (pool && typeof pool.connect === 'function') {
1187
+ const client = await pool.connect();
1188
+ try {
1189
+ await client.query('BEGIN');
1190
+ const claim = await claimRunWith(client, {
1191
+ tenantId,
1192
+ plan,
1193
+ workerId: input.workerId,
1194
+ applyToken: input.applyToken,
1195
+ claimLeaseSeconds: input.claimLeaseSeconds,
1196
+ staleAfterSeconds: input.staleAfterSeconds,
1197
+ reclaimStaleClaims: input.reclaimStaleClaims,
1198
+ });
1199
+ if (!claim) {
1200
+ summary.skipped += statusUpdates.length;
1201
+ await client.query('COMMIT');
1202
+ return { status: 'skipped', run: null, claim: null, plan, applyResult: summary };
1203
+ }
1204
+ const txRecords = createMemoryRecords({
1205
+ pool: client,
1206
+ schema,
1207
+ defaultTenantId,
1208
+ inTransaction: true,
1209
+ });
1210
+ await applyWithRecords(txRecords);
1211
+ const candidateRows = candidates.length > 0
1212
+ ? await recordCompactionCandidateResultsWith(client, {
1213
+ tenantId,
1214
+ run: claim,
1215
+ candidates,
1216
+ results: candidates.map(candidate => ({
1217
+ candidate,
1218
+ action: 'planned',
1219
+ reason: 'promotion_not_requested',
1220
+ })),
1221
+ })
1222
+ : [];
1223
+ const status = summary.applied > 0 ? 'applied' : 'skipped';
1224
+ const run = await finalizeClaimWith(client, {
1225
+ ...runInput(status),
1226
+ claim,
1227
+ });
1228
+ if (!run) {
1229
+ throw new Error('memory.consolidation.applyPlan failed to finalize claimed run');
1230
+ }
1231
+ await client.query('COMMIT');
1232
+ return { status, run, claim, plan, applyResult: summary, candidateRows };
1233
+ } catch (error) {
1234
+ await client.query('ROLLBACK').catch(() => {});
1235
+ throw error;
1236
+ } finally {
1237
+ client.release();
1238
+ }
1239
+ }
1240
+
1241
+ if (typeof records.withTransaction === 'function') {
1242
+ await records.withTransaction(txRecords => applyWithRecords(txRecords));
1243
+ } else {
1244
+ await applyWithRecords(records);
1245
+ }
1246
+
1247
+ const status = summary.applied > 0 ? 'applied' : 'skipped';
1248
+ const run = await recordRun(runInput(status));
1249
+ return { status, run, plan, applyResult: summary };
1250
+ }
1251
+
1252
+ async function executePlan(input = {}) {
1253
+ const plan = input.plan || input;
1254
+ const tenantId = input.tenantId || defaultTenantId;
1255
+ const statusUpdates = Array.isArray(plan.statusUpdates) ? plan.statusUpdates : [];
1256
+ const candidates = Array.isArray(input.candidates) ? input.candidates : (Array.isArray(plan.candidates) ? plan.candidates : []);
1257
+ const appliedAt = input.appliedAt || new Date().toISOString();
1258
+ const promoteCandidates = input.promoteCandidates === true;
1259
+ const summary = createApplySummary(statusUpdates);
1260
+
1261
+ if (!records || typeof records.updateMemoryStatusIfCurrent !== 'function') {
1262
+ throw new Error('memory.consolidation.executePlan requires records.updateMemoryStatusIfCurrent');
1263
+ }
1264
+ if (!pool || typeof pool.connect !== 'function') {
1265
+ throw new Error('memory.consolidation.executePlan requires DB pool transaction support');
1266
+ }
1267
+
1268
+ const runInput = (status, promotionResult, outputCandidates) => {
1269
+ const output = {
1270
+ ...plan,
1271
+ candidates: outputCandidates,
1272
+ applyResult: summary,
1273
+ promotionResult,
1274
+ };
1275
+ const outputCoverage = {
1276
+ ...(plan.outputCoverage || plan.meta?.outputCoverage || {}),
1277
+ appliedStatusUpdateCount: summary.applied,
1278
+ skippedStatusUpdateCount: summary.skipped,
1279
+ unsupportedStatusUpdateCount: summary.unsupported,
1280
+ promotionCandidateCount: promotionResult.candidates,
1281
+ plannedCandidateCount: promotionResult.planned,
1282
+ promotedCandidateCount: promotionResult.promoted,
1283
+ quarantinedCandidateCount: promotionResult.quarantined,
1284
+ erroredCandidateCount: promotionResult.errored,
1285
+ };
1286
+ return {
1287
+ tenantId,
1288
+ plan,
1289
+ status,
1290
+ output,
1291
+ sourceCoverage: plan.sourceCoverage || plan.meta?.sourceCoverage || {},
1292
+ outputCoverage,
1293
+ appliedAt: status === 'applied' ? appliedAt : null,
1294
+ };
1295
+ };
1296
+
1297
+ const client = await pool.connect();
1298
+ try {
1299
+ await client.query('BEGIN');
1300
+ const claim = await claimRunWith(client, {
1301
+ tenantId,
1302
+ plan,
1303
+ workerId: input.workerId,
1304
+ applyToken: input.applyToken,
1305
+ claimLeaseSeconds: input.claimLeaseSeconds,
1306
+ staleAfterSeconds: input.staleAfterSeconds,
1307
+ reclaimStaleClaims: input.reclaimStaleClaims,
1308
+ });
1309
+ if (!claim) {
1310
+ summary.skipped += statusUpdates.length;
1311
+ await client.query('COMMIT');
1312
+ return {
1313
+ status: 'skipped',
1314
+ run: null,
1315
+ claim: null,
1316
+ plan,
1317
+ applyResult: summary,
1318
+ promotionResult: summarizePromotionResults([]),
1319
+ candidateRows: [],
1320
+ };
1321
+ }
1322
+
1323
+ const txRecords = createMemoryRecords({
1324
+ pool: client,
1325
+ schema,
1326
+ defaultTenantId,
1327
+ inTransaction: true,
1328
+ });
1329
+ await applyStatusUpdatesWithRecords(statusUpdates, txRecords, tenantId, summary);
1330
+
1331
+ const promotion = promoteCandidates ? createMemoryPromotion({ records: txRecords }) : null;
1332
+ const promotionResults = promoteCandidates && candidates.length > 0
1333
+ ? await promotion.promote(candidates, {
1334
+ tenantId,
1335
+ acceptedAt: input.acceptedAt || appliedAt,
1336
+ createdByCompactionRunId: claim.id,
1337
+ })
1338
+ : candidates.map(candidate => ({
1339
+ candidate,
1340
+ action: 'planned',
1341
+ reason: 'promotion_not_requested',
1342
+ }));
1343
+ const candidateRows = await recordCompactionCandidateResultsWith(client, {
1344
+ tenantId,
1345
+ run: claim,
1346
+ candidates,
1347
+ results: promotionResults,
1348
+ });
1349
+ const promotionResult = summarizePromotionResults(promotionResults);
1350
+ const status = summary.applied > 0 || promotionResult.promoted > 0 || promotionResult.planned > 0
1351
+ ? 'applied'
1352
+ : 'skipped';
1353
+ const run = await finalizeClaimWith(client, {
1354
+ ...runInput(status, promotionResult, candidates),
1355
+ claim,
1356
+ });
1357
+ if (!run) {
1358
+ throw new Error('memory.consolidation.executePlan failed to finalize claimed run');
1359
+ }
1360
+ await client.query('COMMIT');
1361
+ return {
1362
+ status,
1363
+ run,
1364
+ claim,
1365
+ plan,
1366
+ applyResult: summary,
1367
+ promotionResult,
1368
+ promotionResults,
1369
+ candidateRows,
1370
+ };
1371
+ } catch (error) {
1372
+ await client.query('ROLLBACK').catch(() => {});
1373
+ throw error;
1374
+ } finally {
1375
+ client.release();
1376
+ }
1377
+ }
1378
+
1379
+ async function loadActiveSnapshot(input = {}) {
1380
+ if (!records || typeof records.listActive !== 'function') {
1381
+ throw new Error('memory.consolidation.runJob requires records.listActive');
1382
+ }
1383
+
1384
+ const tenantId = input.tenantId || defaultTenantId;
1385
+ const scopeKeys = normalizeStringList(
1386
+ input.scopeKeys
1387
+ || input.scopeKey
1388
+ || input.activeScopeKey
1389
+ || input.activeScopePath
1390
+ );
1391
+ const limit = normalizeOperatorSnapshotLimit(input.snapshotLimit ?? input.limit);
1392
+ const rows = await records.listActive({
1393
+ tenantId,
1394
+ scopeId: input.scopeId,
1395
+ scopeKeys: scopeKeys.length > 0 ? scopeKeys : undefined,
1396
+ asOf: input.snapshotAsOf || input.asOf || undefined,
1397
+ limit,
1398
+ });
1399
+ return {
1400
+ rows,
1401
+ scopeKeys,
1402
+ snapshotAsOf: input.snapshotAsOf || input.asOf || null,
1403
+ snapshotLimit: limit,
1404
+ snapshotTruncated: rows.length >= limit,
1405
+ };
1406
+ }
1407
+
1408
+ async function findExistingRun(input = {}) {
1409
+ const tenantId = input.tenantId || defaultTenantId;
1410
+ const plan = input.plan || input;
1411
+ const result = await pool.query(
1412
+ `SELECT *
1413
+ FROM ${schema}.compaction_runs
1414
+ WHERE tenant_id = $1
1415
+ AND cadence = $2
1416
+ AND period_start = $3
1417
+ AND period_end = $4
1418
+ AND policy_version = $5
1419
+ ORDER BY CASE
1420
+ WHEN input_hash = $6 AND status = 'applied' THEN 0
1421
+ WHEN status = 'applied' THEN 1
1422
+ WHEN input_hash = $6 AND status = 'applying' THEN 2
1423
+ WHEN status = 'applying' THEN 3
1424
+ WHEN input_hash = $6 AND status = 'planned' THEN 4
1425
+ ELSE 5
1426
+ END,
1427
+ id DESC
1428
+ LIMIT 1`,
1429
+ [
1430
+ tenantId,
1431
+ plan.cadence,
1432
+ plan.periodStart,
1433
+ plan.periodEnd,
1434
+ plan.policyVersion || 'v1',
1435
+ plan.inputHash,
1436
+ ]
1437
+ );
1438
+ return result.rows[0] || null;
1439
+ }
1440
+
1441
+ async function runJob(input = {}) {
1442
+ const job = String(input.job || 'compaction').trim().toLowerCase();
1443
+ if (job === 'archive-distill') {
1444
+ if (input.apply === true || input.promoteCandidates === true) {
1445
+ throw new Error('memory.consolidation.runJob archive-distill is dry-run only');
1446
+ }
1447
+ const archiveSnapshot = input.archiveSnapshot || input.snapshot || null;
1448
+ if (!archiveSnapshot || typeof archiveSnapshot !== 'object') {
1449
+ throw new Error('memory.consolidation.runJob archive-distill requires archiveSnapshot');
1450
+ }
1451
+ const distill = distillArchiveSnapshot(archiveSnapshot, input);
1452
+ return {
1453
+ job,
1454
+ status: 'planned',
1455
+ dryRun: true,
1456
+ inputHash: distill.inputHash,
1457
+ candidates: distill.candidates,
1458
+ meta: distill.meta,
1459
+ };
1460
+ }
1461
+
1462
+ const tenantId = input.tenantId || defaultTenantId;
1463
+ const window = resolveOperatorWindow(input);
1464
+ const snapshot = Array.isArray(input.records)
1465
+ ? {
1466
+ rows: input.records,
1467
+ scopeKeys: normalizeStringList(
1468
+ input.scopeKeys
1469
+ || input.scopeKey
1470
+ || input.activeScopeKey
1471
+ || input.activeScopePath
1472
+ ),
1473
+ snapshotAsOf: input.snapshotAsOf || input.asOf || window.periodEnd,
1474
+ snapshotLimit: Array.isArray(input.records) ? input.records.length : null,
1475
+ snapshotTruncated: false,
1476
+ }
1477
+ : await loadActiveSnapshot({
1478
+ tenantId,
1479
+ scopeId: input.scopeId,
1480
+ scopeKeys: input.scopeKeys || input.scopeKey || input.activeScopePath || input.activeScopeKey,
1481
+ snapshotAsOf: input.snapshotAsOf || input.asOf || window.periodEnd,
1482
+ snapshotLimit: input.snapshotLimit ?? input.limit,
1483
+ });
1484
+ const plan = planCompaction(snapshot.rows, {
1485
+ tenantId,
1486
+ cadence: window.cadence,
1487
+ periodStart: window.periodStart,
1488
+ periodEnd: window.periodEnd,
1489
+ policyVersion: input.policyVersion || 'v1',
1490
+ });
1491
+ const synthesisSummary = input.synthesisSummary || input.timerSynthesisSummary || null;
1492
+ const effectivePlan = synthesisSummary
1493
+ ? attachTimerSynthesis(plan, synthesisSummary, {
1494
+ ...input,
1495
+ tenantId,
1496
+ })
1497
+ : plan;
1498
+
1499
+ if (input.apply !== true) {
1500
+ return {
1501
+ job,
1502
+ status: 'planned',
1503
+ dryRun: true,
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,
1512
+ snapshotCount: snapshot.rows.length,
1513
+ snapshotLimit: snapshot.snapshotLimit,
1514
+ snapshotTruncated: snapshot.snapshotTruncated,
1515
+ snapshotAsOf: snapshot.snapshotAsOf,
1516
+ scopeKeys: snapshot.scopeKeys,
1517
+ };
1518
+ }
1519
+
1520
+ const result = input.promoteCandidates === true
1521
+ ? await executePlan({
1522
+ plan: effectivePlan,
1523
+ tenantId,
1524
+ workerId: input.workerId,
1525
+ applyToken: input.applyToken,
1526
+ appliedAt: input.appliedAt,
1527
+ promoteCandidates: true,
1528
+ claimLeaseSeconds: input.claimLeaseSeconds,
1529
+ reclaimStaleClaims: input.reclaimStaleClaims,
1530
+ })
1531
+ : await applyPlan({
1532
+ plan: effectivePlan,
1533
+ tenantId,
1534
+ workerId: input.workerId,
1535
+ applyToken: input.applyToken,
1536
+ appliedAt: input.appliedAt,
1537
+ claimLeaseSeconds: input.claimLeaseSeconds,
1538
+ reclaimStaleClaims: input.reclaimStaleClaims,
1539
+ });
1540
+ const existingRun = result.run || await findExistingRun({ tenantId, plan: effectivePlan });
1541
+ return {
1542
+ ...result,
1543
+ job,
1544
+ dryRun: false,
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,
1554
+ snapshotCount: snapshot.rows.length,
1555
+ snapshotLimit: snapshot.snapshotLimit,
1556
+ snapshotTruncated: snapshot.snapshotTruncated,
1557
+ snapshotAsOf: snapshot.snapshotAsOf,
1558
+ scopeKeys: snapshot.scopeKeys,
1559
+ existingRun,
1560
+ skipReason: result.run ? null : classifySkippedRun(existingRun, effectivePlan),
1561
+ };
1562
+ }
1563
+
1564
+ return {
1565
+ plan: planCompaction,
1566
+ distillArchiveSnapshot,
1567
+ runJob,
1568
+ recordRun,
1569
+ claimRun,
1570
+ applyPlan,
1571
+ executePlan,
1572
+ };
1573
+ }
1574
+
1575
+ module.exports = {
1576
+ stableJson,
1577
+ hashSnapshot,
1578
+ advisoryLockKeys,
1579
+ canonicalInstant,
1580
+ normalizeClaimLeaseSeconds,
1581
+ resolveOperatorWindow,
1582
+ planCompaction,
1583
+ buildTimerSynthesisInput,
1584
+ buildTimerSynthesisPrompt,
1585
+ buildTimerSynthesisCandidates,
1586
+ attachTimerSynthesis,
1587
+ buildPromotionReview,
1588
+ distillArchiveSnapshot,
1589
+ createMemoryConsolidation,
1590
+ };