@shadowforge0/aquifer-memory 0.9.0 → 1.0.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.
package/consumers/cli.js CHANGED
@@ -16,6 +16,26 @@
16
16
 
17
17
  const { createAquiferFromConfig } = require('./shared/factory');
18
18
 
19
+ function formatDate(value, fallback) {
20
+ if (!value) return fallback;
21
+ const parsed = new Date(value);
22
+ return isNaN(parsed.getTime()) ? fallback : parsed.toISOString().slice(0, 10);
23
+ }
24
+
25
+ function quoteIdentifier(identifier) {
26
+ if (!/^[a-zA-Z_]\w{0,62}$/.test(identifier)) {
27
+ throw new Error(`Invalid schema name: "${identifier}"`);
28
+ }
29
+ return `"${identifier}"`;
30
+ }
31
+
32
+ function parsePositiveInt(value, fallback) {
33
+ if (value === undefined || value === null || value === true) return fallback;
34
+ const parsed = parseInt(value, 10);
35
+ if (!Number.isFinite(parsed)) return fallback;
36
+ return Math.max(1, parsed);
37
+ }
38
+
19
39
  // ---------------------------------------------------------------------------
20
40
  // Argument parser (minimal, no deps)
21
41
  // ---------------------------------------------------------------------------
@@ -23,7 +43,7 @@ const { createAquiferFromConfig } = require('./shared/factory');
23
43
  function parseArgs(argv) {
24
44
  const args = { _: [], flags: {} };
25
45
  // Flags that take a value (not boolean)
26
- const VALUE_FLAGS = new Set(['limit', 'agent-id', 'source', 'date-from', 'date-to', 'output', 'format', 'config', 'status', 'concurrency', 'entities', 'entity-mode', 'session-id', 'verdict', 'note']);
46
+ const VALUE_FLAGS = new Set(['limit', 'agent-id', 'source', 'date-from', 'date-to', 'output', 'format', 'config', 'status', 'concurrency', 'entities', 'entity-mode', 'session-id', 'verdict', 'note', 'db', 'since', 'min-messages', 'lookback-days', 'max-chars']);
27
47
  for (let i = 0; i < argv.length; i++) {
28
48
  if (argv[i] === '--') { args._.push(...argv.slice(i + 1)); break; }
29
49
  if (argv[i].startsWith('--')) {
@@ -57,7 +77,7 @@ async function cmdRecall(aquifer, args) {
57
77
  }
58
78
 
59
79
  const recallOpts = {
60
- limit: parseInt(args.flags.limit || '5', 10),
80
+ limit: parsePositiveInt(args.flags.limit, 5),
61
81
  agentId: args.flags['agent-id'] || undefined,
62
82
  source: args.flags.source || undefined,
63
83
  dateFrom: args.flags['date-from'] || undefined,
@@ -83,7 +103,7 @@ async function cmdRecall(aquifer, args) {
83
103
  const r = results[i];
84
104
  const ss = r.structuredSummary || {};
85
105
  const title = ss.title || r.summaryText?.slice(0, 60) || '(untitled)';
86
- const date = r.startedAt ? new Date(r.startedAt).toISOString().slice(0, 10) : '?';
106
+ const date = formatDate(r.startedAt, '?');
87
107
  console.log(`${i + 1}. [${r.score?.toFixed(3)}] ${title} (${date}, ${r.agentId})`);
88
108
  if (ss.overview) console.log(` ${ss.overview.slice(0, 200)}`);
89
109
  if (r.matchedTurnText) console.log(` > ${r.matchedTurnText.slice(0, 150)}`);
@@ -114,7 +134,7 @@ async function cmdFeedback(aquifer, args) {
114
134
  }
115
135
 
116
136
  async function cmdBackfill(aquifer, args) {
117
- const limit = parseInt(args.flags.limit || '100', 10);
137
+ const limit = parsePositiveInt(args.flags.limit, 100);
118
138
  const dryRun = !!args.flags['dry-run'];
119
139
  const skipSummary = !!args.flags['skip-summary'];
120
140
  const skipTurnEmbed = !!args.flags['skip-turn-embed'];
@@ -160,7 +180,7 @@ async function cmdStats(aquifer, args) {
160
180
  console.log(`Summaries: ${stats.summaries}`);
161
181
  console.log(`Turn embeddings: ${stats.turnEmbeddings}`);
162
182
  console.log(`Entities: ${stats.entities}`);
163
- if (stats.earliest) console.log(`Range: ${new Date(stats.earliest).toISOString().slice(0, 10)} — ${new Date(stats.latest).toISOString().slice(0, 10)}`);
183
+ if (stats.earliest) console.log(`Range: ${formatDate(stats.earliest, '?')} — ${formatDate(stats.latest, '?')}`);
164
184
  }
165
185
  }
166
186
 
@@ -211,20 +231,54 @@ async function cmdQuickstart(aquifer) {
211
231
  const { loadConfig } = require('./shared/config');
212
232
  const config = loadConfig();
213
233
  const pool = new Pool({ connectionString: config.db.url });
214
- const schema = config.schema || 'aquifer';
215
- await pool.query(`DELETE FROM ${schema}.turn_embeddings WHERE session_id IN (SELECT id FROM ${schema}.sessions WHERE session_id = $1)`, [sessionId]);
216
- await pool.query(`DELETE FROM ${schema}.session_summaries WHERE session_id IN (SELECT id FROM ${schema}.sessions WHERE session_id = $1)`, [sessionId]);
217
- await pool.query(`DELETE FROM ${schema}.sessions WHERE session_id = $1`, [sessionId]);
218
- await pool.end();
234
+ const schema = quoteIdentifier(config.schema || 'aquifer');
235
+ const tenantId = config.tenantId || 'default';
236
+ try {
237
+ await pool.query('BEGIN');
238
+ await pool.query(
239
+ `DELETE FROM ${schema}.sessions WHERE tenant_id = $1 AND agent_id = $2 AND session_id = $3`,
240
+ [tenantId, 'quickstart', sessionId]
241
+ );
242
+ await pool.query('COMMIT');
243
+ } catch (err) {
244
+ await pool.query('ROLLBACK').catch(() => {});
245
+ throw err;
246
+ } finally {
247
+ await pool.end();
248
+ }
219
249
  console.log(' OK\n');
220
250
 
221
251
  console.log('✓ Aquifer is working. You can now start the MCP server:');
222
252
  console.log(' npx aquifer mcp');
223
253
  }
224
254
 
255
+ async function cmdBootstrap(aquifer, args) {
256
+ const result = await aquifer.bootstrap({
257
+ agentId: args.flags['agent-id'] || undefined,
258
+ source: args.flags.source || undefined,
259
+ limit: parsePositiveInt(args.flags.limit, 5),
260
+ lookbackDays: parsePositiveInt(args.flags['lookback-days'], 14),
261
+ maxChars: parsePositiveInt(args.flags['max-chars'], 4000),
262
+ format: args.flags.json ? 'structured' : 'text',
263
+ });
264
+
265
+ if (args.flags.json) {
266
+ console.log(JSON.stringify(result, null, 2));
267
+ } else {
268
+ if (result.text) {
269
+ console.log(result.text);
270
+ } else {
271
+ // structured without text — format it
272
+ const { formatBootstrapText } = require('../core/aquifer');
273
+ const { text } = formatBootstrapText(result, result.meta?.maxChars || 4000);
274
+ console.log(text);
275
+ }
276
+ }
277
+ }
278
+
225
279
  async function cmdExport(aquifer, args) {
226
280
  const output = args.flags.output || null;
227
- const limit = parseInt(args.flags.limit || '1000', 10);
281
+ const limit = parsePositiveInt(args.flags.limit, 1000);
228
282
 
229
283
  const rows = await aquifer.exportSessions({
230
284
  agentId: args.flags['agent-id'],
@@ -267,6 +321,8 @@ Commands:
267
321
  backfill Enrich pending sessions
268
322
  stats Show database statistics
269
323
  export Export sessions as JSONL
324
+ bootstrap Show recent session context (for new session start)
325
+ ingest-opencode Import sessions from OpenCode's local SQLite DB
270
326
  mcp Start MCP server
271
327
 
272
328
  Options:
@@ -283,7 +339,12 @@ Options:
283
339
  --json JSON output
284
340
  --dry-run Preview only (backfill)
285
341
  --output PATH Output file (export)
286
- --config PATH Config file path`);
342
+ --config PATH Config file path
343
+ --lookback-days N How far back in days (bootstrap, default: 14)
344
+ --max-chars N Max output characters (bootstrap, default: 4000)
345
+ --db PATH OpenCode SQLite path (ingest-opencode)
346
+ --since YYYY-MM-DD Only ingest sessions after date (ingest-opencode)
347
+ --min-messages N Min user messages to ingest (ingest-opencode, default: 3)`);
287
348
  process.exit(0);
288
349
  }
289
350
 
@@ -331,6 +392,14 @@ Options:
331
392
  case 'export':
332
393
  await cmdExport(aquifer, args);
333
394
  break;
395
+ case 'bootstrap':
396
+ await cmdBootstrap(aquifer, args);
397
+ break;
398
+ case 'ingest-opencode': {
399
+ const { ingestOpenCode } = require('./opencode');
400
+ await ingestOpenCode(aquifer, args);
401
+ break;
402
+ }
334
403
  default:
335
404
  console.error(`Unknown command: ${command}. Run 'aquifer --help' for usage.`);
336
405
  process.exit(1);
package/consumers/mcp.js CHANGED
@@ -38,9 +38,11 @@ function formatResults(results, query) {
38
38
  const r = results[i];
39
39
  const ss = r.structuredSummary || {};
40
40
  const title = ss.title || r.summaryText?.slice(0, 60) || '(untitled)';
41
- const date = r.startedAt
42
- ? new Date(r.startedAt).toISOString().slice(0, 10)
43
- : 'unknown';
41
+ let date = 'unknown';
42
+ if (r.startedAt) {
43
+ const parsed = new Date(r.startedAt);
44
+ if (!isNaN(parsed.getTime())) date = parsed.toISOString().slice(0, 10);
45
+ }
44
46
 
45
47
  lines.push(`### ${i + 1}. ${title} (${date}, ${r.agentId || 'default'})`);
46
48
  if (ss.overview || r.summaryText) {
@@ -65,9 +67,11 @@ async function main() {
65
67
  ({ StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'));
66
68
  ({ z } = require('zod'));
67
69
  } catch (e) {
70
+ const missingDep = e && (e.code === 'MODULE_NOT_FOUND' || /Cannot find module|^missing\b/i.test(e.message || ''));
71
+ if (!missingDep) throw e;
68
72
  process.stderr.write(
69
73
  'aquifer mcp requires @modelcontextprotocol/sdk and zod.\n' +
70
- 'These should be installed automatically. Try: npm install\n'
74
+ 'Install: npm install @modelcontextprotocol/sdk zod\n'
71
75
  );
72
76
  process.exit(1);
73
77
  }
@@ -204,6 +208,35 @@ async function main() {
204
208
  }
205
209
  );
206
210
 
211
+ server.tool(
212
+ 'session_bootstrap',
213
+ 'Load recent session context for a new conversation. Returns summaries, open items, and decisions from recent sessions. Call this at the start of a conversation for continuity; use session_recall for keyword search.',
214
+ {
215
+ agentId: z.string().optional().describe('Filter by agent ID'),
216
+ limit: z.number().int().min(1).max(20).optional().describe('Max sessions (default 5)'),
217
+ lookbackDays: z.number().int().min(1).max(90).optional().describe('How far back in days (default 14)'),
218
+ maxChars: z.number().int().min(500).max(12000).optional().describe('Max output characters (default 4000)'),
219
+ },
220
+ async (params) => {
221
+ try {
222
+ const aquifer = getAquifer();
223
+ const result = await aquifer.bootstrap({
224
+ agentId: params.agentId,
225
+ limit: params.limit,
226
+ lookbackDays: params.lookbackDays,
227
+ maxChars: params.maxChars,
228
+ format: 'text',
229
+ });
230
+ return { content: [{ type: 'text', text: result.text }] };
231
+ } catch (err) {
232
+ return {
233
+ content: [{ type: 'text', text: `session_bootstrap error: ${err.message}` }],
234
+ isError: true,
235
+ };
236
+ }
237
+ }
238
+ );
239
+
207
240
  // Graceful shutdown
208
241
  const cleanup = async () => {
209
242
  if (_aquifer) await _aquifer.close().catch(() => {});
@@ -79,15 +79,19 @@ function normalizeEntries(rawEntries) {
79
79
  };
80
80
  }
81
81
 
82
+ function formatDate(value) {
83
+ if (!value) return 'unknown';
84
+ const parsed = new Date(value);
85
+ return isNaN(parsed.getTime()) ? 'unknown' : parsed.toISOString().slice(0, 10);
86
+ }
87
+
82
88
  function formatRecallResults(results) {
83
89
  if (results.length === 0) return 'No matching sessions found.';
84
90
 
85
91
  return results.map((r, i) => {
86
92
  const ss = r.structuredSummary || {};
87
93
  const title = ss.title || r.summaryText?.slice(0, 60) || '(untitled)';
88
- const date = r.startedAt
89
- ? new Date(r.startedAt).toISOString().slice(0, 10)
90
- : 'unknown';
94
+ const date = formatDate(r.startedAt);
91
95
 
92
96
  const lines = [`### ${i + 1}. ${title} (${date}, ${r.agentId || 'default'})`];
93
97
  if (ss.overview || r.summaryText) {
@@ -0,0 +1,345 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Aquifer — OpenCode Ingest Consumer
5
+ *
6
+ * Reads session history from OpenCode's local SQLite database and commits
7
+ * conversations into Aquifer for long-term memory (embedding + recall).
8
+ *
9
+ * OpenCode stores sessions in ~/.local/share/opencode/opencode.db (SQLite)
10
+ * with a Drizzle-managed schema: session → message → part.
11
+ *
12
+ * Usage (via CLI):
13
+ * aquifer ingest-opencode [options]
14
+ *
15
+ * Options:
16
+ * --db PATH OpenCode SQLite path (default: ~/.local/share/opencode/opencode.db)
17
+ * --agent-id ID Aquifer agent ID to store under (default: "opencode")
18
+ * --limit N Max sessions to ingest per run (default: 50)
19
+ * --since YYYY-MM-DD Only ingest sessions updated after this date
20
+ * --min-messages N Min user messages to ingest (default: 3)
21
+ * --dry-run Show what would be ingested without committing
22
+ * --enrich Run enrich (summary + embedding) after commit
23
+ * --json JSON output
24
+ * --session-id ID Ingest a single OpenCode session by ID
25
+ */
26
+
27
+ const path = require('path');
28
+ const os = require('os');
29
+ const { createAquiferFromConfig } = require('./shared/factory');
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // SQLite access — use Node 22+ built-in or fall back to better-sqlite3
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function openSqlite(dbPath) {
36
+ // Try node:sqlite (Node 22+)
37
+ try {
38
+ const { DatabaseSync } = require('node:sqlite');
39
+ return new DatabaseSync(dbPath, { open: true, readOnly: true });
40
+ } catch (_) {
41
+ // not available
42
+ }
43
+
44
+ // Try better-sqlite3
45
+ try {
46
+ const Database = require('better-sqlite3');
47
+ return new Database(dbPath, { readonly: true });
48
+ } catch (_) {
49
+ // not available
50
+ }
51
+
52
+ throw new Error(
53
+ 'No SQLite driver found. Upgrade to Node 22+ or install better-sqlite3:\n' +
54
+ ' npm install better-sqlite3'
55
+ );
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Read sessions from OpenCode SQLite
60
+ // ---------------------------------------------------------------------------
61
+
62
+ function getOpenCodeSessions(db, { limit = 50, since = null, sessionId = null } = {}) {
63
+ if (sessionId) {
64
+ const row = db.prepare(
65
+ 'SELECT id, title, directory, time_created, time_updated FROM session WHERE id = ?'
66
+ ).get(sessionId);
67
+ return row ? [row] : [];
68
+ }
69
+
70
+ let sql = `
71
+ SELECT id, title, directory, time_created, time_updated
72
+ FROM session
73
+ WHERE 1=1
74
+ `;
75
+ const params = [];
76
+
77
+ if (since) {
78
+ sql += ' AND time_updated >= ?';
79
+ params.push(new Date(since).getTime());
80
+ }
81
+
82
+ sql += ' ORDER BY time_updated DESC LIMIT ?';
83
+ params.push(limit);
84
+
85
+ return db.prepare(sql).all(...params);
86
+ }
87
+
88
+ function getSessionConversation(db, sessionId) {
89
+ // Get messages ordered by creation time
90
+ const messages = db.prepare(`
91
+ SELECT id, data, time_created
92
+ FROM message
93
+ WHERE session_id = ?
94
+ ORDER BY time_created ASC
95
+ `).all(sessionId);
96
+
97
+ // Get all parts for this session, grouped by message
98
+ const parts = db.prepare(`
99
+ SELECT id, message_id, data, time_created
100
+ FROM part
101
+ WHERE session_id = ?
102
+ ORDER BY time_created ASC
103
+ `).all(sessionId);
104
+
105
+ const partsByMsg = new Map();
106
+ for (const p of parts) {
107
+ const msgId = p.message_id;
108
+ if (!partsByMsg.has(msgId)) partsByMsg.set(msgId, []);
109
+ partsByMsg.get(msgId).push(p);
110
+ }
111
+
112
+ return { messages, partsByMsg };
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Normalize OpenCode conversation → Aquifer messages format
117
+ // ---------------------------------------------------------------------------
118
+
119
+ function normalizeConversation(messages, partsByMsg) {
120
+ const normalized = [];
121
+ let model = null;
122
+ let tokensIn = 0, tokensOut = 0;
123
+ let startedAt = null, lastMessageAt = null;
124
+
125
+ for (const msg of messages) {
126
+ const msgData = JSON.parse(msg.data);
127
+ const role = msgData.role;
128
+ if (!role || !['user', 'assistant'].includes(role)) continue;
129
+
130
+ // Extract model info
131
+ if (!model) {
132
+ if (msgData.model?.modelID) model = msgData.model.modelID;
133
+ else if (msgData.modelID) model = msgData.modelID;
134
+ else if (msgData.providerID && msgData.modelID) model = `${msgData.providerID}/${msgData.modelID}`;
135
+ }
136
+
137
+ // Accumulate tokens
138
+ if (msgData.tokens) {
139
+ tokensIn += msgData.tokens.input || 0;
140
+ tokensOut += msgData.tokens.output || 0;
141
+ }
142
+
143
+ // Timestamp (ms → ISO)
144
+ const ts = msg.time_created ? new Date(msg.time_created).toISOString() : null;
145
+ if (ts && !startedAt) startedAt = ts;
146
+ if (ts) lastMessageAt = ts;
147
+
148
+ // Build content from parts
149
+ const msgParts = partsByMsg.get(msg.id) || [];
150
+ const textParts = [];
151
+
152
+ for (const part of msgParts) {
153
+ let partData;
154
+ try { partData = JSON.parse(part.data); } catch { continue; }
155
+
156
+ if (partData.type === 'text' && partData.text) {
157
+ textParts.push(partData.text);
158
+ } else if (partData.type === 'tool' && partData.state?.output) {
159
+ // Include tool results as context (truncated)
160
+ const toolName = partData.tool || 'tool';
161
+ const output = partData.state.output;
162
+ const truncated = output.length > 500 ? output.slice(0, 500) + '...' : output;
163
+ textParts.push(`[${toolName}]: ${truncated}`);
164
+ }
165
+ }
166
+
167
+ const content = textParts.join('\n').trim();
168
+ if (!content) continue;
169
+
170
+ // Merge consecutive same-role messages (OpenCode splits assistant into steps)
171
+ const last = normalized[normalized.length - 1];
172
+ if (last && last.role === role) {
173
+ last.content += '\n\n' + content;
174
+ last.timestamp = ts || last.timestamp;
175
+ } else {
176
+ normalized.push({ role, content, timestamp: ts });
177
+ }
178
+ }
179
+
180
+ return {
181
+ messages: normalized,
182
+ userCount: normalized.filter(m => m.role === 'user').length,
183
+ assistantCount: normalized.filter(m => m.role === 'assistant').length,
184
+ model,
185
+ tokensIn,
186
+ tokensOut,
187
+ startedAt,
188
+ lastMessageAt,
189
+ };
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Ingest command
194
+ // ---------------------------------------------------------------------------
195
+
196
+ async function ingestOpenCode(aquifer, args) {
197
+ const defaultDb = path.join(os.homedir(), '.local/share/opencode/opencode.db');
198
+ const dbPath = args.flags.db || defaultDb;
199
+ const agentId = args.flags['agent-id'] || 'opencode';
200
+ const limit = Math.max(1, parseInt(args.flags.limit || '50', 10) || 50);
201
+ const since = args.flags.since || null;
202
+ const dryRun = !!args.flags['dry-run'];
203
+ const doEnrich = !!args.flags.enrich;
204
+ const jsonOutput = !!args.flags.json;
205
+ const sessionId = args.flags['session-id'] || null;
206
+ const minUserMessages = parseInt(args.flags['min-messages'] || '3', 10);
207
+
208
+ // Open OpenCode DB
209
+ let db;
210
+ try {
211
+ db = openSqlite(dbPath);
212
+ } catch (err) {
213
+ console.error(`Cannot open OpenCode database: ${err.message}`);
214
+ console.error(`Expected at: ${dbPath}`);
215
+ process.exit(1);
216
+ }
217
+
218
+ // Check which sessions are already in Aquifer
219
+ const existingSet = new Set();
220
+ try {
221
+ const existing = await aquifer.exportSessions({ source: 'opencode', limit: 10000 });
222
+ for (const row of existing) existingSet.add(row.session_id);
223
+ } catch (_) {
224
+ // exportSessions may not exist in all versions
225
+ }
226
+
227
+ // Get OpenCode sessions
228
+ const sessions = getOpenCodeSessions(db, { limit, since, sessionId });
229
+
230
+ const results = [];
231
+ let committed = 0, skipped = 0, failed = 0;
232
+
233
+ for (const session of sessions) {
234
+ const sid = session.id;
235
+
236
+ // Skip already ingested (unless explicitly requested by session-id)
237
+ if (!sessionId && existingSet.has(sid)) {
238
+ skipped++;
239
+ if (jsonOutput) results.push({ sessionId: sid, status: 'exists' });
240
+ continue;
241
+ }
242
+
243
+ // Read conversation
244
+ const { messages, partsByMsg } = getSessionConversation(db, sid);
245
+ const norm = normalizeConversation(messages, partsByMsg);
246
+
247
+ if (norm.userCount < minUserMessages) {
248
+ skipped++;
249
+ if (jsonOutput) results.push({ sessionId: sid, status: 'too_short', userMessages: norm.userCount });
250
+ else if (!jsonOutput && !dryRun) {
251
+ console.log(` [skip] ${sid} — ${norm.userCount} user msg(s)`);
252
+ }
253
+ continue;
254
+ }
255
+
256
+ const info = {
257
+ sessionId: sid,
258
+ title: session.title,
259
+ messages: norm.messages.length,
260
+ userMessages: norm.userCount,
261
+ model: norm.model,
262
+ };
263
+
264
+ if (dryRun) {
265
+ info.status = 'dry-run';
266
+ if (jsonOutput) {
267
+ results.push(info);
268
+ } else {
269
+ console.log(` [dry-run] ${sid} "${session.title}" — ${norm.messages.length} msgs (${norm.userCount} user)`);
270
+ }
271
+ continue;
272
+ }
273
+
274
+ // Commit to Aquifer
275
+ try {
276
+ await aquifer.commit(sid, norm.messages, {
277
+ agentId,
278
+ source: 'opencode',
279
+ model: norm.model,
280
+ tokensIn: norm.tokensIn,
281
+ tokensOut: norm.tokensOut,
282
+ startedAt: norm.startedAt,
283
+ lastMessageAt: norm.lastMessageAt,
284
+ });
285
+ committed++;
286
+ info.status = 'committed';
287
+
288
+ // Enrich if requested
289
+ if (doEnrich) {
290
+ try {
291
+ const enrichResult = await aquifer.enrich(sid, { agentId });
292
+ info.status = 'enriched';
293
+ info.turnsEmbedded = enrichResult.turnsEmbedded;
294
+ info.entitiesFound = enrichResult.entitiesFound;
295
+ } catch (enrichErr) {
296
+ info.enrichError = enrichErr.message;
297
+ }
298
+ }
299
+
300
+ if (jsonOutput) {
301
+ results.push(info);
302
+ } else {
303
+ const enrichNote = info.turnsEmbedded != null ? ` (${info.turnsEmbedded} turns, ${info.entitiesFound} entities)` : '';
304
+ console.log(` [${committed}] ${sid} "${session.title}"${enrichNote}`);
305
+ }
306
+ } catch (err) {
307
+ failed++;
308
+ info.status = 'error';
309
+ info.error = err.message;
310
+ if (jsonOutput) {
311
+ results.push(info);
312
+ } else {
313
+ console.error(` [error] ${sid}: ${err.message}`);
314
+ }
315
+ }
316
+ }
317
+
318
+ // Close SQLite
319
+ db.close();
320
+
321
+ // Summary
322
+ if (jsonOutput) {
323
+ console.log(JSON.stringify({ committed, skipped, failed, total: sessions.length, sessions: results }, null, 2));
324
+ } else {
325
+ console.log(`\nDone. committed=${committed} skipped=${skipped} failed=${failed} total=${sessions.length}`);
326
+ if (committed > 0 && !doEnrich) {
327
+ console.log('Tip: run "aquifer backfill" to enrich committed sessions.');
328
+ }
329
+ }
330
+
331
+ if (failed > 0) process.exitCode = 2;
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Exports
336
+ // ---------------------------------------------------------------------------
337
+
338
+ module.exports = {
339
+ ingestOpenCode,
340
+ // Exposed for testing
341
+ openSqlite,
342
+ getOpenCodeSessions,
343
+ getSessionConversation,
344
+ normalizeConversation,
345
+ };
package/core/aquifer.js CHANGED
@@ -583,7 +583,22 @@ function createAquifer(config) {
583
583
  weights: overrideWeights,
584
584
  entities: explicitEntities,
585
585
  entityMode = 'any',
586
+ strictSearchErrors = false,
586
587
  } = opts;
588
+ const searchErrors = [];
589
+
590
+ function recordSearchError(pathName, err) {
591
+ searchErrors.push({
592
+ path: pathName,
593
+ message: err && err.message ? err.message : String(err),
594
+ });
595
+ }
596
+
597
+ function maybeThrowSearchErrors() {
598
+ if (!strictSearchErrors || searchErrors.length === 0) return;
599
+ const details = searchErrors.map(e => `${e.path}: ${e.message}`).join('; ');
600
+ throw new Error(`Recall search failed: ${details}`);
601
+ }
587
602
 
588
603
  // Normalize agentId/agentIds into a single resolved value
589
604
  // agentIds takes precedence; agentId is sugar for agentIds: [agentId]
@@ -692,17 +707,26 @@ function createAquifer(config) {
692
707
  runFts
693
708
  ? storage.searchSessions(pool, query, {
694
709
  schema, tenantId, agentIds: resolvedAgentIds, source, dateFrom, dateTo, limit: fetchLimit, ftsConfig,
695
- }).catch(() => [])
710
+ }).catch((err) => {
711
+ recordSearchError('fts', err);
712
+ return [];
713
+ })
696
714
  : Promise.resolve([]),
697
715
  runVector
698
716
  ? embeddingSearchSummaries(queryVec, {
699
717
  agentIds: resolvedAgentIds, source, dateFrom, dateTo, limit: fetchLimit,
700
- }).catch(() => [])
718
+ }).catch((err) => {
719
+ recordSearchError('summary-vector', err);
720
+ return [];
721
+ })
701
722
  : Promise.resolve([]),
702
723
  runVector
703
724
  ? storage.searchTurnEmbeddings(pool, {
704
725
  schema, tenantId, queryVec, dateFrom, dateTo, agentIds: resolvedAgentIds, source, limit: fetchLimit,
705
- }).catch(() => ({ rows: [] }))
726
+ }).catch((err) => {
727
+ recordSearchError('turn-vector', err);
728
+ return { rows: [] };
729
+ })
706
730
  : Promise.resolve({ rows: [] }),
707
731
  ]);
708
732
 
@@ -718,6 +742,7 @@ function createAquifer(config) {
718
742
  const filteredTurn = filterFn(turnRows);
719
743
 
720
744
  if (filteredFts.length === 0 && filteredEmb.length === 0 && filteredTurn.length === 0) {
745
+ maybeThrowSearchErrors();
721
746
  return [];
722
747
  }
723
748
 
@@ -737,7 +762,7 @@ function createAquifer(config) {
737
762
  const EXTERNAL_TIMEOUT = 10000;
738
763
  const externalRows = [];
739
764
  const externalPromises = [];
740
- for (const [, sourceConfig] of sources) {
765
+ for (const [name, sourceConfig] of sources) {
741
766
  if (typeof sourceConfig.search === 'function') {
742
767
  const w = sourceConfig.weight !== null && sourceConfig.weight !== undefined ? sourceConfig.weight : 1.0;
743
768
  externalPromises.push(
@@ -750,7 +775,9 @@ function createAquifer(config) {
750
775
  if (r && r.session_id) externalRows.push({ ...r, _externalWeight: w });
751
776
  }
752
777
  }
753
- }).catch(() => { /* external source failure/timeout non-fatal */ })
778
+ }).catch((err) => {
779
+ recordSearchError(`external:${name}`, err);
780
+ })
754
781
  );
755
782
  }
756
783
  }
@@ -835,6 +862,7 @@ function createAquifer(config) {
835
862
  hybridScore: r._hybridScore ?? r._score,
836
863
  rerankScore: r._rerankScore ?? null,
837
864
  rerankFallback: r._rerankFallback || false,
865
+ searchErrors: searchErrors.slice(),
838
866
  },
839
867
  }));
840
868
  },
@@ -1008,13 +1036,164 @@ function createAquifer(config) {
1008
1036
  );
1009
1037
  return result.rows;
1010
1038
  },
1039
+
1040
+ async bootstrap(opts = {}) {
1041
+ await ensureMigrated();
1042
+
1043
+ const agentId = opts.agentId || null;
1044
+ const source = opts.source || null;
1045
+ const limit = Math.max(1, Math.min(20, opts.limit || 5));
1046
+ const lookbackDays = opts.lookbackDays || 14;
1047
+ const maxChars = opts.maxChars || 4000;
1048
+ const format = opts.format || 'structured';
1049
+
1050
+ const where = [`s.tenant_id = $1`, `s.processing_status = 'succeeded'`];
1051
+ const params = [tenantId];
1052
+
1053
+ if (agentId) {
1054
+ params.push(agentId);
1055
+ where.push(`s.agent_id = $${params.length}`);
1056
+ }
1057
+ if (source) {
1058
+ params.push(source);
1059
+ where.push(`s.source = $${params.length}`);
1060
+ }
1061
+
1062
+ params.push(lookbackDays);
1063
+ where.push(`s.started_at > now() - ($${params.length} || ' days')::interval`);
1064
+
1065
+ params.push(limit);
1066
+
1067
+ const result = await pool.query(
1068
+ `SELECT s.session_id, s.agent_id, s.source, s.started_at, s.msg_count,
1069
+ ss.summary_text, ss.structured_summary
1070
+ FROM ${qi(schema)}.sessions s
1071
+ JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
1072
+ WHERE ${where.join(' AND ')}
1073
+ ORDER BY s.started_at DESC
1074
+ LIMIT $${params.length}`,
1075
+ params
1076
+ );
1077
+
1078
+ const sessions = result.rows.map(r => {
1079
+ const ss = r.structured_summary || {};
1080
+ const hasSS = ss.title || ss.overview;
1081
+ return {
1082
+ sessionId: r.session_id,
1083
+ agentId: r.agent_id,
1084
+ source: r.source,
1085
+ startedAt: r.started_at,
1086
+ title: ss.title || (hasSS ? null : (r.summary_text || '').slice(0, 60).trim() || null),
1087
+ overview: ss.overview || (hasSS ? null : (r.summary_text || '').slice(0, 200).trim() || null),
1088
+ topics: Array.isArray(ss.topics) ? ss.topics : [],
1089
+ decisions: Array.isArray(ss.decisions) ? ss.decisions : [],
1090
+ openLoops: Array.isArray(ss.open_loops) ? ss.open_loops : [],
1091
+ importantFacts: Array.isArray(ss.important_facts) ? ss.important_facts : [],
1092
+ };
1093
+ });
1094
+
1095
+ // Cross-session open loops merge + dedup + sentinel filter
1096
+ const SENTINELS = new Set(['無', 'none', 'n/a', 'na', 'done', '']);
1097
+ const seenLoops = new Set();
1098
+ const openLoops = [];
1099
+ for (const s of sessions) {
1100
+ for (const loop of s.openLoops) {
1101
+ const raw = typeof loop === 'string' ? loop : (loop.item || '');
1102
+ const normalized = raw.trim().replace(/\s+/g, ' ').toLowerCase();
1103
+ if (SENTINELS.has(normalized) || !normalized || seenLoops.has(normalized)) continue;
1104
+ seenLoops.add(normalized);
1105
+ openLoops.push({ item: raw.trim(), fromSession: s.sessionId, latestStartedAt: s.startedAt });
1106
+ }
1107
+ }
1108
+
1109
+ // Cross-session recent decisions dedup
1110
+ const seenDecisions = new Set();
1111
+ const recentDecisions = [];
1112
+ for (const s of sessions) {
1113
+ for (const d of s.decisions) {
1114
+ const key = typeof d === 'string' ? d : (d.decision || '');
1115
+ const normalized = key.trim().replace(/\s+/g, ' ').toLowerCase();
1116
+ if (!normalized || seenDecisions.has(normalized)) continue;
1117
+ seenDecisions.add(normalized);
1118
+ recentDecisions.push({ decision: key.trim(), reason: d.reason || null, fromSession: s.sessionId });
1119
+ }
1120
+ }
1121
+
1122
+ const structured = {
1123
+ sessions,
1124
+ openLoops,
1125
+ recentDecisions,
1126
+ meta: { lookbackDays, count: sessions.length, maxChars, truncated: false },
1127
+ };
1128
+
1129
+ if (format === 'text' || format === 'both') {
1130
+ const textResult = formatBootstrapText(structured, maxChars);
1131
+ structured.text = textResult.text;
1132
+ structured.meta.truncated = textResult.truncated;
1133
+ }
1134
+
1135
+ return structured;
1136
+ },
1011
1137
  };
1012
1138
 
1013
1139
  return aquifer;
1014
1140
  }
1015
1141
 
1142
+ // ---------------------------------------------------------------------------
1143
+ // formatBootstrapText — pure function, builds <session-bootstrap> XML block
1144
+ // ---------------------------------------------------------------------------
1145
+
1146
+ function formatBootstrapText(data, maxChars) {
1147
+ if (!data.sessions || data.sessions.length === 0) {
1148
+ return { text: 'No recent sessions found.', truncated: false };
1149
+ }
1150
+
1151
+ let truncated = false;
1152
+ const parts = [];
1153
+
1154
+ // Build session lines (newest first, truncate from oldest if over budget)
1155
+ const sessionLines = [];
1156
+ for (const s of data.sessions) {
1157
+ const date = s.startedAt ? new Date(s.startedAt).toISOString().slice(0, 10) : '?';
1158
+ const title = s.title || '(untitled)';
1159
+ const overview = s.overview ? s.overview.slice(0, 200) : '';
1160
+ let line = `- ${date} | ${title}`;
1161
+ if (overview) line += ` — ${overview}`;
1162
+ const decisions = s.decisions
1163
+ .map(d => typeof d === 'string' ? d : d.decision)
1164
+ .filter(Boolean);
1165
+ if (decisions.length > 0) line += `\n Decisions: ${decisions.join('; ')}`;
1166
+ sessionLines.push(line);
1167
+ }
1168
+
1169
+ // Fit within maxChars by removing oldest sessions
1170
+ let bodyLines = [...sessionLines];
1171
+ const footer = [];
1172
+ if (data.openLoops.length > 0) {
1173
+ footer.push(`Open items: ${data.openLoops.map(l => l.item).join(', ')}`);
1174
+ }
1175
+ if (data.recentDecisions.length > 0) {
1176
+ footer.push(`Recent decisions: ${data.recentDecisions.map(d => d.decision).join(', ')}`);
1177
+ }
1178
+
1179
+ const buildText = (lines) => {
1180
+ const body = ['Recent sessions:', ...lines].join('\n');
1181
+ const full = footer.length > 0 ? body + '\n' + footer.join('\n') : body;
1182
+ return `<session-bootstrap sessions="${lines.length}" open_loops="${data.openLoops.length}">\n${full}\n</session-bootstrap>`;
1183
+ };
1184
+
1185
+ let text = buildText(bodyLines);
1186
+ while (text.length > maxChars && bodyLines.length > 1) {
1187
+ bodyLines.pop(); // remove oldest
1188
+ truncated = true;
1189
+ text = buildText(bodyLines);
1190
+ }
1191
+
1192
+ return { text, truncated };
1193
+ }
1194
+
1016
1195
  // ---------------------------------------------------------------------------
1017
1196
  // Exports
1018
1197
  // ---------------------------------------------------------------------------
1019
1198
 
1020
- module.exports = { createAquifer };
1199
+ module.exports = { createAquifer, formatBootstrapText };
@@ -36,12 +36,12 @@ function rrfFusion(ftsResults = [], embResults = [], turnResults = [], K = 60) {
36
36
  // timeDecay — sigmoid decay based on age in days
37
37
  // ---------------------------------------------------------------------------
38
38
 
39
- function timeDecay(startedAt, midpointDays = 45, steepness = 0.05) {
39
+ function timeDecay(startedAt, midpointDays = 45, steepness = 0.05, nowMs = Date.now()) {
40
40
  if (!startedAt) return 0.5;
41
41
  const dt = typeof startedAt === 'string' ? new Date(startedAt) : startedAt;
42
42
  if (isNaN(dt.getTime())) return 0.5;
43
43
 
44
- const ageDays = (Date.now() - dt.getTime()) / (1000 * 60 * 60 * 24);
44
+ const ageDays = (nowMs - dt.getTime()) / (1000 * 60 * 60 * 24);
45
45
  return 1 / (1 + Math.exp(steepness * (ageDays - midpointDays)));
46
46
  }
47
47
 
@@ -49,14 +49,14 @@ function timeDecay(startedAt, midpointDays = 45, steepness = 0.05) {
49
49
  // accessScore — exponential decay on access recency (30-day half-life)
50
50
  // ---------------------------------------------------------------------------
51
51
 
52
- function accessScore(accessCount, lastAccessedAt) {
52
+ function accessScore(accessCount, lastAccessedAt, nowMs = Date.now()) {
53
53
  if (!accessCount || accessCount <= 0) return 0;
54
54
  if (!lastAccessedAt) return 0;
55
55
 
56
56
  const dt = typeof lastAccessedAt === 'string' ? new Date(lastAccessedAt) : lastAccessedAt;
57
57
  if (isNaN(dt.getTime())) return 0;
58
58
 
59
- const daysSince = (Date.now() - dt.getTime()) / (1000 * 60 * 60 * 24);
59
+ const daysSince = (nowMs - dt.getTime()) / (1000 * 60 * 60 * 24);
60
60
  return accessCount * Math.exp(-0.693 * daysSince / 30);
61
61
  }
62
62
 
@@ -89,6 +89,7 @@ function hybridRank(ftsResults, embResults, turnResults, opts = {}) {
89
89
  } = opts;
90
90
 
91
91
  const w = { ...DEFAULT_WEIGHTS, ...weights };
92
+ const nowMs = opts.nowMs ?? Date.now();
92
93
 
93
94
  // Build allResults map: session_id → result object
94
95
  const allResults = new Map();
@@ -140,11 +141,12 @@ function hybridRank(ftsResults, embResults, turnResults, opts = {}) {
140
141
  const rawRrf = rrfScores.get(sessionId) || 0;
141
142
  const normRrf = maxRrf > 0 ? rawRrf / maxRrf : 0;
142
143
 
143
- const td = timeDecay(result.started_at);
144
+ const td = timeDecay(result.started_at, 45, 0.05, nowMs);
144
145
 
145
146
  const accessEff = accessScore(
146
147
  result.access_count || 0,
147
148
  result.last_accessed_at,
149
+ nowMs,
148
150
  );
149
151
  const as = 1 - Math.exp(-accessEff / 5);
150
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shadowforge0/aquifer-memory",
3
- "version": "0.9.0",
3
+ "version": "1.0.0",
4
4
  "description": "PG-native long-term memory for AI agents. Turn-level embedding, hybrid RRF ranking, optional knowledge graph. MCP server, CLI, and library API.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -21,6 +21,7 @@
21
21
  "./pipeline/*": "./pipeline/*.js",
22
22
  "./consumers/mcp": "./consumers/mcp.js",
23
23
  "./consumers/openclaw-plugin": "./consumers/openclaw-plugin.js",
24
+ "./consumers/opencode": "./consumers/opencode.js",
24
25
  "./consumers/shared/config": "./consumers/shared/config.js",
25
26
  "./consumers/shared/factory": "./consumers/shared/factory.js"
26
27
  },