@shadowforge0/aquifer-memory 0.9.1 → 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 +41 -2
- package/consumers/mcp.js +29 -0
- package/consumers/opencode.js +345 -0
- package/core/aquifer.js +152 -1
- package/package.json +2 -1
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
|
@@ -1036,13 +1036,164 @@ function createAquifer(config) {
|
|
|
1036
1036
|
);
|
|
1037
1037
|
return result.rows;
|
|
1038
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
|
+
},
|
|
1039
1137
|
};
|
|
1040
1138
|
|
|
1041
1139
|
return aquifer;
|
|
1042
1140
|
}
|
|
1043
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
|
+
|
|
1044
1195
|
// ---------------------------------------------------------------------------
|
|
1045
1196
|
// Exports
|
|
1046
1197
|
// ---------------------------------------------------------------------------
|
|
1047
1198
|
|
|
1048
|
-
module.exports = { createAquifer };
|
|
1199
|
+
module.exports = { createAquifer, formatBootstrapText };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shadowforge0/aquifer-memory",
|
|
3
|
-
"version": "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
|
},
|