@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,340 @@
1
+ 'use strict';
2
+
3
+ // aq.consolidation.* — session-level multi-phase orchestration.
4
+ //
5
+ // Spec: aquifer-completion §3 consolidationOrchestration. State lives in
6
+ // sessions.consolidation_phases JSONB keyed by phase name. 10 phases cover
7
+ // the post-session pipeline: summary_extract, entity_extract, fact_extract,
8
+ // fact_consolidation, narrative_refresh, decision_write, handoff_write,
9
+ // session_state_write, timeline_write, artifact_dispatch.
10
+ //
11
+ // Status vocabulary: pending|claimed|running|succeeded|failed|skipped.
12
+ // State transitions (enforced by transitionPhase):
13
+ // pending → claimed
14
+ // claimed → running|failed|skipped|claimed (stale reclaim only)
15
+ // running → succeeded|failed|claimed (stale reclaim only)
16
+ // failed → claimed (retry)
17
+ // succeeded|skipped → non-terminal requires forceReplay=true
18
+ //
19
+ // Advisory lock (pg_advisory_xact_lock on session_row_id) wraps the
20
+ // read-modify-write in a transaction so two workers can't claim the same
21
+ // session phase simultaneously.
22
+
23
+ const crypto = require('crypto');
24
+ const { AqError, ok, err } = require('./errors');
25
+
26
+ const PHASES = Object.freeze([
27
+ 'summary_extract',
28
+ 'entity_extract',
29
+ 'fact_extract',
30
+ 'fact_consolidation',
31
+ 'narrative_refresh',
32
+ 'decision_write',
33
+ 'handoff_write',
34
+ 'session_state_write',
35
+ 'timeline_write',
36
+ 'artifact_dispatch',
37
+ ]);
38
+ const PHASE_SET = new Set(PHASES);
39
+
40
+ const STATUSES = Object.freeze([
41
+ 'pending', 'claimed', 'running', 'succeeded', 'failed', 'skipped',
42
+ ]);
43
+ const STATUS_SET = new Set(STATUSES);
44
+
45
+ const TERMINAL = new Set(['succeeded', 'skipped']);
46
+
47
+ // Valid transitions. Caller must specify fromStatus to guard against races.
48
+ const VALID_TRANSITIONS = {
49
+ pending: new Set(['claimed']),
50
+ claimed: new Set(['running', 'failed', 'skipped', 'claimed']),
51
+ running: new Set(['succeeded', 'failed', 'claimed']),
52
+ failed: new Set(['claimed']),
53
+ succeeded: new Set([]),
54
+ skipped: new Set([]),
55
+ };
56
+
57
+ function toNumber(v) {
58
+ if (v === null || v === undefined) return null;
59
+ const n = Number(v);
60
+ return Number.isFinite(n) ? n : null;
61
+ }
62
+
63
+ function emptyPhaseState() {
64
+ return { status: 'pending', attempts: 0 };
65
+ }
66
+
67
+ function fillDefaults(phasesJson) {
68
+ const out = { ...(phasesJson || {}) };
69
+ for (const phase of PHASES) {
70
+ if (!out[phase]) out[phase] = emptyPhaseState();
71
+ }
72
+ return out;
73
+ }
74
+
75
+ function isStale(phaseState, staleAfterSeconds) {
76
+ if (phaseState.status !== 'claimed' && phaseState.status !== 'running') return false;
77
+ const startedAt = phaseState.startedAt;
78
+ if (!startedAt) return true;
79
+ const ageMs = Date.now() - new Date(startedAt).getTime();
80
+ return ageMs > staleAfterSeconds * 1000;
81
+ }
82
+
83
+ function newClaimToken() {
84
+ return crypto.randomBytes(12).toString('hex');
85
+ }
86
+
87
+ function advisoryLockKey(sessionRowId) {
88
+ // Map bigint-ish id to signed int4 range for pg_advisory_xact_lock.
89
+ const id = Number(sessionRowId);
90
+ return (id ^ 0x9e3779b9) & 0x7fffffff;
91
+ }
92
+
93
+ function createConsolidation({ pool, schema, defaultTenantId }) {
94
+
95
+ async function claimNext(input = {}) {
96
+ try {
97
+ if (!input.workerId) return err('AQ_INVALID_INPUT', 'workerId is required');
98
+ const tenantId = input.tenantId || defaultTenantId || 'default';
99
+ const phases = Array.isArray(input.phases) && input.phases.length > 0
100
+ ? input.phases.filter(p => PHASE_SET.has(p))
101
+ : PHASES;
102
+ if (phases.length === 0) {
103
+ return err('AQ_INVALID_INPUT', 'phases filter produced empty list');
104
+ }
105
+ const staleAfterSeconds = Number.isFinite(input.staleAfterSeconds)
106
+ ? Math.max(10, input.staleAfterSeconds)
107
+ : 600;
108
+
109
+ // Look at candidate sessions with at least one non-terminal phase,
110
+ // then iterate under advisory lock to claim atomically.
111
+ const candidates = await pool.query(
112
+ `SELECT id AS session_row_id, session_id, agent_id, processing_status,
113
+ consolidation_phases
114
+ FROM ${schema}.sessions
115
+ WHERE tenant_id = $1
116
+ ORDER BY id ASC
117
+ LIMIT 200`,
118
+ [tenantId],
119
+ );
120
+
121
+ for (const row of candidates.rows) {
122
+ const current = fillDefaults(row.consolidation_phases);
123
+ let targetPhase = null;
124
+ for (const p of phases) {
125
+ const st = current[p];
126
+ if (st.status === 'pending' || st.status === 'failed') {
127
+ targetPhase = p; break;
128
+ }
129
+ if ((st.status === 'claimed' || st.status === 'running')
130
+ && isStale(st, staleAfterSeconds)) {
131
+ targetPhase = p; break;
132
+ }
133
+ }
134
+ if (!targetPhase) continue;
135
+
136
+ const client = await pool.connect();
137
+ try {
138
+ await client.query('BEGIN');
139
+ await client.query('SELECT pg_advisory_xact_lock($1)',
140
+ [advisoryLockKey(row.session_row_id)]);
141
+
142
+ // Re-read under lock — another worker may have claimed in between.
143
+ const { rows: freshRows } = await client.query(
144
+ `SELECT consolidation_phases FROM ${schema}.sessions WHERE id = $1`,
145
+ [row.session_row_id],
146
+ );
147
+ const fresh = fillDefaults(freshRows[0] && freshRows[0].consolidation_phases);
148
+ const freshState = fresh[targetPhase];
149
+ const eligible =
150
+ freshState.status === 'pending'
151
+ || freshState.status === 'failed'
152
+ || ((freshState.status === 'claimed' || freshState.status === 'running')
153
+ && isStale(freshState, staleAfterSeconds));
154
+ if (!eligible) {
155
+ await client.query('ROLLBACK');
156
+ client.release();
157
+ continue;
158
+ }
159
+
160
+ const claimToken = newClaimToken();
161
+ const attempts = (freshState.attempts || 0) + 1;
162
+ fresh[targetPhase] = {
163
+ ...freshState,
164
+ status: 'claimed',
165
+ claimToken,
166
+ workerId: input.workerId,
167
+ startedAt: new Date().toISOString(),
168
+ finishedAt: null,
169
+ attempts,
170
+ errorCode: null,
171
+ errorMessage: null,
172
+ };
173
+
174
+ await client.query(
175
+ `UPDATE ${schema}.sessions SET consolidation_phases = $1
176
+ WHERE id = $2`,
177
+ [JSON.stringify(fresh), row.session_row_id],
178
+ );
179
+ await client.query('COMMIT');
180
+ client.release();
181
+
182
+ return ok({
183
+ session: {
184
+ sessionRowId: toNumber(row.session_row_id),
185
+ sessionId: row.session_id,
186
+ agentId: row.agent_id,
187
+ processingStatus: row.processing_status,
188
+ phases: fresh,
189
+ },
190
+ claimToken,
191
+ claimedPhase: targetPhase,
192
+ });
193
+ } catch (e) {
194
+ await client.query('ROLLBACK').catch(() => {});
195
+ client.release();
196
+ throw e;
197
+ }
198
+ }
199
+
200
+ return ok({ session: null, claimToken: null, claimedPhase: null });
201
+ } catch (e) {
202
+ if (e instanceof AqError) return err(e);
203
+ return err('AQ_INTERNAL', e.message, { cause: e });
204
+ }
205
+ }
206
+
207
+ async function transitionPhase(input = {}) {
208
+ try {
209
+ if (!input.sessionId) return err('AQ_INVALID_INPUT', 'sessionId is required');
210
+ if (!input.phase || !PHASE_SET.has(input.phase)) {
211
+ return err('AQ_INVALID_INPUT', `phase must be one of ${PHASES.join(', ')}`);
212
+ }
213
+ if (!input.fromStatus || !STATUS_SET.has(input.fromStatus)) {
214
+ return err('AQ_INVALID_INPUT', 'valid fromStatus is required');
215
+ }
216
+ if (!input.toStatus || !STATUS_SET.has(input.toStatus)) {
217
+ return err('AQ_INVALID_INPUT', 'valid toStatus is required');
218
+ }
219
+ const tenantId = input.tenantId || defaultTenantId || 'default';
220
+
221
+ const sessionRow = await pool.query(
222
+ `SELECT id FROM ${schema}.sessions WHERE tenant_id = $1 AND session_id = $2`,
223
+ [tenantId, input.sessionId],
224
+ );
225
+ if (sessionRow.rowCount === 0) {
226
+ return err('AQ_NOT_FOUND', `session ${input.sessionId} not found`);
227
+ }
228
+ const sessionRowId = sessionRow.rows[0].id;
229
+
230
+ const client = await pool.connect();
231
+ try {
232
+ await client.query('BEGIN');
233
+ await client.query('SELECT pg_advisory_xact_lock($1)',
234
+ [advisoryLockKey(sessionRowId)]);
235
+
236
+ const { rows } = await client.query(
237
+ `SELECT consolidation_phases FROM ${schema}.sessions WHERE id = $1`,
238
+ [sessionRowId],
239
+ );
240
+ const phases = fillDefaults(rows[0] && rows[0].consolidation_phases);
241
+ const current = phases[input.phase];
242
+
243
+ // Guard: fromStatus must match current.
244
+ if (current.status !== input.fromStatus) {
245
+ await client.query('ROLLBACK');
246
+ return err('AQ_PHASE_CLAIM_CONFLICT',
247
+ `phase ${input.phase} currently ${current.status}, not ${input.fromStatus}`);
248
+ }
249
+
250
+ // Guard: claimToken must match when transitioning from claimed/running.
251
+ if ((input.fromStatus === 'claimed' || input.fromStatus === 'running')
252
+ && input.claimToken && current.claimToken !== input.claimToken) {
253
+ await client.query('ROLLBACK');
254
+ return err('AQ_PHASE_CLAIM_CONFLICT',
255
+ `claimToken mismatch for phase ${input.phase}`);
256
+ }
257
+
258
+ // Validate transition.
259
+ const allowed = VALID_TRANSITIONS[input.fromStatus] || new Set();
260
+ if (!allowed.has(input.toStatus)) {
261
+ // Terminal → non-terminal requires forceReplay.
262
+ const leavingTerminal = TERMINAL.has(input.fromStatus);
263
+ if (!(leavingTerminal && input.forceReplay === true)) {
264
+ await client.query('ROLLBACK');
265
+ return err('AQ_PHASE_TRANSITION_INVALID',
266
+ `cannot transition ${input.phase} from ${input.fromStatus} to ${input.toStatus}`);
267
+ }
268
+ }
269
+
270
+ const next = { ...current, status: input.toStatus };
271
+ if (input.toStatus === 'running') {
272
+ next.startedAt = current.startedAt || new Date().toISOString();
273
+ }
274
+ if (input.toStatus === 'succeeded' || input.toStatus === 'failed'
275
+ || input.toStatus === 'skipped') {
276
+ next.finishedAt = new Date().toISOString();
277
+ if (input.toStatus === 'succeeded' || input.toStatus === 'skipped') {
278
+ next.errorCode = null;
279
+ next.errorMessage = null;
280
+ }
281
+ }
282
+ if (input.toStatus === 'failed' && input.error) {
283
+ next.errorCode = input.error.code || 'AQ_INTERNAL';
284
+ next.errorMessage = input.error.message || '';
285
+ }
286
+ if (input.retryAfter) next.retryAfter = input.retryAfter;
287
+ if (input.idempotencyKey) next.idempotencyKey = input.idempotencyKey;
288
+ if (input.outputRef) next.outputRef = { ...(current.outputRef || {}), ...input.outputRef };
289
+
290
+ phases[input.phase] = next;
291
+
292
+ await client.query(
293
+ `UPDATE ${schema}.sessions SET consolidation_phases = $1 WHERE id = $2`,
294
+ [JSON.stringify(phases), sessionRowId],
295
+ );
296
+ await client.query('COMMIT');
297
+
298
+ return ok({
299
+ sessionId: input.sessionId,
300
+ phase: input.phase,
301
+ state: next,
302
+ });
303
+ } catch (e) {
304
+ await client.query('ROLLBACK').catch(() => {});
305
+ throw e;
306
+ } finally {
307
+ client.release();
308
+ }
309
+ } catch (e) {
310
+ if (e instanceof AqError) return err(e);
311
+ return err('AQ_INTERNAL', e.message, { cause: e });
312
+ }
313
+ }
314
+
315
+ async function getState(input = {}) {
316
+ try {
317
+ if (!input.sessionId) return err('AQ_INVALID_INPUT', 'sessionId is required');
318
+ const tenantId = input.tenantId || defaultTenantId || 'default';
319
+ const { rows } = await pool.query(
320
+ `SELECT processing_status, consolidation_phases
321
+ FROM ${schema}.sessions
322
+ WHERE tenant_id = $1 AND session_id = $2`,
323
+ [tenantId, input.sessionId],
324
+ );
325
+ if (rows.length === 0) {
326
+ return err('AQ_NOT_FOUND', `session ${input.sessionId} not found`);
327
+ }
328
+ return ok({
329
+ processingStatus: rows[0].processing_status,
330
+ phases: fillDefaults(rows[0].consolidation_phases),
331
+ });
332
+ } catch (e) {
333
+ return err('AQ_INTERNAL', e.message, { cause: e });
334
+ }
335
+ }
336
+
337
+ return { claimNext, transitionPhase, getState, PHASES, STATUSES };
338
+ }
339
+
340
+ module.exports = { createConsolidation, PHASES, STATUSES };
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ // aq.decisions.* — append-only decision log capability.
4
+ //
5
+ // Spec: aquifer-completion §9 decisionLog. status vocabulary
6
+ // (proposed/committed/reversed) enforced both at API layer (fast reject)
7
+ // and by DB CHECK constraint (defense in depth). reversal is implemented
8
+ // by appending a new 'reversed' decision and optionally pointing
9
+ // reversed_by_decision_id; Aquifer doesn't auto-compute the chain.
10
+
11
+ const crypto = require('crypto');
12
+ const { AqError, ok, err } = require('./errors');
13
+
14
+ const DEFAULT_PROFILE = Object.freeze({
15
+ id: 'anon',
16
+ version: 0,
17
+ schemaHash: 'pending',
18
+ });
19
+
20
+ const VALID_STATUSES = new Set(['proposed', 'committed', 'reversed']);
21
+
22
+ function resolveProfile(profile) {
23
+ if (!profile) return DEFAULT_PROFILE;
24
+ return {
25
+ id: profile.id || DEFAULT_PROFILE.id,
26
+ version: Number.isInteger(profile.version) ? profile.version : DEFAULT_PROFILE.version,
27
+ schemaHash: profile.schemaHash || DEFAULT_PROFILE.schemaHash,
28
+ };
29
+ }
30
+
31
+ function toNumber(v) {
32
+ if (v === null || v === undefined) return null;
33
+ const n = Number(v);
34
+ return Number.isFinite(n) ? n : null;
35
+ }
36
+
37
+ function defaultIdempotencyKey({ tenantId, agentId, sessionId, payload }) {
38
+ return crypto.createHash('sha256')
39
+ .update(`${tenantId}:${agentId}:${sessionId}:${JSON.stringify(payload)}`)
40
+ .digest('hex');
41
+ }
42
+
43
+ function mapRow(row) {
44
+ if (!row) return null;
45
+ return {
46
+ decisionId: toNumber(row.id),
47
+ sessionId: row.source_session_id,
48
+ agentId: row.agent_id,
49
+ status: row.status,
50
+ decisionText: row.decision_text,
51
+ reasonText: row.reason_text,
52
+ payload: row.payload || {},
53
+ metadata: row.metadata || {},
54
+ decidedAt: row.decided_at,
55
+ reversedByDecisionId: toNumber(row.reversed_by_decision_id),
56
+ createdAt: row.created_at,
57
+ };
58
+ }
59
+
60
+ function createDecisions({ pool, schema, defaultTenantId }) {
61
+ async function append(input) {
62
+ try {
63
+ if (!input || typeof input !== 'object') {
64
+ return err('AQ_INVALID_INPUT', 'append requires an input object');
65
+ }
66
+ if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
67
+ if (!input.sessionId) return err('AQ_INVALID_INPUT', 'sessionId is required');
68
+ if (!input.payload || typeof input.payload !== 'object') {
69
+ return err('AQ_INVALID_INPUT', 'payload is required');
70
+ }
71
+ const tenantId = input.tenantId || defaultTenantId || 'default';
72
+ const agentId = input.agentId;
73
+ const sessionId = input.sessionId;
74
+ const payload = input.payload;
75
+ const profile = resolveProfile(input.profile);
76
+
77
+ const status = payload.status || 'committed';
78
+ if (!VALID_STATUSES.has(status)) {
79
+ return err('AQ_INVALID_INPUT',
80
+ `status must be one of ${Array.from(VALID_STATUSES).join(', ')}`);
81
+ }
82
+ const decisionText = typeof payload.decision === 'string'
83
+ ? payload.decision
84
+ : (typeof payload.decision_text === 'string' ? payload.decision_text : null);
85
+ if (!decisionText) {
86
+ return err('AQ_INVALID_INPUT', 'payload.decision (or decision_text) is required');
87
+ }
88
+ const reasonText = typeof payload.reason === 'string'
89
+ ? payload.reason
90
+ : (typeof payload.reason_text === 'string' ? payload.reason_text : null);
91
+
92
+ const idempotencyKey = input.idempotencyKey
93
+ || defaultIdempotencyKey({ tenantId, agentId, sessionId, payload });
94
+
95
+ const insertResult = await pool.query(
96
+ `INSERT INTO ${schema}.decisions (
97
+ tenant_id, agent_id, source_session_id,
98
+ consumer_profile_id, consumer_profile_version, consumer_schema_hash,
99
+ idempotency_key, payload, status, decision_text, reason_text,
100
+ decided_at, metadata
101
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
102
+ COALESCE($12::timestamptz, now()), $13)
103
+ ON CONFLICT (idempotency_key) DO NOTHING
104
+ RETURNING *`,
105
+ [
106
+ tenantId, agentId, sessionId,
107
+ profile.id, profile.version, profile.schemaHash,
108
+ idempotencyKey, JSON.stringify(payload), status,
109
+ decisionText, reasonText,
110
+ input.decidedAt || null,
111
+ JSON.stringify(payload.metadata || {}),
112
+ ],
113
+ );
114
+ let row = insertResult.rows[0];
115
+ if (!row) {
116
+ const existing = await pool.query(
117
+ `SELECT * FROM ${schema}.decisions WHERE idempotency_key = $1`,
118
+ [idempotencyKey],
119
+ );
120
+ row = existing.rows[0];
121
+ }
122
+ const mapped = mapRow(row);
123
+ return ok({ decisionId: mapped.decisionId, payload: mapped.payload });
124
+ } catch (e) {
125
+ if (e instanceof AqError) return err(e);
126
+ return err('AQ_INTERNAL', e.message, { cause: e });
127
+ }
128
+ }
129
+
130
+ async function list(input = {}) {
131
+ try {
132
+ if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
133
+ const tenantId = input.tenantId || defaultTenantId || 'default';
134
+ const limit = Math.min(Math.max(input.limit || 50, 1), 500);
135
+
136
+ const params = [tenantId, input.agentId];
137
+ let where = 'tenant_id = $1 AND agent_id = $2';
138
+ if (Array.isArray(input.statuses) && input.statuses.length > 0) {
139
+ params.push(input.statuses);
140
+ where += ` AND status = ANY($${params.length})`;
141
+ }
142
+ if (input.sessionId) {
143
+ params.push(input.sessionId);
144
+ where += ` AND source_session_id = $${params.length}`;
145
+ }
146
+ params.push(limit);
147
+
148
+ const { rows } = await pool.query(
149
+ `SELECT * FROM ${schema}.decisions
150
+ WHERE ${where}
151
+ ORDER BY decided_at DESC, id DESC
152
+ LIMIT $${params.length}`,
153
+ params,
154
+ );
155
+ return ok({ rows: rows.map(mapRow) });
156
+ } catch (e) {
157
+ return err('AQ_INTERNAL', e.message, { cause: e });
158
+ }
159
+ }
160
+
161
+ return { append, list };
162
+ }
163
+
164
+ module.exports = { createDecisions };