agent-memory-store 0.0.7 → 0.0.9
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 +75 -59
- package/package.json +1 -1
- package/src/db.js +40 -27
- package/src/index.js +20 -2
- package/src/search.js +77 -29
- package/src/store.js +2 -2
package/README.MD
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
`agent-memory-store` gives your AI agents a shared, searchable, persistent memory — powered by SQLite with native FTS5 full-text search and optional semantic embeddings. No external services required.
|
|
10
10
|
|
|
11
|
+
## Why this exists
|
|
12
|
+
|
|
13
|
+
Every time you start a new session with Claude Code, Cursor, or any MCP-compatible agent, it starts from zero. It doesn't know your project uses Fastify instead of Express. It doesn't know you decided on JWT two weeks ago. It doesn't know the staging deploy is on ECS.
|
|
14
|
+
|
|
15
|
+
`agent-memory-store` gives agents a shared, searchable memory that survives across sessions. Agents write what they learn, search what they need, and build on each other's work — just like a team with good documentation, except it happens automatically.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
11
19
|
Agents read and write **chunks** through MCP tools. Search combines **BM25 ranking** (via SQLite FTS5) with **semantic vector similarity** (via local embeddings), merged through Reciprocal Rank Fusion for best-of-both-worlds retrieval.
|
|
12
20
|
|
|
13
21
|
```
|
|
@@ -61,18 +69,6 @@ To use a custom path:
|
|
|
61
69
|
AGENT_STORE_PATH=/your/project/.agent-memory-store npx agent-memory-store
|
|
62
70
|
```
|
|
63
71
|
|
|
64
|
-
## Performance
|
|
65
|
-
|
|
66
|
-
Benchmarked on Apple Silicon (Node v25, darwin arm64):
|
|
67
|
-
|
|
68
|
-
| Operation | 100 chunks | 1K chunks | 5K chunks | 10K chunks |
|
|
69
|
-
|-----------|-----------|-----------|-----------|------------|
|
|
70
|
-
| **write** | 2.16 ms | 0.15 ms | 0.15 ms | 0.15 ms |
|
|
71
|
-
| **read** | 0.02 ms | 0.02 ms | 0.02 ms | 0.02 ms |
|
|
72
|
-
| **search (BM25)** | 0.4 ms | 1.2 ms | 5.3 ms | 9.9 ms |
|
|
73
|
-
| **list** | 0.2 ms | 1.4 ms | 9.9 ms | 14.7 ms |
|
|
74
|
-
| **state get/set** | 0.03 ms | 0.03 ms | 0.03 ms | 0.03 ms |
|
|
75
|
-
|
|
76
72
|
## Configuration
|
|
77
73
|
|
|
78
74
|
### Claude Code
|
|
@@ -168,21 +164,57 @@ If you need to store memory outside the project directory, set `AGENT_STORE_PATH
|
|
|
168
164
|
|
|
169
165
|
### Environment variables
|
|
170
166
|
|
|
171
|
-
| Variable
|
|
172
|
-
|
|
167
|
+
| Variable | Default | Description |
|
|
168
|
+
| ------------------ | ----------------------- | ------------------------------------------------------------------ |
|
|
173
169
|
| `AGENT_STORE_PATH` | `./.agent-memory-store` | Custom path to the storage directory. Omit to use project default. |
|
|
174
170
|
|
|
171
|
+
## Teach your agent to use memory
|
|
172
|
+
|
|
173
|
+
Add this to your agent's system prompt (or `CLAUDE.md` / `AGENTS.md`):
|
|
174
|
+
|
|
175
|
+
```markdown
|
|
176
|
+
## Memory
|
|
177
|
+
|
|
178
|
+
You have persistent memory via agent-memory-store MCP tools.
|
|
179
|
+
|
|
180
|
+
**Before acting on any task:**
|
|
181
|
+
|
|
182
|
+
1. `search_context` with 2–3 queries related to the task. Check for prior decisions, conventions, and relevant outputs.
|
|
183
|
+
2. `get_state("project_tags")` to load the tag vocabulary. If empty, this is a new project — ask the user about stack, conventions, and structure, then persist them with `write_context` and `set_state`.
|
|
184
|
+
|
|
185
|
+
**After completing work:**
|
|
186
|
+
|
|
187
|
+
1. `write_context` to persist decisions (with rationale), outputs (with file paths), and discoveries (with impact).
|
|
188
|
+
2. Use short, lowercase tags consistent with the vocabulary: `auth`, `config`, `decision`, `output`, `discovery`.
|
|
189
|
+
3. Set `importance: "critical"` for decisions other agents depend on, `"high"` for outputs, `"medium"` for background context.
|
|
190
|
+
|
|
191
|
+
**Before every write:**
|
|
192
|
+
|
|
193
|
+
1. `search_context` for the same topic first. If a chunk exists, `delete_context` it, then write the updated version. One chunk per topic.
|
|
194
|
+
|
|
195
|
+
**Rules:**
|
|
196
|
+
|
|
197
|
+
- Never guess a fact that might be in memory — search first, it costs <10ms.
|
|
198
|
+
- Never store secrets — write references to where they live, not the values.
|
|
199
|
+
- `set_state` is for mutable values (current phase, counters). `write_context` is for searchable knowledge (decisions, outputs). Don't mix them.
|
|
200
|
+
- Use `search_mode: "semantic"` when exact terms don't match (e.g., searching "autenticação" when the chunk says "auth").
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Copy, paste, done. This is enough for any agent to use memory effectively.
|
|
204
|
+
|
|
205
|
+
> **Want to go deeper?** The [`skills/SKILL.md`](./skills/SKILL.md) file is a comprehensive skill that teaches agents advanced patterns: cold start bootstrap for new projects, multi-agent pipeline handoffs, tag vocabulary management, deduplication workflows, and when to use each search mode. Install it in your project's skill directory if your agents run multi-step pipelines or need to coordinate across sessions.
|
|
206
|
+
|
|
175
207
|
## Tools
|
|
176
208
|
|
|
177
|
-
| Tool
|
|
178
|
-
|
|
209
|
+
| Tool | When to use |
|
|
210
|
+
| ---------------- | ------------------------------------------------------------------------- |
|
|
179
211
|
| `search_context` | **Start of every task** — retrieve relevant prior knowledge before acting |
|
|
180
|
-
| `write_context`
|
|
181
|
-
| `read_context`
|
|
182
|
-
| `list_context`
|
|
183
|
-
| `delete_context` | Remove outdated or incorrect chunks
|
|
184
|
-
| `get_state`
|
|
185
|
-
| `set_state`
|
|
212
|
+
| `write_context` | After decisions, discoveries, or outputs that other agents will need |
|
|
213
|
+
| `read_context` | Read a specific chunk by ID |
|
|
214
|
+
| `list_context` | Inventory the memory store (metadata only, no body) |
|
|
215
|
+
| `delete_context` | Remove outdated or incorrect chunks |
|
|
216
|
+
| `get_state` | Read a pipeline variable (progress, flags, counters) |
|
|
217
|
+
| `set_state` | Write a pipeline variable |
|
|
186
218
|
|
|
187
219
|
### `search_context`
|
|
188
220
|
|
|
@@ -197,11 +229,11 @@ search_mode string (optional) "hybrid" (default), "bm25", or "semantic".
|
|
|
197
229
|
|
|
198
230
|
**Search modes:**
|
|
199
231
|
|
|
200
|
-
| Mode
|
|
201
|
-
|
|
202
|
-
| `hybrid`
|
|
203
|
-
| `bm25`
|
|
204
|
-
| `semantic` | Vector cosine similarity only
|
|
232
|
+
| Mode | How it works | Best for |
|
|
233
|
+
| ---------- | ------------------------------------------------------------ | ----------------------------------- |
|
|
234
|
+
| `hybrid` | BM25 + semantic similarity merged via Reciprocal Rank Fusion | General use (default) |
|
|
235
|
+
| `bm25` | FTS5 keyword matching only | Exact term lookups, canonical tags |
|
|
236
|
+
| `semantic` | Vector cosine similarity only | Finding conceptually related chunks |
|
|
205
237
|
|
|
206
238
|
### `write_context`
|
|
207
239
|
|
|
@@ -254,43 +286,27 @@ WAL mode is enabled for concurrent read performance. No manual flush needed.
|
|
|
254
286
|
|
|
255
287
|
The embedding model (~23MB) is downloaded automatically on first use and cached in `~/.cache/huggingface/`. If the model fails to load, the system falls back to BM25-only search transparently.
|
|
256
288
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
If you're upgrading from a previous version that used `.md` files, the migration happens automatically on first startup. Your existing chunks and state are imported into SQLite, and the old directories are renamed to `chunks_backup/` and `state_backup/`.
|
|
260
|
-
|
|
261
|
-
## Agent system prompt
|
|
262
|
-
|
|
263
|
-
Paste this into the system prompt of every agent that should use the memory store:
|
|
264
|
-
|
|
265
|
-
```markdown
|
|
266
|
-
## Memory usage
|
|
267
|
-
|
|
268
|
-
You have access to a persistent local memory store via agent-memory-store MCP tools.
|
|
269
|
-
|
|
270
|
-
**At the start of each task:**
|
|
289
|
+
## Performance
|
|
271
290
|
|
|
272
|
-
|
|
273
|
-
2. Incorporate retrieved chunks into your reasoning.
|
|
274
|
-
3. Call `get_state` to check pipeline status if relevant.
|
|
291
|
+
Benchmarked on Apple Silicon (Node v25, darwin arm64, BM25 mode):
|
|
275
292
|
|
|
276
|
-
|
|
293
|
+
| Operation | 1K chunks | 10K chunks | 50K chunks | 100K chunks | 250K chunks |
|
|
294
|
+
| ----------------- | --------- | ---------- | ---------- | ----------- | ----------- |
|
|
295
|
+
| **write** | 0.17 ms | 0.19 ms | 0.23 ms | 0.21 ms | 0.25 ms |
|
|
296
|
+
| **read** | 0.01 ms | 0.05 ms | 0.21 ms | 0.22 ms | 0.85 ms |
|
|
297
|
+
| **search (BM25)** | ~5 ms† | ~10 ms† | ~60 ms† | ~110 ms† | ~390 ms† |
|
|
298
|
+
| **list** | 0.2 ms | 0.3 ms | 0.3 ms | 0.3 ms | 1.1 ms |
|
|
299
|
+
| **state get/set** | 0.03 ms | 0.03 ms | 0.07 ms | 0.05 ms | 0.03 ms |
|
|
277
300
|
|
|
278
|
-
|
|
279
|
-
- Decisions made and their rationale
|
|
280
|
-
- Key discoveries or findings
|
|
281
|
-
- Structured outputs intended for downstream agents
|
|
282
|
-
2. Use canonical tags consistent with the rest of the team.
|
|
283
|
-
3. Set `importance: high` or `critical` for information other agents will need.
|
|
301
|
+
† Search times from isolated run (no model loading interference). During warmup, first queries may be slower.
|
|
284
302
|
|
|
285
|
-
**
|
|
303
|
+
**Key insights:**
|
|
286
304
|
|
|
287
|
-
-
|
|
288
|
-
-
|
|
289
|
-
-
|
|
290
|
-
-
|
|
291
|
-
-
|
|
292
|
-
- Use `search_mode: "bm25"` for exact tag/keyword lookups
|
|
293
|
-
```
|
|
305
|
+
- **list is O(1) in practice** — pagination caps results at 100 rows by default, so list time stays flat regardless of corpus size (0.2–1.1 ms at any scale)
|
|
306
|
+
- **write is stable at ~0.2 ms/op** — FTS5 triggers and embedding backfill are non-blocking; inserts stay constant
|
|
307
|
+
- **read is a single index lookup** — sub-millisecond up to 50K chunks, still <1 ms at 250K
|
|
308
|
+
- **search scales linearly with FTS5 corpus** — this is inherent to BM25 full-text scan; for typical agent memory usage (≤25K chunks), search stays under 30 ms
|
|
309
|
+
- **state ops are O(1)** — key/value store backed by a B-tree primary key, constant at all scales
|
|
294
310
|
|
|
295
311
|
## Development
|
|
296
312
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-memory-store",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "Local-first MCP memory server for multi-agent systems. Hybrid search (BM25 + semantic embeddings), SQLite-backed, zero-config.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": "./src/index.js",
|
package/src/db.js
CHANGED
|
@@ -17,6 +17,20 @@ const STORE_PATH = process.env.AGENT_STORE_PATH
|
|
|
17
17
|
const DB_PATH = path.join(STORE_PATH, "store.db");
|
|
18
18
|
|
|
19
19
|
let db = null;
|
|
20
|
+
const stmtCache = new Map();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns a cached prepared statement for static SQL.
|
|
24
|
+
* Avoids re-preparing the same SQL on every call.
|
|
25
|
+
*/
|
|
26
|
+
function stmt(sql) {
|
|
27
|
+
let s = stmtCache.get(sql);
|
|
28
|
+
if (!s) {
|
|
29
|
+
s = getDb().prepare(sql);
|
|
30
|
+
stmtCache.set(sql, s);
|
|
31
|
+
}
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
20
34
|
|
|
21
35
|
// ─── Schema ─────────────────────────────────────────────────────────────────
|
|
22
36
|
|
|
@@ -98,9 +112,9 @@ export function getDb() {
|
|
|
98
112
|
db.exec(SCHEMA_TRIGGERS);
|
|
99
113
|
|
|
100
114
|
// Purge expired chunks
|
|
101
|
-
db.
|
|
115
|
+
db.exec(
|
|
102
116
|
`DELETE FROM chunks WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`,
|
|
103
|
-
)
|
|
117
|
+
);
|
|
104
118
|
|
|
105
119
|
// Graceful shutdown
|
|
106
120
|
const shutdown = () => {
|
|
@@ -130,8 +144,7 @@ export function insertChunk({
|
|
|
130
144
|
updatedAt,
|
|
131
145
|
expiresAt,
|
|
132
146
|
}) {
|
|
133
|
-
|
|
134
|
-
d.prepare(
|
|
147
|
+
stmt(
|
|
135
148
|
`INSERT OR REPLACE INTO chunks (id, topic, agent, tags, importance, content, embedding, created_at, updated_at, expires_at)
|
|
136
149
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
137
150
|
).run(
|
|
@@ -153,8 +166,7 @@ export function insertChunk({
|
|
|
153
166
|
* @returns {object|null}
|
|
154
167
|
*/
|
|
155
168
|
export function getChunk(id) {
|
|
156
|
-
const
|
|
157
|
-
const row = d.prepare(`SELECT * FROM chunks WHERE id = ?`).get(id);
|
|
169
|
+
const row = stmt(`SELECT * FROM chunks WHERE id = ?`).get(id);
|
|
158
170
|
if (!row) return null;
|
|
159
171
|
return parseChunkRow(row);
|
|
160
172
|
}
|
|
@@ -164,8 +176,7 @@ export function getChunk(id) {
|
|
|
164
176
|
* @returns {boolean} true if a row was deleted
|
|
165
177
|
*/
|
|
166
178
|
export function deleteChunkById(id) {
|
|
167
|
-
const
|
|
168
|
-
const result = d.prepare(`DELETE FROM chunks WHERE id = ?`).run(id);
|
|
179
|
+
const result = stmt(`DELETE FROM chunks WHERE id = ?`).run(id);
|
|
169
180
|
return result.changes > 0;
|
|
170
181
|
}
|
|
171
182
|
|
|
@@ -173,7 +184,7 @@ export function deleteChunkById(id) {
|
|
|
173
184
|
* Lists chunk metadata, with optional agent/tags filters.
|
|
174
185
|
* Sorted by updated_at descending.
|
|
175
186
|
*/
|
|
176
|
-
export function listChunksDb({ agent, tags = [] } = {}) {
|
|
187
|
+
export function listChunksDb({ agent, tags = [], limit = 100, offset = 0 } = {}) {
|
|
177
188
|
const d = getDb();
|
|
178
189
|
let sql = `SELECT id, topic, agent, tags, importance, updated_at FROM chunks`;
|
|
179
190
|
const conditions = [];
|
|
@@ -191,7 +202,8 @@ export function listChunksDb({ agent, tags = [] } = {}) {
|
|
|
191
202
|
}
|
|
192
203
|
|
|
193
204
|
if (conditions.length) sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
194
|
-
sql += ` ORDER BY updated_at DESC
|
|
205
|
+
sql += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?`;
|
|
206
|
+
params.push(limit, offset);
|
|
195
207
|
|
|
196
208
|
const rows = d.prepare(sql).all(...params);
|
|
197
209
|
return rows.map((r) => ({
|
|
@@ -206,7 +218,7 @@ export function listChunksDb({ agent, tags = [] } = {}) {
|
|
|
206
218
|
|
|
207
219
|
/**
|
|
208
220
|
* Full-text search via FTS5 (BM25).
|
|
209
|
-
* Returns ranked results with
|
|
221
|
+
* Returns ranked results with full chunk data (avoids separate lookups).
|
|
210
222
|
*/
|
|
211
223
|
export function searchFTS({ query, agent, tags = [], topK = 18 }) {
|
|
212
224
|
const d = getDb();
|
|
@@ -221,19 +233,19 @@ export function searchFTS({ query, agent, tags = [], topK = 18 }) {
|
|
|
221
233
|
if (!ftsQuery) return [];
|
|
222
234
|
|
|
223
235
|
let sql = `
|
|
224
|
-
SELECT
|
|
236
|
+
SELECT c.id, c.topic, c.agent, c.tags, c.importance, c.content, c.updated_at, rank
|
|
225
237
|
FROM chunks_fts
|
|
226
|
-
JOIN chunks ON
|
|
238
|
+
JOIN chunks c ON c.id = chunks_fts.id
|
|
227
239
|
WHERE chunks_fts MATCH ?`;
|
|
228
240
|
const params = [ftsQuery];
|
|
229
241
|
|
|
230
242
|
if (agent) {
|
|
231
|
-
sql += ` AND
|
|
243
|
+
sql += ` AND c.agent = ?`;
|
|
232
244
|
params.push(agent);
|
|
233
245
|
}
|
|
234
246
|
|
|
235
247
|
if (tags.length > 0) {
|
|
236
|
-
const tagConditions = tags.map(() => `
|
|
248
|
+
const tagConditions = tags.map(() => `c.tags LIKE ?`);
|
|
237
249
|
sql += ` AND (${tagConditions.join(" OR ")})`;
|
|
238
250
|
params.push(...tags.map((t) => `%"${t}"%`));
|
|
239
251
|
}
|
|
@@ -244,7 +256,13 @@ export function searchFTS({ query, agent, tags = [], topK = 18 }) {
|
|
|
244
256
|
const rows = d.prepare(sql).all(...params);
|
|
245
257
|
return rows.map((r) => ({
|
|
246
258
|
id: r.id,
|
|
247
|
-
|
|
259
|
+
topic: r.topic,
|
|
260
|
+
agent: r.agent,
|
|
261
|
+
tags: JSON.parse(r.tags),
|
|
262
|
+
importance: r.importance,
|
|
263
|
+
content: r.content,
|
|
264
|
+
updated: r.updated_at,
|
|
265
|
+
score: -r.rank,
|
|
248
266
|
}));
|
|
249
267
|
}
|
|
250
268
|
|
|
@@ -285,8 +303,7 @@ export function getAllEmbeddings({ agent, tags = [] } = {}) {
|
|
|
285
303
|
* Updates only the embedding for a chunk.
|
|
286
304
|
*/
|
|
287
305
|
export function updateEmbedding(id, embedding) {
|
|
288
|
-
|
|
289
|
-
d.prepare(`UPDATE chunks SET embedding = ? WHERE id = ?`).run(
|
|
306
|
+
stmt(`UPDATE chunks SET embedding = ? WHERE id = ?`).run(
|
|
290
307
|
Buffer.from(embedding.buffer),
|
|
291
308
|
id,
|
|
292
309
|
);
|
|
@@ -296,11 +313,9 @@ export function updateEmbedding(id, embedding) {
|
|
|
296
313
|
* Returns chunks that have no embedding yet.
|
|
297
314
|
*/
|
|
298
315
|
export function getChunksWithoutEmbedding() {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
`SELECT id, topic, tags, content FROM chunks WHERE embedding IS NULL`,
|
|
303
|
-
)
|
|
316
|
+
return stmt(
|
|
317
|
+
`SELECT id, topic, tags, content FROM chunks WHERE embedding IS NULL`,
|
|
318
|
+
)
|
|
304
319
|
.all()
|
|
305
320
|
.map((r) => ({
|
|
306
321
|
id: r.id,
|
|
@@ -313,16 +328,14 @@ export function getChunksWithoutEmbedding() {
|
|
|
313
328
|
// ─── State Operations ───────────────────────────────────────────────────────
|
|
314
329
|
|
|
315
330
|
export function getStateDb(key) {
|
|
316
|
-
const
|
|
317
|
-
const row = d.prepare(`SELECT value FROM state WHERE key = ?`).get(key);
|
|
331
|
+
const row = stmt(`SELECT value FROM state WHERE key = ?`).get(key);
|
|
318
332
|
if (!row) return null;
|
|
319
333
|
return JSON.parse(row.value);
|
|
320
334
|
}
|
|
321
335
|
|
|
322
336
|
export function setStateDb(key, value) {
|
|
323
|
-
const d = getDb();
|
|
324
337
|
const updatedAt = new Date().toISOString();
|
|
325
|
-
|
|
338
|
+
stmt(
|
|
326
339
|
`INSERT OR REPLACE INTO state (key, value, updated_at) VALUES (?, ?, ?)`,
|
|
327
340
|
).run(key, JSON.stringify(value), updatedAt);
|
|
328
341
|
return { key, updated: updatedAt };
|
package/src/index.js
CHANGED
|
@@ -223,9 +223,27 @@ server.tool(
|
|
|
223
223
|
{
|
|
224
224
|
agent: z.string().optional().describe("Filter by agent ID."),
|
|
225
225
|
tags: z.array(z.string()).optional().describe("Filter by tags."),
|
|
226
|
+
limit: z
|
|
227
|
+
.number()
|
|
228
|
+
.int()
|
|
229
|
+
.min(1)
|
|
230
|
+
.max(500)
|
|
231
|
+
.optional()
|
|
232
|
+
.describe("Maximum number of results to return (default: 100)."),
|
|
233
|
+
offset: z
|
|
234
|
+
.number()
|
|
235
|
+
.int()
|
|
236
|
+
.min(0)
|
|
237
|
+
.optional()
|
|
238
|
+
.describe("Number of results to skip for pagination (default: 0)."),
|
|
226
239
|
},
|
|
227
|
-
async ({ agent, tags }) => {
|
|
228
|
-
const chunks = await listChunks({
|
|
240
|
+
async ({ agent, tags, limit, offset }) => {
|
|
241
|
+
const chunks = await listChunks({
|
|
242
|
+
agent,
|
|
243
|
+
tags: tags ?? [],
|
|
244
|
+
limit: limit ?? 100,
|
|
245
|
+
offset: offset ?? 0,
|
|
246
|
+
});
|
|
229
247
|
|
|
230
248
|
if (chunks.length === 0) {
|
|
231
249
|
return { content: [{ type: "text", text: "Memory store is empty." }] };
|
package/src/search.js
CHANGED
|
@@ -12,6 +12,22 @@
|
|
|
12
12
|
import { searchFTS, getAllEmbeddings, getChunk } from "./db.js";
|
|
13
13
|
import { embed, isEmbeddingAvailable } from "./embeddings.js";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Converts a full FTS result into the enriched output format.
|
|
17
|
+
*/
|
|
18
|
+
function ftsResultToEnriched(r) {
|
|
19
|
+
return {
|
|
20
|
+
id: r.id,
|
|
21
|
+
topic: r.topic,
|
|
22
|
+
agent: r.agent,
|
|
23
|
+
tags: r.tags,
|
|
24
|
+
importance: r.importance,
|
|
25
|
+
score: Math.round(r.score * 100) / 100,
|
|
26
|
+
content: r.content,
|
|
27
|
+
updated: r.updated,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
// ─── Vector Search ──────────────────────────────────────────────────────────
|
|
16
32
|
|
|
17
33
|
/**
|
|
@@ -94,47 +110,80 @@ export async function hybridSearch({
|
|
|
94
110
|
effectiveMode = "bm25";
|
|
95
111
|
}
|
|
96
112
|
|
|
97
|
-
|
|
98
|
-
|
|
113
|
+
// BM25-only: searchFTS already returns full chunk data, no enrichment needed
|
|
99
114
|
if (effectiveMode === "bm25") {
|
|
100
|
-
|
|
101
|
-
|
|
115
|
+
const results = searchFTS({ query, agent, tags, topK: candidateK });
|
|
116
|
+
return results.slice(0, topK).map(ftsResultToEnriched);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Semantic-only
|
|
120
|
+
if (effectiveMode === "semantic") {
|
|
102
121
|
const queryEmbedding = await embed(query);
|
|
103
122
|
if (!queryEmbedding) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
fusedResults = vectorSearch(queryEmbedding, {
|
|
107
|
-
agent,
|
|
108
|
-
tags,
|
|
109
|
-
topK: candidateK,
|
|
110
|
-
});
|
|
123
|
+
const results = searchFTS({ query, agent, tags, topK: candidateK });
|
|
124
|
+
return results.slice(0, topK).map(ftsResultToEnriched);
|
|
111
125
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const bm25Hits = searchFTS({ query, agent, tags, topK: candidateK });
|
|
116
|
-
const queryEmbedding = await queryEmbeddingPromise;
|
|
126
|
+
const vecHits = vectorSearch(queryEmbedding, { agent, tags, topK: candidateK });
|
|
127
|
+
return enrichVectorResults(vecHits.slice(0, topK));
|
|
128
|
+
}
|
|
117
129
|
|
|
118
|
-
|
|
119
|
-
|
|
130
|
+
// Hybrid: run FTS5 (sync) and embed query (async) in parallel
|
|
131
|
+
const queryEmbeddingPromise = embed(query);
|
|
132
|
+
const bm25Hits = searchFTS({ query, agent, tags, topK: candidateK });
|
|
133
|
+
const queryEmbedding = await queryEmbeddingPromise;
|
|
134
|
+
|
|
135
|
+
if (!queryEmbedding) {
|
|
136
|
+
return bm25Hits.slice(0, topK).map(ftsResultToEnriched);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const vecHits = vectorSearch(queryEmbedding, { agent, tags, topK: candidateK });
|
|
140
|
+
const fused = reciprocalRankFusion(bm25Hits, vecHits);
|
|
141
|
+
|
|
142
|
+
// Enrich fused results: build lookup from BM25 data, only fetch missing from DB
|
|
143
|
+
const bm25Map = new Map(bm25Hits.map((r) => [r.id, r]));
|
|
144
|
+
const topResults = fused.slice(0, topK);
|
|
145
|
+
const enriched = [];
|
|
146
|
+
|
|
147
|
+
for (const { id, score } of topResults) {
|
|
148
|
+
const cached = bm25Map.get(id);
|
|
149
|
+
if (cached) {
|
|
150
|
+
enriched.push({
|
|
151
|
+
id: cached.id,
|
|
152
|
+
topic: cached.topic,
|
|
153
|
+
agent: cached.agent,
|
|
154
|
+
tags: cached.tags,
|
|
155
|
+
importance: cached.importance,
|
|
156
|
+
score: Math.round(score * 100) / 100,
|
|
157
|
+
content: cached.content,
|
|
158
|
+
updated: cached.updated,
|
|
159
|
+
});
|
|
120
160
|
} else {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
161
|
+
const chunk = getChunk(id);
|
|
162
|
+
if (!chunk) continue;
|
|
163
|
+
enriched.push({
|
|
164
|
+
id: chunk.id,
|
|
165
|
+
topic: chunk.topic,
|
|
166
|
+
agent: chunk.agent,
|
|
167
|
+
tags: chunk.tags,
|
|
168
|
+
importance: chunk.importance,
|
|
169
|
+
score: Math.round(score * 100) / 100,
|
|
170
|
+
content: chunk.content,
|
|
171
|
+
updated: chunk.updatedAt,
|
|
125
172
|
});
|
|
126
|
-
fusedResults = reciprocalRankFusion(bm25Hits, vecHits);
|
|
127
173
|
}
|
|
128
174
|
}
|
|
129
175
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const enriched = [];
|
|
176
|
+
return enriched;
|
|
177
|
+
}
|
|
133
178
|
|
|
134
|
-
|
|
179
|
+
/**
|
|
180
|
+
* Enriches vector-only results by fetching full chunk data from DB.
|
|
181
|
+
*/
|
|
182
|
+
function enrichVectorResults(vecHits) {
|
|
183
|
+
const enriched = [];
|
|
184
|
+
for (const { id, score } of vecHits) {
|
|
135
185
|
const chunk = getChunk(id);
|
|
136
186
|
if (!chunk) continue;
|
|
137
|
-
|
|
138
187
|
enriched.push({
|
|
139
188
|
id: chunk.id,
|
|
140
189
|
topic: chunk.topic,
|
|
@@ -146,6 +195,5 @@ export async function hybridSearch({
|
|
|
146
195
|
updated: chunk.updatedAt,
|
|
147
196
|
});
|
|
148
197
|
}
|
|
149
|
-
|
|
150
198
|
return enriched;
|
|
151
199
|
}
|
package/src/store.js
CHANGED
|
@@ -193,8 +193,8 @@ export async function deleteChunk(id) {
|
|
|
193
193
|
* @param {string[]} [opts.tags]
|
|
194
194
|
* @returns {Promise<Array>}
|
|
195
195
|
*/
|
|
196
|
-
export async function listChunks({ agent, tags = [] } = {}) {
|
|
197
|
-
return listChunksDb({ agent, tags });
|
|
196
|
+
export async function listChunks({ agent, tags = [], limit = 100, offset = 0 } = {}) {
|
|
197
|
+
return listChunksDb({ agent, tags, limit, offset });
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
/**
|