@shadowforge0/aquifer-memory 1.0.2 → 1.2.1

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 (45) hide show
  1. package/README.md +29 -20
  2. package/consumers/claude-code.js +117 -0
  3. package/consumers/cli.js +17 -0
  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/prompts/summary.js +303 -0
  14. package/consumers/miranda/recall-format.js +74 -0
  15. package/consumers/miranda/workspace-files.js +91 -0
  16. package/consumers/openclaw-ext/index.js +38 -0
  17. package/consumers/openclaw-ext/openclaw.plugin.json +9 -0
  18. package/consumers/openclaw-ext/package.json +10 -0
  19. package/consumers/openclaw-plugin.js +66 -74
  20. package/consumers/opencode.js +21 -24
  21. package/consumers/shared/autodetect.js +64 -0
  22. package/consumers/shared/entity-parser.js +119 -0
  23. package/consumers/shared/ingest.js +148 -0
  24. package/consumers/shared/llm-autodetect.js +137 -0
  25. package/consumers/shared/normalize.js +129 -0
  26. package/consumers/shared/recall-format.js +110 -0
  27. package/core/aquifer.js +200 -82
  28. package/core/entity.js +29 -17
  29. package/core/storage.js +116 -45
  30. package/docs/postprocess-contract.md +132 -0
  31. package/index.js +9 -1
  32. package/package.json +23 -2
  33. package/pipeline/_http.js +1 -1
  34. package/pipeline/consolidation/apply.js +176 -0
  35. package/pipeline/consolidation/index.js +21 -0
  36. package/pipeline/extract-entities.js +2 -2
  37. package/pipeline/rerank.js +1 -1
  38. package/pipeline/summarize.js +4 -1
  39. package/schema/001-base.sql +61 -24
  40. package/schema/002-entities.sql +17 -3
  41. package/schema/004-facts.sql +67 -0
  42. package/scripts/diagnose-fts-zh.js +168 -134
  43. package/scripts/diagnose-vector.js +188 -0
  44. package/scripts/install-openclaw.sh +59 -0
  45. package/scripts/smoke.mjs +2 -2
package/core/aquifer.js CHANGED
@@ -9,6 +9,7 @@ const entity = require('./entity');
9
9
  const { hybridRank } = require('./hybrid-rank');
10
10
  const { summarize } = require('../pipeline/summarize');
11
11
  const { extractEntities } = require('../pipeline/extract-entities');
12
+ const { createEmbedder } = require('../pipeline/embed');
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Schema name validation
@@ -54,48 +55,104 @@ function buildRerankDocument(row, maxChars) {
54
55
  return text;
55
56
  }
56
57
 
58
+ // ---------------------------------------------------------------------------
59
+ // resolveEmbedFn — v1.2.0 embed autodetect (explicit > object > env > null)
60
+ // ---------------------------------------------------------------------------
61
+
62
+ function resolveEmbedFn(embedConfig, env) {
63
+ if (embedConfig && typeof embedConfig.fn === 'function') {
64
+ return embedConfig.fn;
65
+ }
66
+ if (embedConfig && embedConfig.provider) {
67
+ const embedder = createEmbedder(embedConfig);
68
+ return (texts) => embedder.embedBatch(texts);
69
+ }
70
+ const provider = env.EMBED_PROVIDER;
71
+ if (!provider) return null;
72
+
73
+ const opts = { provider };
74
+ if (provider === 'ollama') {
75
+ opts.ollamaUrl = env.OLLAMA_URL || env.AQUIFER_EMBED_BASE_URL || 'http://localhost:11434';
76
+ opts.model = env.AQUIFER_EMBED_MODEL || 'bge-m3';
77
+ } else if (provider === 'openai') {
78
+ opts.openaiApiKey = env.OPENAI_API_KEY;
79
+ if (!opts.openaiApiKey) {
80
+ throw new Error('EMBED_PROVIDER=openai requires OPENAI_API_KEY');
81
+ }
82
+ opts.openaiModel = env.AQUIFER_EMBED_MODEL || 'text-embedding-3-small';
83
+ if (env.AQUIFER_EMBED_DIM) opts.openaiDimensions = Number(env.AQUIFER_EMBED_DIM);
84
+ } else {
85
+ throw new Error(`EMBED_PROVIDER=${provider} not supported by autodetect (use 'ollama' or 'openai', or pass config.embed.fn explicitly)`);
86
+ }
87
+ const embedder = createEmbedder(opts);
88
+ return (texts) => embedder.embedBatch(texts);
89
+ }
90
+
57
91
  // ---------------------------------------------------------------------------
