@shadowforge0/aquifer-memory 1.2.1 → 1.5.8

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 (43) hide show
  1. package/README.md +8 -9
  2. package/consumers/cli.js +11 -1
  3. package/consumers/default/index.js +17 -4
  4. package/consumers/mcp.js +21 -0
  5. package/consumers/miranda/index.js +15 -4
  6. package/consumers/miranda/profile.json +145 -0
  7. package/consumers/miranda/recall-format.js +5 -3
  8. package/consumers/miranda/render-daily-md.js +186 -0
  9. package/consumers/shared/config.js +8 -0
  10. package/consumers/shared/factory.js +2 -1
  11. package/consumers/shared/llm.js +1 -1
  12. package/consumers/shared/recall-format.js +21 -1
  13. package/core/aquifer.js +693 -87
  14. package/core/artifacts.js +174 -0
  15. package/core/bundles.js +400 -0
  16. package/core/consolidation.js +340 -0
  17. package/core/decisions.js +164 -0
  18. package/core/entity-state.js +483 -0
  19. package/core/errors.js +97 -0
  20. package/core/handoff.js +153 -0
  21. package/core/insights.js +499 -0
  22. package/core/mcp-manifest.js +131 -0
  23. package/core/narratives.js +212 -0
  24. package/core/profiles.js +171 -0
  25. package/core/state.js +163 -0
  26. package/core/storage.js +82 -5
  27. package/core/timeline.js +152 -0
  28. package/index.js +14 -0
  29. package/package.json +1 -1
  30. package/pipeline/extract-state-changes.js +205 -0
  31. package/schema/001-base.sql +186 -16
  32. package/schema/002-entities.sql +35 -1
  33. package/schema/004-completion.sql +391 -0
  34. package/schema/005-entity-state-history.sql +87 -0
  35. package/schema/006-insights.sql +138 -0
  36. package/scripts/diagnose-fts-zh.js +37 -4
  37. package/scripts/drop-entity-state-history.sql +17 -0
  38. package/scripts/drop-insights.sql +12 -0
  39. package/scripts/extract-insights-from-recent-sessions.js +315 -0
  40. package/scripts/find-dburl-hints.js +29 -0
  41. package/scripts/queries.json +45 -0
  42. package/scripts/retro-recall-bench.js +409 -0
  43. package/scripts/sample-bench-queries.sql +75 -0
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ // aq.handoff.* — append-only session handoff capability.
4
+ //
5
+ // Spec: aquifer-completion §8 sessionHandoff. Every write is an append.
6
+ // getLatest retrieves by created_at DESC, optionally narrowed to a single
7
+ // sessionId.
8
+
9
+ const crypto = require('crypto');
10
+ const { AqError, ok, err } = require('./errors');
11
+
12
+ const DEFAULT_PROFILE = Object.freeze({
13
+ id: 'anon',
14
+ version: 0,
15
+ schemaHash: 'pending',
16
+ });
17
+
18
+ const VALID_STATUSES = new Set(['in_progress', 'completed', 'blocked']);
19
+
20
+ function resolveProfile(profile) {
21
+ if (!profile) return DEFAULT_PROFILE;
22
+ return {
23
+ id: profile.id || DEFAULT_PROFILE.id,
24
+ version: Number.isInteger(profile.version) ? profile.version : DEFAULT_PROFILE.version,
25
+ schemaHash: profile.schemaHash || DEFAULT_PROFILE.schemaHash,
26
+ };
27
+ }
28
+
29
+ function toNumber(v) {
30
+ if (v === null || v === undefined) return null;
31
+ const n = Number(v);
32
+ return Number.isFinite(n) ? n : null;
33
+ }
34
+
35
+ function defaultIdempotencyKey({ tenantId, agentId, sessionId, payload }) {
36
+ return crypto.createHash('sha256')
37
+ .update(`${tenantId}:${agentId}:${sessionId}:${JSON.stringify(payload)}`)
38
+ .digest('hex');
39
+ }
40
+
41
+ function mapRow(row) {
42
+ if (!row) return null;
43
+ return {
44
+ handoffId: toNumber(row.id),
45
+ sessionId: row.source_session_id,
46
+ agentId: row.agent_id,
47
+ status: row.status,
48
+ lastStep: row.last_step,
49
+ nextStep: row.next_step,
50
+ blockers: row.blockers || [],
51
+ decided: row.decided || [],
52
+ openLoops: row.open_loops || [],
53
+ payload: row.payload || {},
54
+ createdAt: row.created_at,
55
+ };
56
+ }
57
+
58
+ function createHandoff({ pool, schema, defaultTenantId }) {
59
+ async function write(input) {
60
+ try {
61
+ if (!input || typeof input !== 'object') {
62
+ return err('AQ_INVALID_INPUT', 'write requires an input object');
63
+ }
64
+ if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
65
+ if (!input.sessionId) return err('AQ_INVALID_INPUT', 'sessionId is required');
66
+ if (!input.payload || typeof input.payload !== 'object') {
67
+ return err('AQ_INVALID_INPUT', 'payload is required');
68
+ }
69
+ const tenantId = input.tenantId || defaultTenantId || 'default';
70
+ const agentId = input.agentId;
71
+ const sessionId = input.sessionId;
72
+ const payload = input.payload;
73
+ const profile = resolveProfile(input.profile);
74
+
75
+ const status = payload.status || 'in_progress';
76
+ if (!VALID_STATUSES.has(status)) {
77
+ return err('AQ_INVALID_INPUT', `status must be one of ${Array.from(VALID_STATUSES).join(', ')}`);
78
+ }
79
+ const lastStep = typeof payload.last_step === 'string' ? payload.last_step : null;
80
+ const nextStep = typeof payload.next === 'string' ? payload.next : null;
81
+ const blockers = Array.isArray(payload.blockers) ? payload.blockers : [];
82
+ const decided = Array.isArray(payload.decided) ? payload.decided : [];
83
+ const openLoops = Array.isArray(payload.open_loops) ? payload.open_loops : [];
84
+ const idempotencyKey = input.idempotencyKey
85
+ || defaultIdempotencyKey({ tenantId, agentId, sessionId, payload });
86
+
87
+ // ON CONFLICT DO NOTHING + fallback SELECT for the canonical row.
88
+ const insertResult = await pool.query(
89
+ `INSERT INTO ${schema}.session_handoffs (
90
+ tenant_id, agent_id, source_session_id,
91
+ consumer_profile_id, consumer_profile_version, consumer_schema_hash,
92
+ idempotency_key, status, last_step, next_step,
93
+ blockers, decided, open_loops, payload
94
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
95
+ ON CONFLICT (idempotency_key) DO NOTHING
96
+ RETURNING *`,
97
+ [
98
+ tenantId, agentId, sessionId,
99
+ profile.id, profile.version, profile.schemaHash,
100
+ idempotencyKey, status, lastStep, nextStep,
101
+ JSON.stringify(blockers), JSON.stringify(decided),
102
+ JSON.stringify(openLoops), JSON.stringify(payload),
103
+ ],
104
+ );
105
+ let row = insertResult.rows[0];
106
+ if (!row) {
107
+ const existing = await pool.query(
108
+ `SELECT * FROM ${schema}.session_handoffs WHERE idempotency_key = $1`,
109
+ [idempotencyKey],
110
+ );
111
+ row = existing.rows[0];
112
+ }
113
+ const mapped = mapRow(row);
114
+ return ok({ handoffId: mapped.handoffId, payload: mapped.payload });
115
+ } catch (e) {
116
+ if (e instanceof AqError) return err(e);
117
+ return err('AQ_INTERNAL', e.message, { cause: e });
118
+ }
119
+ }
120
+
121
+ async function getLatest(input = {}) {
122
+ try {
123
+ if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
124
+ const tenantId = input.tenantId || defaultTenantId || 'default';
125
+
126
+ const params = [tenantId, input.agentId];
127
+ let where = 'tenant_id = $1 AND agent_id = $2';
128
+ if (input.sessionId) {
129
+ params.push(input.sessionId);
130
+ where += ` AND source_session_id = $${params.length}`;
131
+ }
132
+
133
+ const { rows } = await pool.query(
134
+ `SELECT * FROM ${schema}.session_handoffs
135
+ WHERE ${where}
136
+ ORDER BY created_at DESC, id DESC
137
+ LIMIT 1`,
138
+ params,
139
+ );
140
+ const mapped = mapRow(rows[0]);
141
+ return ok({
142
+ handoff: mapped ? mapped.payload : null,
143
+ handoffId: mapped ? mapped.handoffId : null,
144
+ });
145
+ } catch (e) {
146
+ return err('AQ_INTERNAL', e.message, { cause: e });
147
+ }
148
+ }
149
+
150
+ return { write, getLatest };
151
+ }
152
+
153
+ module.exports = { createHandoff };
@@ -0,0 +1,499 @@
1
+ 'use strict';
2
+
3
+ // aquifer.insights.* — higher-order observations distilled from sessions.
4
+ //
5
+ // Insight types: preference / pattern / frustration / workflow.
6
+ // Recall blends semantic similarity (vector), importance, and recency
7
+ // (linear decay over recencyWindowDays, default 90).
8
+ //
9
+ // Lifecycle is EXPLICIT — no read-time "auto-stale". Statuses:
10
+ // 'active' — returned by recall by default
11
+ // 'stale' — set via markStale(id); recall excludes unless includeStale
12
+ // 'superseded' — set via supersede(oldId, newId); excluded unless includeStale
13
+ // The scripts/extract-insights-from-recent-sessions.js cron job is the
14
+ // only thing that typically calls supersede() (when a newer extraction run
15
+ // fully covers the old evidence).
16
+
17
+ const crypto = require('crypto');
18
+ const { ok, err } = require('./errors');
19
+ const { normalizeEntityName } = require('./entity');
20
+
21
+ const VALID_TYPES = new Set(['preference', 'pattern', 'frustration', 'workflow']);
22
+
23
+ const DEFAULT_RECALL_WEIGHTS = Object.freeze({
24
+ semantic: 0.65,
25
+ importance: 0.25,
26
+ recency: 0.10,
27
+ });
28
+
29
+ // Recency linear decay horizon — an insight is treated as "fully recent" at
30
+ // creation (age=0) and "zero recency" at age >= recencyWindowDays. Beyond,
31
+ // recency contribution is clamped to 0 rather than going negative. Configurable
32
+ // via createAquifer({ insights: { recencyWindowDays } }).
33
+ const DEFAULT_RECENCY_WINDOW_DAYS = 90;
34
+
35
+ const LEADING_PUNCT_RE = /^[\s\-_.,;:!?'"()\[\]{}@#]+/;
36
+ const TRAILING_PUNCT_RE = /[\s\-_.,;:!?'"()\[\]{}@#]+$/;
37
+
38
+ function _normalizeText(input) {
39
+ if (typeof input !== 'string' || !input) return '';
40
+ let s = input.normalize('NFKC');
41
+ s = s.toLowerCase();
42
+ s = s.replace(/\s+/g, ' ');
43
+ s = s.replace(LEADING_PUNCT_RE, '');
44
+ s = s.replace(TRAILING_PUNCT_RE, '');
45
+ return s;
46
+ }
47
+
48
+ function normalizeCanonicalClaim(text) {
49
+ return _normalizeText(text);
50
+ }
51
+
52
+ function normalizeBody(text) {
53
+ return _normalizeText(text);
54
+ }
55
+
56
+ function normalizeEntitySet(entities) {
57
+ if (!entities || !Array.isArray(entities)) return '';
58
+ const { normalizeEntityName } = require('./entity');
59
+ const normalized = entities
60
+ .map(e => normalizeEntityName(e))
61
+ .filter(Boolean);
62
+ const deduped = [...new Set(normalized)];
63
+ deduped.sort();
64
+ return deduped.join('|');
65
+ }
66
+
67
+ function defaultCanonicalKey({ tenantId, agentId, type, canonicalClaim, entities }) {
68
+ const normClaim = normalizeCanonicalClaim(canonicalClaim);
69
+ const normEntities = normalizeEntitySet(entities);
70
+ const input = `${tenantId || ''}|${agentId || ''}|${type || ''}|${normClaim}|${normEntities}`;
71
+ return crypto.createHash('sha256').update(input).digest('hex');
72
+ }
73
+
74
+ function defaultIdempotencyKey({
75
+ tenantId, agentId, type, title, body, sourceSessionIds, evidenceWindow,
76
+ }) {
77
+ const sorted = (sourceSessionIds || []).slice().sort().join('|');
78
+ const winFrom = evidenceWindow && evidenceWindow.from ? new Date(evidenceWindow.from).toISOString() : '';
79
+ const winTo = evidenceWindow && evidenceWindow.to ? new Date(evidenceWindow.to).toISOString() : '';
80
+ // Hash must include body + window so legitimate revisions (same sessions but
81
+ // tightened body, or extended window) get a new key and replace the old row
82
+ // via supersede, not get swallowed as a duplicate.
83
+ return crypto.createHash('sha256')
84
+ .update(`${tenantId}|${agentId}|${type}|${title}|${body || ''}|${sorted}|${winFrom}|${winTo}`)
85
+ .digest('hex');
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Canonical identity helpers (Phase 2 C1)
90
+ //
91
+ // Two-layer identity:
92
+ // canonical_key_v2 — "which claim is this" (type + canonicalClaim + entitySet)
93
+ // idempotency_key — "which revision of that claim" (legacy, unchanged)
94
+ //
95
+ // canonicalClaim is produced by the extractor LLM (a normalized declarative
96
+ // claim without rhetoric/examples/time words). Title/body/sessions/window
97
+ // are revision-level and stay out of canonical_key_v2.
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function normalizeCanonicalClaim(text) {
101
+ if (typeof text !== 'string') return '';
102
+
103
+ let s = text.normalize('NFKC');
104
+ s = s.toLowerCase();
105
+ s = s.replace(/\s+/g, ' ');
106
+ s = s.trim();
107
+ s = s.replace(/^[\s\-_.,;:!?'"()\[\]{}]+/, '');
108
+ s = s.replace(/[\s\-_.,;:!?'"()\[\]{}]+$/, '');
109
+
110
+ return s;
111
+ }
112
+
113
+ function normalizeBody(text) {
114
+ return normalizeCanonicalClaim(text);
115
+ }
116
+
117
+ function normalizeEntitySet(entities) {
118
+ if (!Array.isArray(entities) || entities.length === 0) return '';
119
+
120
+ return [...new Set(
121
+ entities
122
+ .map(entity => normalizeEntityName(entity))
123
+ .filter(Boolean)
124
+ )]
125
+ .sort()
126
+ .join('|');
127
+ }
128
+
129
+ function defaultCanonicalKey({ tenantId, agentId, type, canonicalClaim, entities }) {
130
+ return crypto.createHash('sha256')
131
+ .update(`${tenantId ?? ''}|${agentId ?? ''}|${type ?? ''}|${normalizeCanonicalClaim(canonicalClaim)}|${normalizeEntitySet(entities)}`)
132
+ .digest('hex');
133
+ }
134
+
135
+ // Parse the upper bound of a tstzrange returned by node-postgres as a raw
136
+ // string (default mapping when range types aren't explicitly parsed). Accepts
137
+ // the forms `[lower,upper)` / `(lower,upper]` / infinity sentinels.
138
+ function parseUpperFromRange(raw) {
139
+ if (!raw || typeof raw !== 'string') return null;
140
+ const m = raw.match(/^[[(]([^,]*),([^)\]]*)[\])]$/);
141
+ if (!m) return null;
142
+ const upper = m[2].trim().replace(/^"|"$/g, '');
143
+ if (!upper || upper === 'infinity') return null;
144
+ const d = new Date(upper);
145
+ return Number.isFinite(d.getTime()) ? d : null;
146
+ }
147
+
148
+ // Revision-level idempotency key: same claim (canonicalKeyV2) + same body +
149
+ // same source sessions + same evidence window = duplicate. Body tightening or
150
+ // window extension produces a new revision (old one is superseded).
151
+ function revisionIdempotencyKey({ canonicalKeyV2, body, sourceSessionIds, fromIso, toIso }) {
152
+ const sorted = (sourceSessionIds || []).slice().sort().join('|');
153
+ return crypto.createHash('sha256')
154
+ .update(`${canonicalKeyV2}|${normalizeBody(body)}|${sorted}|${fromIso || ''}|${toIso || ''}`)
155
+ .digest('hex');
156
+ }
157
+
158
+ function vecToPgLiteral(v) {
159
+ if (!Array.isArray(v) || v.length === 0) return null;
160
+ return `[${v.join(',')}]`;
161
+ }
162
+
163
+ function mapRow(row) {
164
+ if (!row) return null;
165
+ return {
166
+ id: Number(row.id),
167
+ tenantId: row.tenant_id,
168
+ agentId: row.agent_id,
169
+ insightType: row.insight_type,
170
+ title: row.title,
171
+ body: row.body,
172
+ sourceSessionIds: row.source_session_ids || [],
173
+ evidenceWindow: row.evidence_window, // raw tstzrange string from PG
174
+ importance: (row.importance !== null && row.importance !== undefined) ? Number(row.importance) : null,
175
+ status: row.status,
176
+ supersededBy: (row.superseded_by !== null && row.superseded_by !== undefined) ? Number(row.superseded_by) : null,
177
+ idempotencyKey: row.idempotency_key || null,
178
+ canonicalKeyV2: row.canonical_key_v2 || null,
179
+ metadata: row.metadata || {},
180
+ createdAt: row.created_at,
181
+ updatedAt: row.updated_at,
182
+ score: (row._score !== null && row._score !== undefined) ? Number(row._score) : undefined,
183
+ semanticScore: (row._semantic_score !== null && row._semantic_score !== undefined) ? Number(row._semantic_score) : undefined,
184
+ };
185
+ }
186
+
187
+ function createInsights({ pool, schema, defaultTenantId, embedFn, recallWeights, recencyWindowDays }) {
188
+ if (!pool) throw new Error('createInsights: pool is required');
189
+ if (!schema) throw new Error('createInsights: schema is required');
190
+
191
+ const weights = { ...DEFAULT_RECALL_WEIGHTS, ...(recallWeights || {}) };
192
+ const recencyWindow = Number.isFinite(recencyWindowDays) && recencyWindowDays > 0
193
+ ? recencyWindowDays : DEFAULT_RECENCY_WINDOW_DAYS;
194
+ const tbl = `${schema}.insights`;
195
+
196
+ // -------------------------------------------------------------------------
197
+ // commitInsight
198
+ // -------------------------------------------------------------------------
199
+ async function commitInsight(input = {}) {
200
+ try {
201
+ const tenantId = input.tenantId || defaultTenantId || 'default';
202
+ const agentId = input.agentId;
203
+ if (!agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
204
+ const type = input.type;
205
+ if (!VALID_TYPES.has(type)) return err('AQ_INVALID_INPUT', `type must be one of ${[...VALID_TYPES].join('|')}`);
206
+ const title = typeof input.title === 'string' ? input.title.trim() : '';
207
+ if (!title) return err('AQ_INVALID_INPUT', 'title must be non-empty string');
208
+ const body = typeof input.body === 'string' ? input.body.trim() : '';
209
+ if (!body) return err('AQ_INVALID_INPUT', 'body must be non-empty string');
210
+ const sourceSessionIds = Array.isArray(input.sourceSessionIds) ? input.sourceSessionIds : [];
211
+ if (!sourceSessionIds.length) return err('AQ_INVALID_INPUT', 'sourceSessionIds must contain at least one id');
212
+ const win = input.evidenceWindow || {};
213
+ if (!win.from || !win.to) return err('AQ_INVALID_INPUT', 'evidenceWindow.from and .to are required');
214
+ const fromIso = new Date(win.from).toISOString();
215
+ const toIso = new Date(win.to).toISOString();
216
+ if (!Number.isFinite(new Date(fromIso).getTime()) || !Number.isFinite(new Date(toIso).getTime())) {
217
+ return err('AQ_INVALID_INPUT', 'evidenceWindow.from / .to must parse to timestamps');
218
+ }
219
+ const importance = (input.importance !== null && input.importance !== undefined) ? Number(input.importance) : 0.5;
220
+ if (!Number.isFinite(importance) || importance < 0 || importance > 1) {
221
+ return err('AQ_INVALID_INPUT', 'importance must be in [0,1]');
222
+ }
223
+ let metadata = input.metadata && typeof input.metadata === 'object' ? input.metadata : {};
224
+
225
+ // ---------------------------------------------------------------------
226
+ // Phase 2 C1: two-layer identity.
227
+ // canonicalKeyV2 = "which claim" (type + canonicalClaim + entitySet)
228
+ // idempotencyKey = "which revision of that claim"
229
+ // canonicalClaim comes from the extractor LLM; when absent we fall back
230
+ // to title and flag dedupQuality so callers know the dedupe is weak.
231
+ // ---------------------------------------------------------------------
232
+ const canonicalClaim = typeof input.canonicalClaim === 'string' ? input.canonicalClaim : '';
233
+ const entities = Array.isArray(input.entities) ? input.entities : [];
234
+ const canonicalKeyV2 = input.canonicalKey
235
+ || defaultCanonicalKey({
236
+ tenantId, agentId, type,
237
+ canonicalClaim: canonicalClaim || title,
238
+ entities,
239
+ });
240
+
241
+ if (!input.canonicalClaim && !input.canonicalKey) {
242
+ metadata = { ...metadata, dedupQuality: 'title_fallback' };
243
+ }
244
+
245
+ const idempotencyKey = input.idempotencyKey
246
+ || revisionIdempotencyKey({
247
+ canonicalKeyV2, body, sourceSessionIds, fromIso, toIso,
248
+ });
249
+
250
+ // Step A — revision dedupe. Exact same claim/body/sessions/window.
251
+ const existing = await pool.query(
252
+ `SELECT * FROM ${tbl} WHERE idempotency_key = $1 LIMIT 1`,
253
+ [idempotencyKey]
254
+ );
255
+ if (existing.rowCount > 0) return ok({ insight: mapRow(existing.rows[0]), duplicate: true });
256
+
257
+ // Step B — canonical lookup: is this claim already active? If so, decide
258
+ // between stale replay (incoming window older than active) vs revision
259
+ // (incoming same or newer, body/window differ enough that Step A missed).
260
+ const canonLookup = await pool.query(
261
+ `SELECT * FROM ${tbl}
262
+ WHERE tenant_id = $1
263
+ AND agent_id = $2
264
+ AND insight_type = $3
265
+ AND canonical_key_v2 = $4
266
+ AND status = 'active'
267
+ ORDER BY created_at DESC
268
+ LIMIT 1`,
269
+ [tenantId, agentId, type, canonicalKeyV2]
270
+ );
271
+
272
+ let toSupersede = null;
273
+ if (canonLookup.rowCount > 0) {
274
+ const activeRow = canonLookup.rows[0];
275
+ const activeUpper = parseUpperFromRange(activeRow.evidence_window);
276
+ // Rule 4 — stale replay: incoming evidence is older than what's
277
+ // already active. Keep the active row, tell caller it's a duplicate.
278
+ if (activeUpper && new Date(toIso).getTime() < activeUpper.getTime()) {
279
+ return ok({ insight: mapRow(activeRow), duplicate: true });
280
+ }
281
+ // Rule 2/3 — revision: different revision key, incoming window is not
282
+ // stale. Insert new and mark the previous active row as superseded.
283
+ toSupersede = Number(activeRow.id);
284
+ }
285
+
286
+ // Optional embedding.
287
+ let embedding = null;
288
+ if (embedFn) {
289
+ try {
290
+ const v = await embedFn([`${title}\n\n${body}`]);
291
+ if (Array.isArray(v) && Array.isArray(v[0])) embedding = vecToPgLiteral(v[0]);
292
+ } catch {
293
+ // Embed failure is non-fatal — insight saved without semantic recall path.
294
+ }
295
+ }
296
+
297
+ const evidenceRange = `[${fromIso},${toIso})`;
298
+ const inserted = await pool.query(
299
+ `INSERT INTO ${tbl}
300
+ (tenant_id, agent_id, insight_type, title, body, source_session_ids,
301
+ evidence_window, embedding, importance, status, idempotency_key,
302
+ canonical_key_v2, metadata)
303
+ VALUES ($1,$2,$3,$4,$5,$6, $7::tstzrange, $8::vector, $9, 'active', $10, $11, $12::jsonb)
304
+ RETURNING *`,
305
+ [tenantId, agentId, type, title, body, sourceSessionIds,
306
+ evidenceRange, embedding, importance, idempotencyKey,
307
+ canonicalKeyV2, JSON.stringify(metadata)]
308
+ );
309
+ const newRow = inserted.rows[0];
310
+
311
+ // Best-effort supersede of the prior active revision. Insights are
312
+ // eventually consistent — if the old row was already superseded by a
313
+ // racing writer, log and continue without failing the new insert.
314
+ if (toSupersede && Number(newRow.id) !== toSupersede) {
315
+ try {
316
+ await pool.query(
317
+ `UPDATE ${tbl}
318
+ SET status = 'superseded', superseded_by = $2, updated_at = now()
319
+ WHERE id = $1 AND status = 'active'`,
320
+ [toSupersede, Number(newRow.id)]
321
+ );
322
+ } catch {
323
+ // swallow — new row is already persisted
324
+ }
325
+ }
326
+
327
+ return ok({ insight: mapRow(newRow), duplicate: false });
328
+ } catch (e) {
329
+ if (/duplicate key/.test(e.message)) return err('AQ_CONFLICT', e.message);
330
+ return err('AQ_INTERNAL', e.message);
331
+ }
332
+ }
333
+
334
+ // -------------------------------------------------------------------------
335
+ // recallInsights
336
+ // -------------------------------------------------------------------------
337
+ async function recallInsights(query, input = {}) {
338
+ try {
339
+ const tenantId = input.tenantId || defaultTenantId || 'default';
340
+ const agentId = input.agentId;
341
+ if (!agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
342
+ const type = input.type || null;
343
+ if (type && !VALID_TYPES.has(type)) {
344
+ return err('AQ_INVALID_INPUT', `type must be one of ${[...VALID_TYPES].join('|')}`);
345
+ }
346
+ const limit = Math.max(1, Math.min(50, Number(input.limit) || 5));
347
+ const minImportance = (input.minImportance !== null && input.minImportance !== undefined) ? Number(input.minImportance) : 0;
348
+ const includeStale = input.includeStale === true;
349
+
350
+ const where = ['tenant_id = $1', 'agent_id = $2', 'importance >= $3'];
351
+ const params = [tenantId, agentId, minImportance];
352
+ if (!includeStale) where.push(`status = 'active'`);
353
+ if (type) {
354
+ params.push(type);
355
+ where.push(`insight_type = $${params.length}`);
356
+ }
357
+
358
+ // Empty query → blend importance × recency (linear decay over
359
+ // recencyWindow days), no semantic component. Falls back to created_at
360
+ // DESC as tiebreak so identical blended scores remain deterministic.
361
+ if (!query || typeof query !== 'string' || !query.trim()) {
362
+ params.push(recencyWindow);
363
+ const winPos = params.length;
364
+ params.push(weights.importance);
365
+ const wImpPos = params.length;
366
+ params.push(weights.recency);
367
+ const wRecPos = params.length;
368
+ params.push(limit);
369
+ const r = await pool.query(
370
+ `SELECT *,
371
+ (
372
+ $${wImpPos}::real * importance +
373
+ $${wRecPos}::real * GREATEST(0, 1.0 - (extract(epoch FROM (now() - created_at)) / 86400.0) / $${winPos}::real)
374
+ ) AS _score
375
+ FROM ${tbl}
376
+ WHERE ${where.join(' AND ')}
377
+ ORDER BY _score DESC, created_at DESC
378
+ LIMIT $${params.length}`,
379
+ params
380
+ );
381
+ return ok({ rows: r.rows.map(mapRow) });
382
+ }
383
+
384
+ // Vector recall: requires embedFn.
385
+ if (!embedFn) return err('AQ_DEPENDENCY', 'recallInsights with query requires embedFn');
386
+ let queryVec;
387
+ try {
388
+ const v = await embedFn([query]);
389
+ queryVec = vecToPgLiteral(v[0]);
390
+ } catch (e) {
391
+ return err('AQ_DEPENDENCY', `embedFn failed: ${e.message}`);
392
+ }
393
+ if (!queryVec) return err('AQ_DEPENDENCY', 'embedFn returned empty vector');
394
+
395
+ params.push(queryVec);
396
+ const vecPos = params.length;
397
+ params.push(weights.semantic);
398
+ const wSemPos = params.length;
399
+ params.push(weights.importance);
400
+ const wImpPos = params.length;
401
+ params.push(weights.recency);
402
+ const wRecPos = params.length;
403
+ params.push(limit);
404
+ const limitPos = params.length;
405
+
406
+ params.push(recencyWindow);
407
+ const winPos = params.length;
408
+ const r = await pool.query(
409
+ `WITH scored AS (
410
+ SELECT *,
411
+ 1.0 - (embedding <=> $${vecPos}::vector) AS _semantic_score,
412
+ extract(epoch FROM (now() - created_at)) / 86400.0 AS _age_days
413
+ FROM ${tbl}
414
+ WHERE embedding IS NOT NULL
415
+ AND ${where.join(' AND ')}
416
+ )
417
+ SELECT *,
418
+ (
419
+ $${wSemPos}::real * GREATEST(0, _semantic_score) +
420
+ $${wImpPos}::real * importance +
421
+ $${wRecPos}::real * GREATEST(0, 1.0 - _age_days / $${winPos}::real)
422
+ ) AS _score
423
+ FROM scored
424
+ ORDER BY _score DESC
425
+ LIMIT $${limitPos}`,
426
+ params
427
+ );
428
+ return ok({ rows: r.rows.map(mapRow) });
429
+ } catch (e) {
430
+ return err('AQ_INTERNAL', e.message);
431
+ }
432
+ }
433
+
434
+ // -------------------------------------------------------------------------
435
+ // markStale / supersede — explicit lifecycle (callers / scripts use these).
436
+ // -------------------------------------------------------------------------
437
+ async function markStale(insightId) {
438
+ try {
439
+ const id = Number(insightId);
440
+ if (!Number.isInteger(id) || id <= 0) return err('AQ_INVALID_INPUT', 'insightId must be positive integer');
441
+ const r = await pool.query(
442
+ `UPDATE ${tbl} SET status='stale', updated_at=now()
443
+ WHERE id=$1 AND status <> 'stale' RETURNING id, status`,
444
+ [id]
445
+ );
446
+ if (r.rowCount === 0) return err('AQ_NOT_FOUND', `insight ${id} not found or already stale`);
447
+ return ok({ id: Number(r.rows[0].id), status: r.rows[0].status });
448
+ } catch (e) {
449
+ return err('AQ_INTERNAL', e.message);
450
+ }
451
+ }
452
+
453
+ async function supersede(oldId, newId) {
454
+ try {
455
+ const o = Number(oldId), n = Number(newId);
456
+ if (!Number.isInteger(o) || !Number.isInteger(n)) return err('AQ_INVALID_INPUT', 'oldId/newId must be integers');
457
+ if (o === n) return err('AQ_INVALID_INPUT', 'oldId and newId must differ (no self-supersede)');
458
+ // Verify both exist and share tenant + agent. FK alone would allow a
459
+ // caller with a cross-tenant id to form an illegal supersession chain.
460
+ const vr = await pool.query(
461
+ `SELECT id, tenant_id, agent_id FROM ${tbl} WHERE id = ANY($1)`,
462
+ [[o, n]]
463
+ );
464
+ if (vr.rowCount < 2) return err('AQ_NOT_FOUND', `insight ${o} or ${n} not found`);
465
+ const oldRow = vr.rows.find(r => Number(r.id) === o);
466
+ const newRow = vr.rows.find(r => Number(r.id) === n);
467
+ if (!oldRow || !newRow) return err('AQ_NOT_FOUND', `insight ${o} or ${n} not found`);
468
+ if (oldRow.tenant_id !== newRow.tenant_id || oldRow.agent_id !== newRow.agent_id) {
469
+ return err('AQ_CONFLICT', `supersede crosses tenant/agent: old=${oldRow.tenant_id}/${oldRow.agent_id}, new=${newRow.tenant_id}/${newRow.agent_id}`);
470
+ }
471
+ const r = await pool.query(
472
+ `UPDATE ${tbl} SET status='superseded', superseded_by=$2, updated_at=now()
473
+ WHERE id=$1 AND status <> 'superseded' RETURNING id, status, superseded_by`,
474
+ [o, n]
475
+ );
476
+ if (r.rowCount === 0) return err('AQ_NOT_FOUND', `insight ${o} not found or already superseded`);
477
+ return ok({ id: Number(r.rows[0].id), status: r.rows[0].status, supersededBy: Number(r.rows[0].superseded_by) });
478
+ } catch (e) {
479
+ return err('AQ_INTERNAL', e.message);
480
+ }
481
+ }
482
+
483
+ return {
484
+ commitInsight,
485
+ recallInsights,
486
+ markStale,
487
+ supersede,
488
+ _internal: { defaultIdempotencyKey, vecToPgLiteral, mapRow, weights },
489
+ };
490
+ }
491
+
492
+ module.exports = {
493
+ createInsights,
494
+ defaultIdempotencyKey,
495
+ defaultCanonicalKey,
496
+ normalizeCanonicalClaim,
497
+ normalizeBody,
498
+ normalizeEntitySet,
499
+ };