@shadowforge0/aquifer-memory 1.8.1 → 1.9.1

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 (57) hide show
  1. package/.env.example +1 -0
  2. package/README.md +82 -26
  3. package/README_CN.md +33 -23
  4. package/README_TW.md +25 -24
  5. package/aquifer.config.example.json +2 -1
  6. package/consumers/cli.js +587 -33
  7. package/consumers/codex-active-checkpoint.js +3 -1
  8. package/consumers/codex-current-memory.js +10 -6
  9. package/consumers/codex.js +6 -3
  10. package/consumers/default/daily-entries.js +2 -2
  11. package/consumers/default/index.js +40 -30
  12. package/consumers/default/prompts/summary.js +2 -2
  13. package/consumers/mcp.js +56 -46
  14. package/consumers/openclaw-ext/index.js +65 -7
  15. package/consumers/openclaw-ext/openclaw.plugin.json +1 -1
  16. package/consumers/openclaw-ext/package.json +1 -1
  17. package/consumers/openclaw-install.js +326 -0
  18. package/consumers/openclaw-plugin.js +105 -24
  19. package/consumers/shared/compat-recall.js +101 -0
  20. package/consumers/shared/config.js +2 -0
  21. package/consumers/shared/openclaw-product-tools.js +130 -0
  22. package/consumers/shared/recall-format.js +2 -2
  23. package/core/aquifer.js +553 -41
  24. package/core/backends/local.js +169 -1
  25. package/core/doctor.js +924 -0
  26. package/core/finalization-inspector.js +164 -0
  27. package/core/finalization-review.js +88 -42
  28. package/core/interface.js +629 -0
  29. package/core/mcp-manifest.js +11 -3
  30. package/core/memory-bootstrap.js +25 -27
  31. package/core/memory-consolidation.js +564 -42
  32. package/core/memory-explain.js +593 -0
  33. package/core/memory-promotion.js +392 -55
  34. package/core/memory-recall.js +75 -71
  35. package/core/memory-records.js +107 -108
  36. package/core/memory-review.js +891 -0
  37. package/core/memory-serving.js +61 -4
  38. package/core/memory-type-policy.js +298 -0
  39. package/core/operator-observability.js +249 -0
  40. package/core/postgres-migrations.js +22 -0
  41. package/core/session-checkpoint-producer.js +3 -1
  42. package/core/session-checkpoints.js +1 -1
  43. package/core/session-finalization.js +78 -3
  44. package/core/storage.js +124 -8
  45. package/docs/getting-started.md +50 -4
  46. package/docs/setup.md +163 -24
  47. package/package.json +5 -4
  48. package/schema/004-completion.sql +4 -4
  49. package/schema/010-v1-finalization-review.sql +72 -0
  50. package/schema/019-v1-memory-review-resolutions.sql +53 -0
  51. package/schema/020-v1-assistant-shaping-memory.sql +30 -0
  52. package/scripts/backfill-canonical-key.js +1 -1
  53. package/scripts/codex-checkpoint-commands.js +28 -0
  54. package/scripts/codex-checkpoint-runtime.js +109 -0
  55. package/scripts/codex-recovery.js +16 -4
  56. package/scripts/diagnose-fts-zh.js +1 -1
  57. package/scripts/extract-insights-from-recent-sessions.js +4 -4