58
92
  // createAquifer
59
93
  // ---------------------------------------------------------------------------
60
94
 
61
- function createAquifer(config) {
62
- if (!config || !config.db) {
63
- throw new Error('config.db (pg.Pool or connection string) is required');
95
+ function createAquifer(config = {}) {
96
+ // v1.2.0: db falls back to DATABASE_URL / AQUIFER_DB_URL env so hosts can
97
+ // call createAquifer() with zero args for install-and-go.
98
+ const dbInput = config.db !== undefined
99
+ ? config.db
100
+ : (process.env.DATABASE_URL || process.env.AQUIFER_DB_URL || null);
101
+
102
+ if (!dbInput) {
103
+ throw new Error(
104
+ 'Aquifer requires a database: pass config.db (pg.Pool or connection string), '
105
+ + 'or set DATABASE_URL / AQUIFER_DB_URL in the environment.'
106
+ );
64
107
  }
65
108
 
66
- const schema = config.schema || 'aquifer';
109
+ const schema = config.schema || process.env.AQUIFER_SCHEMA || 'aquifer';
67
110
  validateSchema(schema);
68
111
 
69
112
  if (config.tenantId === '') throw new Error('config.tenantId must not be empty');
70
- const tenantId = config.tenantId || 'default';
113
+ const tenantId = config.tenantId || process.env.AQUIFER_TENANT_ID || 'default';
71
114
 
72
115
  // Pool management
73
116
  let pool;
74
117
  let ownsPool = false;
75
- if (typeof config.db === 'string') {
76
- pool = new Pool({ connectionString: config.db });
118
+ if (typeof dbInput === 'string') {
119
+ pool = new Pool({ connectionString: dbInput });
77
120
  ownsPool = true;
78
121
  } else {
79
- pool = config.db;
122
+ pool = dbInput;
80
123
  ownsPool = !!config.ownsPool; // allow factory to claim ownership
81
124
  }
82
125
 
83
126
  // Embed config (lazy — only required for recall/enrich)
84
- const embedFn = config.embed && typeof config.embed.fn === 'function' ? config.embed.fn : null;
85
- let embedDim = config.embed ? (config.embed.dim || null) : null;
86
-
127
+ // v1.2.0 fallback chain:
128
+ // 1. config.embed.fn (explicit function)
129
+ // 2. config.embed.provider (build via createEmbedder)
130
+ // 3. EMBED_PROVIDER env + provider-specific key (zero-arg install-and-go)
131
+ // 4. null — defer to requireEmbed() at call time
132
+ const embedFn = resolveEmbedFn(config.embed, process.env);
87
133
  function requireEmbed(op) {
88
- if (!embedFn) throw new Error(`Aquifer.${op}() requires config.embed.fn (async (texts) => number[][])`);
134
+ if (!embedFn) throw new Error(`Aquifer.${op}() requires config.embed.fn or EMBED_PROVIDER env (async (texts) => number[][])`);
89
135
  }
90
136
 
91
137
  // LLM config (optional — only needed for enrich with built-in summarize)
92
- const llmFn = config.llm && typeof config.llm.fn === 'function' ? config.llm.fn : null;
138
+ // v1.2.0: falls back to AQUIFER_LLM_PROVIDER env + provider-specific key.
139
+ const { resolveLlmFn } = require('../consumers/shared/llm-autodetect');
140
+ const llmFn = resolveLlmFn(config.llm, process.env);
93
141
 
94
142
  // Summarize config
95
143
  const summarizePromptFn = config.summarize && config.summarize.prompt ? config.summarize.prompt : null;
96
144
 
145
+ // Enrich stale-claim window: a 'processing' session older than this is
146
+ // reclaimable by a concurrent enrich() caller (covers crashed workers).
147
+ const staleEnrichMinutes = Number.isFinite(config.staleEnrichMinutes)
148
+ ? Math.max(1, Math.floor(config.staleEnrichMinutes))
149
+ : 10;
150
+
97
151
  // Entity config
98
152
  let entitiesEnabled = config.entities && config.entities.enabled === true;
153
+
154
+ // Facts config (opt-in consolidation lifecycle)
155
+ let factsEnabled = config.facts && config.facts.enabled === true;
99
156
  const mergeCall = config.entities && config.entities.mergeCall !== undefined ? config.entities.mergeCall : true;
100
157
  const entityPromptFn = config.entities && config.entities.prompt ? config.entities.prompt : null;
101
158
  const entityScope = (config.entities && config.entities.scope) || 'default';
@@ -187,21 +244,38 @@ function createAquifer(config) {
187
244
  // --- lifecycle ---
188
245
 
189
246
  async migrate() {
190
- // 1. Run base DDL
191
- const baseSql = loadSql('001-base.sql', schema);
192
- await pool.query(baseSql);
247
+ // Advisory lock prevents concurrent migrations across processes.
248
+ // Lock key is derived from schema name to allow parallel migration
249
+ // of different schemas in the same database.
250
+ const lockKey = Buffer.from(`aquifer:${schema}`).reduce((h, b) => (h * 31 + b) & 0x7fffffff, 0);
251
+ await pool.query('SELECT pg_advisory_lock($1)', [lockKey]);
252
+ try {
253
+ // 1. Run base DDL
254
+ const baseSql = loadSql('001-base.sql', schema);
255
+ await pool.query(baseSql);
256
+
257
+ // 2. If entities enabled, run entity DDL
258
+ if (entitiesEnabled) {
259
+ const entitySql = loadSql('002-entities.sql', schema);
260
+ await pool.query(entitySql);
261
+ }
193
262
 
194
- // 2. If entities enabled, run entity DDL
195
- if (entitiesEnabled) {
196
- const entitySql = loadSql('002-entities.sql', schema);
197
- await pool.query(entitySql);
198
- }
263
+ // 3. Trust + feedback (always, not gated by entities)
264
+ const trustSql = loadSql('003-trust-feedback.sql', schema);
265
+ await pool.query(trustSql);
199
266
 
200
- // 3. Trust + feedback (always, not gated by entities)
201
- const trustSql = loadSql('003-trust-feedback.sql', schema);
202
- await pool.query(trustSql);
267
+ // 4. Facts / consolidation (opt-in)
268
+ if (factsEnabled) {
269
+ const factsSql = loadSql('004-facts.sql', schema);
270
+ await pool.query(factsSql);
271
+ }
203
272
 
204
- migrated = true;
273
+ migrated = true;
274
+ } finally {
275
+ await pool.query('SELECT pg_advisory_unlock($1)', [lockKey]).catch((err) => {
276
+ console.warn(`[aquifer] failed to release migration advisory lock for schema "${schema}": ${err.message}`);
277
+ });
278
+ }
205
279
  },
206
280
 
207
281
  async close() {
@@ -216,7 +290,7 @@ function createAquifer(config) {
216
290
  sources.set(name, {
217
291
  type: opts.type || 'custom',
218
292
  search: opts.search || null,
219
- weight: opts.weight !== null && opts.weight !== undefined ? opts.weight : 1.0,
293
+ weight: opts.weight !== undefined && opts.weight !== undefined ? opts.weight : 1.0,
220
294
  });
221
295
  },
222
296
 
@@ -229,6 +303,32 @@ function createAquifer(config) {
229
303
  }
230
304
  },
231
305
 
306
+ async enableFacts() {
307
+ factsEnabled = true;
308
+ // Run the facts DDL (idempotent — all CREATE/ALTER use IF NOT EXISTS).
309
+ // Safe to call repeatedly; also safe to call before migrate() (will no-op
310
+ // until base schema exists, which enrich/commit will materialize).
311
+ await ensureMigrated();
312
+ const factsSql = loadSql('004-facts.sql', schema);
313
+ await pool.query(factsSql);
314
+ },
315
+
316
+ async consolidate(sessionId, opts = {}) {
317
+ if (!factsEnabled) throw new Error('aquifer.consolidate() requires enableFacts() first');
318
+ await ensureMigrated();
319
+ const { applyConsolidation } = require('../pipeline/consolidation');
320
+ const agentId = opts.agentId || 'agent';
321
+ return applyConsolidation(pool, {
322
+ actions: opts.actions || [],
323
+ agentId,
324
+ sessionId,
325
+ schema,
326
+ tenantId,
327
+ normalizeSubject: opts.normalizeSubject || null,
328
+ recapOverview: opts.recapOverview || '',
329
+ });
330
+ },
331
+
232
332
  // --- write path ---
233
333
 
234
334
  async commit(sessionId, messages, opts = {}) {
@@ -293,17 +393,19 @@ function createAquifer(config) {
293
393
  const postProcess = opts.postProcess || null; // async (ctx) => void
294
394
  const optModel = 'model' in opts ? opts.model : undefined; // undefined = no override
295
395
 
296
- // 1. Optimistic lock: claim session for processing
297
- // Also reclaim stale 'processing' sessions (stuck > 10 min = likely killed process)
298
- const STALE_MINUTES = 10;
396
+ // 1. Optimistic lock: claim session for processing.
397
+ // Also reclaim stale 'processing' sessions (likely killed worker).
398
+ // Stale window is config.staleEnrichMinutes (default 10).
299
399
  const claimResult = await pool.query(
300
400
  `UPDATE ${qi(schema)}.sessions
301
401
  SET processing_status = 'processing', processing_started_at = NOW()
302
402
  WHERE session_id = $1 AND agent_id = $2 AND tenant_id = $3
303
403
  AND (processing_status IN ('pending', 'failed')
304
- OR (processing_status = 'processing' AND (processing_started_at IS NULL OR processing_started_at < NOW() - INTERVAL '${STALE_MINUTES} minutes')))
404
+ OR (processing_status = 'processing'
405
+ AND (processing_started_at IS NULL
406
+ OR processing_started_at < NOW() - make_interval(mins => $4))))
305
407
  RETURNING *`,
306
- [sessionId, agentId, tenantId]
408
+ [sessionId, agentId, tenantId, staleEnrichMinutes]
307
409
  );
308
410
  const session = claimResult.rows[0];
309
411
  if (!session) {
@@ -324,34 +426,46 @@ function createAquifer(config) {
324
426
  // 2. Extract user turns
325
427
  const turns = storage.extractUserTurns(normalized);
326
428
 
429
+ // Collected across pre-tx and tx phases; any non-empty warnings demote
430
+ // the final status from 'succeeded' to 'partial' (see step 8 below).
431
+ const warnings = [];
432
+
327
433
  // 3. Summarize (custom or built-in)
328
434
  let summaryResult = null;
329
435
  let entityRaw = null;
330
436
  let extra = null;
331
437
 
332
438
  if (!skipSummary && normalized.length > 0) {
333
- if (customSummaryFn) {
334
- // Custom pipeline: caller handles LLM call and parsing
335
- summaryResult = await customSummaryFn(normalized);
336
- if (summaryResult.entityRaw) entityRaw = summaryResult.entityRaw;
337
- if (summaryResult.extra) extra = summaryResult.extra;
338
- } else {
339
- // Built-in pipeline
340
- const doMergeEntities = entitiesEnabled && mergeCall && !skipEntities;
341
- summaryResult = await summarize(normalized, {
342
- llmFn,
343
- promptFn: summarizePromptFn,
344
- mergeEntities: doMergeEntities,
345
- });
346
- if (summaryResult.entityRaw) {
347
- entityRaw = summaryResult.entityRaw;
439
+ // Pre-transaction failures (customSummaryFn / summarize throws) would
440
+ // otherwise bubble out and leave the session stuck in 'processing'
441
+ // until stale reclaim. Capture as a warning so status ends 'partial',
442
+ // keeping parity with how embed/entity-extract failures are treated.
443
+ try {
444
+ if (customSummaryFn) {
445
+ // Custom pipeline: caller handles LLM call and parsing
446
+ summaryResult = await customSummaryFn(normalized);
447
+ if (summaryResult && summaryResult.entityRaw) entityRaw = summaryResult.entityRaw;
448
+ if (summaryResult && summaryResult.extra) extra = summaryResult.extra;
449
+ } else {
450
+ // Built-in pipeline
451
+ const doMergeEntities = entitiesEnabled && mergeCall && !skipEntities;
452
+ summaryResult = await summarize(normalized, {
453
+ llmFn,
454
+ promptFn: summarizePromptFn,
455
+ mergeEntities: doMergeEntities,
456
+ });
457
+ if (summaryResult.entityRaw) {
458
+ entityRaw = summaryResult.entityRaw;
459
+ }
348
460
  }
461
+ } catch (e) {
462
+ warnings.push(`summary step failed: ${e.message}`);
463
+ summaryResult = null;
349
464
  }
350
465
  }
351
466
 
352
467
  // 4. Pre-compute all LLM/embed results BEFORE opening transaction
353
468
  // (avoids holding pool connection during slow LLM/embed calls)
354
- const warnings = [];
355
469
  let summaryEmbedding = null;
356
470
  let turnVectors = null;
357
471
  let parsedEntities = [];
@@ -485,7 +599,11 @@ function createAquifer(config) {
485
599
  await client.query('ROLLBACK').catch(() => {});
486
600
  try {
487
601
  await storage.markStatus(pool, session.id, 'failed', err.message, { schema });
488
- } catch (_) { /* swallow */ }
602
+ } catch (markErr) {
603
+ // Secondary failure: session is stuck in 'processing' until stale reclaim.
604
+ // Surface so operators notice and don't silently rely on the timeout.
605
+ console.warn(`[aquifer] enrich failed for session ${sessionId} AND markStatus('failed') also failed: ${markErr.message}`);
606
+ }
489
607
  throw err;
490
608
  } finally {
491
609
  client.release();
@@ -683,7 +801,7 @@ function createAquifer(config) {
683
801
  entityScoreBySession.set(row.session_id, parseInt(row.entity_count) / maxCount);
684
802
  }
685
803
  }
686
- } catch (_) { /* entity search failure non-fatal */ }
804
+ } catch { /* entity search failure non-fatal */ }
687
805
  }
688
806
 
689
807
  // 3. Run search paths in parallel (conditioned on mode)
@@ -738,7 +856,7 @@ function createAquifer(config) {
738
856
  for (const r of [...filteredFts, ...filteredEmb, ...filteredTurn]) {
739
857
  const sid = r.session_id || String(r.id);
740
858
  const ss = typeof r.structured_summary === 'string'
741
- ? (() => { try { return JSON.parse(r.structured_summary); } catch (_) { return null; } })()
859
+ ? (() => { try { return JSON.parse(r.structured_summary); } catch { return null; } })()
742
860
  : r.structured_summary;
743
861
  if (ss && Array.isArray(ss.open_loops) && ss.open_loops.length > 0) {
744
862
  openLoopSet.add(sid);
@@ -751,7 +869,7 @@ function createAquifer(config) {
751
869
  const externalPromises = [];
752
870
  for (const [name, sourceConfig] of sources) {
753
871
  if (typeof sourceConfig.search === 'function') {
754
- const w = sourceConfig.weight !== null && sourceConfig.weight !== undefined ? sourceConfig.weight : 1.0;
872
+ const w = sourceConfig.weight !== undefined && sourceConfig.weight !== undefined ? sourceConfig.weight : 1.0;
755
873
  externalPromises.push(
756
874
  Promise.race([
757
875
  sourceConfig.search(query, opts),
@@ -822,7 +940,7 @@ function createAquifer(config) {
822
940
  if (sessionRowIds.length > 0) {
823
941
  try {
824
942
  await storage.recordAccess(pool, sessionRowIds, { schema });
825
- } catch (_) { /* access recording non-fatal */ }
943
+ } catch { /* access recording non-fatal */ }
826
944
  }
827
945
 
828
946
  // 8. Format results
@@ -833,7 +951,6 @@ function createAquifer(config) {
833
951
  startedAt: r.started_at,
834
952
  summaryText: r.summary_text || null,
835
953
  structuredSummary: r.structured_summary || null,
836
- summarySnippet: r.summary_snippet || null,
837
954
  matchedTurnText: r.matched_turn_text || null,
838
955
  matchedTurnIndex: r.matched_turn_index || null,
839
956
  score: r._rerankScore ?? r._score,
@@ -904,35 +1021,31 @@ function createAquifer(config) {
904
1021
  return { id: result.rows[0].id, sessionId, agentId, status: 'skipped' };
905
1022
  },
906
1023
 
907
- async getSessionFull(sessionId) {
908
- const result = await pool.query(
909
- `SELECT * FROM ${qi(schema)}.sessions
910
- WHERE session_id = $1 AND tenant_id = $2
911
- LIMIT 1`,
912
- [sessionId, tenantId]
913
- );
914
- const session = result.rows[0];
915
- if (!session) return null;
916
-
917
- const sumResult = await pool.query(
918
- `SELECT * FROM ${qi(schema)}.session_summaries
919
- WHERE session_row_id = $1
920
- LIMIT 1`,
921
- [session.id]
922
- );
923
-
924
- return {
925
- session,
926
- summary: sumResult.rows[0] || null,
927
- };
928
- },
929
-
930
1024
  // --- public config accessor ---
931
1025
 
932
1026
  getConfig() {
933
1027
  return { schema, tenantId };
934
1028
  },
935
1029
 
1030
+ // v1.2.0: expose the internal pool so host persona layers can reuse it
1031
+ // for host-owned tables (e.g. daily_entries). Read-only — callers should
1032
+ // not call pool.end() on it; use aquifer.close() for that.
1033
+ getPool() {
1034
+ return pool;
1035
+ },
1036
+
1037
+ // v1.2.0: expose resolved LLM function. May be null if no llm.fn was
1038
+ // supplied and AQUIFER_LLM_PROVIDER env is unset. Persona layers that
1039
+ // implement custom summaryFn can reuse this instead of wiring their own.
1040
+ getLlmFn() {
1041
+ return llmFn;
1042
+ },
1043
+
1044
+ // v1.2.0: expose resolved embed function (may be null same as LLM).
1045
+ getEmbedFn() {
1046
+ return embedFn;
1047
+ },
1048
+
936
1049
  // --- admin query helpers ---
937
1050
 
938
1051
  async getStats() {
@@ -965,7 +1078,7 @@ function createAquifer(config) {
965
1078
  [tenantId]
966
1079
  );
967
1080
  entityCount = entResult.rows[0]?.count || 0;
968
- } catch (_) { /* entities table may not exist */ }
1081
+ } catch { /* entities table may not exist */ }
969
1082
 
970
1083
  return {
971
1084
  sessions: Object.fromEntries(sessions.rows.map(r => [r.processing_status, r.count])),
@@ -1024,7 +1137,11 @@ function createAquifer(config) {
1024
1137
  const maxChars = opts.maxChars || 4000;
1025
1138
  const format = opts.format || 'structured';
1026
1139
 
1027
- const where = [`s.tenant_id = $1`, `s.processing_status = 'succeeded'`];
1140
+ // 'partial' sessions have a summary but recorded warnings during enrich;
1141
+ // they are user-visible content, not in-progress — bootstrap must include
1142
+ // them alongside 'succeeded'. 'pending' / 'processing' have no summary
1143
+ // yet and are correctly excluded.
1144
+ const where = [`s.tenant_id = $1`, `s.processing_status IN ('succeeded', 'partial')`];
1028
1145
  const params = [tenantId];
1029
1146
 
1030
1147
  if (agentId) {
@@ -1037,7 +1154,10 @@ function createAquifer(config) {
1037
1154
  }
1038
1155
 
1039
1156
  params.push(lookbackDays);
1040
- where.push(`s.started_at > now() - ($${params.length} || ' days')::interval`);
1157
+ // upsertSession sets ended_at on every commit but started_at / last_message_at
1158
+ // only when the caller supplies them — fall back through both so sessions
1159
+ // committed without explicit timestamps remain reachable.
1160
+ where.push(`COALESCE(s.last_message_at, s.ended_at, s.started_at) > now() - ($${params.length} || ' days')::interval`);
1041
1161
 
1042
1162
  params.push(limit);
1043
1163
 
@@ -1047,7 +1167,7 @@ function createAquifer(config) {
1047
1167
  FROM ${qi(schema)}.sessions s
1048
1168
  JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
1049
1169
  WHERE ${where.join(' AND ')}
1050
- ORDER BY s.started_at DESC
1170
+ ORDER BY COALESCE(s.last_message_at, s.ended_at, s.started_at) DESC
1051
1171
  LIMIT $${params.length}`,
1052
1172
  params
1053
1173
  );
@@ -1126,8 +1246,6 @@ function formatBootstrapText(data, maxChars) {
1126
1246
  }
1127
1247
 
1128
1248
  let truncated = false;
1129
- const parts = [];
1130
-
1131
1249
  // Build session lines (newest first, truncate from oldest if over budget)
1132
1250
  const sessionLines = [];
1133
1251
  for (const s of data.sessions) {
package/core/entity.js CHANGED
@@ -222,27 +222,40 @@ async function upsertEntityRelations(pool, {
222
222
  }) {
223
223
  if (!pairs || pairs.length === 0) return { upserted: 0 };
224
224
  const ts = occurredAt || new Date().toISOString();
225
- let upserted = 0;
226
225
 
226
+ // Filter and normalize pairs
227
+ const validPairs = [];
227
228
  for (const { srcEntityId, dstEntityId } of pairs) {
228
229
  if (!srcEntityId || !dstEntityId || srcEntityId === dstEntityId) continue;
230
+ validPairs.push({
231
+ lo: Math.min(srcEntityId, dstEntityId),
232
+ hi: Math.max(srcEntityId, dstEntityId),
233
+ });
234
+ }
229
235
 
230
- const lo = Math.min(srcEntityId, dstEntityId);
231
- const hi = Math.max(srcEntityId, dstEntityId);
232
-
233
- await pool.query(
234
- `INSERT INTO ${qi(schema)}.entity_relations
235
- (src_entity_id, dst_entity_id, co_occurrence_count, first_seen_at, last_seen_at)
236
- VALUES ($1, $2, 1, $3, $3)
237
- ON CONFLICT (src_entity_id, dst_entity_id) DO UPDATE SET
238
- co_occurrence_count = ${qi(schema)}.entity_relations.co_occurrence_count + 1,
239
- last_seen_at = GREATEST(${qi(schema)}.entity_relations.last_seen_at, EXCLUDED.last_seen_at)`,
240
- [lo, hi, ts]
241
- );
242
- upserted++;
236
+ if (validPairs.length === 0) return { upserted: 0 };
237
+
238
+ // Batch insert: multi-row VALUES
239
+ const valueClauses = [];
240
+ const params = [];
241
+
242
+ for (const { lo, hi } of validPairs) {
243
+ const off = params.length;
244
+ params.push(lo, hi, ts);
245
+ valueClauses.push(`($${off+1}, $${off+2}, 1, $${off+3}, $${off+3})`);
243
246
  }
244
247
 
245
- return { upserted };
248
+ await pool.query(
249
+ `INSERT INTO ${qi(schema)}.entity_relations
250
+ (src_entity_id, dst_entity_id, co_occurrence_count, first_seen_at, last_seen_at)
251
+ VALUES ${valueClauses.join(',\n')}
252
+ ON CONFLICT (src_entity_id, dst_entity_id) DO UPDATE SET
253
+ co_occurrence_count = ${qi(schema)}.entity_relations.co_occurrence_count + 1,
254
+ last_seen_at = GREATEST(${qi(schema)}.entity_relations.last_seen_at, EXCLUDED.last_seen_at)`,
255
+ params
256
+ );
257
+
258
+ return { upserted: validPairs.length };
246
259
  }
247
260
 
248
261
  // ---------------------------------------------------------------------------
@@ -373,8 +386,7 @@ async function resolveEntities(pool, {
373
386
  if (!normQ || seen.has(normQ)) continue;
374
387
  seen.set(normQ, true);
375
388
 
376
- const escaped = _escapeIlike(normQ);
377
- const result = await pool.query(
389
+ const result = await pool.query(
378
390
  `SELECT id, name, normalized_name
379
391
  FROM ${qi(schema)}.entities
380
392
  WHERE status = 'active'