@shadowforge0/aquifer-memory 0.9.1 → 1.0.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.
package/README.md CHANGED
@@ -130,13 +130,14 @@ Full env-to-config mapping is in [consumers/shared/config.js](consumers/shared/c
130
130
 
131
131
  ## Host Integration
132
132
 
133
- MCP is the primary integration surface. Agent hosts connect to the Aquifer MCP server, which exposes four tools: `session_recall`, `session_feedback`, `memory_stats`, `memory_pending`.
133
+ MCP is the primary integration surface. Agent hosts connect to the Aquifer MCP server, which exposes five tools: `session_recall`, `session_feedback`, `session_bootstrap`, `memory_stats`, `memory_pending`.
134
134
 
135
135
  | Integration | Route | Status | When to use |
136
136
  |-------------|-------|--------|-------------|
137
137
  | MCP server | `consumers/mcp.js` | Primary | Claude Code, OpenClaw, Codex, any MCP-capable host |
138
138
  | Library API | `createAquifer()` | Primary | Backend apps, custom pipelines, direct Node.js usage |
139
- | CLI | `consumers/cli.js` | Secondary | Operations, debugging, manual recall/backfill |
139
+ | CLI | `consumers/cli.js` | Secondary | Operations, debugging, manual recall/backfill (`aquifer bootstrap`, `aquifer ingest-opencode`, etc.) |
140
+ | OpenCode ingest | `consumers/opencode.js` | Secondary | Import sessions from OpenCode's SQLite DB |
140
141
  | OpenClaw plugin | `consumers/openclaw-plugin.js` | Compatibility only | Session capture via `before_reset` — not for tool delivery |
141
142
 
142
143
  ### Claude Code
@@ -160,7 +161,7 @@ Add to your project's `.claude.json` or user-level MCP config:
160
161
  }
161
162
  ```
162
163
 
163
- Tools appear as `mcp__aquifer__session_recall`, `mcp__aquifer__session_feedback`, etc.
164
+ Tools appear as `mcp__aquifer__session_recall`, `mcp__aquifer__session_feedback`, `mcp__aquifer__session_bootstrap`, etc.
164
165
 
165
166
  ### OpenClaw
166
167
 
@@ -184,7 +185,7 @@ Add to `openclaw.json` under `mcp.servers`:
184
185
  }
185
186
  ```
186
187
 
187
- Tools materialize as `aquifer__session_recall`, `aquifer__session_feedback`, `aquifer__memory_stats`, `aquifer__memory_pending` (server name prefix added by the host).
188
+ Tools materialize as `aquifer__session_recall`, `aquifer__session_feedback`, `aquifer__session_bootstrap`, `aquifer__memory_stats`, `aquifer__memory_pending` (server name prefix added by the host).
188
189
 
189
190
  The OpenClaw plugin (`consumers/openclaw-plugin.js`) is retained for session capture via `before_reset` but is **not** the recommended tool delivery path. Use MCP.
190
191
 
@@ -245,6 +246,7 @@ Any host that supports MCP stdio can connect the same way — point it at `node
245
246
  | `pipeline/extract-entities.js` | LLM-powered entity extraction (12 types) |
246
247
  | `pipeline/rerank.js` | Cross-encoder reranking (TEI, Jina, OpenRouter) |
247
248
  | `pipeline/normalize/` | Session normalization for Claude Code / gateway noise |
249
+ | `consumers/opencode.js` | OpenCode SQLite ingest — reads sessions from OpenCode's local DB |
248
250
  | `schema/001-base.sql` | DDL: sessions, summaries, turn_embeddings, FTS indexes |
249
251
  | `schema/002-entities.sql` | DDL: entities, mentions, relations, entity_sessions |
250
252
  | `schema/003-trust-feedback.sql` | DDL: trust_score column, session_feedback audit trail |
@@ -435,6 +437,24 @@ await aquifer.feedback('session-id', {
435
437
  });
436
438
  ```
437
439
 
