@shadowforge0/aquifer-memory 1.0.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +37 -29
  2. package/consumers/claude-code.js +117 -0
  3. package/consumers/cli.js +28 -1
  4. package/consumers/default/daily-entries.js +196 -0
  5. package/consumers/default/index.js +282 -0
  6. package/consumers/default/prompts/summary.js +153 -0
  7. package/consumers/mcp.js +3 -23
  8. package/consumers/miranda/context-inject.js +119 -0
  9. package/consumers/miranda/daily-entries.js +224 -0
  10. package/consumers/miranda/index.js +353 -0
  11. package/consumers/miranda/instance.js +55 -0
  12. package/consumers/miranda/llm.js +99 -0
  13. package/consumers/miranda/profile.json +145 -0
  14. package/consumers/miranda/prompts/summary.js +303 -0
  15. package/consumers/miranda/recall-format.js +74 -0
  16. package/consumers/miranda/render-daily-md.js +186 -0
  17. package/consumers/miranda/workspace-files.js +91 -0
  18. package/consumers/openclaw-ext/index.js +38 -0
  19. package/consumers/openclaw-ext/openclaw.plugin.json +9 -0
  20. package/consumers/openclaw-ext/package.json +10 -0
  21. package/consumers/openclaw-plugin.js +66 -74
  22. package/consumers/opencode.js +21 -24
  23. package/consumers/shared/autodetect.js +64 -0
  24. package/consumers/shared/entity-parser.js +119 -0
  25. package/consumers/shared/ingest.js +148 -0
  26. package/consumers/shared/llm-autodetect.js +137 -0
  27. package/consumers/shared/normalize.js +129 -0
  28. package/consumers/shared/recall-format.js +110 -0
  29. package/core/aquifer.js +209 -71
  30. package/core/artifacts.js +174 -0
  31. package/core/bundles.js +400 -0
  32. package/core/consolidation.js +340 -0
  33. package/core/decisions.js +164 -0
  34. package/core/entity.js +1 -3
  35. package/core/errors.js +97 -0
  36. package/core/handoff.js +153 -0
  37. package/core/mcp-manifest.js +131 -0
  38. package/core/narratives.js +212 -0
  39. package/core/profiles.js +171 -0
  40. package/core/state.js +163 -0
  41. package/core/storage.js +86 -28
  42. package/core/timeline.js +152 -0
  43. package/docs/postprocess-contract.md +132 -0
  44. package/index.js +23 -1
  45. package/package.json +23 -2
  46. package/pipeline/_http.js +1 -1
  47. package/pipeline/consolidation/apply.js +176 -0
  48. package/pipeline/consolidation/index.js +21 -0
  49. package/pipeline/extract-entities.js +2 -2
  50. package/pipeline/rerank.js +1 -1
  51. package/pipeline/summarize.js +4 -1
  52. package/schema/001-base.sql +61 -24
  53. package/schema/002-entities.sql +17 -3
  54. package/schema/004-completion.sql +375 -0
  55. package/schema/004-facts.sql +67 -0
  56. package/scripts/diagnose-fts-zh.js +168 -134
  57. package/scripts/diagnose-vector.js +188 -0
  58. package/scripts/install-openclaw.sh +59 -0
  59. package/scripts/smoke.mjs +2 -2
