@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
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Shared recall formatter — turns aquifer.recall() rows into human-readable
5
+ // text. The default is English and markdown-ish; consumers with a persona
6
+ // (Miranda: zh-TW narrative) can override individual renderers.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function truncate(s, n) {
10
+ if (!s) return '';
11
+ const str = String(s);
12
+ return str.length > n ? `${str.slice(0, n)}...` : str;
13
+ }
14
+
15
+ function formatDateIso(value) {
16
+ if (!value) return 'unknown';
17
+ const d = new Date(value);
18
+ return Number.isNaN(d.getTime()) ? 'unknown' : d.toISOString().slice(0, 10);
19
+ }
20
+
21
+ // Default English renderers --------------------------------------------------
22
+
23
+ const defaultRenderers = {
24
+ header({ results, query }) {
25
+ if (!query) return null;
26
+ return `Found ${results.length} result(s) for "${query}":`;
27
+ },
28
+ empty({ query }) {
29
+ return query ? `No results found for "${query}".` : 'No matching sessions found.';
30
+ },
31
+ title(result, index) {
32
+ const ss = result.structuredSummary || {};
33
+ const title = ss.title || truncate(result.summaryText, 60) || '(untitled)';
34
+ const date = formatDateIso(result.startedAt);
35
+ const agent = result.agentId || 'default';
36
+ return `### ${index + 1}. ${title} (${date}, ${agent})`;
37
+ },
38
+ body(result) {
39
+ const ss = result.structuredSummary || {};
40
+ const text = ss.overview || result.summaryText || '';
41
+ return text ? truncate(text, 300) : null;
42
+ },
43
+ matched(result) {
44
+ return result.matchedTurnText ? `Matched turn: ${truncate(result.matchedTurnText, 200)}` : null;
45
+ },
46
+ score(result, { showScore }) {
47
+ if (!showScore) return null;
48
+ return `Score: ${typeof result.score === 'number' ? result.score.toFixed(3) : '?'}`;
49
+ },
50
+ separator() {
51
+ return '';
52
+ },
53
+ };
54
+
55
+ /**
56
+ * Create a formatter with optional per-renderer overrides.
57
+ *
58
+ * @param {object} [overrides] — renderers to override: header/empty/title/body/matched/score/separator
59
+ * @returns {(results: any[], opts?: object) => string}
60
+ */
61
+ function createRecallFormatter(overrides = {}) {
62
+ const r = { ...defaultRenderers, ...overrides };
63
+
64
+ return function format(results, opts = {}) {
65
+ const safeResults = Array.isArray(results) ? results : [];
66
+ const ctx = { query: opts.query || null, results: safeResults };
67
+
68
+ if (safeResults.length === 0) {
69
+ return r.empty(ctx);
70
+ }
71
+
72
+ const lines = [];
73
+ const header = r.header(ctx);
74
+ if (header) { lines.push(header); lines.push(''); }
75
+
76
+ for (let i = 0; i < safeResults.length; i++) {
77
+ const res = safeResults[i];
78
+ const title = r.title(res, i, ctx);
79
+ if (title) lines.push(title);
80
+ const body = r.body(res, i, ctx);
81
+ if (body) lines.push(body);
82
+ const matched = r.matched(res, i, ctx);
83
+ if (matched) lines.push(matched);
84
+ const score = r.score(res, { showScore: !!opts.showScore, ...ctx });
85
+ if (score) lines.push(score);
86
+ const sep = r.separator(i, ctx);
87
+ if (sep !== null && sep !== undefined) lines.push(sep);
88
+ }
89
+
90
+ // Trim trailing empty separator
91
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
92
+
93
+ return lines.join('\n');
94
+ };
95
+ }
96
+
97
+ // Pre-built default English formatter
98
+ const defaultFormatter = createRecallFormatter();
99
+
100
+ function formatRecallResults(results, opts = {}) {
101
+ return defaultFormatter(results, opts);
102
+ }
103
+
104
+ module.exports = {
105
+ createRecallFormatter,
106
+ formatRecallResults,
107
+ truncate,
108
+ formatDateIso,
109
+ defaultRenderers,
110
+ };
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';
@@ -207,9 +264,23 @@ function createAquifer(config) {
207
264
  const trustSql = loadSql('003-trust-feedback.sql', schema);
208
265
  await pool.query(trustSql);
209
266
 
267
+ // 4. Facts / consolidation (opt-in)
268
+ if (factsEnabled) {
269
+ const factsSql = loadSql('004-facts.sql', schema);
270
+ await pool.query(factsSql);
271
+ }
272
+
273
+ // 5. Completion foundation (always, additive): narratives,
274
+ // consumer_profiles, sessions.consolidation_phases. Pure additive DDL
275
+ // with IF NOT EXISTS guards — safe on every migrate() call.
276
+ const completionSql = loadSql('004-completion.sql', schema);
277
+ await pool.query(completionSql);
278
+
210
279
  migrated = true;
211
280
  } finally {
212
- await pool.query('SELECT pg_advisory_unlock($1)', [lockKey]).catch(() => {});
281
+ await pool.query('SELECT pg_advisory_unlock($1)', [lockKey]).catch((err) => {
282
+ console.warn(`[aquifer] failed to release migration advisory lock for schema "${schema}": ${err.message}`);
283
+ });
213
284
  }
