@shadowforge0/aquifer-memory 1.3.0 → 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.
- package/consumers/default/index.js +17 -4
- package/consumers/mcp.js +21 -0
- package/consumers/miranda/index.js +15 -4
- package/consumers/miranda/recall-format.js +5 -3
- package/consumers/shared/config.js +8 -0
- package/consumers/shared/factory.js +2 -1
- package/consumers/shared/llm.js +1 -1
- package/consumers/shared/recall-format.js +21 -1
- package/core/aquifer.js +669 -92
- package/core/entity-state.js +483 -0
- package/core/insights.js +499 -0
- package/core/mcp-manifest.js +1 -1
- package/core/storage.js +82 -5
- package/package.json +1 -1
- package/pipeline/extract-state-changes.js +205 -0
- package/schema/001-base.sql +186 -16
- package/schema/002-entities.sql +35 -1
- package/schema/004-completion.sql +23 -7
- package/schema/005-entity-state-history.sql +87 -0
- package/schema/006-insights.sql +138 -0
- package/scripts/diagnose-fts-zh.js +37 -4
- package/scripts/drop-entity-state-history.sql +17 -0
- package/scripts/drop-insights.sql +12 -0
- package/scripts/extract-insights-from-recent-sessions.js +315 -0
- package/scripts/find-dburl-hints.js +29 -0
- package/scripts/queries.json +45 -0
- package/scripts/retro-recall-bench.js +409 -0
- package/scripts/sample-bench-queries.sql +75 -0
package/core/insights.js
ADDED
|
@@ -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
|
+
};
|
package/core/mcp-manifest.js
CHANGED
|
@@ -20,7 +20,7 @@ const MCP_SERVER_NAME = 'aquifer-memory';
|
|
|
20
20
|
const MCP_TOOL_MANIFEST = Object.freeze([
|
|
21
21
|
{
|
|
22
22
|
name: 'session_recall',
|
|
23
|
-
description: 'Search stored sessions by keyword.
|
|
23
|
+
description: 'Search stored sessions by keyword or natural language. Use entities when the user names specific people, projects, files, tools, or concepts; entityMode="all" hard-filters to sessions containing every entity (default "any" boosts). Use mode to force fts/vector/hybrid (default hybrid). Use dateFrom/dateTo for time-bounded recall.',
|
|
24
24
|
inputSchema: {
|
|
25
25
|
type: 'object',
|
|
26
26
|
additionalProperties: false,
|
package/core/storage.js
CHANGED
|
@@ -59,7 +59,7 @@ async function upsertSession(pool, {
|
|
|
59
59
|
(tenant_id, session_id, session_key, agent_id, source, messages,
|
|
60
60
|
msg_count, user_count, assistant_count, model, tokens_in, tokens_out,
|
|
61
61
|
started_at, ended_at, last_message_at, processing_status)
|
|
62
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12
|
|
62
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,COALESCE($13,now()),COALESCE($14,now()),$14,'pending')
|
|
63
63
|
ON CONFLICT (tenant_id, agent_id, session_id) DO UPDATE SET
|
|
64
64
|
session_key = EXCLUDED.session_key,
|
|
65
65
|
source = COALESCE(EXCLUDED.source, ${qi(schema)}.sessions.source),
|
|
@@ -71,7 +71,7 @@ async function upsertSession(pool, {
|
|
|
71
71
|
tokens_in = EXCLUDED.tokens_in,
|
|
72
72
|
tokens_out = EXCLUDED.tokens_out,
|
|
73
73
|
started_at = COALESCE(EXCLUDED.started_at, ${qi(schema)}.sessions.started_at),
|
|
74
|
-
ended_at =
|
|
74
|
+
ended_at = COALESCE(EXCLUDED.last_message_at, ${qi(schema)}.sessions.ended_at),
|
|
75
75
|
last_message_at = COALESCE(EXCLUDED.last_message_at, ${qi(schema)}.sessions.last_message_at),
|
|
76
76
|
processing_status = 'pending',
|
|
77
77
|
processing_error = NULL
|
|
@@ -223,9 +223,13 @@ async function searchSessions(pool, query, {
|
|
|
223
223
|
dateFrom,
|
|
224
224
|
dateTo,
|
|
225
225
|
limit = 20,
|
|
226
|
+
ftsConfig = 'simple',
|
|
226
227
|
} = {}) {
|
|
227
228
|
const clampedLimit = Math.max(1, Math.min(100, limit));
|
|
228
229
|
|
|
230
|
+
// Whitelist tsconfig to prevent injection
|
|
231
|
+
const cfg = (ftsConfig === 'zhcfg' || ftsConfig === 'simple') ? ftsConfig : 'simple';
|
|
232
|
+
|
|
229
233
|
// Normalize agentId/agentIds
|
|
230
234
|
const agentIds = rawAgentIds && rawAgentIds.length > 0
|
|
231
235
|
? rawAgentIds
|
|
@@ -237,7 +241,7 @@ async function searchSessions(pool, query, {
|
|
|
237
241
|
// Primary: trigram ILIKE on search_text (works for CJK + Latin)
|
|
238
242
|
// Fallback: tsvector FTS (for installations without search_text populated)
|
|
239
243
|
const where = [
|
|
240
|
-
`(ss.search_text ILIKE '%' || $1 || '%' OR ss.search_tsv @@ plainto_tsquery('
|
|
244
|
+
`(ss.search_text ILIKE '%' || $1 || '%' OR ss.search_tsv @@ plainto_tsquery('${cfg}', $2))`,
|
|
241
245
|
`s.tenant_id = $3`,
|
|
242
246
|
];
|
|
243
247
|
const params = [likeQuery, query, tenantId];
|
|
@@ -276,10 +280,15 @@ async function searchSessions(pool, query, {
|
|
|
276
280
|
ss.trust_score,
|
|
277
281
|
CASE WHEN ss.search_text IS NOT NULL
|
|
278
282
|
THEN similarity(ss.search_text, $2)
|
|
279
|
-
ELSE ts_rank(ss.search_tsv, plainto_tsquery('
|
|
283
|
+
ELSE ts_rank(ss.search_tsv, plainto_tsquery('${cfg}', $2))
|
|
280
284
|
END AS fts_rank
|
|
281
285
|
FROM ${qi(schema)}.sessions s
|
|
282
|
-
|
|
286
|
+
-- INNER JOIN: the WHERE clause references ss.search_text / ss.search_tsv,
|
|
287
|
+
-- which a LEFT JOIN would leave NULL for unenriched sessions — filtering
|
|
288
|
+
-- them out. Be explicit: FTS recall is a SUMMARIZED-sessions search. Raw
|
|
289
|
+
-- unenriched sessions don't participate. Named searchSessions for historic
|
|
290
|
+
-- reasons; semantically it is search-over-enriched-sessions.
|
|
291
|
+
JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
|
|
283
292
|
WHERE ${where.join(' AND ')}
|
|
284
293
|
ORDER BY
|
|
285
294
|
COALESCE(ss.search_text ILIKE '%' || $1 || '%', FALSE) DESC,
|
|
@@ -512,6 +521,73 @@ async function searchTurnEmbeddings(pool, {
|
|
|
512
521
|
return { rows: fallback.rows };
|
|
513
522
|
}
|
|
514
523
|
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
// searchSummaryEmbeddings — pgvector cosine search on session_summaries.embedding
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
async function searchSummaryEmbeddings(pool, {
|
|
529
|
+
schema,
|
|
530
|
+
tenantId,
|
|
531
|
+
queryVec,
|
|
532
|
+
agentId,
|
|
533
|
+
agentIds: rawAgentIds,
|
|
534
|
+
source,
|
|
535
|
+
dateFrom,
|
|
536
|
+
dateTo,
|
|
537
|
+
candidateSessionIds,
|
|
538
|
+
limit = 15,
|
|
539
|
+
} = {}) {
|
|
540
|
+
const where = ['s.tenant_id = $1'];
|
|
541
|
+
const params = [tenantId];
|
|
542
|
+
|
|
543
|
+
params.push(`[${queryVec.join(',')}]`);
|
|
544
|
+
const vecPos = params.length;
|
|
545
|
+
|
|
546
|
+
const agentIds = rawAgentIds && rawAgentIds.length > 0
|
|
547
|
+
? rawAgentIds
|
|
548
|
+
: (agentId ? [agentId] : null);
|
|
549
|
+
|
|
550
|
+
if (dateFrom) {
|
|
551
|
+
params.push(dateFrom);
|
|
552
|
+
where.push(`s.started_at::date >= $${params.length}::date`);
|
|
553
|
+
}
|
|
554
|
+
if (dateTo) {
|
|
555
|
+
params.push(dateTo);
|
|
556
|
+
where.push(`s.started_at::date <= $${params.length}::date`);
|
|
557
|
+
}
|
|
558
|
+
if (agentIds) {
|
|
559
|
+
params.push(agentIds);
|
|
560
|
+
where.push(`s.agent_id = ANY($${params.length})`);
|
|
561
|
+
}
|
|
562
|
+
if (source) {
|
|
563
|
+
params.push(source);
|
|
564
|
+
where.push(`s.source = $${params.length}`);
|
|
565
|
+
}
|
|
566
|
+
if (candidateSessionIds && candidateSessionIds.length > 0) {
|
|
567
|
+
params.push(candidateSessionIds);
|
|
568
|
+
where.push(`s.session_id = ANY($${params.length})`);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
params.push(limit);
|
|
572
|
+
|
|
573
|
+
const result = await pool.query(
|
|
574
|
+
`SELECT
|
|
575
|
+
s.id, s.session_id, s.agent_id, s.source, s.started_at, s.last_message_at,
|
|
576
|
+
ss.summary_text, ss.structured_summary, ss.access_count, ss.last_accessed_at,
|
|
577
|
+
ss.trust_score,
|
|
578
|
+
(ss.embedding <=> $${vecPos}::vector) AS distance
|
|
579
|
+
FROM ${qi(schema)}.session_summaries ss
|
|
580
|
+
JOIN ${qi(schema)}.sessions s ON s.id = ss.session_row_id
|
|
581
|
+
WHERE ss.embedding IS NOT NULL
|
|
582
|
+
AND ${where.join(' AND ')}
|
|
583
|
+
ORDER BY distance ASC
|
|
584
|
+
LIMIT $${params.length}`,
|
|
585
|
+
params
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
return { rows: result.rows };
|
|
589
|
+
}
|
|
590
|
+
|
|
515
591
|
// ---------------------------------------------------------------------------
|
|
516
592
|
// recordFeedback — explicit trust feedback with audit trail
|
|
517
593
|
// ---------------------------------------------------------------------------
|
|
@@ -605,5 +681,6 @@ module.exports = {
|
|
|
605
681
|
extractUserTurns,
|
|
606
682
|
upsertTurnEmbeddings,
|
|
607
683
|
searchTurnEmbeddings,
|
|
684
|
+
searchSummaryEmbeddings,
|
|
608
685
|
recordFeedback,
|
|
609
686
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shadowforge0/aquifer-memory",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.8",
|
|
4
4
|
"description": "PG-native long-term memory for AI agents. Turn-level embedding, hybrid RRF ranking, optional knowledge graph. MCP server, CLI, and library API.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|