@adamrdrew/agent-memory-mcp 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/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # agent-memory-mcp
2
+
3
+ MCP server for persistent agent memory, backed by [LanceDB](https://lancedb.com/) with hybrid BM25 + vector search. Gives AI agents the ability to store, search, and manage memories across sessions using the [Model Context Protocol](https://modelcontextprotocol.io/).
4
+
5
+ All data stays on your machine. Embeddings are generated locally using [all-MiniLM-L6-v2](https://huggingface.co/Xenova/all-MiniLM-L6-v2) via ONNX — no API keys, no network dependencies after initial setup.
6
+
7
+ ## Features
8
+
9
+ - **Hybrid search** — combines BM25 full-text search with cosine vector similarity via Reciprocal Rank Fusion (RRF)
10
+ - **Local embeddings** — runs Xenova/all-MiniLM-L6-v2 locally via ONNX, no external API calls
11
+ - **12 memory categories** — structured taxonomy for organising memories
12
+ - **Batch operations** — store multiple memories in a single call
13
+ - **Hardcopy backup** — optional JSON file mirror of all mutations for human-readable backup
14
+ - **Fully local** — all data stays on disk, no network dependencies after first model download
15
+
16
+ ## Installation
17
+
18
+ Install the package globally first. This downloads the embedding model (~80 MB) so it's ready when the server starts:
19
+
20
+ ```bash
21
+ npm install -g @adamrdrew/agent-memory-mcp
22
+ ```
23
+
24
+ Then add the server to your MCP client configuration.
25
+
26
+ ### Claude Desktop
27
+
28
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
29
+
30
+ ```json
31
+ {
32
+ "mcpServers": {
33
+ "agent-memory": {
34
+ "command": "agent-memory-mcp",
35
+ "env": {
36
+ "MEMORY_DB_PATH": "/path/to/your/memory-db"
37
+ }
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ ### Claude Code
44
+
45
+ Add to your project's `.mcp.json`:
46
+
47
+ ```json
48
+ {
49
+ "agent-memory": {
50
+ "command": "agent-memory-mcp",
51
+ "env": {
52
+ "MEMORY_DB_PATH": "/path/to/your/memory-db"
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ ## Configuration
59
+
60
+ | Variable | Required | Description |
61
+ |---|---|---|
62
+ | `MEMORY_DB_PATH` | Yes | Path to the LanceDB database directory |
63
+ | `EMBEDDING_MODEL` | No | HuggingFace model ID (default: `Xenova/all-MiniLM-L6-v2`) |
64
+ | `ENABLE_HARDCOPY` | No | Set to `true` to enable JSON file backup |
65
+ | `HARDCOPY_PATH` | If hardcopy enabled | Directory for JSON mirror files |
66
+
67
+ ## Tools
68
+
69
+ | Tool | Description |
70
+ |---|---|
71
+ | `store` | Store a single memory with content, category, and tags |
72
+ | `store_batch` | Store multiple memories in one call |
73
+ | `search` | Search memories by meaning and/or keywords. Supports hybrid, keyword, and semantic modes |
74
+ | `recall` | Multi-topic contextual recall — searches multiple topics in parallel and includes recent memories |
75
+ | `find_related` | Find memories similar to a specific memory |
76
+ | `list_recent` | List most recent memories, optionally filtered by category |
77
+ | `update` | Update an existing memory — re-embeds automatically if content changes |
78
+ | `delete` | Permanently remove a memory by ID |
79
+ | `stats` | Get database statistics: total count, breakdown by category, timestamps |
80
+
81
+ ## Search Modes
82
+
83
+ The `search` tool supports three modes:
84
+
85
+ - **`hybrid`** (default) — combines BM25 keyword scoring with vector similarity using RRF reranking. Falls back to semantic-only if the full-text index is unavailable.
86
+ - **`keyword`** — BM25 full-text search only.
87
+ - **`semantic`** — cosine vector similarity only.
88
+
89
+ All modes support filtering by category, tags, and date range.
90
+
91
+ ## Memory Categories
92
+
93
+ `code-solution` · `bug-fix` · `architecture` · `learning` · `tool-usage` · `debugging` · `performance` · `security` · `observation` · `personal` · `relationship` · `other`
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ git clone https://github.com/adamrdrew/agent-memory-mcp.git
99
+ cd agent-memory-mcp
100
+ npm install
101
+ npm run dev # Run with tsx (no build step)
102
+ npm run build # Compile TypeScript to dist/
103
+ npm test # Run all tests
104
+ npm run test:watch # Run tests in watch mode
105
+ ```
106
+
107
+ ## License
108
+
109
+ This project is licensed under the [GNU General Public License v3.0](LICENSE).
@@ -0,0 +1,11 @@
1
+ import type { Embedder } from './types.js';
2
+ export declare class TransformersEmbedder implements Embedder {
3
+ private extractor;
4
+ private readonly modelName;
5
+ constructor(modelName?: string);
6
+ initialize(): Promise<void>;
7
+ embed(text: string): Promise<number[]>;
8
+ embedBatch(texts: string[]): Promise<number[][]>;
9
+ dimensions(): number;
10
+ private requireExtractor;
11
+ }
@@ -0,0 +1,38 @@
1
+ const EMBEDDING_DIMENSIONS = 384;
2
+ export class TransformersEmbedder {
3
+ extractor = null;
4
+ modelName;
5
+ constructor(modelName = 'Xenova/all-MiniLM-L6-v2') {
6
+ this.modelName = modelName;
7
+ }
8
+ async initialize() {
9
+ const { pipeline } = await import('@xenova/transformers');
10
+ this.extractor = await pipeline('feature-extraction', this.modelName);
11
+ }
12
+ async embed(text) {
13
+ const extractor = this.requireExtractor();
14
+ const output = await extractor(text, { pooling: 'mean', normalize: true });
15
+ return Array.from(output.data);
16
+ }
17
+ async embedBatch(texts) {
18
+ const extractor = this.requireExtractor();
19
+ const output = await extractor(texts, { pooling: 'mean', normalize: true });
20
+ const data = output.data;
21
+ const dim = this.dimensions();
22
+ const vectors = [];
23
+ for (let i = 0; i < texts.length; i++) {
24
+ vectors.push(Array.from(data.slice(i * dim, (i + 1) * dim)));
25
+ }
26
+ return vectors;
27
+ }
28
+ dimensions() {
29
+ return EMBEDDING_DIMENSIONS;
30
+ }
31
+ requireExtractor() {
32
+ if (!this.extractor) {
33
+ throw new Error('Embedder not initialised. Call initialize() first.');
34
+ }
35
+ return this.extractor;
36
+ }
37
+ }
38
+ //# sourceMappingURL=embedder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"embedder.js","sourceRoot":"","sources":["../src/embedder.ts"],"names":[],"mappings":"AAEA,MAAM,oBAAoB,GAAG,GAAG,CAAC;AAEjC,MAAM,OAAO,oBAAoB;IACvB,SAAS,GAA6B,IAAI,CAAC;IAClC,SAAS,CAAS;IAEnC,YAAY,YAAoB,yBAAyB;QACvD,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC7B,CAAC;IAED,KAAK,CAAC,UAAU;QACd,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,MAAM,CAAC,sBAAsB,CAAC,CAAC;QAC1D,IAAI,CAAC,SAAS,GAAG,MAAM,QAAQ,CAAC,oBAAoB,EAAE,IAAI,CAAC,SAAS,CAAsB,CAAC;IAC7F,CAAC;IAED,KAAK,CAAC,KAAK,CAAC,IAAY;QACtB,MAAM,SAAS,GAAG,IAAI,CAAC,gBAAgB,EAAE,CAAC;QAC1C,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,IAAI,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC3E,OAAO,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,IAAoB,CAAC,CAAC;IACjD,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,KAAe;QAC9B,MAAM,SAAS,GAAG,IAAI,CAAC,gBAAgB,EAAE,CAAC;QAC1C,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,KAAK,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC5E,MAAM,IAAI,GAAG,MAAM,CAAC,IAAoB,CAAC;QACzC,MAAM,GAAG,GAAG,IAAI,CAAC,UAAU,EAAE,CAAC;QAC9B,MAAM,OAAO,GAAe,EAAE,CAAC;QAC/B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACtC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,GAAG,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;QAC/D,CAAC;QACD,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,UAAU;QACR,OAAO,oBAAoB,CAAC;IAC9B,CAAC;IAEO,gBAAgB;QACtB,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;YACpB,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;QACxE,CAAC;QACD,OAAO,IAAI,CAAC,SAAS,CAAC;IACxB,CAAC;CACF"}
@@ -0,0 +1,17 @@
1
+ import type { Memory, MemoryCategory, MemoryStore, MemoryStats, SearchFilters, SearchMode, SearchResult, StoreRequest, UpdateRequest } from './types.js';
2
+ export declare class HardcopyMemoryStore implements MemoryStore {
3
+ private readonly inner;
4
+ private readonly hardcopyPath;
5
+ constructor(inner: MemoryStore, hardcopyPath: string);
6
+ initialize(): Promise<void>;
7
+ store(request: StoreRequest): Promise<Memory>;
8
+ storeBatch(requests: StoreRequest[]): Promise<Memory[]>;
9
+ update(id: string, updates: UpdateRequest): Promise<Memory>;
10
+ delete(id: string): Promise<void>;
11
+ search(query: string, mode: SearchMode, filters: SearchFilters): Promise<SearchResult[]>;
12
+ findRelated(memoryId: string, limit: number): Promise<SearchResult[]>;
13
+ listRecent(limit: number, category?: MemoryCategory): Promise<Memory[]>;
14
+ stats(): Promise<MemoryStats>;
15
+ private writeHardcopy;
16
+ private deleteHardcopy;
17
+ }
@@ -0,0 +1,80 @@
1
+ import { mkdir, writeFile, unlink } from 'fs/promises';
2
+ import { join } from 'path';
3
+ // ── HardcopyMemoryStore ─────────────────────────────────────────
4
+ //
5
+ // Transparent decorator that mirrors all mutations to plain JSON
6
+ // files on disk. One file per memory, named {id}.json. Read
7
+ // operations delegate straight through — the hardcopy is write-only.
8
+ //
9
+ // Hardcopy errors are logged to stderr but never propagate.
10
+ // The primary store is the source of truth; the hardcopy is
11
+ // a human-readable escape hatch.
12
+ export class HardcopyMemoryStore {
13
+ inner;
14
+ hardcopyPath;
15
+ constructor(inner, hardcopyPath) {
16
+ this.inner = inner;
17
+ this.hardcopyPath = hardcopyPath;
18
+ }
19
+ async initialize() {
20
+ await this.inner.initialize();
21
+ await mkdir(this.hardcopyPath, { recursive: true });
22
+ }
23
+ // ── Mutations (mirrored to disk) ────────────────────────────
24
+ async store(request) {
25
+ const memory = await this.inner.store(request);
26
+ await this.writeHardcopy(memory);
27
+ return memory;
28
+ }
29
+ async storeBatch(requests) {
30
+ const memories = await this.inner.storeBatch(requests);
31
+ await Promise.all(memories.map(m => this.writeHardcopy(m)));
32
+ return memories;
33
+ }
34
+ async update(id, updates) {
35
+ const memory = await this.inner.update(id, updates);
36
+ await this.writeHardcopy(memory);
37
+ return memory;
38
+ }
39
+ async delete(id) {
40
+ await this.inner.delete(id);
41
+ await this.deleteHardcopy(id);
42
+ }
43
+ // ── Reads (pass through) ────────────────────────────────────
44
+ async search(query, mode, filters) {
45
+ return this.inner.search(query, mode, filters);
46
+ }
47
+ async findRelated(memoryId, limit) {
48
+ return this.inner.findRelated(memoryId, limit);
49
+ }
50
+ async listRecent(limit, category) {
51
+ return this.inner.listRecent(limit, category);
52
+ }
53
+ async stats() {
54
+ return this.inner.stats();
55
+ }
56
+ // ── Private ─────────────────────────────────────────────────
57
+ async writeHardcopy(memory) {
58
+ try {
59
+ const filePath = join(this.hardcopyPath, `${memory.id}.json`);
60
+ await writeFile(filePath, JSON.stringify(memory, null, 2) + '\n');
61
+ }
62
+ catch (err) {
63
+ console.error(`[hardcopy] Failed to write ${memory.id}:`, err);
64
+ }
65
+ }
66
+ async deleteHardcopy(id) {
67
+ try {
68
+ const filePath = join(this.hardcopyPath, `${id}.json`);
69
+ await unlink(filePath);
70
+ }
71
+ catch (err) {
72
+ // File may not exist (e.g. hardcopy was enabled after the memory
73
+ // was created). That's fine — don't log ENOENT noise.
74
+ if (err.code !== 'ENOENT') {
75
+ console.error(`[hardcopy] Failed to delete ${id}:`, err);
76
+ }
77
+ }
78
+ }
79
+ }
80
+ //# sourceMappingURL=hardcopy-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hardcopy-store.js","sourceRoot":"","sources":["../src/hardcopy-store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { TransformersEmbedder } from './embedder.js';
4
+ import { HardcopyMemoryStore } from './hardcopy-store.js';
5
+ import { LanceMemoryStore } from './memory-store.js';
6
+ import { createServer } from './server.js';
7
+ async function main() {
8
+ const dbPath = process.env.MEMORY_DB_PATH;
9
+ if (!dbPath) {
10
+ console.error('MEMORY_DB_PATH environment variable is required');
11
+ process.exit(1);
12
+ }
13
+ const modelName = process.env.EMBEDDING_MODEL ?? 'Xenova/all-MiniLM-L6-v2';
14
+ // ── Compose dependencies ──
15
+ const embedder = new TransformersEmbedder(modelName);
16
+ let store = new LanceMemoryStore(dbPath, embedder);
17
+ if (process.env.ENABLE_HARDCOPY === 'true' && process.env.HARDCOPY_PATH) {
18
+ store = new HardcopyMemoryStore(store, process.env.HARDCOPY_PATH);
19
+ console.error(`[hardcopy] Mirroring mutations to ${process.env.HARDCOPY_PATH}`);
20
+ }
21
+ const server = createServer(store);
22
+ // ── Initialise (download model on first run, connect to DB) ──
23
+ await embedder.initialize();
24
+ await store.initialize();
25
+ // ── Start MCP transport ──
26
+ const transport = new StdioServerTransport();
27
+ await server.connect(transport);
28
+ }
29
+ main().catch((err) => {
30
+ console.error('Fatal:', err);
31
+ process.exit(1);
32
+ });
33
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAG3C,KAAK,UAAU,IAAI;IACjB,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC;IAC1C,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,OAAO,CAAC,KAAK,CAAC,iDAAiD,CAAC,CAAC;QACjE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,IAAI,yBAAyB,CAAC;IAE3E,6BAA6B;IAC7B,MAAM,QAAQ,GAAG,IAAI,oBAAoB,CAAC,SAAS,CAAC,CAAC;IACrD,IAAI,KAAK,GAAgB,IAAI,gBAAgB,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAEhE,IAAI,OAAO,CAAC,GAAG,CAAC,eAAe,KAAK,MAAM,IAAI,OAAO,CAAC,GAAG,CAAC,aAAa,EAAE,CAAC;QACxE,KAAK,GAAG,IAAI,mBAAmB,CAAC,KAAK,EAAE,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QAClE,OAAO,CAAC,KAAK,CAAC,qCAAqC,OAAO,CAAC,GAAG,CAAC,aAAa,EAAE,CAAC,CAAC;IAClF,CAAC;IAED,MAAM,MAAM,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;IAEnC,gEAAgE;IAChE,MAAM,QAAQ,CAAC,UAAU,EAAE,CAAC;IAC5B,MAAM,KAAK,CAAC,UAAU,EAAE,CAAC;IAEzB,4BAA4B;IAC5B,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;AAClC,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IAC7B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
@@ -0,0 +1,33 @@
1
+ import type { Embedder, Memory, MemoryCategory, MemoryStore, MemoryStats, SearchFilters, SearchMode, SearchResult, StoreRequest, UpdateRequest } from './types.js';
2
+ export declare class LanceMemoryStore implements MemoryStore {
3
+ private readonly dbPath;
4
+ private readonly embedder;
5
+ private db;
6
+ private table;
7
+ private ftsIndexCreated;
8
+ private reranker;
9
+ constructor(dbPath: string, embedder: Embedder);
10
+ initialize(): Promise<void>;
11
+ store(request: StoreRequest): Promise<Memory>;
12
+ storeBatch(requests: StoreRequest[]): Promise<Memory[]>;
13
+ search(query: string, mode?: SearchMode, filters?: SearchFilters): Promise<SearchResult[]>;
14
+ findRelated(memoryId: string, limit?: number): Promise<SearchResult[]>;
15
+ listRecent(limit?: number, category?: MemoryCategory): Promise<Memory[]>;
16
+ update(id: string, updates: UpdateRequest): Promise<Memory>;
17
+ delete(id: string): Promise<void>;
18
+ stats(): Promise<MemoryStats>;
19
+ private semanticSearch;
20
+ private keywordSearch;
21
+ private hybridSearch;
22
+ /**
23
+ * Ensures the LanceDB table exists. If the table does not exist, it is
24
+ * created with `seedRow` as initial data (LanceDB requires at least one
25
+ * row to infer the schema). Returns `true` if the table was just created
26
+ * and the seed row was inserted, `false` if the table already existed.
27
+ * Callers MUST check the return value to avoid double-inserting the seed.
28
+ */
29
+ private ensureTable;
30
+ private tryCreateFtsIndex;
31
+ private fetchById;
32
+ private buildRow;
33
+ }
@@ -0,0 +1,310 @@
1
+ import * as lancedb from '@lancedb/lancedb';
2
+ // ── LanceMemoryStore ───────────────────────────────────────────────
3
+ export class LanceMemoryStore {
4
+ dbPath;
5
+ embedder;
6
+ db = null;
7
+ table = null;
8
+ ftsIndexCreated = false;
9
+ reranker = null;
10
+ constructor(dbPath, embedder) {
11
+ this.dbPath = dbPath;
12
+ this.embedder = embedder;
13
+ }
14
+ async initialize() {
15
+ this.db = await lancedb.connect(this.dbPath);
16
+ this.reranker = await lancedb.rerankers.RRFReranker.create(60);
17
+ const names = await this.db.tableNames();
18
+ if (names.includes('memories')) {
19
+ this.table = await this.db.openTable('memories');
20
+ // Recreate FTS index with proper config (stemming, stop words, positions).
21
+ // replace: true makes this idempotent; negligible cost at our scale.
22
+ await this.tryCreateFtsIndex();
23
+ }
24
+ }
25
+ // ── Storage ────────────────────────────────────────────────────
26
+ async store(request) {
27
+ const row = await this.buildRow(request);
28
+ const seeded = await this.ensureTable(row);
29
+ if (!seeded) {
30
+ await this.table.add([row]);
31
+ }
32
+ return rowToMemory(row);
33
+ }
34
+ async storeBatch(requests) {
35
+ if (requests.length === 0)
36
+ return [];
37
+ const vectors = await this.embedder.embedBatch(requests.map(r => r.content));
38
+ const now = new Date().toISOString();
39
+ const rows = requests.map((req, i) => toRow(req, vectors[i], now));
40
+ const seeded = await this.ensureTable(rows[0]);
41
+ // If the table was just created, rows[0] was already inserted as
42
+ // seed data (LanceDB requires initial data to infer schema).
43
+ const remaining = seeded ? rows.slice(1) : rows;
44
+ if (remaining.length > 0) {
45
+ await this.table.add(remaining);
46
+ }
47
+ return rows.map(rowToMemory);
48
+ }
49
+ // ── Search ─────────────────────────────────────────────────────
50
+ async search(query, mode = 'hybrid', filters = {}) {
51
+ if (!this.table)
52
+ return [];
53
+ const limit = filters.limit ?? 10;
54
+ switch (mode) {
55
+ case 'semantic':
56
+ return this.semanticSearch(query, filters, limit);
57
+ case 'keyword':
58
+ return this.keywordSearch(query, filters, limit);
59
+ case 'hybrid':
60
+ return this.hybridSearch(query, filters, limit);
61
+ }
62
+ }
63
+ async findRelated(memoryId, limit = 5) {
64
+ if (!this.table)
65
+ return [];
66
+ const original = await this.fetchById(memoryId);
67
+ if (!original)
68
+ throw new Error(`Memory ${memoryId} not found`);
69
+ const results = await this.table
70
+ .query()
71
+ .nearestTo(original.vector)
72
+ .distanceType('cosine')
73
+ .limit(limit + 1)
74
+ .toArray();
75
+ return results
76
+ .filter((r) => r.id !== memoryId)
77
+ .slice(0, limit)
78
+ .map(resultToSearchResult);
79
+ }
80
+ async listRecent(limit = 10, category) {
81
+ if (!this.table)
82
+ return [];
83
+ let q = this.table.query();
84
+ if (category) {
85
+ q = q.where(`category = '${sanitise(category)}'`);
86
+ }
87
+ const rows = await q.toArray();
88
+ return rows
89
+ .map(rowToMemory)
90
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
91
+ .slice(0, limit);
92
+ }
93
+ // ── Mutation ────────────────────────────────────────────────────
94
+ async update(id, updates) {
95
+ if (!this.table)
96
+ throw new Error('No memories stored yet');
97
+ const existing = await this.fetchById(id);
98
+ if (!existing)
99
+ throw new Error(`Memory ${id} not found`);
100
+ const content = updates.content ?? existing.content;
101
+ const category = updates.category ?? existing.category;
102
+ const tags = updates.tags ?? JSON.parse(existing.tags);
103
+ const now = new Date().toISOString();
104
+ const vector = updates.content
105
+ ? await this.embedder.embed(content)
106
+ : existing.vector;
107
+ const updatedRow = {
108
+ id,
109
+ content,
110
+ category,
111
+ tags: JSON.stringify(tags),
112
+ created_at: existing.created_at,
113
+ updated_at: now,
114
+ vector,
115
+ };
116
+ await this.table.delete(`id = '${sanitise(id)}'`);
117
+ await this.table.add([updatedRow]);
118
+ return rowToMemory(updatedRow);
119
+ }
120
+ async delete(id) {
121
+ if (!this.table)
122
+ throw new Error('No memories stored yet');
123
+ await this.table.delete(`id = '${sanitise(id)}'`);
124
+ }
125
+ // ── Stats ──────────────────────────────────────────────────────
126
+ async stats() {
127
+ if (!this.table) {
128
+ return { totalMemories: 0, byCategory: {}, oldestMemory: null, newestMemory: null };
129
+ }
130
+ const rows = (await this.table.query().toArray());
131
+ const memories = rows.map(rowToMemory);
132
+ const byCategory = {};
133
+ for (const m of memories) {
134
+ byCategory[m.category] = (byCategory[m.category] ?? 0) + 1;
135
+ }
136
+ const sorted = [...memories].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
137
+ return {
138
+ totalMemories: memories.length,
139
+ byCategory,
140
+ oldestMemory: sorted[0]?.createdAt ?? null,
141
+ newestMemory: sorted.at(-1)?.createdAt ?? null,
142
+ };
143
+ }
144
+ // ── Private: search strategies ─────────────────────────────────
145
+ async semanticSearch(query, filters, limit) {
146
+ const vector = await this.embedder.embed(query);
147
+ const overFetch = limit * 3;
148
+ let search = this.table.query().nearestTo(vector).distanceType('cosine').limit(overFetch);
149
+ search = applyWhereClause(search, filters);
150
+ const rows = await search.toArray();
151
+ return postFilter(rows, filters).slice(0, limit).map(resultToSearchResult);
152
+ }
153
+ async keywordSearch(query, filters, limit) {
154
+ try {
155
+ const overFetch = limit * 3;
156
+ let search = this.table.search(query, 'fts').limit(overFetch);
157
+ search = applyWhereClause(search, filters);
158
+ const rows = await search.toArray();
159
+ return postFilter(rows, filters).slice(0, limit).map(resultToSearchResult);
160
+ }
161
+ catch {
162
+ // FTS index may not exist yet; keyword search degrades gracefully.
163
+ return [];
164
+ }
165
+ }
166
+ async hybridSearch(query, filters, limit) {
167
+ if (!this.ftsIndexCreated || !this.reranker) {
168
+ // FTS index not available — degrade to semantic-only.
169
+ return this.semanticSearch(query, filters, limit);
170
+ }
171
+ try {
172
+ const vector = await this.embedder.embed(query);
173
+ const overFetch = limit * 3;
174
+ let search = this.table
175
+ .query()
176
+ .nearestTo(vector)
177
+ .distanceType('cosine')
178
+ .fullTextSearch(query)
179
+ .rerank(this.reranker)
180
+ .limit(overFetch);
181
+ search = applyWhereClause(search, filters);
182
+ const rows = await search.toArray();
183
+ return postFilter(rows, filters).slice(0, limit).map(resultToSearchResult);
184
+ }
185
+ catch {
186
+ // Built-in hybrid can fail if FTS index is stale or missing.
187
+ // Fall back to semantic-only.
188
+ return this.semanticSearch(query, filters, limit);
189
+ }
190
+ }
191
+ // ── Private: table management ──────────────────────────────────
192
+ /**
193
+ * Ensures the LanceDB table exists. If the table does not exist, it is
194
+ * created with `seedRow` as initial data (LanceDB requires at least one
195
+ * row to infer the schema). Returns `true` if the table was just created
196
+ * and the seed row was inserted, `false` if the table already existed.
197
+ * Callers MUST check the return value to avoid double-inserting the seed.
198
+ */
199
+ async ensureTable(seedRow) {
200
+ if (this.table)
201
+ return false;
202
+ this.table = await this.db.createTable('memories', [seedRow]);
203
+ await this.tryCreateFtsIndex();
204
+ return true;
205
+ }
206
+ async tryCreateFtsIndex() {
207
+ if (this.ftsIndexCreated || !this.table)
208
+ return;
209
+ try {
210
+ await this.table.createIndex('content', {
211
+ config: lancedb.Index.fts({
212
+ withPosition: true,
213
+ stem: true,
214
+ language: 'English',
215
+ removeStopWords: true,
216
+ asciiFolding: true,
217
+ }),
218
+ replace: true,
219
+ });
220
+ this.ftsIndexCreated = true;
221
+ }
222
+ catch {
223
+ // Index creation may fail on very small tables; keyword search degrades gracefully.
224
+ }
225
+ }
226
+ async fetchById(id) {
227
+ if (!this.table)
228
+ return null;
229
+ const rows = await this.table.query().where(`id = '${sanitise(id)}'`).limit(1).toArray();
230
+ return rows[0] ?? null;
231
+ }
232
+ async buildRow(request) {
233
+ const vector = await this.embedder.embed(request.content);
234
+ const now = new Date().toISOString();
235
+ return toRow(request, vector, now);
236
+ }
237
+ }
238
+ // ── Pure functions ─────────────────────────────────────────────────
239
+ function toRow(request, vector, timestamp) {
240
+ return {
241
+ id: crypto.randomUUID(),
242
+ content: request.content,
243
+ category: request.category,
244
+ tags: JSON.stringify(request.tags),
245
+ created_at: timestamp,
246
+ updated_at: timestamp,
247
+ vector,
248
+ };
249
+ }
250
+ function rowToMemory(row) {
251
+ return {
252
+ id: row.id,
253
+ content: row.content,
254
+ category: row.category,
255
+ tags: JSON.parse(row.tags),
256
+ createdAt: row.created_at,
257
+ updatedAt: row.updated_at,
258
+ };
259
+ }
260
+ function resultToSearchResult(row) {
261
+ // Three possible score fields depending on search mode:
262
+ // _relevance_score — from the RRF reranker (hybrid search), higher = better
263
+ // _distance — from vector search (cosine: 0–2 range), lower = better
264
+ // _score — from FTS/BM25 search, higher = better
265
+ const relevanceScore = row._relevance_score;
266
+ const distance = row._distance;
267
+ const ftsScore = row._score;
268
+ let score;
269
+ if (relevanceScore != null) {
270
+ score = relevanceScore;
271
+ }
272
+ else if (distance != null) {
273
+ score = 1 / (1 + distance);
274
+ }
275
+ else if (ftsScore != null) {
276
+ score = ftsScore;
277
+ }
278
+ else {
279
+ score = 0;
280
+ }
281
+ return { memory: rowToMemory(row), score };
282
+ }
283
+ function applyWhereClause(search, filters) {
284
+ const clauses = [];
285
+ if (filters.category) {
286
+ clauses.push(`category = '${sanitise(filters.category)}'`);
287
+ }
288
+ if (filters.after) {
289
+ clauses.push(`created_at >= '${sanitise(filters.after)}'`);
290
+ }
291
+ if (filters.before) {
292
+ clauses.push(`created_at <= '${sanitise(filters.before)}'`);
293
+ }
294
+ if (clauses.length > 0) {
295
+ return search.where(clauses.join(' AND '));
296
+ }
297
+ return search;
298
+ }
299
+ function postFilter(rows, filters) {
300
+ if (!filters.tags || filters.tags.length === 0)
301
+ return rows;
302
+ return rows.filter(row => {
303
+ const tags = JSON.parse(row.tags);
304
+ return filters.tags.some(t => tags.includes(t));
305
+ });
306
+ }
307
+ function sanitise(value) {
308
+ return value.replace(/'/g, "''");
309
+ }
310
+ //# sourceMappingURL=memory-store.js.map