@@ -0,0 +1,891 @@
1
+ 'use strict';
2
+
3
+ const { resolveApplicableRecords } = require('./memory-bootstrap');
4
+
5
+ const DEFAULT_LIMIT = 20;
6
+ const MAX_LIMIT = 100;
7
+ const MAX_FETCH_LIMIT = 400;
8
+ const DEFAULT_VISIBILITY = 'either';
9
+ const DEFAULT_RESOLUTION_LIMIT = 10;
10
+ const RESOLUTION_ACTIONS = ['resolved', 'ignored', 'deferred'];
11
+
12
+ const ISSUE_FEEDBACK_TYPES = [
13
+ 'incorrect',
14
+ 'sensitive',
15
+ 'scope_mismatch',
16
+ 'authority_mismatch',
17
+ 'stale',
18
+ 'expired',
19
+ 'conflict',
20
+ 'superseded',
21
+ 'archive',
22
+ 'irrelevant',
23
+ ];
24
+
25
+ const SEVERITY_SCORE = {
26
+ incorrect: 100,
27
+ sensitive: 95,
28
+ scope_mismatch: 85,
29
+ authority_mismatch: 80,
30
+ stale: 75,
31
+ expired: 70,
32
+ conflict: 65,
33
+ superseded: 60,
34
+ archive: 50,
35
+ irrelevant: 40,
36
+ };
37
+
38
+ function clampLimit(value, fallback = DEFAULT_LIMIT) {
39
+ const parsed = Number.parseInt(value, 10);
40
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
41
+ return Math.min(MAX_LIMIT, parsed);
42
+ }
43
+
44
+ function parseCounts(value) {
45
+ if (!value || typeof value !== 'object') return {};
46
+ return Object.fromEntries(
47
+ Object.entries(value)
48
+ .map(([key, count]) => [key, Number.parseInt(count, 10) || 0])
49
+ .filter(([, count]) => count > 0)
50
+ .sort(([left], [right]) => left.localeCompare(right)),
51
+ );
52
+ }
53
+
54
+ function severityCaseSql() {
55
+ const clauses = Object.entries(SEVERITY_SCORE)
56
+ .map(([type, score]) => `WHEN '${type}' THEN ${score}`)
57
+ .join(' ');
58
+ return `CASE feedback_type ${clauses} ELSE 0 END`;
59
+ }
60
+
61
+ function normalizeFeedbackType(value) {
62
+ if (!value || value === true) return null;
63
+ const type = String(value).trim();
64
+ return type || null;
65
+ }
66
+
67
+ function normalizeList(value) {
68
+ if (Array.isArray(value)) return value.map(String).map(s => s.trim()).filter(Boolean);
69
+ if (!value || value === true) return [];
70
+ return String(value).split(',').map(s => s.trim()).filter(Boolean);
71
+ }
72
+
73
+ function normalizeVisibility(value) {
74
+ if (!value || value === true) return DEFAULT_VISIBILITY;
75
+ const visibility = String(value).trim();
76
+ if (!['bootstrap', 'recall', 'either', 'both'].includes(visibility)) {
77
+ throw new Error('review visibility must be one of: bootstrap, recall, either, both');
78
+ }
79
+ return visibility;
80
+ }
81
+
82
+ function normalizeIsoOrNull(value) {
83
+ if (!value || value === true) return null;
84
+ const date = new Date(value);
85
+ return Number.isFinite(date.getTime()) ? date.toISOString() : null;
86
+ }
87
+
88
+ function requireIso(value, label) {
89
+ const iso = normalizeIsoOrNull(value);
90
+ if (!iso) throw new Error(`${label} must be a valid ISO timestamp`);
91
+ return iso;
92
+ }
93
+
94
+ function normalizeReason(value) {
95
+ if (!value || value === true) return null;
96
+ const text = String(value).replace(/\s+/g, ' ').trim();
97
+ if (!text) return null;
98
+ return text.length > 200 ? `${text.slice(0, 197)}...` : text;
99
+ }
100
+
101
+ function normalizeActorKind(value) {
102
+ if (!value || value === true) return 'user';
103
+ const actorKind = String(value).trim();
104
+ if (!['user', 'agent', 'system', 'curator'].includes(actorKind)) {
105
+ throw new Error('review resolution actorKind must be one of: user, agent, system, curator');
106
+ }
107
+ return actorKind;
108
+ }
109
+
110
+ function normalizeResolution(value) {
111
+ if (!value || value === true) throw new Error('review.resolve requires resolution');
112
+ const resolution = String(value).trim();
113
+ if (!RESOLUTION_ACTIONS.includes(resolution)) {
114
+ throw new Error('review resolution must be one of: resolved, ignored, deferred');
115
+ }
116
+ return resolution;
117
+ }
118
+
119
+ function scopePathFromOpts(opts = {}) {
120
+ const path = normalizeList(opts.activeScopePath);
121
+ if (path.length > 0) return path;
122
+ const key = opts.scopeKey || opts.activeScopeKey || 'global';
123
+ return [String(key)];
124
+ }
125
+
126
+ function scopeContextFromOpts(opts = {}) {
127
+ const activeScopePath = scopePathFromOpts(opts);
128
+ return {
129
+ activeScopeKey: opts.activeScopeKey || opts.scopeKey || activeScopePath[activeScopePath.length - 1] || 'global',
130
+ activeScopePath,
131
+ };
132
+ }
133
+
134
+ function issueTypes(input) {
135
+ return Array.isArray(input) && input.length > 0
136
+ ? input.map(String)
137
+ : ISSUE_FEEDBACK_TYPES.slice();
138
+ }
139
+
140
+ function issueTypesForResolution(input = {}) {
141
+ return issueTypes(input.issueFeedbackTypes);
142
+ }
143
+
144
+ function severityLabel(score) {
145
+ const value = Number.parseInt(score, 10) || 0;
146
+ if (value >= 85) return 'high';
147
+ if (value >= 65) return 'medium';
148
+ return 'low';
149
+ }
150
+
151
+ function normalizeQueueRow(row = {}) {
152
+ const severityScore = Number.parseInt(row.severity_score, 10) || 0;
153
+ const resolution = normalizeResolutionRow({
154
+ id: row.resolution_id,
155
+ memory_id: row.resolution_memory_id || row.memory_id,
156
+ canonical_key: row.resolution_canonical_key || row.canonical_key,
157
+ scope_id: row.resolution_scope_id || row.scope_id,
158
+ resolution: row.resolution,
159
+ reason: row.resolution_reason,
160
+ actor_kind: row.resolution_actor_kind,
161
+ actor_id: row.resolution_actor_id,
162
+ issue_feedback_types: row.resolution_issue_feedback_types,
163
+ resolved_through_feedback_id: row.resolution_resolved_through_feedback_id,
164
+ resolved_through_feedback_at: row.resolution_resolved_through_feedback_at,
165
+ defer_until: row.resolution_defer_until,
166
+ resolved_at: row.resolved_at,
167
+ created_at: row.resolution_created_at,
168
+ });
169
+ return {
170
+ memoryId: row.memory_id === null || row.memory_id === undefined ? null : String(row.memory_id),
171
+ canonicalKey: row.canonical_key,
172
+ memoryType: row.memory_type,
173
+ status: row.status,
174
+ visibility: {
175
+ bootstrap: row.visible_in_bootstrap === true,
176
+ recall: row.visible_in_recall === true,
177
+ },
178
+ authority: row.authority || null,
179
+ acceptedAt: row.accepted_at || null,
180
+ updatedAt: row.updated_at || null,
181
+ scope: {
182
+ id: row.scope_id === null || row.scope_id === undefined ? null : String(row.scope_id),
183
+ kind: row.scope_kind || null,
184
+ key: row.scope_key || null,
185
+ inheritanceMode: row.inheritance_mode || null,
186
+ },
187
+ feedbackCounts: parseCounts(row.feedback_counts),
188
+ feedbackCount: Number.parseInt(row.feedback_count, 10) || 0,
189
+ issueCount: Number.parseInt(row.issue_count, 10) || 0,
190
+ matchingFeedbackCount: Number.parseInt(row.matching_feedback_count, 10) || 0,
191
+ severity: severityLabel(severityScore),
192
+ severityScore,
193
+ firstFeedbackAt: row.first_feedback_at || null,
194
+ latestFeedbackAt: row.latest_feedback_at || null,
195
+ latestIssueFeedbackId: row.latest_issue_feedback_id === null || row.latest_issue_feedback_id === undefined
196
+ ? null : String(row.latest_issue_feedback_id),
197
+ latestIssueFeedbackAt: row.latest_issue_feedback_at || null,
198
+ resolution,
199
+ queueState: row.queue_state || (resolution ? resolution.resolution : 'open'),
200
+ };
201
+ }
202
+
203
+ function normalizeFeedbackEvent(row = {}) {
204
+ return {
205
+ id: row.id === null || row.id === undefined ? null : String(row.id),
206
+ feedbackType: row.feedback_type,
207
+ actorKind: row.actor_kind || null,
208
+ actorId: row.actor_id || null,
209
+ createdAt: row.created_at || null,
210
+ };
211
+ }
212
+
213
+ function normalizeResolutionRow(row = {}) {
214
+ if (!row || row.id === null || row.id === undefined) return null;
215
+ return {
216
+ id: String(row.id),
217
+ memoryId: row.memory_id === null || row.memory_id === undefined ? null : String(row.memory_id),
218
+ canonicalKey: row.canonical_key || null,
219
+ scopeId: row.scope_id === null || row.scope_id === undefined ? null : String(row.scope_id),
220
+ resolution: row.resolution,
221
+ reason: row.reason || null,
222
+ actorKind: row.actor_kind || null,
223
+ actorId: row.actor_id || null,
224
+ issueFeedbackTypes: Array.isArray(row.issue_feedback_types) ? row.issue_feedback_types : [],
225
+ resolvedThroughFeedbackId: row.resolved_through_feedback_id === null || row.resolved_through_feedback_id === undefined
226
+ ? null : String(row.resolved_through_feedback_id),
227
+ resolvedThroughFeedbackAt: row.resolved_through_feedback_at || null,
228
+ deferUntil: row.defer_until || null,
229
+ resolvedAt: row.resolved_at || null,
230
+ createdAt: row.created_at || null,
231
+ };
232
+ }
233
+
234
+ function compareIso(left, right) {
235
+ if (!left || !right) return null;
236
+ const leftTime = new Date(left).getTime();
237
+ const rightTime = new Date(right).getTime();
238
+ if (!Number.isFinite(leftTime) || !Number.isFinite(rightTime)) return null;
239
+ if (leftTime === rightTime) return 0;
240
+ return leftTime > rightTime ? 1 : -1;
241
+ }
242
+
243
+ function createMemoryReview({ pool, schema, defaultTenantId }) {
244
+ function assertPool() {
245
+ if (!pool || typeof pool.query !== 'function') {
246
+ throw new Error('memory.review requires a pool with query(sql, params)');
247
+ }
248
+ }
249
+
250
+ function buildQueueQuery(opts = {}) {
251
+ const params = [opts.tenantId || defaultTenantId, issueTypes(opts.issueFeedbackTypes)];
252
+ const filters = [];
253
+ const feedbackType = normalizeFeedbackType(opts.feedbackType);
254
+ const memoryTypes = normalizeList(opts.memoryType || opts.memoryTypes);
255
+ const visibility = normalizeVisibility(opts.visibility);
256
+ const scopeContext = scopeContextFromOpts(opts);
257
+ const severityExpr = severityCaseSql();
258
+ const includeResolved = opts.includeResolved === true;
259
+ const feedbackFilters = [];
260
+ let matchTypeExpr = `NULL::text`;
261
+
262
+ if (opts.dateFrom) {
263
+ params.push(String(opts.dateFrom));
264
+ feedbackFilters.push(`created_at >= $${params.length}`);
265
+ }
266
+ if (opts.dateTo) {
267
+ params.push(String(opts.dateTo));
268
+ feedbackFilters.push(`created_at < ($${params.length}::date + INTERVAL '1 day')`);
269
+ }
270
+ if (feedbackType && feedbackType !== 'all') {
271
+ params.push(feedbackType);
272
+ matchTypeExpr = `$${params.length}::text`;
273
+ filters.push(`r.matching_feedback_count > 0`);
274
+ } else if (feedbackType === 'all') {
275
+ filters.push(`r.feedback_count > 0`);
276
+ } else {
277
+ filters.push(`r.issue_count > 0`);
278
+ }
279
+
280
+ params.push(scopeContext.activeScopePath);
281
+ filters.push(`s.scope_key = ANY($${params.length}::text[])`);
282
+ filters.push(`m.status = 'active'`);
283
+ params.push(normalizeIsoOrNull(opts.asOf));
284
+ const asOfRef = `$${params.length}::timestamptz`;
285
+ filters.push(`(m.valid_from IS NULL OR m.valid_from <= COALESCE(${asOfRef}, now()))`);
286
+ filters.push(`(m.valid_to IS NULL OR m.valid_to > COALESCE(${asOfRef}, now()))`);
287
+ filters.push(`(m.stale_after IS NULL OR m.stale_after > COALESCE(${asOfRef}, now()))`);
288
+ if (opts.canonicalKey) {
289
+ params.push(String(opts.canonicalKey));
290
+ filters.push(`m.canonical_key = $${params.length}`);
291
+ }
292
+ if (memoryTypes.length > 0) {
293
+ params.push(memoryTypes);
294
+ filters.push(`m.memory_type = ANY($${params.length}::text[])`);
295
+ }
296
+ if (visibility === 'bootstrap') {
297
+ filters.push(`m.visible_in_bootstrap = true`);
298
+ } else if (visibility === 'recall') {
299
+ filters.push(`m.visible_in_recall = true`);
300
+ } else if (visibility === 'both') {
301
+ filters.push(`m.visible_in_bootstrap = true AND m.visible_in_recall = true`);
302
+ } else {
303
+ filters.push(`(m.visible_in_bootstrap = true OR m.visible_in_recall = true)`);
304
+ }
305
+
306
+ const requestedLimit = clampLimit(opts.limit);
307
+ const fetchLimit = Math.min(MAX_FETCH_LIMIT, Math.max(requestedLimit * 4, requestedLimit));
308
+ params.push(fetchLimit);
309
+ const limitRef = `$${params.length}`;
310
+ return {
311
+ params,
312
+ sql: `
313
+ WITH feedback_counts AS (
314
+ SELECT
315
+ target_id,
316
+ feedback_type,
317
+ COUNT(*)::int AS count,
318
+ MIN(created_at) AS first_feedback_at,
319
+ MAX(created_at) AS latest_feedback_at,
320
+ MAX(id)::bigint AS latest_feedback_id,
321
+ MAX(${severityExpr})::int AS severity_score
322
+ FROM ${schema}.feedback
323
+ WHERE tenant_id = $1
324
+ AND target_kind = 'memory_record'
325
+ ${feedbackFilters.length > 0 ? `AND ${feedbackFilters.join('\n AND ')}` : ''}
326
+ GROUP BY target_id, feedback_type
327
+ ),
328
+ feedback_rollup AS (
329
+ SELECT
330
+ target_id,
331
+ jsonb_object_agg(feedback_type, count ORDER BY feedback_type) AS feedback_counts,
332
+ SUM(count)::int AS feedback_count,
333
+ SUM(CASE WHEN feedback_type = ANY($2::text[]) THEN count ELSE 0 END)::int AS issue_count,
334
+ SUM(CASE WHEN ${matchTypeExpr} IS NOT NULL AND feedback_type = ${matchTypeExpr} THEN count ELSE 0 END)::int AS matching_feedback_count,
335
+ MIN(first_feedback_at) AS first_feedback_at,
336
+ MAX(latest_feedback_at) AS latest_feedback_at,
337
+ MAX(CASE WHEN feedback_type = ANY($2::text[]) THEN latest_feedback_id ELSE NULL END)::bigint AS latest_issue_feedback_id,
338
+ MAX(CASE WHEN feedback_type = ANY($2::text[]) THEN latest_feedback_at ELSE NULL END) AS latest_issue_feedback_at,
339
+ MAX(severity_score)::int AS severity_score
340
+ FROM feedback_counts
341
+ GROUP BY target_id
342
+ ),
343
+ latest_resolution AS (
344
+ SELECT DISTINCT ON (memory_id)
345
+ id,
346
+ memory_id,
347
+ canonical_key,
348
+ scope_id,
349
+ resolution,
350
+ reason,
351
+ actor_kind,
352
+ actor_id,
353
+ issue_feedback_types,
354
+ resolved_through_feedback_id,
355
+ resolved_through_feedback_at,
356
+ defer_until,
357
+ resolved_at,
358
+ created_at
359
+ FROM ${schema}.memory_review_resolutions
360
+ WHERE tenant_id = $1
361
+ ORDER BY memory_id, resolved_at DESC, id DESC
362
+ )
363
+ SELECT
364
+ m.id AS memory_id,
365
+ m.canonical_key,
366
+ m.memory_type,
367
+ m.status,
368
+ m.visible_in_bootstrap,
369
+ m.visible_in_recall,
370
+ m.authority,
371
+ m.accepted_at,
372
+ m.updated_at,
373
+ m.scope_id,
374
+ s.scope_kind,
375
+ s.scope_key,
376
+ s.inheritance_mode,
377
+ r.feedback_counts,
378
+ r.feedback_count,
379
+ r.issue_count,
380
+ r.matching_feedback_count,
381
+ r.severity_score,
382
+ r.first_feedback_at,
383
+ r.latest_feedback_at,
384
+ r.latest_issue_feedback_id,
385
+ r.latest_issue_feedback_at,
386
+ lr.id AS resolution_id,
387
+ lr.memory_id AS resolution_memory_id,
388
+ lr.canonical_key AS resolution_canonical_key,
389
+ lr.scope_id AS resolution_scope_id,
390
+ lr.resolution,
391
+ lr.reason AS resolution_reason,
392
+ lr.actor_kind AS resolution_actor_kind,
393
+ lr.actor_id AS resolution_actor_id,
394
+ lr.issue_feedback_types AS resolution_issue_feedback_types,
395
+ lr.resolved_through_feedback_id AS resolution_resolved_through_feedback_id,
396
+ lr.resolved_through_feedback_at AS resolution_resolved_through_feedback_at,
397
+ lr.defer_until AS resolution_defer_until,
398
+ lr.resolved_at,
399
+ lr.created_at AS resolution_created_at,
400
+ CASE
401
+ WHEN lr.id IS NULL THEN 'open'
402
+ WHEN lr.resolved_through_feedback_id IS NULL THEN 'open'
403
+ WHEN lr.resolved_through_feedback_id < r.latest_issue_feedback_id THEN 'open'
404
+ WHEN lr.resolution = 'deferred'
405
+ AND lr.defer_until IS NOT NULL
406
+ AND lr.defer_until > COALESCE(${asOfRef}, now()) THEN 'deferred'
407
+ WHEN lr.resolution IN ('resolved','ignored') THEN lr.resolution
408
+ ELSE 'open'
409
+ END AS queue_state
410
+ FROM feedback_rollup r
411
+ JOIN ${schema}.memory_records m
412
+ ON m.tenant_id = $1
413
+ AND r.target_id = m.id::text
414
+ JOIN ${schema}.scopes s
415
+ ON s.id = m.scope_id
416
+ AND s.tenant_id = m.tenant_id
417
+ LEFT JOIN latest_resolution lr
418
+ ON lr.memory_id = m.id
419
+ WHERE ${filters.join('\n AND ')}
420
+ ${includeResolved ? '' : `AND (
421
+ lr.id IS NULL
422
+ OR lr.resolved_through_feedback_id IS NULL
423
+ OR lr.resolved_through_feedback_id < r.latest_issue_feedback_id
424
+ OR (
425
+ lr.resolution = 'deferred'
426
+ AND (
427
+ lr.defer_until IS NULL
428
+ OR lr.defer_until <= COALESCE(${asOfRef}, now())
429
+ )
430
+ )
431
+ )`}
432
+ ORDER BY
433
+ r.severity_score DESC,
434
+ r.issue_count DESC,
435
+ r.latest_feedback_at DESC NULLS LAST,
436
+ m.id DESC
437
+ LIMIT ${limitRef}`,
438
+ };
439
+ }
440
+
441
+ async function queue(opts = {}) {
442
+ assertPool();
443
+ const query = buildQueueQuery(opts);
444
+ const result = await pool.query(query.sql, query.params);
445
+ const feedbackType = normalizeFeedbackType(opts.feedbackType);
446
+ const memoryTypes = normalizeList(opts.memoryType || opts.memoryTypes);
447
+ const visibility = normalizeVisibility(opts.visibility);
448
+ const scopeContext = scopeContextFromOpts(opts);
449
+ const requestedLimit = clampLimit(opts.limit);
450
+ const applicableRows = resolveApplicableRecords(result.rows || [], scopeContext);
451
+ const items = applicableRows.slice(0, requestedLimit).map(normalizeQueueRow);
452
+ const feedbackTypeCounts = {};
453
+ const severityCounts = {};
454
+ const queueStateCounts = {};
455
+ const latestResolutionCounts = {};
456
+ for (const item of items) {
457
+ severityCounts[item.severity] = (severityCounts[item.severity] || 0) + 1;
458
+ const queueState = item.queueState || 'open';
459
+ queueStateCounts[queueState] = (queueStateCounts[queueState] || 0) + 1;
460
+ if (item.resolution?.resolution) {
461
+ latestResolutionCounts[item.resolution.resolution] = (latestResolutionCounts[item.resolution.resolution] || 0) + 1;
462
+ }
463
+ for (const [type, count] of Object.entries(item.feedbackCounts)) {
464
+ feedbackTypeCounts[type] = (feedbackTypeCounts[type] || 0) + count;
465
+ }
466
+ }
467
+ return {
468
+ readOnly: true,
469
+ derived: true,
470
+ available: true,
471
+ items,
472
+ reviewQueue: items,
473
+ scope: {
474
+ activeScopeKey: scopeContext.activeScopeKey,
475
+ activeScopePath: scopeContext.activeScopePath,
476
+ },
477
+ filters: {
478
+ feedbackTypes: feedbackType ? [feedbackType] : issueTypes(opts.issueFeedbackTypes),
479
+ memoryTypes,
480
+ visibility,
481
+ dateFrom: opts.dateFrom || null,
482
+ dateTo: opts.dateTo || null,
483
+ asOf: opts.asOf || null,
484
+ scopeKey: scopeContext.activeScopeKey,
485
+ canonicalKey: opts.canonicalKey || null,
486
+ includeResolved: opts.includeResolved === true,
487
+ limit: requestedLimit,
488
+ },
489
+ summary: {
490
+ scanned: applicableRows.length,
491
+ queued: items.length,
492
+ truncated: applicableRows.length > requestedLimit,
493
+ severityCounts,
494
+ feedbackTypeCounts,
495
+ queueStateCounts,
496
+ resolutionCounts: queueStateCounts,
497
+ latestResolutionCounts,
498
+ },
499
+ totals: {
500
+ items: items.length,
501
+ issueFeedback: items.reduce((sum, item) => sum + item.issueCount, 0),
502
+ allFeedback: items.reduce((sum, item) => sum + item.feedbackCount, 0),
503
+ },
504
+ };
505
+ }
506
+
507
+ async function findMemory(input = {}) {
508
+ const tenantId = input.tenantId || defaultTenantId;
509
+ const visibility = normalizeVisibility(input.visibility);
510
+ const scopeContext = scopeContextFromOpts(input);
511
+ const memoryId = input.memoryId || input.id || null;
512
+ let canonicalKey = input.canonicalKey || null;
513
+ if (!memoryId && !canonicalKey) {
514
+ throw new Error('review.inspect requires memoryId or canonicalKey');
515
+ }
516
+
517
+ function addCurrentFilters(params, filters) {
518
+ params.push(scopeContext.activeScopePath);
519
+ filters.push(`s.scope_key = ANY($${params.length}::text[])`);
520
+ filters.push(`m.status = 'active'`);
521
+ params.push(normalizeIsoOrNull(input.asOf));
522
+ const asOfRef = `$${params.length}::timestamptz`;
523
+ filters.push(`(m.valid_from IS NULL OR m.valid_from <= COALESCE(${asOfRef}, now()))`);
524
+ filters.push(`(m.valid_to IS NULL OR m.valid_to > COALESCE(${asOfRef}, now()))`);
525
+ filters.push(`(m.stale_after IS NULL OR m.stale_after > COALESCE(${asOfRef}, now()))`);
526
+ if (visibility === 'bootstrap') {
527
+ filters.push(`m.visible_in_bootstrap = true`);
528
+ } else if (visibility === 'recall') {
529
+ filters.push(`m.visible_in_recall = true`);
530
+ } else if (visibility === 'both') {
531
+ filters.push(`m.visible_in_bootstrap = true AND m.visible_in_recall = true`);
532
+ } else {
533
+ filters.push(`(m.visible_in_bootstrap = true OR m.visible_in_recall = true)`);
534
+ }
535
+ }
536
+
537
+ if (memoryId && !canonicalKey) {
538
+ const targetParams = [tenantId, String(memoryId)];
539
+ const targetFilters = [`m.tenant_id = $1`, `m.id::text = $2`];
540
+ addCurrentFilters(targetParams, targetFilters);
541
+ const target = await pool.query(
542
+ `SELECT m.canonical_key
543
+ FROM ${schema}.memory_records m
544
+ JOIN ${schema}.scopes s
545
+ ON s.id = m.scope_id
546
+ AND s.tenant_id = m.tenant_id
547
+ WHERE ${targetFilters.join(' AND ')}
548
+ LIMIT 1`,
549
+ targetParams,
550
+ );
551
+ canonicalKey = target.rows?.[0]?.canonical_key || null;
552
+ if (!canonicalKey) return null;
553
+ }
554
+
555
+ const params = [tenantId, String(canonicalKey)];
556
+ const filters = [`m.tenant_id = $1`, `m.canonical_key = $2`];
557
+ addCurrentFilters(params, filters);
558
+
559
+ const result = await pool.query(
560
+ `SELECT
561
+ m.id AS memory_id,
562
+ m.canonical_key,
563
+ m.memory_type,
564
+ m.status,
565
+ m.visible_in_bootstrap,
566
+ m.visible_in_recall,
567
+ m.authority,
568
+ m.accepted_at,
569
+ m.updated_at,
570
+ m.scope_id,
571
+ s.scope_kind,
572
+ s.scope_key,
573
+ s.inheritance_mode
574
+ FROM ${schema}.memory_records m
575
+ JOIN ${schema}.scopes s
576
+ ON s.id = m.scope_id
577
+ AND s.tenant_id = m.tenant_id
578
+ WHERE ${filters.join(' AND ')}
579
+ ORDER BY CASE WHEN m.status = 'active' THEN 0 ELSE 1 END, m.updated_at DESC NULLS LAST, m.id DESC
580
+ LIMIT 20`,
581
+ params,
582
+ );
583
+ const rows = resolveApplicableRecords(result.rows || [], scopeContext);
584
+ if (memoryId) {
585
+ return rows.find(row => String(row.memory_id) === String(memoryId)) || null;
586
+ }
587
+ if (rows.length > 1) {
588
+ throw new Error('Multiple memory records matched. Use --memory-id to inspect one row.');
589
+ }
590
+ return rows[0] || null;
591
+ }
592
+
593
+ async function loadFeedback(targetId, opts = {}) {
594
+ const tenantId = opts.tenantId || defaultTenantId;
595
+ const limit = clampLimit(opts.limit, 20);
596
+ const params = [tenantId, String(targetId)];
597
+ const filters = [];
598
+ const feedbackType = normalizeFeedbackType(opts.feedbackType);
599
+ if (feedbackType && feedbackType !== 'all') {
600
+ params.push(feedbackType);
601
+ filters.push(`feedback_type = $${params.length}`);
602
+ }
603
+ if (opts.dateFrom) {
604
+ params.push(String(opts.dateFrom));
605
+ filters.push(`created_at >= $${params.length}`);
606
+ }
607
+ if (opts.dateTo) {
608
+ params.push(String(opts.dateTo));
609
+ filters.push(`created_at < ($${params.length}::date + INTERVAL '1 day')`);
610
+ }
611
+ const where = filters.length > 0 ? `\n AND ${filters.join('\n AND ')}` : '';
612
+ const eventParams = params.concat([limit]);
613
+ const [events, counts] = await Promise.all([
614
+ pool.query(
615
+ `SELECT id, feedback_type, actor_kind, actor_id, created_at
616
+ FROM ${schema}.feedback
617
+ WHERE tenant_id = $1
618
+ AND target_kind = 'memory_record'
619
+ AND target_id = $2
620
+ ${where}
621
+ ORDER BY created_at DESC, id DESC
622
+ LIMIT $${eventParams.length}`,
623
+ eventParams,
624
+ ),
625
+ pool.query(
626
+ `SELECT feedback_type, COUNT(*)::int AS count
627
+ FROM ${schema}.feedback
628
+ WHERE tenant_id = $1
629
+ AND target_kind = 'memory_record'
630
+ AND target_id = $2
631
+ ${where}
632
+ GROUP BY feedback_type
633
+ ORDER BY feedback_type`,
634
+ params,
635
+ ),
636
+ ]);
637
+ return {
638
+ recentFeedback: (events.rows || []).map(normalizeFeedbackEvent),
639
+ feedbackCounts: parseCounts(Object.fromEntries((counts.rows || []).map(row => [row.feedback_type, row.count]))),
640
+ };
641
+ }
642
+
643
+ async function loadResolutions(targetId, opts = {}) {
644
+ const tenantId = opts.tenantId || defaultTenantId;
645
+ const limit = clampLimit(opts.resolutionLimit || opts.limit, DEFAULT_RESOLUTION_LIMIT);
646
+ const result = await pool.query(
647
+ `SELECT
648
+ id,
649
+ memory_id,
650
+ canonical_key,
651
+ scope_id,
652
+ resolution,
653
+ reason,
654
+ actor_kind,
655
+ actor_id,
656
+ issue_feedback_types,
657
+ resolved_through_feedback_id,
658
+ resolved_through_feedback_at,
659
+ defer_until,
660
+ resolved_at,
661
+ created_at
662
+ FROM ${schema}.memory_review_resolutions
663
+ WHERE tenant_id = $1
664
+ AND memory_id::text = $2
665
+ ORDER BY resolved_at DESC, id DESC
666
+ LIMIT $3`,
667
+ [tenantId, String(targetId), limit],
668
+ );
669
+ const resolutions = (result.rows || []).map(normalizeResolutionRow).filter(Boolean);
670
+ return {
671
+ latestResolution: resolutions[0] || null,
672
+ recentResolutions: resolutions,
673
+ };
674
+ }
675
+
676
+ async function loadIssueFeedbackWatermark(targetId, opts = {}) {
677
+ const tenantId = opts.tenantId || defaultTenantId;
678
+ const types = issueTypesForResolution(opts);
679
+ const result = await pool.query(
680
+ `SELECT
681
+ feedback_type,
682
+ COUNT(*)::int AS count,
683
+ MAX(id)::bigint AS latest_feedback_id,
684
+ MAX(created_at) AS latest_feedback_at
685
+ FROM ${schema}.feedback
686
+ WHERE tenant_id = $1
687
+ AND target_kind = 'memory_record'
688
+ AND target_id = $2
689
+ AND feedback_type = ANY($3::text[])
690
+ GROUP BY feedback_type
691
+ ORDER BY feedback_type`,
692
+ [tenantId, String(targetId), types],
693
+ );
694
+ const counts = parseCounts(Object.fromEntries((result.rows || []).map(row => [row.feedback_type, row.count])));
695
+ const latest = (result.rows || [])
696
+ .map(row => row.latest_feedback_at)
697
+ .filter(Boolean)
698
+ .reduce((max, value) => {
699
+ const time = new Date(value).getTime();
700
+ if (!Number.isFinite(time)) return max;
701
+ if (!max || time > max.time) return { time, value };
702
+ return max;
703
+ }, null)?.value || null;
704
+ const latestId = (result.rows || [])
705
+ .map(row => Number.parseInt(row.latest_feedback_id, 10))
706
+ .filter(Number.isFinite)
707
+ .reduce((max, value) => Math.max(max, value), 0);
708
+ return {
709
+ issueFeedbackTypes: types,
710
+ issueFeedbackCounts: counts,
711
+ latestIssueFeedbackId: latestId > 0 ? String(latestId) : null,
712
+ issueFeedbackLatestAt: latest,
713
+ issueFeedbackCount: Object.values(counts).reduce((sum, count) => sum + count, 0),
714
+ };
715
+ }
716
+
717
+ async function inspect(input = {}) {
718
+ assertPool();
719
+ const memory = await findMemory(input);
720
+ if (!memory) throw new Error('Memory record not found');
721
+ const normalized = normalizeQueueRow({
722
+ ...memory,
723
+ feedback_counts: {},
724
+ feedback_count: 0,
725
+ issue_count: 0,
726
+ matching_feedback_count: 0,
727
+ severity_score: 0,
728
+ });
729
+ const feedback = await loadFeedback(normalized.memoryId, input);
730
+ const resolutions = await loadResolutions(normalized.memoryId, input);
731
+ const issueSet = new Set(issueTypes(input.issueFeedbackTypes));
732
+ const issueCount = Object.entries(feedback.feedbackCounts)
733
+ .filter(([type]) => issueSet.has(type))
734
+ .reduce((sum, [, count]) => sum + count, 0);
735
+ const issueWatermark = await loadIssueFeedbackWatermark(normalized.memoryId, input);
736
+ return {
737
+ readOnly: true,
738
+ derived: true,
739
+ available: true,
740
+ memory: normalized,
741
+ review: {
742
+ severity: severityLabel(Math.max(...Object.keys(feedback.feedbackCounts).map(type => SEVERITY_SCORE[type] || 0), 0)),
743
+ feedbackCounts: feedback.feedbackCounts,
744
+ issueCount,
745
+ feedbackCount: Object.values(feedback.feedbackCounts).reduce((sum, count) => sum + count, 0),
746
+ latestIssueFeedbackId: issueWatermark.latestIssueFeedbackId,
747
+ latestIssueFeedbackAt: issueWatermark.issueFeedbackLatestAt,
748
+ recentFeedback: feedback.recentFeedback,
749
+ latestResolution: resolutions.latestResolution,
750
+ recentResolutions: resolutions.recentResolutions,
751
+ },
752
+ };
753
+ }
754
+
755
+ async function resolve(input = {}) {
756
+ assertPool();
757
+ const memory = await findMemory(input);
758
+ if (!memory) throw new Error('Memory record not found');
759
+ const resolution = normalizeResolution(input.resolution || input.action);
760
+ const deferUntil = input.deferUntil || input.defer_until || null;
761
+ if (resolution === 'deferred') {
762
+ requireIso(deferUntil, 'review.resolve deferUntil');
763
+ } else if (deferUntil) {
764
+ throw new Error('review.resolve deferUntil is only valid for deferred resolution');
765
+ }
766
+ const reason = normalizeReason(input.reason || input.note);
767
+ if ((resolution === 'ignored' || resolution === 'deferred') && !reason) {
768
+ throw new Error(`review.resolve reason is required for ${resolution}`);
769
+ }
770
+ const expectedIssueFeedbackId = input.expectedLatestIssueFeedbackId || input.expected_latest_issue_feedback_id || null;
771
+ const expectedIssueFeedbackAt = input.expectedLatestIssueFeedbackAt || input.expected_latest_issue_feedback_at || null;
772
+ if (!expectedIssueFeedbackId && !expectedIssueFeedbackAt) {
773
+ throw new Error('review.resolve requires expectedLatestIssueFeedbackId or expectedLatestIssueFeedbackAt');
774
+ }
775
+ const issueFeedback = await loadIssueFeedbackWatermark(memory.memory_id, input);
776
+ if (issueFeedback.issueFeedbackCount <= 0 || !issueFeedback.latestIssueFeedbackId) {
777
+ throw new Error('review.resolve requires current issue feedback on the memory record');
778
+ }
779
+ if (expectedIssueFeedbackId && String(expectedIssueFeedbackId) !== String(issueFeedback.latestIssueFeedbackId)) {
780
+ throw new Error(`review.resolve stale issue feedback snapshot: expected latest issue feedback id ${expectedIssueFeedbackId}, current ${issueFeedback.latestIssueFeedbackId}`);
781
+ }
782
+ if (expectedIssueFeedbackAt) {
783
+ const expectedIso = requireIso(expectedIssueFeedbackAt, 'review.resolve expectedLatestIssueFeedbackAt');
784
+ const currentIso = requireIso(issueFeedback.issueFeedbackLatestAt, 'review.resolve current issueFeedbackLatestAt');
785
+ if (compareIso(expectedIso, currentIso) !== 0) {
786
+ throw new Error(`review.resolve stale issue feedback snapshot: expected latest issue feedback at ${expectedIso}, current ${currentIso}`);
787
+ }
788
+ }
789
+ const tenantId = input.tenantId || defaultTenantId;
790
+ const params = [
791
+ tenantId,
792
+ memory.memory_id,
793
+ memory.canonical_key,
794
+ memory.scope_id,
795
+ resolution,
796
+ reason,
797
+ normalizeActorKind(input.actorKind || input.actor_kind),
798
+ input.actorId || input.actor_id || input.agentId || null,
799
+ issueFeedback.issueFeedbackTypes,
800
+ issueFeedback.latestIssueFeedbackId,
801
+ issueFeedback.issueFeedbackLatestAt,
802
+ resolution === 'deferred' ? requireIso(deferUntil, 'review.resolve deferUntil') : null,
803
+ {
804
+ publicSurface: 'memoryReviewResolve',
805
+ issueFeedbackCounts: issueFeedback.issueFeedbackCounts,
806
+ issueFeedbackCount: issueFeedback.issueFeedbackCount,
807
+ memoryStatus: memory.status || null,
808
+ },
809
+ ];
810
+ const inserted = await pool.query(
811
+ `INSERT INTO ${schema}.memory_review_resolutions (
812
+ tenant_id,
813
+ memory_id,
814
+ canonical_key,
815
+ scope_id,
816
+ resolution,
817
+ reason,
818
+ actor_kind,
819
+ actor_id,
820
+ issue_feedback_types,
821
+ resolved_through_feedback_id,
822
+ resolved_through_feedback_at,
823
+ defer_until,
824
+ metadata
825
+ )
826
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::text[],$10,$11,$12,$13::jsonb)
827
+ RETURNING
828
+ id,
829
+ memory_id,
830
+ canonical_key,
831
+ scope_id,
832
+ resolution,
833
+ reason,
834
+ actor_kind,
835
+ actor_id,
836
+ issue_feedback_types,
837
+ resolved_through_feedback_id,
838
+ resolved_through_feedback_at,
839
+ defer_until,
840
+ resolved_at,
841
+ created_at`,
842
+ params,
843
+ );
844
+ const normalizedMemory = normalizeQueueRow({
845
+ ...memory,
846
+ feedback_counts: issueFeedback.issueFeedbackCounts,
847
+ feedback_count: issueFeedback.issueFeedbackCount,
848
+ issue_count: issueFeedback.issueFeedbackCount,
849
+ matching_feedback_count: issueFeedback.issueFeedbackCount,
850
+ severity_score: Math.max(...issueFeedback.issueFeedbackTypes.map(type => SEVERITY_SCORE[type] || 0), 0),
851
+ latest_issue_feedback_id: issueFeedback.latestIssueFeedbackId,
852
+ latest_issue_feedback_at: issueFeedback.issueFeedbackLatestAt,
853
+ });
854
+ return {
855
+ appendedOnly: true,
856
+ appendOnly: true,
857
+ memoryMutated: false,
858
+ feedbackMutated: false,
859
+ available: true,
860
+ memory: normalizedMemory,
861
+ resolution: normalizeResolutionRow(inserted.rows?.[0]),
862
+ review: {
863
+ issueFeedbackTypes: issueFeedback.issueFeedbackTypes,
864
+ issueFeedbackCounts: issueFeedback.issueFeedbackCounts,
865
+ issueFeedbackCount: issueFeedback.issueFeedbackCount,
866
+ latestIssueFeedbackId: issueFeedback.latestIssueFeedbackId,
867
+ issueFeedbackLatestAt: issueFeedback.issueFeedbackLatestAt,
868
+ },
869
+ queue: {
870
+ wasQueued: true,
871
+ nowQueued: false,
872
+ suppressedBy: resolution,
873
+ reopensAt: resolution === 'deferred' ? requireIso(deferUntil, 'review.resolve deferUntil') : null,
874
+ reopensOnNewIssueFeedback: true,
875
+ },
876
+ };
877
+ }
878
+
879
+ return {
880
+ queue,
881
+ inspect,
882
+ resolve,
883
+ ISSUE_FEEDBACK_TYPES: ISSUE_FEEDBACK_TYPES.slice(),
884
+ RESOLUTION_ACTIONS: RESOLUTION_ACTIONS.slice(),
885
+ };
886
+ }
887
+
888
+ module.exports = {
889
+ ISSUE_FEEDBACK_TYPES,
890
+ createMemoryReview,
891
+ };