@shadowforge0/aquifer-memory 1.8.0 → 1.9.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.
@@ -0,0 +1,624 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ assertAllowedScopeRequest,
5
+ assertCuratedBootstrapOpts,
6
+ assertCuratedRecallOpts,
7
+ normalizeScopeList,
8
+ splitScopePath,
9
+ } = require('./memory-serving');
10
+
11
+ const BOOTSTRAP_TYPE_PRIORITY = {
12
+ constraint: 0,
13
+ state: 1,
14
+ open_loop: 2,
15
+ decision: 3,
16
+ preference: 4,
17
+ fact: 5,
18
+ conclusion: 6,
19
+ entity_note: 7,
20
+ };
21
+
22
+ const BOOTSTRAP_AUTHORITY_PRIORITY = {
23
+ user_explicit: 0,
24
+ executable_evidence: 1,
25
+ manual: 2,
26
+ system: 3,
27
+ verified_summary: 4,
28
+ llm_inference: 5,
29
+ raw_transcript: 6,
30
+ };
31
+
32
+ const RECALL_TYPE_RANK = {
33
+ constraint: 80,
34
+ preference: 70,
35
+ state: 60,
36
+ open_loop: 55,
37
+ decision: 50,
38
+ fact: 40,
39
+ conclusion: 30,
40
+ entity_note: 20,
41
+ };
42
+
43
+ const FEEDBACK_WEIGHT = {
44
+ helpful: 0.15,
45
+ confirm: 0.10,
46
+ irrelevant: -0.20,
47
+ scope_mismatch: -0.25,
48
+ stale: -0.30,
49
+ incorrect: -0.50,
50
+ };
51
+
52
+ const RECALL_TYPE_BOOST = 0.05;
53
+ const RECALL_SIGNAL_PRIORITY = {
54
+ memory_row: 3,
55
+ };
56
+
57
+ const QUARANTINED_OR_INCORRECT = new Set(['quarantined', 'incorrect']);
58
+ const DEFAULT_SCAN_LIMIT = 200;
59
+ const MAX_SCAN_LIMIT = 500;
60
+
61
+ function recordId(record) {
62
+ const value = record.memoryId ?? record.memory_id ?? record.id ?? record.canonicalKey ?? record.canonical_key ?? null;
63
+ return value === null || value === undefined || value === '' ? null : String(value);
64
+ }
65
+
66
+ function canonicalKey(record) {
67
+ return String(record.canonicalKey ?? record.canonical_key ?? recordId(record) ?? '');
68
+ }
69
+
70
+ function scopeKey(record) {
71
+ return String(record.scopeKey ?? record.scope_key ?? '');
72
+ }
73
+
74
+ function inheritanceMode(record) {
75
+ return String(record.inheritanceMode ?? record.inheritance_mode ?? record.scope_inheritance_mode ?? 'defaultable');
76
+ }
77
+
78
+ function parseTime(value) {
79
+ const parsed = Date.parse(value || '');
80
+ return Number.isFinite(parsed) ? parsed : null;
81
+ }
82
+
83
+ function normalizeScopePath(activeScopePath, activeScopeKey) {
84
+ const fromPath = splitScopePath(activeScopePath);
85
+ const source = fromPath && fromPath.length > 0
86
+ ? fromPath
87
+ : (activeScopeKey ? [String(activeScopeKey).trim()] : ['global']);
88
+ const seen = new Set();
89
+ const normalized = [];
90
+ for (const value of source) {
91
+ const key = String(value || '').trim();
92
+ if (!key || seen.has(key)) continue;
93
+ seen.add(key);
94
+ normalized.push(key);
95
+ }
96
+ return normalized.length > 0 ? normalized : ['global'];
97
+ }
98
+
99
+ function normalizeAsOf(value) {
100
+ if (!value) return null;
101
+ const parsed = Date.parse(value);
102
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
103
+ }
104
+
105
+ function normalizeRow(row = {}) {
106
+ return {
107
+ ...row,
108
+ memoryId: recordId(row),
109
+ canonicalKey: canonicalKey(row),
110
+ memoryType: row.memoryType ?? row.memory_type ?? null,
111
+ scopeKey: row.scopeKey ?? row.scope_key ?? null,
112
+ scopeKind: row.scopeKind ?? row.scope_kind ?? null,
113
+ inheritanceMode: inheritanceMode(row),
114
+ status: row.status ?? 'candidate',
115
+ visibleInBootstrap: row.visibleInBootstrap ?? row.visible_in_bootstrap ?? false,
116
+ visibleInRecall: row.visibleInRecall ?? row.visible_in_recall ?? false,
117
+ authority: row.authority ?? null,
118
+ acceptedAt: row.acceptedAt ?? row.accepted_at ?? null,
119
+ validFrom: row.validFrom ?? row.valid_from ?? null,
120
+ validTo: row.validTo ?? row.valid_to ?? null,
121
+ staleAfter: row.staleAfter ?? row.stale_after ?? null,
122
+ title: row.title ?? null,
123
+ summary: row.summary ?? null,
124
+ contextKey: row.contextKey ?? row.context_key ?? null,
125
+ topicKey: row.topicKey ?? row.topic_key ?? null,
126
+ };
127
+ }
128
+
129
+ function createContext(opts = {}) {
130
+ const activeScopePath = normalizeScopePath(opts.activeScopePath, opts.activeScopeKey);
131
+ const activeScopeKey = opts.activeScopeKey || activeScopePath[activeScopePath.length - 1] || null;
132
+ const asOf = normalizeAsOf(opts.asOf);
133
+ return {
134
+ activeScopeKey,
135
+ activeScopePath,
136
+ asOf,
137
+ scopePositions: new Map(activeScopePath.map((key, index) => [key, index])),
138
+ };
139
+ }
140
+
141
+ function compareAcceptedDesc(left, right) {
142
+ return (parseTime(right.acceptedAt) ?? 0) - (parseTime(left.acceptedAt) ?? 0);
143
+ }
144
+
145
+ function compareRecordIdAsc(left, right) {
146
+ const leftNum = Number(left.memoryId);
147
+ const rightNum = Number(right.memoryId);
148
+ if (Number.isFinite(leftNum) && Number.isFinite(rightNum) && leftNum !== rightNum) return leftNum - rightNum;
149
+ return String(left.memoryId ?? '').localeCompare(String(right.memoryId ?? ''));
150
+ }
151
+
152
+ function compareBootstrapRows(left, right, context) {
153
+ const leftScope = context.scopePositions.get(scopeKey(left)) ?? -1;
154
+ const rightScope = context.scopePositions.get(scopeKey(right)) ?? -1;
155
+ if (rightScope !== leftScope) return rightScope - leftScope;
156
+
157
+ const leftType = BOOTSTRAP_TYPE_PRIORITY[left.memoryType] ?? 99;
158
+ const rightType = BOOTSTRAP_TYPE_PRIORITY[right.memoryType] ?? 99;
159
+ if (leftType !== rightType) return leftType - rightType;
160
+
161
+ const leftAuthority = BOOTSTRAP_AUTHORITY_PRIORITY[left.authority] ?? 99;
162
+ const rightAuthority = BOOTSTRAP_AUTHORITY_PRIORITY[right.authority] ?? 99;
163
+ if (leftAuthority !== rightAuthority) return leftAuthority - rightAuthority;
164
+
165
+ const accepted = compareAcceptedDesc(left, right);
166
+ if (accepted !== 0) return accepted;
167
+
168
+ const keyCompare = canonicalKey(left).localeCompare(canonicalKey(right));
169
+ if (keyCompare !== 0) return keyCompare;
170
+
171
+ return compareRecordIdAsc(left, right);
172
+ }
173
+
174
+ function rankValue(record, key) {
175
+ const value = record[key];
176
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
177
+ const parsed = Number(value);
178
+ return Number.isFinite(parsed) ? parsed : 0;
179
+ }
180
+
181
+ function sortRecallRows(left, right) {
182
+ const leftSignal = rankValue(left, 'signal_priority');
183
+ const rightSignal = rankValue(right, 'signal_priority');
184
+ if (rightSignal !== leftSignal) return rightSignal - leftSignal;
185
+
186
+ const leftTitleMatch = left.title_match === true ? 1 : 0;
187
+ const rightTitleMatch = right.title_match === true ? 1 : 0;
188
+ if (rightTitleMatch !== leftTitleMatch) return rightTitleMatch - leftTitleMatch;
189
+
190
+ const leftScore = rankValue(left, 'recall_score') || rankValue(left, 'score');
191
+ const rightScore = rankValue(right, 'recall_score') || rankValue(right, 'score');
192
+ if (rightScore !== leftScore) return rightScore - leftScore;
193
+
194
+ const accepted = compareAcceptedDesc(left, right);
195
+ if (accepted !== 0) return accepted;
196
+
197
+ const keyCompare = canonicalKey(left).localeCompare(canonicalKey(right));
198
+ if (keyCompare !== 0) return keyCompare;
199
+
200
+ return compareRecordIdAsc(left, right);
201
+ }
202
+
203
+ function compareExplainRows(left, right, context) {
204
+ const bootstrapOrder = compareBootstrapRows(left, right, context);
205
+ if (bootstrapOrder !== 0) return bootstrapOrder;
206
+ const scopeCompare = scopeKey(left).localeCompare(scopeKey(right));
207
+ if (scopeCompare !== 0) return scopeCompare;
208
+ return canonicalKey(left).localeCompare(canonicalKey(right));
209
+ }
210
+
211
+ function lineFor(record) {
212
+ const type = record.memoryType || 'memory';
213
+ const text = record.summary || record.title || '';
214
+ return `- ${type}: ${String(text).trim()}`;
215
+ }
216
+
217
+ function buildBootstrapText(records, meta) {
218
+ const lines = records.map(lineFor);
219
+ return [
220
+ `<memory-bootstrap memories="${records.length}" overflow="${meta.overflow}" degraded="${meta.degraded}">`,
221
+ ...lines,
222
+ '</memory-bootstrap>',
223
+ ].join('\n');
224
+ }
225
+
226
+ function scopedReason(record, context) {
227
+ const recordScope = scopeKey(record);
228
+ const exactScope = context.activeScopeKey && recordScope === context.activeScopeKey;
229
+ const inPath = context.scopePositions.has(recordScope);
230
+ if (inheritanceMode(record) === 'non_inheritable' && !exactScope) return 'non_inheritable_scope';
231
+ if (!inPath) return 'out_of_scope';
232
+ return null;
233
+ }
234
+
235
+ function scopeInheritanceDetails(record, context, reason = null) {
236
+ const recordScopeKey = scopeKey(record);
237
+ const pathIndex = context.scopePositions.has(recordScopeKey)
238
+ ? context.scopePositions.get(recordScopeKey)
239
+ : null;
240
+ const exactActiveScope = Boolean(context.activeScopeKey && recordScopeKey === context.activeScopeKey);
241
+ const inActiveScopePath = pathIndex !== null;
242
+ const mode = inheritanceMode(record);
243
+ const appliesByScope = mode === 'non_inheritable'
244
+ ? exactActiveScope
245
+ : inActiveScopePath;
246
+ return {
247
+ mode,
248
+ recordScopeKey,
249
+ activeScopeKey: context.activeScopeKey,
250
+ inActiveScopePath,
251
+ exactActiveScope,
252
+ pathIndex,
253
+ appliesByScope,
254
+ decision: reason || (appliesByScope ? 'eligible_by_scope' : 'excluded_by_scope'),
255
+ };
256
+ }
257
+
258
+ function timeReason(record, context) {
259
+ if (!context.asOf) return null;
260
+ const at = Date.parse(context.asOf);
261
+ const validFrom = parseTime(record.validFrom);
262
+ const validTo = parseTime(record.validTo);
263
+ const staleAfter = parseTime(record.staleAfter);
264
+ if (validFrom !== null && validFrom > at) return 'valid_from_future';
265
+ if (validTo !== null && validTo <= at) return 'valid_to_expired';
266
+ if (staleAfter !== null && staleAfter <= at) return 'stale_after_expired';
267
+ return null;
268
+ }
269
+
270
+ function baselineReason(record, lane, context) {
271
+ if (QUARANTINED_OR_INCORRECT.has(record.status)) return 'quarantined_or_incorrect';
272
+ if (record.status !== 'active') return 'inactive_status';
273
+ if (lane === 'bootstrap' && record.visibleInBootstrap !== true) return 'not_visible_in_bootstrap';
274
+ if (lane === 'memory' && record.visibleInRecall !== true) return 'not_visible_in_recall';
275
+ const timing = timeReason(record, context);
276
+ if (timing) return timing;
277
+ return scopedReason(record, context);
278
+ }
279
+
280
+ function shadowWinnerCandidate(left, right, lane, context) {
281
+ const leftScope = context.scopePositions.get(scopeKey(left)) ?? -1;
282
+ const rightScope = context.scopePositions.get(scopeKey(right)) ?? -1;
283
+ if (leftScope !== rightScope) return leftScope > rightScope ? left : right;
284
+
285
+ if (lane === 'memory') {
286
+ return sortRecallRows(left, right) <= 0 ? left : right;
287
+ }
288
+ return compareBootstrapRows(left, right, context) <= 0 ? left : right;
289
+ }
290
+
291
+ function resolveShadowing(records, lane, context) {
292
+ const additive = [];
293
+ const shadowed = [];
294
+ const winnersByCanonical = new Map();
295
+ const ordered = records.slice().sort((left, right) => compareExplainRows(left, right, context));
296
+
297
+ for (const record of ordered) {
298
+ if (inheritanceMode(record) === 'additive') {
299
+ additive.push(record);
300
+ continue;
301
+ }
302
+ const key = canonicalKey(record);
303
+ const existing = winnersByCanonical.get(key);
304
+ if (!existing) {
305
+ winnersByCanonical.set(key, record);
306
+ continue;
307
+ }
308
+ const winner = shadowWinnerCandidate(existing, record, lane, context);
309
+ const loser = winner === existing ? record : existing;
310
+ winnersByCanonical.set(key, winner);
311
+ shadowed.push(loser);
312
+ }
313
+
314
+ return {
315
+ winners: [...winnersByCanonical.values(), ...additive],
316
+ shadowed: shadowed.sort((left, right) => compareExplainRows(left, right, context)),
317
+ };
318
+ }
319
+
320
+ function serializeRecord(record, reason, extra = {}, context = null) {
321
+ const selected = reason === 'selected';
322
+ const redactedIdentity = selected ? null : (record.memoryId ? `memory:${record.memoryId}` : null);
323
+ return {
324
+ memoryId: record.memoryId,
325
+ canonicalKey: selected ? record.canonicalKey : null,
326
+ canonicalKeyRedacted: !selected && Boolean(record.canonicalKey),
327
+ redactedIdentity,
328
+ memoryType: record.memoryType,
329
+ scopeKey: record.scopeKey,
330
+ scopeKind: record.scopeKind,
331
+ inheritanceMode: record.inheritanceMode,
332
+ status: record.status,
333
+ authority: record.authority,
334
+ title: selected ? record.title : null,
335
+ summary: selected ? record.summary : null,
336
+ contentRedacted: !selected,
337
+ acceptedAt: record.acceptedAt,
338
+ validFrom: record.validFrom,
339
+ validTo: record.validTo,
340
+ staleAfter: record.staleAfter,
341
+ visibleInBootstrap: record.visibleInBootstrap,
342
+ visibleInRecall: record.visibleInRecall,
343
+ reason,
344
+ ...(context ? { scopeInheritance: scopeInheritanceDetails(record, context, reason) } : {}),
345
+ ...extra,
346
+ };
347
+ }
348
+
349
+ function bootstrapLimit(limit) {
350
+ if (!Number.isFinite(limit)) return null;
351
+ return Math.max(1, Math.min(100, Math.floor(limit)));
352
+ }
353
+
354
+ function bootstrapMaxChars(maxChars) {
355
+ return Math.max(120, Number.isFinite(maxChars) ? Math.floor(maxChars) : 4000);
356
+ }
357
+
358
+ function feedbackScore(record, feedbackEvents = []) {
359
+ const id = record.memoryId;
360
+ if (!id || feedbackEvents.length === 0) return 0;
361
+ let score = 0;
362
+ for (const event of feedbackEvents) {
363
+ const targetId = String(event.targetId ?? event.target_id ?? '');
364
+ if (targetId !== id) continue;
365
+ const type = event.feedbackType ?? event.feedback_type ?? event.verdict;
366
+ score += FEEDBACK_WEIGHT[type] || 0;
367
+ }
368
+ return score;
369
+ }
370
+
371
+ function textOf(record) {
372
+ return [
373
+ record.title,
374
+ record.summary,
375
+ record.contextKey,
376
+ record.topicKey,
377
+ ].filter(Boolean).join(' ');
378
+ }
379
+
380
+ function lexicalScore(haystack, query) {
381
+ if (haystack.includes(query)) return 1;
382
+ const tokens = query.split(/\s+/).filter(Boolean);
383
+ if (tokens.length === 0) return 0;
384
+ const hits = tokens.filter(token => haystack.includes(token)).length;
385
+ return hits / tokens.length;
386
+ }
387
+
388
+ function scoreRecallRows(records, query, opts = {}) {
389
+ const q = String(query || '').trim().toLowerCase();
390
+ if (!q) throw new Error('memory.explainMemory(query): query must be a non-empty string');
391
+ const feedbackEvents = opts.feedbackEvents || [];
392
+
393
+ return records
394
+ .map(record => {
395
+ const haystack = textOf(record).toLowerCase();
396
+ const lexical = lexicalScore(haystack, q);
397
+ const typeRank = ((RECALL_TYPE_RANK[record.memoryType] || 0) / 100) * RECALL_TYPE_BOOST;
398
+ const feedback = feedbackScore(record, feedbackEvents);
399
+ const title = String(record.title || '').toLowerCase();
400
+ const scored = {
401
+ ...record,
402
+ signal_priority: RECALL_SIGNAL_PRIORITY.memory_row,
403
+ title_match: title.includes(q),
404
+ score: lexical + typeRank + feedback,
405
+ recall_score: lexical + typeRank + feedback,
406
+ _debug: { lexical, typeRank, feedback },
407
+ };
408
+ return scored;
409
+ })
410
+ .sort(sortRecallRows);
411
+ }
412
+
413
+ function buildBootstrapExplanation(rows = [], opts = {}) {
414
+ const context = createContext(opts);
415
+ assertAllowedScopeRequest({
416
+ ...opts,
417
+ activeScopeKey: context.activeScopeKey,
418
+ activeScopePath: context.activeScopePath,
419
+ });
420
+ const normalizedRows = rows.map(normalizeRow).sort((left, right) => compareExplainRows(left, right, context));
421
+ const excluded = [];
422
+ const eligible = [];
423
+
424
+ for (const row of normalizedRows) {
425
+ const reason = baselineReason(row, 'bootstrap', context);
426
+ if (reason) {
427
+ excluded.push(serializeRecord(row, reason, {}, context));
428
+ continue;
429
+ }
430
+ eligible.push(row);
431
+ }
432
+
433
+ const { winners, shadowed } = resolveShadowing(eligible, 'bootstrap', context);
434
+ for (const row of shadowed) excluded.push(serializeRecord(row, 'scope_shadowed', {}, context));
435
+
436
+ const ordered = winners.sort((left, right) => compareBootstrapRows(left, right, context));
437
+ const limit = bootstrapLimit(opts.limit);
438
+ const maxChars = bootstrapMaxChars(opts.maxChars);
439
+ let selected = limit ? ordered.slice(0, limit) : ordered.slice();
440
+ const overflowByLimit = limit ? ordered.slice(limit) : [];
441
+ for (const row of overflowByLimit) excluded.push(serializeRecord(row, 'limit_trimmed', {}, context));
442
+
443
+ const meta = {
444
+ overflow: overflowByLimit.length > 0,
445
+ degraded: overflowByLimit.length > 0,
446
+ };
447
+ let text = buildBootstrapText(selected, meta);
448
+ const budgetTrimmed = [];
449
+ while (text.length > maxChars && selected.length > 1) {
450
+ const removed = selected.pop();
451
+ budgetTrimmed.push(removed);
452
+ meta.overflow = true;
453
+ meta.degraded = true;
454
+ text = buildBootstrapText(selected, meta);
455
+ }
456
+ for (const row of budgetTrimmed) excluded.push(serializeRecord(row, 'budget_trimmed', {}, context));
457
+ if (text.length > maxChars) {
458
+ meta.overflow = true;
459
+ meta.degraded = true;
460
+ }
461
+
462
+ return {
463
+ readOnly: true,
464
+ lane: 'bootstrap',
465
+ scope: {
466
+ activeScopeKey: context.activeScopeKey,
467
+ activeScopePath: context.activeScopePath,
468
+ asOf: context.asOf,
469
+ },
470
+ selected: selected.map(row => serializeRecord(row, 'selected', {}, context)),
471
+ excluded,
472
+ budget: {
473
+ limit,
474
+ maxChars,
475
+ overflow: meta.overflow,
476
+ degraded: meta.degraded,
477
+ },
478
+ };
479
+ }
480
+
481
+ function recallLimit(limit) {
482
+ return Math.max(1, Math.min(50, Number.isFinite(limit) ? Math.floor(limit) : 10));
483
+ }
484
+
485
+ function buildMemoryExplanation(rows = [], query, opts = {}) {
486
+ const context = createContext(opts);
487
+ assertAllowedScopeRequest({
488
+ ...opts,
489
+ activeScopeKey: context.activeScopeKey,
490
+ activeScopePath: context.activeScopePath,
491
+ });
492
+ const normalizedRows = rows.map(normalizeRow).sort((left, right) => compareExplainRows(left, right, context));
493
+ const excluded = [];
494
+ const eligible = [];
495
+
496
+ for (const row of normalizedRows) {
497
+ const reason = baselineReason(row, 'memory', context);
498
+ if (reason) {
499
+ excluded.push(serializeRecord(row, reason, {}, context));
500
+ continue;
501
+ }
502
+ eligible.push(row);
503
+ }
504
+
505
+ const { winners, shadowed } = resolveShadowing(eligible, 'memory', context);
506
+ for (const row of shadowed) excluded.push(serializeRecord(row, 'scope_shadowed', {}, context));
507
+
508
+ const scored = scoreRecallRows(winners, query, opts);
509
+ const matched = scored.filter(row => row._debug.lexical > 0);
510
+ const unmatched = scored.filter(row => row._debug.lexical <= 0);
511
+ const limit = recallLimit(opts.limit);
512
+ const selected = matched.slice(0, limit);
513
+ const trimmed = matched.slice(limit);
514
+
515
+ for (const row of trimmed) excluded.push(serializeRecord(row, 'limit_trimmed', { score: row.recall_score }, context));
516
+ for (const row of unmatched) excluded.push(serializeRecord(row, 'query_no_match', { score: row.recall_score }, context));
517
+
518
+ return {
519
+ readOnly: true,
520
+ lane: 'memory',
521
+ scope: {
522
+ activeScopeKey: context.activeScopeKey,
523
+ activeScopePath: context.activeScopePath,
524
+ asOf: context.asOf,
525
+ },
526
+ query: String(query || ''),
527
+ selected: selected.map(row => serializeRecord(row, 'selected', { score: row.recall_score }, context)),
528
+ excluded,
529
+ budget: {
530
+ limit,
531
+ matched: matched.length,
532
+ },
533
+ };
534
+ }
535
+
536
+ function scanLimit(input = {}) {
537
+ const value = Number.isFinite(input.scanLimit) ? Math.floor(input.scanLimit) : DEFAULT_SCAN_LIMIT;
538
+ return Math.max(1, Math.min(MAX_SCAN_LIMIT, value));
539
+ }
540
+
541
+ function recordsQuery(schema, hasAllowedScopeKeys = false) {
542
+ return `SELECT
543
+ m.*, s.scope_kind, s.scope_key, s.inheritance_mode AS scope_inheritance_mode
544
+ FROM ${schema}.memory_records m
545
+ JOIN ${schema}.scopes s ON s.id = m.scope_id
546
+ WHERE m.tenant_id = $1
547
+ ${hasAllowedScopeKeys ? 'AND s.scope_key = ANY($4::text[])' : ''}
548
+ ORDER BY
549
+ CASE WHEN s.scope_key = ANY($2::text[]) THEN 0 ELSE 1 END ASC,
550
+ m.accepted_at DESC NULLS LAST,
551
+ m.id ASC
552
+ LIMIT $3`;
553
+ }
554
+
555
+ function feedbackQuery(schema) {
556
+ return `SELECT target_id, feedback_type
557
+ FROM ${schema}.feedback
558
+ WHERE tenant_id = $1
559
+ AND target_kind = 'memory_record'
560
+ AND target_id = ANY($2::text[])`;
561
+ }
562
+
563
+ function createMemoryExplain({ pool, schema, defaultTenantId }) {
564
+ function assertPool() {
565
+ if (!pool || typeof pool.query !== 'function') {
566
+ throw new Error('memory.explain requires a pool with query(sql, params)');
567
+ }
568
+ }
569
+
570
+ async function loadRows(opts = {}) {
571
+ assertPool();
572
+ const tenantId = opts.tenantId || defaultTenantId;
573
+ const activeScopePath = normalizeScopePath(opts.activeScopePath, opts.activeScopeKey);
574
+ const activeScopeKey = opts.activeScopeKey || activeScopePath[activeScopePath.length - 1] || null;
575
+ assertAllowedScopeRequest({
576
+ ...opts,
577
+ activeScopeKey,
578
+ activeScopePath,
579
+ });
580
+ const allowedScopeKeys = normalizeScopeList(opts.allowedScopeKeys);
581
+ const params = [
582
+ tenantId,
583
+ activeScopePath,
584
+ scanLimit(opts),
585
+ ];
586
+ if (allowedScopeKeys.length > 0) params.push(allowedScopeKeys);
587
+ const result = await pool.query(recordsQuery(schema, allowedScopeKeys.length > 0), params);
588
+ return result.rows || [];
589
+ }
590
+
591
+ async function loadFeedbackEvents(rows, opts = {}) {
592
+ const ids = rows.map(recordId).filter(Boolean);
593
+ if (ids.length === 0) return [];
594
+ assertPool();
595
+ const tenantId = opts.tenantId || defaultTenantId;
596
+ const result = await pool.query(feedbackQuery(schema), [tenantId, ids]);
597
+ return result.rows || [];
598
+ }
599
+
600
+ async function explainBootstrap(opts = {}) {
601
+ assertCuratedBootstrapOpts(opts);
602
+ const rows = await loadRows(opts);
603
+ return buildBootstrapExplanation(rows, opts);
604
+ }
605
+
606
+ async function explainMemory(query, opts = {}) {
607
+ assertCuratedRecallOpts(opts);
608
+ const rows = await loadRows(opts);
609
+ const feedbackEvents = await loadFeedbackEvents(rows, opts);
610
+ return buildMemoryExplanation(rows, query, { ...opts, feedbackEvents });
611
+ }
612
+
613
+ return {
614
+ explainBootstrap,
615
+ explainCurrent: explainMemory,
616
+ explainMemory,
617
+ };
618
+ }
619
+
620
+ module.exports = {
621
+ buildBootstrapExplanation,
622
+ buildMemoryExplanation,
623
+ createMemoryExplain,
624
+ };