@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
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// aquifer.entityState.* — temporal state-change tracking on entities.
|
|
4
|
+
//
|
|
5
|
+
// One row per (entity, attribute) value valid over [valid_from, valid_to).
|
|
6
|
+
// Partial UNIQUE on (tenant, agent, entity, attribute) WHERE valid_to IS NULL
|
|
7
|
+
// enforces at-most-one-current.
|
|
8
|
+
//
|
|
9
|
+
// Out-of-order backfill is supported: applying a change with valid_from < the
|
|
10
|
+
// current row's valid_from inserts a closed-interval historical row instead
|
|
11
|
+
// of overwriting current. Same-value replays are no-ops (return action='noop_same_value').
|
|
12
|
+
//
|
|
13
|
+
// Source conflicts (current row source != incoming source AND values differ)
|
|
14
|
+
// return AQ_CONFLICT — the DB layer never assumes priority between manual /
|
|
15
|
+
// infra / llm; callers decide.
|
|
16
|
+
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const { ok, err } = require('./errors');
|
|
19
|
+
|
|
20
|
+
const VALID_SOURCES = new Set(['llm', 'manual', 'infra']);
|
|
21
|
+
const ATTRIBUTE_RE = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
|
|
22
|
+
|
|
23
|
+
function canonicalJson(value) {
|
|
24
|
+
// Stable JSON for idempotency hashing — sort object keys recursively.
|
|
25
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
26
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
27
|
+
const keys = Object.keys(value).sort();
|
|
28
|
+
return `{${keys.map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function defaultIdempotencyKey({ tenantId, agentId, entityId, attribute, value, validFrom, source, evidenceSessionId }) {
|
|
32
|
+
return crypto.createHash('sha256').update([
|
|
33
|
+
tenantId, agentId, String(entityId), attribute, canonicalJson(value),
|
|
34
|
+
new Date(validFrom).toISOString(), source, evidenceSessionId || '',
|
|
35
|
+
].join('|')).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toIsoOrNull(v) {
|
|
39
|
+
if (v === null || v === undefined) return null;
|
|
40
|
+
const d = v instanceof Date ? v : new Date(v);
|
|
41
|
+
return Number.isFinite(d.getTime()) ? d.toISOString() : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mapRow(row) {
|
|
45
|
+
if (!row) return null;
|
|
46
|
+
return {
|
|
47
|
+
stateId: Number(row.id),
|
|
48
|
+
tenantId: row.tenant_id,
|
|
49
|
+
agentId: row.agent_id,
|
|
50
|
+
entityId: Number(row.entity_id),
|
|
51
|
+
sessionRowId: (row.session_row_id !== null && row.session_row_id !== undefined) ? Number(row.session_row_id) : null,
|
|
52
|
+
evidenceSessionId: row.evidence_session_id || null,
|
|
53
|
+
attribute: row.attribute,
|
|
54
|
+
value: row.value,
|
|
55
|
+
validFrom: row.valid_from,
|
|
56
|
+
validTo: row.valid_to,
|
|
57
|
+
evidenceText: row.evidence_text || '',
|
|
58
|
+
confidence: (row.confidence !== null && row.confidence !== undefined) ? Number(row.confidence) : null,
|
|
59
|
+
source: row.source,
|
|
60
|
+
idempotencyKey: row.idempotency_key || null,
|
|
61
|
+
supersedesStateId: (row.supersedes_state_id !== null && row.supersedes_state_id !== undefined) ? Number(row.supersedes_state_id) : null,
|
|
62
|
+
createdAt: row.created_at,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateChange(change, idx) {
|
|
67
|
+
if (!change || typeof change !== 'object') {
|
|
68
|
+
return `changes[${idx}] is not an object`;
|
|
69
|
+
}
|
|
70
|
+
if (change.entityId === null || change.entityId === undefined || !Number.isInteger(Number(change.entityId))) {
|
|
71
|
+
return `changes[${idx}].entityId is required (integer)`;
|
|
72
|
+
}
|
|
73
|
+
if (typeof change.attribute !== 'string' || !ATTRIBUTE_RE.test(change.attribute)) {
|
|
74
|
+
return `changes[${idx}].attribute must match /^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$/ (got: ${JSON.stringify(change.attribute)})`;
|
|
75
|
+
}
|
|
76
|
+
if (change.value === undefined) {
|
|
77
|
+
return `changes[${idx}].value is required (use null for explicit null, undefined is forbidden)`;
|
|
78
|
+
}
|
|
79
|
+
const validFromIso = toIsoOrNull(change.validFrom);
|
|
80
|
+
if (!validFromIso) {
|
|
81
|
+
return `changes[${idx}].validFrom must parse to a valid timestamp`;
|
|
82
|
+
}
|
|
83
|
+
if (change.confidence !== null && change.confidence !== undefined) {
|
|
84
|
+
const c = Number(change.confidence);
|
|
85
|
+
if (!Number.isFinite(c) || c < 0 || c > 1) {
|
|
86
|
+
return `changes[${idx}].confidence must be in [0,1]`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (change.source !== null && change.source !== undefined && !VALID_SOURCES.has(change.source)) {
|
|
90
|
+
return `changes[${idx}].source must be one of llm|manual|infra`;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Apply a single change against a transaction client. Caller MUST be inside
|
|
96
|
+
// a transaction. Returns { action, row } on success. On AQ_CONFLICT it throws
|
|
97
|
+
// a tagged Error — the outer applyChanges() catches this (see err.code ===
|
|
98
|
+
// 'AQ_CONFLICT') and returns the err() envelope, keeping the public surface
|
|
99
|
+
// pure-envelope while letting tx-level callers still use try/catch. DB errors
|
|
100
|
+
// (syntax, permission, etc.) propagate as real exceptions so savepoint logic
|
|
101
|
+
// in aquifer.enrich() can rollback cleanly.
|
|
102
|
+
async function applyOneChange(client, change, ctx) {
|
|
103
|
+
const tenantId = change.tenantId || ctx.defaultTenantId || 'default';
|
|
104
|
+
const agentId = change.agentId || ctx.agentId || 'main';
|
|
105
|
+
const entityId = Number(change.entityId);
|
|
106
|
+
const attribute = change.attribute;
|
|
107
|
+
const value = change.value;
|
|
108
|
+
const validFrom = new Date(change.validFrom).toISOString();
|
|
109
|
+
const evidenceText = change.evidenceText || '';
|
|
110
|
+
const confidence = (change.confidence !== null && change.confidence !== undefined) ? Number(change.confidence) : (ctx.defaultConfidence ?? 0.7);
|
|
111
|
+
const source = change.source || 'llm';
|
|
112
|
+
const evidenceSessionId = change.evidenceSessionId || null;
|
|
113
|
+
const sessionRowId = (change.sessionRowId !== null && change.sessionRowId !== undefined) ? Number(change.sessionRowId) : (ctx.sessionRowId ?? null);
|
|
114
|
+
const idempotencyKey = change.idempotencyKey || defaultIdempotencyKey({
|
|
115
|
+
tenantId, agentId, entityId, attribute, value, validFrom, source, evidenceSessionId,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const schema = ctx.schema;
|
|
119
|
+
const tbl = `${schema}.entity_state_history`;
|
|
120
|
+
|
|
121
|
+
// Idempotency preflight: if a row with this key already exists, no-op.
|
|
122
|
+
const idemRow = await client.query(
|
|
123
|
+
`SELECT * FROM ${tbl} WHERE idempotency_key = $1 LIMIT 1`,
|
|
124
|
+
[idempotencyKey]
|
|
125
|
+
);
|
|
126
|
+
if (idemRow.rowCount > 0) {
|
|
127
|
+
return { action: 'noop_idempotent', row: mapRow(idemRow.rows[0]) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Lock the current open row (if any) for this (tenant, agent, entity, attribute).
|
|
131
|
+
const currentRes = await client.query(
|
|
132
|
+
`SELECT * FROM ${tbl}
|
|
133
|
+
WHERE tenant_id = $1 AND agent_id = $2 AND entity_id = $3 AND attribute = $4 AND valid_to IS NULL
|
|
134
|
+
FOR UPDATE`,
|
|
135
|
+
[tenantId, agentId, entityId, attribute]
|
|
136
|
+
);
|
|
137
|
+
const current = currentRes.rows[0] || null;
|
|
138
|
+
|
|
139
|
+
// Out-of-order backfill: incoming validFrom is older than current.validFrom →
|
|
140
|
+
// insert a closed-interval historical row [validFrom, predecessorSuccessor).
|
|
141
|
+
// Must check overlap with existing historical rows so the timeline stays
|
|
142
|
+
// non-overlapping (temporal integrity).
|
|
143
|
+
if (current && new Date(validFrom).getTime() < new Date(current.valid_from).getTime()) {
|
|
144
|
+
// Find the nearest neighbours on either side of validFrom.
|
|
145
|
+
const neighbourRes = await client.query(
|
|
146
|
+
`SELECT id, value, valid_from, valid_to, source
|
|
147
|
+
FROM ${tbl}
|
|
148
|
+
WHERE tenant_id = $1 AND agent_id = $2 AND entity_id = $3 AND attribute = $4
|
|
149
|
+
AND valid_from <= $5
|
|
150
|
+
ORDER BY valid_from DESC LIMIT 1`,
|
|
151
|
+
[tenantId, agentId, entityId, attribute, validFrom]
|
|
152
|
+
);
|
|
153
|
+
const predecessor = neighbourRes.rows[0] || null;
|
|
154
|
+
// Exact-timestamp collision on an older row → conflict (can't create
|
|
155
|
+
// duplicate interval start).
|
|
156
|
+
if (predecessor && new Date(predecessor.valid_from).getTime() === new Date(validFrom).getTime()) {
|
|
157
|
+
const conflictErr = new Error(
|
|
158
|
+
`entity_state_history: equal-timestamp historical conflict on (entity=${entityId}, attribute=${attribute}, valid_from=${validFrom}) — predecessor row #${predecessor.id} already has this start`
|
|
159
|
+
);
|
|
160
|
+
conflictErr.code = 'AQ_CONFLICT';
|
|
161
|
+
throw conflictErr;
|
|
162
|
+
}
|
|
163
|
+
// If predecessor has an open-ended interval (valid_to NULL) or a valid_to
|
|
164
|
+
// that extends past validFrom, we'd overlap — refuse.
|
|
165
|
+
if (predecessor) {
|
|
166
|
+
const predEnd = (predecessor.valid_to === null || predecessor.valid_to === undefined)
|
|
167
|
+
? Infinity : new Date(predecessor.valid_to).getTime();
|
|
168
|
+
if (predEnd > new Date(validFrom).getTime()) {
|
|
169
|
+
const conflictErr = new Error(
|
|
170
|
+
`entity_state_history: backfill overlaps predecessor row #${predecessor.id} [${predecessor.valid_from}, ${predecessor.valid_to ?? 'open'}) — incoming valid_from ${validFrom} falls inside`
|
|
171
|
+
);
|
|
172
|
+
conflictErr.code = 'AQ_CONFLICT';
|
|
173
|
+
throw conflictErr;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Successor = next row with valid_from > validFrom; the new historical
|
|
177
|
+
// interval closes at that successor's valid_from (not current's, which
|
|
178
|
+
// may be further in the future than the nearest successor).
|
|
179
|
+
const successorRes = await client.query(
|
|
180
|
+
`SELECT id, valid_from
|
|
181
|
+
FROM ${tbl}
|
|
182
|
+
WHERE tenant_id = $1 AND agent_id = $2 AND entity_id = $3 AND attribute = $4
|
|
183
|
+
AND valid_from > $5
|
|
184
|
+
ORDER BY valid_from ASC LIMIT 1`,
|
|
185
|
+
[tenantId, agentId, entityId, attribute, validFrom]
|
|
186
|
+
);
|
|
187
|
+
const successor = successorRes.rows[0]; // guaranteed to exist — `current` itself qualifies
|
|
188
|
+
const validTo = successor ? successor.valid_from : current.valid_from;
|
|
189
|
+
|
|
190
|
+
const inserted = await client.query(
|
|
191
|
+
`INSERT INTO ${tbl}
|
|
192
|
+
(tenant_id, agent_id, entity_id, session_row_id, evidence_session_id,
|
|
193
|
+
attribute, value, valid_from, valid_to, evidence_text, confidence, source,
|
|
194
|
+
idempotency_key, supersedes_state_id)
|
|
195
|
+
VALUES ($1,$2,$3,$4,$5, $6,$7::jsonb,$8,$9, $10,$11,$12, $13,NULL)
|
|
196
|
+
RETURNING *`,
|
|
197
|
+
[tenantId, agentId, entityId, sessionRowId, evidenceSessionId,
|
|
198
|
+
attribute, JSON.stringify(value), validFrom, validTo,
|
|
199
|
+
evidenceText, confidence, source, idempotencyKey]
|
|
200
|
+
);
|
|
201
|
+
return { action: 'inserted_historical', row: mapRow(inserted.rows[0]) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Same value as current → noop. (Optionally bump last_seen via separate API,
|
|
205
|
+
// but state_history is append-only by design.)
|
|
206
|
+
if (current && canonicalJson(current.value) === canonicalJson(value)) {
|
|
207
|
+
return { action: 'noop_same_value', row: mapRow(current) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Source-conflict guard: current row written by a different source, values
|
|
211
|
+
// differ. Don't auto-override; bubble up so caller decides.
|
|
212
|
+
if (current && current.source !== source) {
|
|
213
|
+
const conflictErr = new Error(
|
|
214
|
+
`entity_state_history: source conflict on (entity=${entityId}, attribute=${attribute}): current source=${current.source} value=${JSON.stringify(current.value)}, incoming source=${source} value=${JSON.stringify(value)}`
|
|
215
|
+
);
|
|
216
|
+
conflictErr.code = 'AQ_CONFLICT';
|
|
217
|
+
throw conflictErr;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Equal-timestamp different-value → conflict (history would be ambiguous).
|
|
221
|
+
if (current && new Date(current.valid_from).getTime() === new Date(validFrom).getTime()) {
|
|
222
|
+
const conflictErr = new Error(
|
|
223
|
+
`entity_state_history: equal-timestamp conflict on (entity=${entityId}, attribute=${attribute}, valid_from=${validFrom}) — value change must advance time`
|
|
224
|
+
);
|
|
225
|
+
conflictErr.code = 'AQ_CONFLICT';
|
|
226
|
+
throw conflictErr;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Forward-in-time supersede: close current, insert new current.
|
|
230
|
+
let supersededId = null;
|
|
231
|
+
if (current) {
|
|
232
|
+
await client.query(
|
|
233
|
+
`UPDATE ${tbl} SET valid_to = $1 WHERE id = $2`,
|
|
234
|
+
[validFrom, current.id]
|
|
235
|
+
);
|
|
236
|
+
supersededId = current.id;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const inserted = await client.query(
|
|
240
|
+
`INSERT INTO ${tbl}
|
|
241
|
+
(tenant_id, agent_id, entity_id, session_row_id, evidence_session_id,
|
|
242
|
+
attribute, value, valid_from, evidence_text, confidence, source,
|
|
243
|
+
idempotency_key, supersedes_state_id)
|
|
244
|
+
VALUES ($1,$2,$3,$4,$5, $6,$7::jsonb,$8, $9,$10,$11, $12,$13)
|
|
245
|
+
RETURNING *`,
|
|
246
|
+
[tenantId, agentId, entityId, sessionRowId, evidenceSessionId,
|
|
247
|
+
attribute, JSON.stringify(value), validFrom,
|
|
248
|
+
evidenceText, confidence, source, idempotencyKey, supersededId]
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
action: supersededId ? 'closed_and_inserted' : 'inserted_current',
|
|
253
|
+
row: mapRow(inserted.rows[0]),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function createEntityState({ pool, schema, defaultTenantId }) {
|
|
258
|
+
if (!pool) throw new Error('createEntityState: pool is required');
|
|
259
|
+
if (!schema) throw new Error('createEntityState: schema is required');
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// applyChanges (tx-aware, caller passes client)
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
async function applyChanges(client, input = {}) {
|
|
265
|
+
try {
|
|
266
|
+
if (!client || typeof client.query !== 'function') {
|
|
267
|
+
return err('AQ_INVALID_INPUT', 'applyChanges requires a tx client');
|
|
268
|
+
}
|
|
269
|
+
const changes = Array.isArray(input.changes) ? input.changes : null;
|
|
270
|
+
if (!changes) return err('AQ_INVALID_INPUT', 'changes must be an array');
|
|
271
|
+
|
|
272
|
+
// Validate up-front (cheap, fail-fast before any DB work).
|
|
273
|
+
for (let i = 0; i < changes.length; i++) {
|
|
274
|
+
const msg = validateChange(changes[i], i);
|
|
275
|
+
if (msg) return err('AQ_INVALID_INPUT', msg);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Sort changes by valid_from ASC so within a single batch the supersede
|
|
279
|
+
// chain is correctly built (older first, current last).
|
|
280
|
+
const sorted = changes.slice().sort((a, b) =>
|
|
281
|
+
new Date(a.validFrom).getTime() - new Date(b.validFrom).getTime()
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const ctx = {
|
|
285
|
+
schema,
|
|
286
|
+
defaultTenantId,
|
|
287
|
+
agentId: input.agentId,
|
|
288
|
+
sessionRowId: input.sessionRowId,
|
|
289
|
+
defaultConfidence: input.defaultConfidence,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const results = [];
|
|
293
|
+
for (const change of sorted) {
|
|
294
|
+
try {
|
|
295
|
+
const r = await applyOneChange(client, change, ctx);
|
|
296
|
+
results.push(r);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
if (e.code === 'AQ_CONFLICT') return err('AQ_CONFLICT', e.message);
|
|
299
|
+
throw e;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return ok({ applied: results });
|
|
303
|
+
} catch (e) {
|
|
304
|
+
return err('AQ_INTERNAL', e.message);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// applyChangesStandalone — convenience wrapper; opens its own savepointed tx.
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
async function applyChangesStandalone(input = {}) {
|
|
312
|
+
const client = await pool.connect();
|
|
313
|
+
try {
|
|
314
|
+
await client.query('BEGIN');
|
|
315
|
+
const result = await applyChanges(client, input);
|
|
316
|
+
if (result.ok) await client.query('COMMIT');
|
|
317
|
+
else await client.query('ROLLBACK');
|
|
318
|
+
return result;
|
|
319
|
+
} catch (e) {
|
|
320
|
+
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
|
|
321
|
+
return err('AQ_INTERNAL', e.message);
|
|
322
|
+
} finally {
|
|
323
|
+
client.release();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// resolveEntity — accept entityId or entityName, return entityId.
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
async function resolveEntity({ entityId, entityName, tenantId, agentId: _agentId, entityScope }) {
|
|
331
|
+
if (entityId !== null && entityId !== undefined) {
|
|
332
|
+
// When caller passed entityScope, require the looked-up entity to
|
|
333
|
+
// match it — otherwise the id can be used to read cross-scope state.
|
|
334
|
+
const params = [Number(entityId), tenantId || defaultTenantId || 'default'];
|
|
335
|
+
let scopeClause = '';
|
|
336
|
+
if (entityScope) {
|
|
337
|
+
params.push(entityScope);
|
|
338
|
+
scopeClause = `AND entity_scope = $${params.length}`;
|
|
339
|
+
}
|
|
340
|
+
const r = await pool.query(
|
|
341
|
+
`SELECT id, name FROM ${schema}.entities
|
|
342
|
+
WHERE id = $1 AND tenant_id = $2 ${scopeClause} LIMIT 1`,
|
|
343
|
+
params
|
|
344
|
+
);
|
|
345
|
+
if (r.rowCount === 0) return null;
|
|
346
|
+
return { entityId: Number(r.rows[0].id), entityName: r.rows[0].name };
|
|
347
|
+
}
|
|
348
|
+
if (entityName) {
|
|
349
|
+
const normalized = String(entityName).toLowerCase().normalize('NFKC').trim();
|
|
350
|
+
const r = await pool.query(
|
|
351
|
+
`SELECT id, name FROM ${schema}.entities
|
|
352
|
+
WHERE tenant_id = $1 AND normalized_name = $2
|
|
353
|
+
AND (entity_scope = $3 OR $3 IS NULL)
|
|
354
|
+
ORDER BY id ASC LIMIT 1`,
|
|
355
|
+
[tenantId || defaultTenantId || 'default', normalized, entityScope || null]
|
|
356
|
+
);
|
|
357
|
+
if (r.rowCount === 0) return null;
|
|
358
|
+
return { entityId: Number(r.rows[0].id), entityName: r.rows[0].name };
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// getEntityCurrentState
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
async function getEntityCurrentState(input = {}) {
|
|
367
|
+
try {
|
|
368
|
+
const tenantId = input.tenantId || defaultTenantId || 'default';
|
|
369
|
+
const agentId = input.agentId;
|
|
370
|
+
if (!agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
|
|
371
|
+
if ((input.entityId === null || input.entityId === undefined) && !input.entityName) {
|
|
372
|
+
return err('AQ_INVALID_INPUT', 'entityId or entityName is required');
|
|
373
|
+
}
|
|
374
|
+
const minConfidence = (input.minConfidence !== null && input.minConfidence !== undefined) ? Number(input.minConfidence) : 0;
|
|
375
|
+
const attributes = Array.isArray(input.attributes) && input.attributes.length > 0
|
|
376
|
+
? input.attributes : null;
|
|
377
|
+
|
|
378
|
+
const ent = await resolveEntity({
|
|
379
|
+
entityId: input.entityId,
|
|
380
|
+
entityName: input.entityName,
|
|
381
|
+
tenantId,
|
|
382
|
+
agentId,
|
|
383
|
+
entityScope: input.entityScope,
|
|
384
|
+
});
|
|
385
|
+
if (!ent) return err('AQ_NOT_FOUND', `entity not found (${input.entityId ?? input.entityName})`);
|
|
386
|
+
|
|
387
|
+
const params = [tenantId, agentId, ent.entityId, minConfidence];
|
|
388
|
+
let attrClause = '';
|
|
389
|
+
if (attributes) {
|
|
390
|
+
params.push(attributes);
|
|
391
|
+
attrClause = `AND attribute = ANY($${params.length})`;
|
|
392
|
+
}
|
|
393
|
+
const r = await pool.query(
|
|
394
|
+
`SELECT * FROM ${schema}.entity_state_history
|
|
395
|
+
WHERE tenant_id = $1 AND agent_id = $2 AND entity_id = $3
|
|
396
|
+
AND valid_to IS NULL
|
|
397
|
+
AND confidence >= $4
|
|
398
|
+
${attrClause}
|
|
399
|
+
ORDER BY attribute ASC`,
|
|
400
|
+
params
|
|
401
|
+
);
|
|
402
|
+
return ok({
|
|
403
|
+
entityId: ent.entityId,
|
|
404
|
+
entityName: ent.entityName,
|
|
405
|
+
states: r.rows.map(mapRow),
|
|
406
|
+
});
|
|
407
|
+
} catch (e) {
|
|
408
|
+
return err('AQ_INTERNAL', e.message);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// getEntityStateHistory
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
async function getEntityStateHistory(input = {}) {
|
|
416
|
+
try {
|
|
417
|
+
const tenantId = input.tenantId || defaultTenantId || 'default';
|
|
418
|
+
const agentId = input.agentId;
|
|
419
|
+
if (!agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
|
|
420
|
+
if ((input.entityId === null || input.entityId === undefined) && !input.entityName) {
|
|
421
|
+
return err('AQ_INVALID_INPUT', 'entityId or entityName is required');
|
|
422
|
+
}
|
|
423
|
+
const attribute = input.attribute || null; // optional: full entity history if omitted
|
|
424
|
+
const limit = Math.max(1, Math.min(200, Number(input.limit) || 50));
|
|
425
|
+
const minConfidence = (input.minConfidence !== null && input.minConfidence !== undefined) ? Number(input.minConfidence) : 0;
|
|
426
|
+
const before = input.before ? toIsoOrNull(input.before) : null;
|
|
427
|
+
|
|
428
|
+
const ent = await resolveEntity({
|
|
429
|
+
entityId: input.entityId,
|
|
430
|
+
entityName: input.entityName,
|
|
431
|
+
tenantId,
|
|
432
|
+
agentId,
|
|
433
|
+
entityScope: input.entityScope,
|
|
434
|
+
});
|
|
435
|
+
if (!ent) return err('AQ_NOT_FOUND', `entity not found (${input.entityId ?? input.entityName})`);
|
|
436
|
+
|
|
437
|
+
const where = [
|
|
438
|
+
'tenant_id = $1', 'agent_id = $2', 'entity_id = $3', 'confidence >= $4',
|
|
439
|
+
];
|
|
440
|
+
const params = [tenantId, agentId, ent.entityId, minConfidence];
|
|
441
|
+
if (attribute) {
|
|
442
|
+
params.push(attribute);
|
|
443
|
+
where.push(`attribute = $${params.length}`);
|
|
444
|
+
}
|
|
445
|
+
if (before) {
|
|
446
|
+
params.push(before);
|
|
447
|
+
where.push(`valid_from < $${params.length}`);
|
|
448
|
+
}
|
|
449
|
+
params.push(limit);
|
|
450
|
+
|
|
451
|
+
const r = await pool.query(
|
|
452
|
+
`SELECT * FROM ${schema}.entity_state_history
|
|
453
|
+
WHERE ${where.join(' AND ')}
|
|
454
|
+
ORDER BY valid_from DESC, id DESC
|
|
455
|
+
LIMIT $${params.length}`,
|
|
456
|
+
params
|
|
457
|
+
);
|
|
458
|
+
return ok({
|
|
459
|
+
entityId: ent.entityId,
|
|
460
|
+
entityName: ent.entityName,
|
|
461
|
+
rows: r.rows.map(mapRow),
|
|
462
|
+
});
|
|
463
|
+
} catch (e) {
|
|
464
|
+
return err('AQ_INTERNAL', e.message);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
applyChanges,
|
|
470
|
+
applyChangesStandalone,
|
|
471
|
+
getEntityCurrentState,
|
|
472
|
+
getEntityStateHistory,
|
|
473
|
+
// expose for testing
|
|
474
|
+
_internal: { defaultIdempotencyKey, canonicalJson, validateChange, applyOneChange, resolveEntity },
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
module.exports = {
|
|
479
|
+
createEntityState,
|
|
480
|
+
defaultIdempotencyKey,
|
|
481
|
+
canonicalJson,
|
|
482
|
+
validateChange,
|
|
483
|
+
};
|