@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 +81 -12
- package/consumers/mcp.js +37 -4
- package/consumers/openclaw-plugin.js +7 -3
- package/consumers/opencode.js +345 -0
- package/core/aquifer.js +185 -6
- package/core/hybrid-rank.js +7 -5
- package/package.json +2 -1
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:
|
|
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 =
|
|
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 =
|
|
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: ${
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 =
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
'
|
|
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(() =>
|
|
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(() => {
|
|
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 };
|
package/core/hybrid-rank.js
CHANGED
|
@@ -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 = (
|
|
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 = (
|
|
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.
|
|
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
|
},
|