214
285
  },
215
286
 
@@ -225,7 +296,7 @@ function createAquifer(config) {
225
296
  sources.set(name, {
226
297
  type: opts.type || 'custom',
227
298
  search: opts.search || null,
228
- weight: opts.weight !== null && opts.weight !== undefined ? opts.weight : 1.0,
299
+ weight: opts.weight !== undefined && opts.weight !== undefined ? opts.weight : 1.0,
229
300
  });
230
301
  },
231
302
 
@@ -238,6 +309,32 @@ function createAquifer(config) {
238
309
  }
239
310
  },
240
311
 
312
+ async enableFacts() {
313
+ factsEnabled = true;
314
+ // Run the facts DDL (idempotent — all CREATE/ALTER use IF NOT EXISTS).
315
+ // Safe to call repeatedly; also safe to call before migrate() (will no-op
316
+ // until base schema exists, which enrich/commit will materialize).
317
+ await ensureMigrated();
318
+ const factsSql = loadSql('004-facts.sql', schema);
319
+ await pool.query(factsSql);
320
+ },
321
+
322
+ async consolidate(sessionId, opts = {}) {
323
+ if (!factsEnabled) throw new Error('aquifer.consolidate() requires enableFacts() first');
324
+ await ensureMigrated();
325
+ const { applyConsolidation } = require('../pipeline/consolidation');
326
+ const agentId = opts.agentId || 'agent';
327
+ return applyConsolidation(pool, {
328
+ actions: opts.actions || [],
329
+ agentId,
330
+ sessionId,
331
+ schema,
332
+ tenantId,
333
+ normalizeSubject: opts.normalizeSubject || null,
334
+ recapOverview: opts.recapOverview || '',
335
+ });
336
+ },
337
+
241
338
  // --- write path ---
242
339
 