package/core/state.js ADDED
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ // aq.state.* — latest-snapshot-per-scope session state capability.
4
+ //
5
+ // Spec: aquifer-completion §7 sessionState. Strict core table is
6
+ // ${schema}.session_states. Default shape
7
+ // { goal, active_work, blockers, affect } is projected to explicit
8
+ // columns for cheap filtering; full payload also stored as JSONB so
9
+ // consumer-specific fields are lossless.
10
+ //
11
+ // write() supersedes the prior is_latest=true row for the same
12
+ // (tenant, agent, scope_key) atomically. idempotencyKey replay returns
13
+ // the existing row unchanged. Partial unique index enforces at-most-one
14
+ // latest per scope.
15
+
16
+ const crypto = require('crypto');
17
+ const { AqError, ok, err } = require('./errors');
18
+
19
+ const DEFAULT_PROFILE = Object.freeze({
20
+ id: 'anon',
21
+ version: 0,
22
+ schemaHash: 'pending',
23
+ });
24
+
25
+ function resolveProfile(profile) {
26
+ if (!profile) return DEFAULT_PROFILE;
27
+ return {
28
+ id: profile.id || DEFAULT_PROFILE.id,
29
+ version: Number.isInteger(profile.version) ? profile.version : DEFAULT_PROFILE.version,
30
+ schemaHash: profile.schemaHash || DEFAULT_PROFILE.schemaHash,
31
+ };
32
+ }
33
+
34
+ function toNumber(v) {
35
+ if (v === null || v === undefined) return null;
36
+ const n = Number(v);
37
+ return Number.isFinite(n) ? n : null;
38
+ }
39
+
40
+ function defaultIdempotencyKey({ tenantId, agentId, scopeKey, payload }) {
41
+ return crypto.createHash('sha256')
42
+ .update(`${tenantId}:${agentId}:${scopeKey}:${JSON.stringify(payload)}`)
43
+ .digest('hex');
44
+ }
45
+
46
+ function mapRow(row) {
47
+ if (!row) return null;
48
+ return {
49
+ stateId: toNumber(row.id),
50
+ agentId: row.agent_id,
51
+ scopeKey: row.scope_key,
52
+ payload: row.payload || {},
53
+ isLatest: row.is_latest,
54
+ supersedesStateId: toNumber(row.supersedes_state_id),
55
+ createdAt: row.created_at,
56
+ };
57
+ }
58
+
59
+ function createState({ pool, schema, defaultTenantId }) {
60
+ async function write(input) {
61
+ try {
62
+ if (!input || typeof input !== 'object') {
63
+ return err('AQ_INVALID_INPUT', 'write requires an input object');
64
+ }
65
+ if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId 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 scopeKey = input.scopeKey || agentId;
72
+ const payload = input.payload;
73
+ const profile = resolveProfile(input.profile);
74
+ const idempotencyKey = input.idempotencyKey
75
+ || defaultIdempotencyKey({ tenantId, agentId, scopeKey, payload });
76
+
77
+ // Projected columns for cheap filtering/indexes. Fall back cleanly
78
+ // when payload uses consumer-specific shape.
79
+ const goal = typeof payload.goal === 'string' ? payload.goal : null;
80
+ const activeWork = Array.isArray(payload.active_work) ? payload.active_work : [];
81
+ const blockers = Array.isArray(payload.blockers) ? payload.blockers : [];
82
+ const affect = payload.affect && typeof payload.affect === 'object' ? payload.affect : {};
83
+
84
+ const client = await pool.connect();
85
+ try {
86
+ await client.query('BEGIN');
87
+
88
+ const existing = await client.query(
89
+ `SELECT * FROM ${schema}.session_states WHERE idempotency_key = $1`,
90
+ [idempotencyKey],
91
+ );
92
+ if (existing.rowCount > 0) {
93
+ await client.query('COMMIT');
94
+ return ok(mapRow(existing.rows[0]));
95
+ }
96
+
97
+ const prev = await client.query(
98
+ `UPDATE ${schema}.session_states
99
+ SET is_latest = false
100
+ WHERE tenant_id = $1 AND agent_id = $2 AND scope_key = $3
101
+ AND is_latest = true
102
+ RETURNING id`,
103
+ [tenantId, agentId, scopeKey],
104
+ );
105
+ const supersedesStateId = prev.rowCount > 0 ? toNumber(prev.rows[0].id) : null;
106
+
107
+ const inserted = await client.query(
108
+ `INSERT INTO ${schema}.session_states (
109
+ tenant_id, agent_id, scope_key, source_session_id,
110
+ consumer_profile_id, consumer_profile_version, consumer_schema_hash,
111
+ idempotency_key, goal, active_work, blockers, affect, payload,
112
+ is_latest, supersedes_state_id
113
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, true, $14)
114
+ RETURNING *`,
115
+ [
116
+ tenantId, agentId, scopeKey, input.sessionId || null,
117
+ profile.id, profile.version, profile.schemaHash,
118
+ idempotencyKey, goal,
119
+ JSON.stringify(activeWork), JSON.stringify(blockers), JSON.stringify(affect),
120
+ JSON.stringify(payload), supersedesStateId,
121
+ ],
122
+ );
123
+
124
+ await client.query('COMMIT');
125
+ return ok(mapRow(inserted.rows[0]));
126
+ } catch (e) {
127
+ await client.query('ROLLBACK').catch(() => {});
128
+ throw e;
129
+ } finally {
130
+ client.release();
131
+ }
132
+ } catch (e) {
133
+ if (e instanceof AqError) return err(e);
134
+ return err('AQ_INTERNAL', e.message, { cause: e });
135
+ }
136
+ }
137
+
138
+ async function getLatest(input = {}) {
139
+ try {
140
+ if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
141
+ const tenantId = input.tenantId || defaultTenantId || 'default';
142
+ const scopeKey = input.scopeKey || input.agentId;
143
+ const { rows } = await pool.query(
144
+ `SELECT * FROM ${schema}.session_states
145
+ WHERE tenant_id = $1 AND agent_id = $2
146
+ AND scope_key = $3 AND is_latest = true
147
+ LIMIT 1`,
148
+ [tenantId, input.agentId, scopeKey],
149
+ );
150
+ const mapped = mapRow(rows[0]);
151
+ return ok({
152
+ state: mapped ? mapped.payload : null,
153
+ stateId: mapped ? mapped.stateId : null,
154
+ });
155
+ } catch (e) {
156
+ return err('AQ_INTERNAL', e.message, { cause: e });
157
+ }
158
+ }
159
+
160
+ return { write, getLatest };
161
+ }
162
+
163
+ module.exports = { createState };
package/core/storage.js CHANGED
@@ -281,7 +281,10 @@ async function searchSessions(pool, query, {
281
281
  FROM ${qi(schema)}.sessions s
282
282
  LEFT JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
283
283
  WHERE ${where.join(' AND ')}
284
- ORDER BY fts_rank DESC, s.last_message_at DESC NULLS LAST
284
+ ORDER BY
285
+ COALESCE(ss.search_text ILIKE '%' || $1 || '%', FALSE) DESC,
286
+ fts_rank DESC,
287
+ s.last_message_at DESC NULLS LAST
285
288
  LIMIT $${params.length}`,
286
289
  params
287
290
  );
@@ -361,7 +364,6 @@ async function upsertTurnEmbeddings(pool, sessionRowId, {
361
364
  }
362
365
 
363
366
  // Batch insert: build multi-row VALUES clause
364
- const COLS_PER_ROW = 10;
365
367
  const valueClauses = [];
366
368
  const params = [];
367
369
 
@@ -416,6 +418,16 @@ async function searchTurnEmbeddings(pool, {
416
418
  source,
417
419
  limit = 15,
418
420
  }) {
421
+ // HNSW index fires only on `ORDER BY embedding <=> $vec LIMIT N` without
422
+ // additional predicates in the same query level. So the CTE does a plain
423
+ // nearest-neighbor scan (uses idx_turn_emb_embedding_hnsw at scale), then
424
+ // the outer SELECT applies tenant/agent/date/source filters and dedups.
425
+ //
426
+ // Filter narrowness may leave fewer than `limit` rows after post-filter;
427
+ // NN_OVERFETCH trades extra vector work for filter survival headroom.
428
+ const NN_OVERFETCH = 10;
429
+ const nnLimit = Math.max(50, limit * NN_OVERFETCH);
430
+
419
431
  const where = ['s.tenant_id = $1'];
420
432
  const params = [tenantId];
421
433
 
@@ -434,40 +446,70 @@ async function searchTurnEmbeddings(pool, {
434
446
  }
435
447
  if (agentIds) {
436
448
  params.push(agentIds);
437
- where.push(`t.agent_id = ANY($${params.length})`);
449
+ where.push(`s.agent_id = ANY($${params.length})`);
438
450
  }
439
451
  if (source) {
440
452
  params.push(source);
441
- where.push(`t.source = $${params.length}`);
453
+ where.push(`s.source = $${params.length}`);
442
454
  }
443
455
 
444
456
  params.push(`[${queryVec.join(',')}]`);
445
457
  const vecPos = params.length;
446
-
447
- // m5: use subquery with LIMIT to avoid scanning all rows
448
- params.push(limit * 3); // fetch more than needed for DISTINCT ON dedup
449
- const innerLimitPos = params.length;
458
+ params.push(nnLimit);
459
+ const nnLimitPos = params.length;
450
460
 
451
461
  const result = await pool.query(
452
- `SELECT * FROM (
453
- SELECT DISTINCT ON (t.session_row_id)
462
+ `WITH nn AS (
463
+ SELECT t.session_row_id, t.content_text, t.turn_index,
464
+ (t.embedding <=> $${vecPos}::vector) AS turn_distance
465
+ FROM ${qi(schema)}.turn_embeddings t
466
+ ORDER BY t.embedding <=> $${vecPos}::vector ASC
467
+ LIMIT $${nnLimitPos}
468
+ )
469
+ SELECT * FROM (
470
+ SELECT DISTINCT ON (nn.session_row_id)
454
471
  s.session_id, s.id AS session_row_id, s.agent_id, s.source, s.started_at,
455
472
  ss.summary_text, ss.structured_summary, ss.access_count, ss.last_accessed_at,
456
473
  COALESCE(ss.trust_score, 0.5) AS trust_score,
457
- t.content_text AS matched_turn_text, t.turn_index AS matched_turn_index,
458
- (t.embedding <=> $${vecPos}::vector) AS turn_distance
459
- FROM ${qi(schema)}.turn_embeddings t
460
- JOIN ${qi(schema)}.sessions s ON s.id = t.session_row_id
474
+ nn.content_text AS matched_turn_text, nn.turn_index AS matched_turn_index,
475
+ nn.turn_distance
476
+ FROM nn
477
+ JOIN ${qi(schema)}.sessions s ON s.id = nn.session_row_id
461
478
  LEFT JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
462
479
  WHERE ${where.join(' AND ')}
463
- ORDER BY t.session_row_id, turn_distance ASC
464
- ) sub
465
- ORDER BY turn_distance ASC
466
- LIMIT $${innerLimitPos}`,
480
+ ORDER BY nn.session_row_id, nn.turn_distance ASC
481
+ ) dedup
482
+ ORDER BY turn_distance ASC`,
467
483
  params
468
484
  );
469
485
 
470
- return { rows: result.rows.slice(0, limit) };
486
+ if (result.rows.length > 0) {
487
+ return { rows: result.rows.slice(0, limit) };
488
+ }
489
+
490
+ // Fallback: HNSW-first path filtered out to nothing. This can happen when
491
+ // tenant/agent filters are narrow enough to eliminate every NN candidate.
492
+ // Pay the cost of a filter-first scan to guarantee we don't silently return
493
+ // empty when qualifying rows exist. No HNSW on this path — slower, correct.
494
+ const fallbackParams = params.slice(0, params.length - 1); // drop nnLimit
495
+ fallbackParams.push(limit);
496
+ const fallbackLimitPos = fallbackParams.length;
497
+ const fallback = await pool.query(
498
+ `SELECT DISTINCT ON (t.session_row_id)
499
+ s.session_id, s.id AS session_row_id, s.agent_id, s.source, s.started_at,
500
+ ss.summary_text, ss.structured_summary, ss.access_count, ss.last_accessed_at,
501
+ COALESCE(ss.trust_score, 0.5) AS trust_score,
502
+ t.content_text AS matched_turn_text, t.turn_index AS matched_turn_index,
503
+ (t.embedding <=> $${vecPos}::vector) AS turn_distance
504
+ FROM ${qi(schema)}.turn_embeddings t
505
+ JOIN ${qi(schema)}.sessions s ON s.id = t.session_row_id
506
+ LEFT JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
507
+ WHERE ${where.join(' AND ')}
508
+ ORDER BY t.session_row_id, t.embedding <=> $${vecPos}::vector ASC
509
+ LIMIT $${fallbackLimitPos}`,
510
+ fallbackParams
511
+ );
512
+ return { rows: fallback.rows };
471
513
  }
472
514
 
473
515
  // ---------------------------------------------------------------------------
@@ -504,16 +546,32 @@ async function recordFeedback(pool, {
504
546
  }
505
547
 
506
548
  const trustBefore = parseFloat(current.rows[0].trust_score);
507
- const trustAfter = verdict === 'helpful'
508
- ? Math.min(1.0, trustBefore + TRUST_UP)
509
- : Math.max(0.0, trustBefore - TRUST_DOWN);
510
549
 
511
- await client.query(
512
- `UPDATE ${qi(schema)}.session_summaries
513
- SET trust_score = $1, updated_at = now()
514
- WHERE session_row_id = $2`,
515
- [trustAfter, sessionRowId]
550
+ // Dedupe: the same (agent, verdict) applied more than once must not stack.
551
+ // Audit row is still inserted so the sequence of feedback events is
552
+ // preserved; only the trust_score delta is skipped.
553
+ const prior = await client.query(
554
+ `SELECT 1 FROM ${qi(schema)}.session_feedback
555
+ WHERE session_row_id = $1 AND agent_id = $2 AND verdict = $3
556
+ LIMIT 1`,
557
+ [sessionRowId, agentId, verdict]
516
558
  );
559
+ const isDup = prior.rows.length > 0;
560
+
561
+ const trustAfter = isDup
562
+ ? trustBefore
563
+ : (verdict === 'helpful'
564
+ ? Math.min(1.0, trustBefore + TRUST_UP)
565
+ : Math.max(0.0, trustBefore - TRUST_DOWN));
566
+
567
+ if (!isDup) {
568
+ await client.query(
569
+ `UPDATE ${qi(schema)}.session_summaries
570
+ SET trust_score = $1, updated_at = now()
571
+ WHERE session_row_id = $2`,
572
+ [trustAfter, sessionRowId]
573
+ );
574
+ }
517
575
 
518
576
  await client.query(
519
577
  `INSERT INTO ${qi(schema)}.session_feedback
@@ -523,7 +581,7 @@ async function recordFeedback(pool, {
523
581
  );
524
582
 
525
583
  await client.query('COMMIT');
526
- return { trustBefore, trustAfter, verdict };
584
+ return { trustBefore, trustAfter, verdict, duplicate: isDup };
527
585
  } catch (err) {
528
586
  await client.query('ROLLBACK').catch(() => {});
529
587
  throw err;
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ // aq.timeline.* — append-only event log capability.
4
+ //
5
+ // Spec: aquifer-completion §10 timeline. Fixed event shape
6
+ // (occurred_at / source / session_ref / category / text / metadata),
7
+ // consumer-owned category vocabulary. idempotency_key UNIQUE across the
8
+ // table; appends with a duplicate key are a safe no-op and return the
9
+ // existing row.
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
+ 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 defaultIdempotencyKey({ tenantId, agentId, occurredAt, category, text }) {
30
+ return crypto.createHash('sha256')
31
+ .update(`${tenantId}:${agentId}:${occurredAt}:${category}:${text}`)
32
+ .digest('hex');
33
+ }
34
+
35
+ function toNumber(v) {
36
+ if (v === null || v === undefined) return null;
37
+ const n = Number(v);
38
+ return Number.isFinite(n) ? n : null;
39
+ }
40
+
41
+ function mapRow(row) {
42
+ if (!row) return null;
43
+ return {
44
+ eventId: toNumber(row.id),
45
+ occurredAt: row.occurred_at,
46
+ source: row.source,
47
+ sessionRef: row.session_ref,
48
+ category: row.category,
49
+ text: row.text,
50
+ metadata: row.metadata || {},
51
+ };
52
+ }
53
+
54
+ function createTimeline({ pool, schema, defaultTenantId }) {
55
+ async function append(input) {
56
+ try {
57
+ if (!input || typeof input !== 'object') {
58
+ return err('AQ_INVALID_INPUT', 'append requires an input object');
59
+ }
60
+ if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
61
+ if (!input.occurredAt) return err('AQ_INVALID_INPUT', 'occurredAt is required');
62
+ if (!input.source) return err('AQ_INVALID_INPUT', 'source is required');
63
+ if (!input.category) return err('AQ_INVALID_INPUT', 'category is required');
64
+ if (!input.text || typeof input.text !== 'string') {
65
+ return err('AQ_INVALID_INPUT', 'text is required');
66
+ }
67
+
68
+ const tenantId = input.tenantId || defaultTenantId || 'default';
69
+ const agentId = input.agentId;
70
+ const profile = resolveProfile(input.profile);
71
+ const idempotencyKey = input.idempotencyKey
72
+ || defaultIdempotencyKey({
73
+ tenantId, agentId,
74
+ occurredAt: input.occurredAt,
75
+ category: input.category,
76
+ text: input.text,
77
+ });
78
+
79
+ // Idempotent append: on conflict, fall back to SELECT so the caller
80
+ // always gets the canonical row (the row that *won* the insert).
81
+ const insertResult = await pool.query(
82
+ `INSERT INTO ${schema}.timeline_events (
83
+ tenant_id, agent_id, occurred_at, source, session_ref,
84
+ category, text, metadata, source_session_id,
85
+ consumer_profile_id, consumer_profile_version, consumer_schema_hash,
86
+ idempotency_key
87
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
88
+ ON CONFLICT (idempotency_key) DO NOTHING
89
+ RETURNING *`,
90
+ [
91
+ tenantId, agentId, input.occurredAt, input.source,
92
+ input.sessionRef || null, input.category, input.text,
93
+ input.metadata || {}, input.sessionId || null,
94
+ profile.id, profile.version, profile.schemaHash,
95
+ idempotencyKey,
96
+ ],
97
+ );
98
+ let row = insertResult.rows[0];
99
+ if (!row) {
100
+ const existing = await pool.query(
101
+ `SELECT * FROM ${schema}.timeline_events WHERE idempotency_key = $1`,
102
+ [idempotencyKey],
103
+ );
104
+ row = existing.rows[0];
105
+ }
106
+ const mapped = mapRow(row);
107
+ return ok({ eventId: mapped.eventId, event: mapped });
108
+ } catch (e) {
109
+ if (e instanceof AqError) return err(e);
110
+ return err('AQ_INTERNAL', e.message, { cause: e });
111
+ }
112
+ }
113
+
114
+ async function list(input = {}) {
115
+ try {
116
+ if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
117
+ const tenantId = input.tenantId || defaultTenantId || 'default';
118
+ const limit = Math.min(Math.max(input.limit || 50, 1), 500);
119
+
120
+ const params = [tenantId, input.agentId];
121
+ let where = 'tenant_id = $1 AND agent_id = $2';
122
+ if (Array.isArray(input.categories) && input.categories.length > 0) {
123
+ params.push(input.categories);
124
+ where += ` AND category = ANY($${params.length})`;
125
+ }
126
+ if (input.since) {
127
+ params.push(input.since);
128
+ where += ` AND occurred_at >= $${params.length}`;
129
+ }
130
+ if (input.until) {
131
+ params.push(input.until);
132
+ where += ` AND occurred_at <= $${params.length}`;
133
+ }
134
+ params.push(limit);
135
+
136
+ const { rows } = await pool.query(
137
+ `SELECT * FROM ${schema}.timeline_events
138
+ WHERE ${where}
139
+ ORDER BY occurred_at DESC, id DESC
140
+ LIMIT $${params.length}`,
141
+ params,
142
+ );
143
+ return ok({ rows: rows.map(mapRow) });
144
+ } catch (e) {
145
+ return err('AQ_INTERNAL', e.message, { cause: e });
146
+ }
147
+ }
148
+
149
+ return { append, list };
150
+ }
151
+
152
+ module.exports = { createTimeline };
@@ -0,0 +1,132 @@
1
+ # `enrich({ postProcess })` Contract
2
+
3
+ `aquifer.enrich(sessionId, opts)` runs commit → summarize → embed → entity-extract → mark-status inside a single DB transaction. After the transaction commits and the client is released, if `opts.postProcess` was supplied, Aquifer invokes it once with a context object. This is how consumers hook persona-specific side-effects (daily logs, workspace files, consolidation, narrative regen, metrics) without mutating core.
4
+
5
+ **Stability**: stable in 1.x. Additive changes only (new ctx fields). No removals or breaking renames without a major bump.
6
+
7
+ ## Signature
8
+
9
+ ```ts
10
+ postProcess?: (ctx: PostProcessContext) => Promise<void>
11
+ ```
12
+
13
+ ## When it runs
14
+
15
+ - **After** transaction commit and client release. The session row is already at its final status (`succeeded` or `partial`); nothing in postProcess can affect that.
16
+ - **At most once per enrich call**. No retry. If `postProcess` throws, the error is captured on the returned result as `postProcessError` (not re-thrown).
17
+ - Best-effort. The enrich call's return value resolves regardless of postProcess outcome.
18
+
19
+ ## `ctx` shape
20
+
21
+ ```ts
22
+ interface PostProcessContext {
23
+ session: {
24
+ id: number; // DB primary key (miranda.sessions.id)
25
+ sessionId: string; // caller-provided session key
26
+ agentId: string;
27
+ model: string | null;
28
+ source: string | null;
29
+ startedAt: string | null; // ISO-8601
30
+ endedAt: string | null; // ISO-8601
31
+ };
32
+
33
+ // opts.model override, falling back to session.model. Handy for consumers
34
+ // that want to pass the runtime model into downstream consolidation prompts.
35
+ effectiveModel: string | null;
36
+
37
+ // Summary result, if summarize ran. Null when skipSummary or summary failed.
38
+ summary: {
39
+ summaryText: string;
40
+ structuredSummary: object | null; // custom summaryFn payload
41
+ } | null;
42
+
43
+ // Summary-level embedding vector (size = embed.dim). Null if embed skipped/failed.
44
+ embedding: number[] | null;
45
+
46
+ // Per-turn embedding vectors (one per user turn). Null if skipped/failed.
47
+ turnVectors: number[][] | null;
48
+
49
+ // Passthrough from customSummaryFn return { extra }. Consumers use this to
50
+ // smuggle intermediate results (recap/sections/workingFacts) from summaryFn
51
+ // into postProcess without recomputing.
52
+ extra: any;
53
+
54
+ // Messages used for embedding/entity extraction. Same array commit() saw.
55
+ normalized: Array<{ role: string; content: string; timestamp?: string }>;
56
+
57
+ // Parsed entities from entityParseFn (or built-in parser).
58
+ parsedEntities: Array<{ name: string; normalizedName: string; aliases: string[]; type: string }>;
59
+
60
+ // Which pipeline steps ran.
61
+ skipped: { summary: boolean; entities: boolean; turns: boolean };
62
+
63
+ // Counts from the tx.
64
+ turnsEmbedded: number;
65
+ entitiesFound: number;
66
+
67
+ // Non-fatal failures collected inside enrich. Defensive copy — mutating this
68
+ // array does NOT affect enrich's own warnings list.
69
+ warnings: string[];
70
+ }
71
+ ```
72
+
73
+ ## Typical usage
74
+
75
+ ```js
76
+ const result = await aquifer.enrich(sessionId, {
77
+ agentId: 'main',
78
+ summaryFn: async (msgs) => {
79
+ const output = await callLlm(buildPrompt({ msgs }));
80
+ const sections = parseSummaryOutput(output);
81
+ const recap = parseRecapLines(sections.recap);
82
+ return {
83
+ summaryText: recap.overview || '',
84
+ structuredSummary: recap,
85
+ entityRaw: sections.entities || null,
86
+ extra: { sections, recap, workingFacts: parseWorkingFacts(sections.working_facts) },
87
+ };
88
+ },
89
+ entityParseFn: (text) => parseEntitySection(text).entities,
90
+ postProcess: async (ctx) => {
91
+ const recap = ctx.extra?.recap;
92
+ const sections = ctx.extra?.sections;
93
+ const workingFacts = ctx.extra?.workingFacts || [];
94
+
95
+ // Daily log
96
+ if (recap || sections) {
97
+ await writeDailyEntries({ recap, sections, sessionId: ctx.session.sessionId, agentId: ctx.session.agentId });
98
+ }
99
+
100
+ // Write fact candidates (consumer-specific table, not in Aquifer schema)
101
+ if (workingFacts.length > 0) {
102
+ await writeFactCandidates({ facts: workingFacts, sessionId: ctx.session.sessionId });
103
+ }
104
+
105
+ // Consolidation (optional — requires enableFacts())
106
+ if (recap) {
107
+ const prompt = buildConsolidationPrompt({ recap, activeFacts, candidates, currentNarrative });
108
+ const output = await callLlm(prompt);
109
+ const { actions } = parseConsolidationOutput(output);
110
+ if (actions.length > 0) {
111
+ await aquifer.consolidate(ctx.session.sessionId, { actions, agentId: ctx.session.agentId });
112
+ }
113
+ }
114
+ },
115
+ });
116
+
117
+ if (result.postProcessError) {
118
+ logger.warn(`postProcess failed: ${result.postProcessError.message}`);
119
+ }
120
+ ```
121
+
122
+ ## What NOT to do in postProcess
123
+
124
+ - Don't throw as a signal of "enrich should have failed" — enrich is already committed. Use warnings or a separate audit table.
125
+ - Don't mutate `ctx.normalized`, `ctx.parsedEntities`, or `ctx.warnings`. They're shared-reference with the enrich return; defensive copy if you need to modify.
126
+ - Don't rely on postProcess running quickly — it's outside the tx. Long-running work should be fire-and-forget (see Miranda's `setImmediate` consolidation) or queued.
127
+
128
+ ## What Aquifer guarantees
129
+
130
+ - `postProcess` receives the same `session` row the tx wrote. No stale reads.
131
+ - If enrich's tx rolls back, postProcess is NOT called.
132
+ - If postProcess throws, the error is on `result.postProcessError`. The session status is unaffected.
package/index.js CHANGED
@@ -3,5 +3,27 @@
3
3
  const { createAquifer } = require('./core/aquifer');
4
4
  const { createEmbedder } = require('./pipeline/embed');
5
5
  const { createReranker } = require('./pipeline/rerank');
6
+ const { normalizeEntityName } = require('./core/entity');
7
+ const { parseEntitySection } = require('./consumers/shared/entity-parser');
8
+ const { AqError, ok, err, asResult, isKnownCode, KNOWN_CODES } = require('./core/errors');
9
+ const { MCP_SERVER_NAME, MCP_TOOL_MANIFEST, getManifest, writeManifestFile } = require('./core/mcp-manifest');
6
10
 
7
- module.exports = { createAquifer, createEmbedder, createReranker };
11
+ module.exports = {
12
+ createAquifer,
13
+ createEmbedder,
14
+ createReranker,
15
+ normalizeEntityName,
16
+ parseEntitySection,
17
+ // Completion-capability error envelope (P1 foundation).
18
+ AqError,
19
+ ok,
20
+ err,
21
+ asResult,
22
+ isKnownCode,
23
+ KNOWN_CODES,
24
+ // MCP tool manifest — canonical for gateway in-process + CC cross-process.
25
+ MCP_SERVER_NAME,
26
+ MCP_TOOL_MANIFEST,
27
+ getMcpManifest: getManifest,
28
+ writeMcpManifestFile: writeManifestFile,
29
+ };