440
+ #### `aquifer.bootstrap(opts)`
441
+
442
+ Loads recent session context for a new conversation — summaries, open loops, and decisions. Time-based (no embedding search), designed for session-start injection.
443
+
444
+ ```javascript
445
+ const result = await aquifer.bootstrap({
446
+ agentId: 'main',
447
+ limit: 5, // max sessions (default: 5)
448
+ lookbackDays: 14, // how far back (default: 14)
449
+ maxChars: 4000, // max output chars (default: 4000)
450
+ format: 'text', // 'text', 'structured', or 'both'
451
+ });
452
+ // format='text': result.text contains XML block ready for injection
453
+ // format='structured': result.sessions, result.openLoops, result.recentDecisions
454
+ ```
455
+
456
+ Cross-session dedup on open loops and decisions, sentinel filtering (removes 無/none/n/a), and maxChars truncation.
457
+
438
458
  #### `aquifer.close()`
439
459
 
440
460
  Closes the PostgreSQL connection pool (only if Aquifer created it).
package/consumers/cli.js CHANGED
@@ -43,7 +43,7 @@ function parsePositiveInt(value, fallback) {
43
43
  function parseArgs(argv) {
44
44
  const args = { _: [], flags: {} };
45
45
  // Flags that take a value (not boolean)
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']);
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']);
47
47
  for (let i = 0; i < argv.length; i++) {
48
48
  if (argv[i] === '--') { args._.push(...argv.slice(i + 1)); break; }
49
49
  if (argv[i].startsWith('--')) {
@@ -252,6 +252,30 @@ async function cmdQuickstart(aquifer) {
252
252
  console.log(' npx aquifer mcp');
253
253
  }
254
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
+
255
279
  async function cmdExport(aquifer, args) {
256
280
  const output = args.flags.output || null;
257
281
  const limit = parsePositiveInt(args.flags.limit, 1000);
@@ -297,6 +321,8 @@ Commands:
297
321
  backfill Enrich pending sessions
298
322
  stats Show database statistics
299
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
300
326
  mcp Start MCP server
301
327
 
302
328
  Options:
@@ -313,7 +339,12 @@ Options:
313
339
  --json JSON output
314
340
  --dry-run Preview only (backfill)
315
341
  --output PATH Output file (export)
316
- --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)`);
317
348
  process.exit(0);
318
349
  }
319
350
 
@@ -361,6 +392,14 @@ Options:
361
392
  case 'export':
362
393
  await cmdExport(aquifer, args);
363
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
+ }
364
403
  default:
365
404
  console.error(`Unknown command: ${command}. Run 'aquifer --help' for usage.`);
366
405
  process.exit(1);
package/consumers/mcp.js CHANGED
@@ -208,6 +208,35 @@ async function main() {
208
208
  }
209
209
  );
210
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
+
211
240
  // Graceful shutdown
212
241
  const cleanup = async () => {
213
242
  if (_aquifer) await _aquifer.close().catch(() => {});
@@ -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
@@ -918,7 +918,6 @@ function createAquifer(config) {
918
918
  },
919
919
 
920
920
  async getSessionFull(sessionId) {
921
- // Try to find the session across agents by querying directly
922
921
  const result = await pool.query(
923
922
  `SELECT * FROM ${qi(schema)}.sessions
924
923
  WHERE session_id = $1 AND tenant_id = $2
@@ -928,24 +927,15 @@ function createAquifer(config) {
928
927
  const session = result.rows[0];
929
928
  if (!session) return null;
930
929
 
931
- const [segResult, sumResult] = await Promise.all([
932
- pool.query(
933
- `SELECT * FROM ${qi(schema)}.session_segments
934
- WHERE session_row_id = $1
935
- ORDER BY segment_no ASC`,
936
- [session.id]
937
- ),
938
- pool.query(
939
- `SELECT * FROM ${qi(schema)}.session_summaries
940
- WHERE session_row_id = $1
941
- LIMIT 1`,
942
- [session.id]
943
- ),
944
- ]);
930
+ const sumResult = await pool.query(
931
+ `SELECT * FROM ${qi(schema)}.session_summaries
932
+ WHERE session_row_id = $1
933
+ LIMIT 1`,
934
+ [session.id]
935
+ );
945
936
 
946
937
  return {
947
938
  session,
948
- segments: segResult.rows,
949
939
  summary: sumResult.rows[0] || null,
950
940
  };
951
941
  },
@@ -1036,13 +1026,164 @@ function createAquifer(config) {
1036
1026
  );
1037
1027
  return result.rows;
1038
1028
  },
1029
+
1030
+ async bootstrap(opts = {}) {
1031
+ await ensureMigrated();
1032
+
1033
+ const agentId = opts.agentId || null;
1034
+ const source = opts.source || null;
1035
+ const limit = Math.max(1, Math.min(20, opts.limit || 5));
1036
+ const lookbackDays = opts.lookbackDays || 14;
1037
+ const maxChars = opts.maxChars || 4000;
1038
+ const format = opts.format || 'structured';
1039
+
1040
+ const where = [`s.tenant_id = $1`, `s.processing_status = 'succeeded'`];
1041
+ const params = [tenantId];
1042
+
1043
+ if (agentId) {
1044
+ params.push(agentId);
1045
+ where.push(`s.agent_id = $${params.length}`);
1046
+ }
1047
+ if (source) {
1048
+ params.push(source);
1049
+ where.push(`s.source = $${params.length}`);
1050
+ }
1051
+
1052
+ params.push(lookbackDays);
1053
+ where.push(`s.started_at > now() - ($${params.length} || ' days')::interval`);
1054
+
1055
+ params.push(limit);
1056
+
1057
+ const result = await pool.query(
1058
+ `SELECT s.session_id, s.agent_id, s.source, s.started_at, s.msg_count,
1059
+ ss.summary_text, ss.structured_summary
1060
+ FROM ${qi(schema)}.sessions s
1061
+ JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
1062
+ WHERE ${where.join(' AND ')}
1063
+ ORDER BY s.started_at DESC
1064
+ LIMIT $${params.length}`,
1065
+ params
1066
+ );
1067
+
1068
+ const sessions = result.rows.map(r => {
1069
+ const ss = r.structured_summary || {};
1070
+ const hasSS = ss.title || ss.overview;
1071
+ return {
1072
+ sessionId: r.session_id,
1073
+ agentId: r.agent_id,
1074
+ source: r.source,
1075
+ startedAt: r.started_at,
1076
+ title: ss.title || (hasSS ? null : (r.summary_text || '').slice(0, 60).trim() || null),
1077
+ overview: ss.overview || (hasSS ? null : (r.summary_text || '').slice(0, 200).trim() || null),
1078
+ topics: Array.isArray(ss.topics) ? ss.topics : [],
1079
+ decisions: Array.isArray(ss.decisions) ? ss.decisions : [],
1080
+ openLoops: Array.isArray(ss.open_loops) ? ss.open_loops : [],
1081
+ importantFacts: Array.isArray(ss.important_facts) ? ss.important_facts : [],
1082
+ };
1083
+ });
1084
+
1085
+ // Cross-session open loops merge + dedup + sentinel filter
1086
+ const SENTINELS = new Set(['無', 'none', 'n/a', 'na', 'done', '']);
1087
+ const seenLoops = new Set();
1088
+ const openLoops = [];
1089
+ for (const s of sessions) {
1090
+ for (const loop of s.openLoops) {
1091
+ const raw = typeof loop === 'string' ? loop : (loop.item || '');
1092
+ const normalized = raw.trim().replace(/\s+/g, ' ').toLowerCase();
1093
+ if (SENTINELS.has(normalized) || !normalized || seenLoops.has(normalized)) continue;
1094
+ seenLoops.add(normalized);
1095
+ openLoops.push({ item: raw.trim(), fromSession: s.sessionId, latestStartedAt: s.startedAt });
1096
+ }
1097
+ }
1098
+
1099
+ // Cross-session recent decisions dedup
1100
+ const seenDecisions = new Set();
1101
+ const recentDecisions = [];
1102
+ for (const s of sessions) {
1103
+ for (const d of s.decisions) {
1104
+ const key = typeof d === 'string' ? d : (d.decision || '');
1105
+ const normalized = key.trim().replace(/\s+/g, ' ').toLowerCase();
1106
+ if (!normalized || seenDecisions.has(normalized)) continue;
1107
+ seenDecisions.add(normalized);
1108
+ recentDecisions.push({ decision: key.trim(), reason: d.reason || null, fromSession: s.sessionId });
1109
+ }
1110
+ }
1111
+
1112
+ const structured = {
1113
+ sessions,
1114
+ openLoops,
1115
+ recentDecisions,
1116
+ meta: { lookbackDays, count: sessions.length, maxChars, truncated: false },
1117
+ };
1118
+
1119
+ if (format === 'text' || format === 'both') {
1120
+ const textResult = formatBootstrapText(structured, maxChars);
1121
+ structured.text = textResult.text;
1122
+ structured.meta.truncated = textResult.truncated;
1123
+ }
1124
+
1125
+ return structured;
1126
+ },
1039
1127
  };
1040
1128
 
1041
1129
  return aquifer;
1042
1130
  }
1043
1131
 
1132
+ // ---------------------------------------------------------------------------
1133
+ // formatBootstrapText — pure function, builds <session-bootstrap> XML block
1134
+ // ---------------------------------------------------------------------------
1135
+
1136
+ function formatBootstrapText(data, maxChars) {
1137
+ if (!data.sessions || data.sessions.length === 0) {
1138
+ return { text: 'No recent sessions found.', truncated: false };
1139
+ }
1140
+
1141
+ let truncated = false;
1142
+ const parts = [];
1143
+
1144
+ // Build session lines (newest first, truncate from oldest if over budget)
1145
+ const sessionLines = [];
1146
+ for (const s of data.sessions) {
1147
+ const date = s.startedAt ? new Date(s.startedAt).toISOString().slice(0, 10) : '?';
1148
+ const title = s.title || '(untitled)';
1149
+ const overview = s.overview ? s.overview.slice(0, 200) : '';
1150
+ let line = `- ${date} | ${title}`;
1151
+ if (overview) line += ` — ${overview}`;
1152
+ const decisions = s.decisions
1153
+ .map(d => typeof d === 'string' ? d : d.decision)
1154
+ .filter(Boolean);
1155
+ if (decisions.length > 0) line += `\n Decisions: ${decisions.join('; ')}`;
1156
+ sessionLines.push(line);
1157
+ }
1158
+
1159
+ // Fit within maxChars by removing oldest sessions
1160
+ let bodyLines = [...sessionLines];
1161
+ const footer = [];
1162
+ if (data.openLoops.length > 0) {
1163
+ footer.push(`Open items: ${data.openLoops.map(l => l.item).join(', ')}`);
1164
+ }
1165
+ if (data.recentDecisions.length > 0) {
1166
+ footer.push(`Recent decisions: ${data.recentDecisions.map(d => d.decision).join(', ')}`);
1167
+ }
1168
+
1169
+ const buildText = (lines) => {
1170
+ const body = ['Recent sessions:', ...lines].join('\n');
1171
+ const full = footer.length > 0 ? body + '\n' + footer.join('\n') : body;
1172
+ return `<session-bootstrap sessions="${lines.length}" open_loops="${data.openLoops.length}">\n${full}\n</session-bootstrap>`;
1173
+ };
1174
+
1175
+ let text = buildText(bodyLines);
1176
+ while (text.length > maxChars && bodyLines.length > 1) {
1177
+ bodyLines.pop(); // remove oldest
1178
+ truncated = true;
1179
+ text = buildText(bodyLines);
1180
+ }
1181
+
1182
+ return { text, truncated };
1183
+ }
1184
+
1044
1185
  // ---------------------------------------------------------------------------
1045
1186
  // Exports
1046
1187
  // ---------------------------------------------------------------------------
1047
1188
 
1048
- module.exports = { createAquifer };
1189
+ module.exports = { createAquifer, formatBootstrapText };
package/core/storage.js CHANGED
@@ -96,44 +96,6 @@ async function upsertSession(pool, {
96
96
  };
97
97
  }
98
98
 
99
- // ---------------------------------------------------------------------------
100
- // upsertSegments
101
- // ---------------------------------------------------------------------------
102
-
103
- async function upsertSegments(pool, sessionRowId, segments, { schema } = {}) {
104
- if (!segments || segments.length === 0) return;
105
- for (const seg of segments) {
106
- await pool.query(
107
- `INSERT INTO ${qi(schema)}.session_segments
108
- (session_row_id, segment_no, start_msg_idx, end_msg_idx,
109
- started_at, ended_at, raw_msg_count, effective_msg_count,
110
- boundary_type, boundary_meta)
111
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
112
- ON CONFLICT (session_row_id, segment_no) DO UPDATE SET
113
- start_msg_idx = EXCLUDED.start_msg_idx,
114
- end_msg_idx = EXCLUDED.end_msg_idx,
115
- started_at = EXCLUDED.started_at,
116
- ended_at = EXCLUDED.ended_at,
117
- raw_msg_count = EXCLUDED.raw_msg_count,
118
- effective_msg_count = EXCLUDED.effective_msg_count,
119
- boundary_type = EXCLUDED.boundary_type,
120
- boundary_meta = EXCLUDED.boundary_meta`,
121
- [
122
- sessionRowId,
123
- seg.segmentNo,
124
- seg.startMsgIdx !== null && seg.startMsgIdx !== undefined ? seg.startMsgIdx : null,
125
- seg.endMsgIdx !== null && seg.endMsgIdx !== undefined ? seg.endMsgIdx : null,
126
- seg.startedAt || null,
127
- seg.endedAt || null,
128
- seg.rawMsgCount || 0,
129
- seg.effectiveMsgCount || 0,
130
- seg.boundaryType || null,
131
- seg.boundaryMeta ? JSON.stringify(seg.boundaryMeta) : '{}',
132
- ]
133
- );
134
- }
135
- }
136
-
137
99
  // ---------------------------------------------------------------------------
138
100
  // upsertSummary
139
101
  // ---------------------------------------------------------------------------
@@ -159,9 +121,8 @@ async function upsertSummary(pool, sessionRowId, {
159
121
  `INSERT INTO ${qi(schema)}.session_summaries
160
122
  (session_row_id, tenant_id, agent_id, session_id, summary_version, model, source_hash,
161
123
  message_count, user_message_count, assistant_message_count,
162
- boundary_count, fresh_tail_count,
163
124
  started_at, ended_at, structured_summary, summary_text, embedding, updated_at)
164
- VALUES ($1,$2,$3,$4,1,$5,$6,$7,$8,$9,0,0,$10,$11,COALESCE($12::jsonb,'{}'::jsonb),COALESCE($13,''),$14::vector,now())
125
+ VALUES ($1,$2,$3,$4,1,$5,$6,$7,$8,$9,$10,$11,COALESCE($12::jsonb,'{}'::jsonb),COALESCE($13,''),$14::vector,now())
165
126
  ON CONFLICT (session_row_id) DO UPDATE SET
166
127
  tenant_id = EXCLUDED.tenant_id,
167
128
  agent_id = EXCLUDED.agent_id,
@@ -211,50 +172,6 @@ async function markStatus(pool, sessionRowId, status, error, { schema } = {}) {
211
172
  return result.rows[0] || null;
212
173
  }
213
174
 
214
- // ---------------------------------------------------------------------------
215
- // persistProcessingResults (@internal — prefer aquifer.enrich() for full pipeline)
216
- // ---------------------------------------------------------------------------
217
-
218
- async function persistProcessingResults(pool, sessionRowId, {
219
- schema,
220
- segments,
221
- summaryText,
222
- structuredSummary,
223
- agentId,
224
- sessionId,
225
- tenantId,
226
- model,
227
- sourceHash,
228
- msgCount,
229
- userCount,
230
- assistantCount,
231
- startedAt,
232
- endedAt,
233
- embedding,
234
- }) {
235
- const client = await pool.connect();
236
- try {
237
- await client.query('BEGIN');
238
- if (segments) await upsertSegments(client, sessionRowId, segments, { schema });
239
- await upsertSummary(client, sessionRowId, {
240
- schema, tenantId, agentId, sessionId, summaryText,
241
- structuredSummary, model, sourceHash,
242
- msgCount, userCount, assistantCount,
243
- startedAt, endedAt, embedding,
244
- });
245
- await markStatus(client, sessionRowId, 'succeeded', null, { schema });
246
- await client.query('COMMIT');
247
- } catch (err) {
248
- await client.query('ROLLBACK').catch(() => {});
249
- try {
250
- await markStatus(pool, sessionRowId, 'failed', err.message, { schema });
251
- } catch (_) { /* swallow */ }
252
- throw err;
253
- } finally {
254
- client.release();
255
- }
256
- }
257
-
258
175
  // ---------------------------------------------------------------------------
259
176
  // getSession
260
177
  // ---------------------------------------------------------------------------
@@ -282,36 +199,6 @@ async function getSession(pool, sessionId, agentId, options = {}, { schema, tena
282
199
  return result.rows[0] || null;
283
200
  }
284
201
 
285
- // ---------------------------------------------------------------------------
286
- // getSessionFull
287
- // ---------------------------------------------------------------------------
288
-
289
- async function getSessionFull(pool, sessionId, agentId, { schema, tenantId } = {}) {
290
- const session = await getSession(pool, sessionId, agentId, { tenantId }, { schema, tenantId });
291
- if (!session) return null;
292
-
293
- const [segResult, sumResult] = await Promise.all([
294
- pool.query(
295
- `SELECT * FROM ${qi(schema)}.session_segments
296
- WHERE session_row_id = $1
297
- ORDER BY segment_no ASC`,
298
- [session.id]
299
- ),
300
- pool.query(
301
- `SELECT * FROM ${qi(schema)}.session_summaries
302
- WHERE session_row_id = $1
303
- LIMIT 1`,
304
- [session.id]
305
- ),
306
- ]);
307
-
308
- return {
309
- session,
310
- segments: segResult.rows,
311
- summary: sumResult.rows[0] || null,
312
- };
313
- }
314
-
315
202
  // ---------------------------------------------------------------------------
316
203
  // getMessages
317
204
  // ---------------------------------------------------------------------------
@@ -414,7 +301,7 @@ async function recordAccess(pool, sessionRowIds, { schema } = {}) {
414
301
  if (!sessionRowIds || sessionRowIds.length === 0) return;
415
302
  await pool.query(
416
303
  `UPDATE ${qi(schema)}.session_summaries
417
- SET access_count = access_count + 1, last_accessed_at = now()
304
+ SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = now()
418
305
  WHERE session_row_id = ANY($1)`,
419
306
  [sessionRowIds]
420
307
  );
@@ -643,12 +530,9 @@ async function recordFeedback(pool, {
643
530
 
644
531
  module.exports = {
645
532
  upsertSession,
646
- upsertSegments,
647
533
  upsertSummary,
648
534
  markStatus,
649
- persistProcessingResults,
650
535
  getSession,
651
- getSessionFull,
652
536
  getMessages,
653
537
  searchSessions,
654
538
  recordAccess,
package/index.js CHANGED
@@ -3,6 +3,5 @@
3
3
  const { createAquifer } = require('./core/aquifer');
4
4
  const { createEmbedder } = require('./pipeline/embed');
5
5
  const { createReranker } = require('./pipeline/rerank');
6
- const { normalizeSession, detectClient } = require('./pipeline/normalize');
7
6
 
8
- module.exports = { createAquifer, createEmbedder, createReranker, normalizeSession, detectClient };
7
+ module.exports = { createAquifer, createEmbedder, createReranker };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shadowforge0/aquifer-memory",
3
- "version": "0.9.1",
3
+ "version": "1.0.1",
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": [
@@ -17,10 +17,9 @@
17
17
  },
18
18
  "exports": {
19
19
  ".": "./index.js",
20
- "./core/*": "./core/*.js",
21
- "./pipeline/*": "./pipeline/*.js",
22
20
  "./consumers/mcp": "./consumers/mcp.js",
23
21
  "./consumers/openclaw-plugin": "./consumers/openclaw-plugin.js",
22
+ "./consumers/opencode": "./consumers/opencode.js",
24
23
  "./consumers/shared/config": "./consumers/shared/config.js",
25
24
  "./consumers/shared/factory": "./consumers/shared/factory.js"
26
25
  },