243
340
  async commit(sessionId, messages, opts = {}) {
@@ -302,17 +399,19 @@ function createAquifer(config) {
302
399
  const postProcess = opts.postProcess || null; // async (ctx) => void
303
400
  const optModel = 'model' in opts ? opts.model : undefined; // undefined = no override
304
401
 
305
- // 1. Optimistic lock: claim session for processing
306
- // Also reclaim stale 'processing' sessions (stuck > 10 min = likely killed process)
307
- const STALE_MINUTES = 10;
402
+ // 1. Optimistic lock: claim session for processing.
403
+ // Also reclaim stale 'processing' sessions (likely killed worker).
404
+ // Stale window is config.staleEnrichMinutes (default 10).
308
405
  const claimResult = await pool.query(
309
406
  `UPDATE ${qi(schema)}.sessions
310
407
  SET processing_status = 'processing', processing_started_at = NOW()
311
408
  WHERE session_id = $1 AND agent_id = $2 AND tenant_id = $3
312
409
  AND (processing_status IN ('pending', 'failed')
313
- OR (processing_status = 'processing' AND (processing_started_at IS NULL OR processing_started_at < NOW() - INTERVAL '${STALE_MINUTES} minutes')))
410
+ OR (processing_status = 'processing'
411
+ AND (processing_started_at IS NULL
412
+ OR processing_started_at < NOW() - make_interval(mins => $4))))
314
413
  RETURNING *`,
315
- [sessionId, agentId, tenantId]
414
+ [sessionId, agentId, tenantId, staleEnrichMinutes]
316
415
  );
317
416
  const session = claimResult.rows[0];
318
417
  if (!session) {
@@ -333,34 +432,46 @@ function createAquifer(config) {
333
432
  // 2. Extract user turns
334
433
  const turns = storage.extractUserTurns(normalized);
335
434
 
435
+ // Collected across pre-tx and tx phases; any non-empty warnings demote
436
+ // the final status from 'succeeded' to 'partial' (see step 8 below).
437
+ const warnings = [];
438
+
336
439
  // 3. Summarize (custom or built-in)
337
440
  let summaryResult = null;
338
441
  let entityRaw = null;
339
442
  let extra = null;
340
443
 
341
444
  if (!skipSummary && normalized.length > 0) {
342
- if (customSummaryFn) {
343
- // Custom pipeline: caller handles LLM call and parsing
344
- summaryResult = await customSummaryFn(normalized);
345
- if (summaryResult.entityRaw) entityRaw = summaryResult.entityRaw;
346
- if (summaryResult.extra) extra = summaryResult.extra;
347
- } else {
348
- // Built-in pipeline
349
- const doMergeEntities = entitiesEnabled && mergeCall && !skipEntities;
350
- summaryResult = await summarize(normalized, {
351
- llmFn,
352
- promptFn: summarizePromptFn,
353
- mergeEntities: doMergeEntities,
354
- });
355
- if (summaryResult.entityRaw) {
356
- entityRaw = summaryResult.entityRaw;
445
+ // Pre-transaction failures (customSummaryFn / summarize throws) would
446
+ // otherwise bubble out and leave the session stuck in 'processing'
447
+ // until stale reclaim. Capture as a warning so status ends 'partial',
448
+ // keeping parity with how embed/entity-extract failures are treated.
449
+ try {
450
+ if (customSummaryFn) {
451
+ // Custom pipeline: caller handles LLM call and parsing
452
+ summaryResult = await customSummaryFn(normalized);
453
+ if (summaryResult && summaryResult.entityRaw) entityRaw = summaryResult.entityRaw;
454
+ if (summaryResult && summaryResult.extra) extra = summaryResult.extra;
455
+ } else {
456
+ // Built-in pipeline
457
+ const doMergeEntities = entitiesEnabled && mergeCall && !skipEntities;
458
+ summaryResult = await summarize(normalized, {
459
+ llmFn,
460
+ promptFn: summarizePromptFn,
461
+ mergeEntities: doMergeEntities,
462
+ });
463
+ if (summaryResult.entityRaw) {
464
+ entityRaw = summaryResult.entityRaw;
465
+ }
357
466
  }
467
+ } catch (e) {
468
+ warnings.push(`summary step failed: ${e.message}`);
469
+ summaryResult = null;
358
470
  }
359
471
  }
360
472
 
361
473
  // 4. Pre-compute all LLM/embed results BEFORE opening transaction
362
474
  // (avoids holding pool connection during slow LLM/embed calls)
363
- const warnings = [];
364
475
  let summaryEmbedding = null;
365
476
  let turnVectors = null;
366
477
  let parsedEntities = [];
@@ -494,7 +605,11 @@ function createAquifer(config) {
494
605
  await client.query('ROLLBACK').catch(() => {});
495
606
  try {
496
607
  await storage.markStatus(pool, session.id, 'failed', err.message, { schema });
497
- } catch (_) { /* swallow */ }
608
+ } catch (markErr) {
609
+ // Secondary failure: session is stuck in 'processing' until stale reclaim.
610
+ // Surface so operators notice and don't silently rely on the timeout.
611
+ console.warn(`[aquifer] enrich failed for session ${sessionId} AND markStatus('failed') also failed: ${markErr.message}`);
612
+ }
498
613
  throw err;
499
614
  } finally {
500
615
  client.release();
@@ -692,7 +807,7 @@ function createAquifer(config) {
692
807
  entityScoreBySession.set(row.session_id, parseInt(row.entity_count) / maxCount);
693
808
  }
694
809
  }
695
- } catch (_) { /* entity search failure non-fatal */ }
810
+ } catch { /* entity search failure non-fatal */ }
696
811
  }
697
812
 
698
813
  // 3. Run search paths in parallel (conditioned on mode)
@@ -747,7 +862,7 @@ function createAquifer(config) {
747
862
  for (const r of [...filteredFts, ...filteredEmb, ...filteredTurn]) {
748
863
  const sid = r.session_id || String(r.id);
749
864
  const ss = typeof r.structured_summary === 'string'
750
- ? (() => { try { return JSON.parse(r.structured_summary); } catch (_) { return null; } })()
865
+ ? (() => { try { return JSON.parse(r.structured_summary); } catch { return null; } })()
751
866
  : r.structured_summary;
752
867
  if (ss && Array.isArray(ss.open_loops) && ss.open_loops.length > 0) {
753
868
  openLoopSet.add(sid);
@@ -760,7 +875,7 @@ function createAquifer(config) {
760
875
  const externalPromises = [];
761
876
  for (const [name, sourceConfig] of sources) {
762
877
  if (typeof sourceConfig.search === 'function') {
763
- const w = sourceConfig.weight !== null && sourceConfig.weight !== undefined ? sourceConfig.weight : 1.0;
878
+ const w = sourceConfig.weight !== undefined && sourceConfig.weight !== undefined ? sourceConfig.weight : 1.0;
764
879
  externalPromises.push(
765
880
  Promise.race([
766
881
  sourceConfig.search(query, opts),
@@ -831,7 +946,7 @@ function createAquifer(config) {
831
946
  if (sessionRowIds.length > 0) {
832
947
  try {
833
948
  await storage.recordAccess(pool, sessionRowIds, { schema });
834
- } catch (_) { /* access recording non-fatal */ }
949
+ } catch { /* access recording non-fatal */ }
835
950
  }
836
951
 
837
952
  // 8. Format results
@@ -842,7 +957,6 @@ function createAquifer(config) {
842
957
  startedAt: r.started_at,
843
958
  summaryText: r.summary_text || null,
844
959
  structuredSummary: r.structured_summary || null,
845
- summarySnippet: r.summary_snippet || null,
846
960
  matchedTurnText: r.matched_turn_text || null,
847
961
  matchedTurnIndex: r.matched_turn_index || null,
848
962
  score: r._rerankScore ?? r._score,
@@ -913,35 +1027,31 @@ function createAquifer(config) {
913
1027
  return { id: result.rows[0].id, sessionId, agentId, status: 'skipped' };
914
1028
  },
915
1029
 
916
- async getSessionFull(sessionId) {
917
- const result = await pool.query(
918
- `SELECT * FROM ${qi(schema)}.sessions
919
- WHERE session_id = $1 AND tenant_id = $2
920
- LIMIT 1`,
921
- [sessionId, tenantId]
922
- );
923
- const session = result.rows[0];
924
- if (!session) return null;
925
-
926
- const sumResult = await pool.query(
927
- `SELECT * FROM ${qi(schema)}.session_summaries
928
- WHERE session_row_id = $1
929
- LIMIT 1`,
930
- [session.id]
931
- );
932
-
933
- return {
934
- session,
935
- summary: sumResult.rows[0] || null,
936
- };
937
- },
938
-
939
1030
  // --- public config accessor ---
940
1031
 
941
1032
  getConfig() {
942
1033
  return { schema, tenantId };
943
1034
  },
944
1035
 
1036
+ // v1.2.0: expose the internal pool so host persona layers can reuse it
1037
+ // for host-owned tables (e.g. daily_entries). Read-only — callers should
1038
+ // not call pool.end() on it; use aquifer.close() for that.
1039
+ getPool() {
1040
+ return pool;
1041
+ },
1042
+
1043
+ // v1.2.0: expose resolved LLM function. May be null if no llm.fn was
1044
+ // supplied and AQUIFER_LLM_PROVIDER env is unset. Persona layers that
1045
+ // implement custom summaryFn can reuse this instead of wiring their own.
1046
+ getLlmFn() {
1047
+ return llmFn;
1048
+ },
1049
+
1050
+ // v1.2.0: expose resolved embed function (may be null same as LLM).
1051
+ getEmbedFn() {
1052
+ return embedFn;
1053
+ },
1054
+
945
1055
  // --- admin query helpers ---
946
1056
 
947
1057
  async getStats() {
@@ -974,7 +1084,7 @@ function createAquifer(config) {
974
1084
  [tenantId]
975
1085
  );
976
1086
  entityCount = entResult.rows[0]?.count || 0;
977
- } catch (_) { /* entities table may not exist */ }
1087
+ } catch { /* entities table may not exist */ }
978
1088
 
979
1089
  return {
980
1090
  sessions: Object.fromEntries(sessions.rows.map(r => [r.processing_status, r.count])),
@@ -1033,7 +1143,11 @@ function createAquifer(config) {
1033
1143
  const maxChars = opts.maxChars || 4000;
1034
1144
  const format = opts.format || 'structured';
1035
1145
 
1036
- const where = [`s.tenant_id = $1`, `s.processing_status = 'succeeded'`];
1146
+ // 'partial' sessions have a summary but recorded warnings during enrich;
1147
+ // they are user-visible content, not in-progress — bootstrap must include
1148
+ // them alongside 'succeeded'. 'pending' / 'processing' have no summary
1149
+ // yet and are correctly excluded.
1150
+ const where = [`s.tenant_id = $1`, `s.processing_status IN ('succeeded', 'partial')`];
1037
1151
  const params = [tenantId];
1038
1152
 
1039
1153
  if (agentId) {
@@ -1046,7 +1160,10 @@ function createAquifer(config) {
1046
1160
  }
1047
1161
 
1048
1162
  params.push(lookbackDays);
1049
- where.push(`s.started_at > now() - ($${params.length} || ' days')::interval`);
1163
+ // upsertSession sets ended_at on every commit but started_at / last_message_at
1164
+ // only when the caller supplies them — fall back through both so sessions
1165
+ // committed without explicit timestamps remain reachable.
1166
+ where.push(`COALESCE(s.last_message_at, s.ended_at, s.started_at) > now() - ($${params.length} || ' days')::interval`);
1050
1167
 
1051
1168
  params.push(limit);
1052
1169
 
@@ -1056,7 +1173,7 @@ function createAquifer(config) {
1056
1173
  FROM ${qi(schema)}.sessions s
1057
1174
  JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
1058
1175
  WHERE ${where.join(' AND ')}
1059
- ORDER BY s.started_at DESC
1176
+ ORDER BY COALESCE(s.last_message_at, s.ended_at, s.started_at) DESC
1060
1177
  LIMIT $${params.length}`,
1061
1178
  params
1062
1179
  );
@@ -1122,6 +1239,29 @@ function createAquifer(config) {
1122
1239
  },
1123
1240
  };
1124
1241
 
1242
+ // Completion-capability surfaces (P2). All methods return AqResult envelope;
1243
+ // DDL materialised in schema/004-completion.sql (migrated unconditionally,
1244
+ // additive only). See core/errors.js for envelope shape.
1245
+ const { createNarratives } = require('./narratives');
1246
+ const { createTimeline } = require('./timeline');
1247
+ const { createState } = require('./state');
1248
+ const { createHandoff } = require('./handoff');
1249
+ const { createProfiles } = require('./profiles');
1250
+ const { createDecisions } = require('./decisions');
1251
+ const { createArtifacts } = require('./artifacts');
1252
+ const { createConsolidation } = require('./consolidation');
1253
+ const { createBundles } = require('./bundles');
1254
+ const qSchema = qi(schema);
1255
+ aquifer.narratives = createNarratives({ pool, schema: qSchema, defaultTenantId: tenantId });
1256
+ aquifer.timeline = createTimeline({ pool, schema: qSchema, defaultTenantId: tenantId });
1257
+ aquifer.state = createState({ pool, schema: qSchema, defaultTenantId: tenantId });
1258
+ aquifer.handoff = createHandoff({ pool, schema: qSchema, defaultTenantId: tenantId });
1259
+ aquifer.profiles = createProfiles({ pool, schema: qSchema, defaultTenantId: tenantId });
1260
+ aquifer.decisions = createDecisions({ pool, schema: qSchema, defaultTenantId: tenantId });
1261
+ aquifer.artifacts = createArtifacts({ pool, schema: qSchema, defaultTenantId: tenantId });
1262
+ aquifer.consolidation = createConsolidation({ pool, schema: qSchema, defaultTenantId: tenantId });
1263
+ aquifer.bundles = createBundles({ pool, schema: qSchema, defaultTenantId: tenantId });
1264
+
1125
1265
  return aquifer;
1126
1266
  }
1127
1267
 
@@ -1135,8 +1275,6 @@ function formatBootstrapText(data, maxChars) {
1135
1275
  }
1136
1276
 
1137
1277
  let truncated = false;
1138
- const parts = [];
1139
-
1140
1278
  // Build session lines (newest first, truncate from oldest if over budget)
1141
1279
  const sessionLines = [];
1142
1280
  for (const s of data.sessions) {