@cartisien/engram-mcp 0.1.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 +114 -0
- package/dist/engram.d.ts +99 -0
- package/dist/engram.js +283 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +183 -0
- package/engram.ts +385 -0
- package/package.json +22 -0
- package/src/index.ts +175 -0
- package/tsconfig.json +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# @cartisien/engram-mcp
|
|
2
|
+
|
|
3
|
+
> Persistent semantic memory for AI agents — MCP server powered by [@cartisien/engram](https://github.com/Cartisien/engram)
|
|
4
|
+
|
|
5
|
+
Give any MCP-compatible AI client (Claude Desktop, Cursor, Windsurf) persistent memory that survives across sessions.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npx -y @cartisien/engram-mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
Exposes 5 tools to any MCP client:
|
|
16
|
+
|
|
17
|
+
| Tool | Description |
|
|
18
|
+
|------|-------------|
|
|
19
|
+
| `remember` | Store a memory with automatic embedding |
|
|
20
|
+
| `recall` | Semantic search across stored memories |
|
|
21
|
+
| `history` | Recent conversation history |
|
|
22
|
+
| `forget` | Delete one memory, a session, or entries before a date |
|
|
23
|
+
| `stats` | Memory statistics for a session |
|
|
24
|
+
|
|
25
|
+
Memories are stored in SQLite. Semantic search uses local Ollama embeddings (`nomic-embed-text`) — no API key, no cloud. Falls back to keyword search if Ollama isn't available.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
### Claude Desktop
|
|
32
|
+
|
|
33
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"engram": {
|
|
39
|
+
"command": "npx",
|
|
40
|
+
"args": ["-y", "@cartisien/engram-mcp"],
|
|
41
|
+
"env": {
|
|
42
|
+
"ENGRAM_DB": "~/.engram/memory.db"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Restart Claude Desktop. You'll see `remember`, `recall`, `history`, `forget`, and `stats` available as tools.
|
|
50
|
+
|
|
51
|
+
### Cursor / Windsurf
|
|
52
|
+
|
|
53
|
+
Add to your MCP config:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"engram": {
|
|
59
|
+
"command": "npx",
|
|
60
|
+
"args": ["-y", "@cartisien/engram-mcp"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
| Env Var | Default | Description |
|
|
71
|
+
|---------|---------|-------------|
|
|
72
|
+
| `ENGRAM_DB` | `~/.engram/memory.db` | SQLite database path |
|
|
73
|
+
| `ENGRAM_EMBEDDING_URL` | `http://localhost:11434` | Ollama base URL for embeddings |
|
|
74
|
+
|
|
75
|
+
### Local Embeddings (Recommended)
|
|
76
|
+
|
|
77
|
+
Install [Ollama](https://ollama.ai) and pull the embedding model:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
ollama pull nomic-embed-text
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Semantic search activates automatically. Without Ollama, keyword search is used.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Example Usage
|
|
88
|
+
|
|
89
|
+
Once connected, your agent can:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
remember(sessionId="myagent", content="User prefers TypeScript over JavaScript", role="user")
|
|
93
|
+
|
|
94
|
+
recall(sessionId="myagent", query="what are the user's coding preferences?", limit=5)
|
|
95
|
+
# Returns: [{ content: "User prefers TypeScript...", similarity: 0.82 }, ...]
|
|
96
|
+
|
|
97
|
+
history(sessionId="myagent", limit=10)
|
|
98
|
+
|
|
99
|
+
stats(sessionId="myagent")
|
|
100
|
+
# { total: 42, byRole: { user: 20, assistant: 22 }, withEmbeddings: 42 }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Part of the Cartisien Memory Suite
|
|
106
|
+
|
|
107
|
+
- [`@cartisien/engram`](https://github.com/Cartisien/engram) — core memory SDK
|
|
108
|
+
- `@cartisien/engram-mcp` — this package, MCP server
|
|
109
|
+
- `@cartisien/extensa` — vector infrastructure *(coming soon)*
|
|
110
|
+
- `@cartisien/cogito` — agent identity & lifecycle *(coming soon)*
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
MIT © [Cartisien Interactive](https://cartisien.com)
|
package/dist/engram.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export interface MemoryEntry {
|
|
2
|
+
id: string;
|
|
3
|
+
sessionId: string;
|
|
4
|
+
content: string;
|
|
5
|
+
role: 'user' | 'assistant' | 'system';
|
|
6
|
+
timestamp: Date;
|
|
7
|
+
metadata?: Record<string, unknown>;
|
|
8
|
+
similarity?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface RecallOptions {
|
|
11
|
+
limit?: number;
|
|
12
|
+
before?: Date;
|
|
13
|
+
after?: Date;
|
|
14
|
+
role?: 'user' | 'assistant' | 'system';
|
|
15
|
+
}
|
|
16
|
+
export interface EngramConfig {
|
|
17
|
+
dbPath?: string;
|
|
18
|
+
maxContextLength?: number;
|
|
19
|
+
embeddingUrl?: string;
|
|
20
|
+
embeddingModel?: string;
|
|
21
|
+
semanticSearch?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Engram - Persistent memory for AI assistants
|
|
25
|
+
*
|
|
26
|
+
* A lightweight, SQLite-backed memory system that gives your AI assistants
|
|
27
|
+
* the ability to remember conversations across sessions.
|
|
28
|
+
* v0.2 adds semantic search via Ollama embeddings (nomic-embed-text).
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* import { Engram } from '@cartisien/engram';
|
|
33
|
+
*
|
|
34
|
+
* const memory = new Engram({ dbPath: './memory.db' });
|
|
35
|
+
*
|
|
36
|
+
* // Store a memory
|
|
37
|
+
* await memory.remember('user_123', 'User prefers dark mode and TypeScript', 'user');
|
|
38
|
+
*
|
|
39
|
+
* // Retrieve relevant memories semantically
|
|
40
|
+
* const context = await memory.recall('user_123', 'What are this user\'s preferences?', 5);
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export declare class Engram {
|
|
44
|
+
private db;
|
|
45
|
+
private maxContextLength;
|
|
46
|
+
private dbPath;
|
|
47
|
+
private initialized;
|
|
48
|
+
private embeddingUrl;
|
|
49
|
+
private embeddingModel;
|
|
50
|
+
private semanticSearch;
|
|
51
|
+
constructor(config?: EngramConfig);
|
|
52
|
+
private init;
|
|
53
|
+
/**
|
|
54
|
+
* Fetch embedding vector from Ollama
|
|
55
|
+
*/
|
|
56
|
+
private embed;
|
|
57
|
+
/**
|
|
58
|
+
* Cosine similarity between two vectors
|
|
59
|
+
*/
|
|
60
|
+
private cosineSimilarity;
|
|
61
|
+
/**
|
|
62
|
+
* Store a memory entry
|
|
63
|
+
*/
|
|
64
|
+
remember(sessionId: string, content: string, role?: 'user' | 'assistant' | 'system', metadata?: Record<string, unknown>): Promise<MemoryEntry>;
|
|
65
|
+
/**
|
|
66
|
+
* Recall memories for a session.
|
|
67
|
+
*
|
|
68
|
+
* With semantic search enabled (default), embeds the query and ranks
|
|
69
|
+
* results by cosine similarity. Falls back to keyword search if Ollama
|
|
70
|
+
* is unreachable or no embeddings are stored.
|
|
71
|
+
*/
|
|
72
|
+
recall(sessionId: string, query?: string, limit?: number, options?: RecallOptions): Promise<MemoryEntry[]>;
|
|
73
|
+
/**
|
|
74
|
+
* Get recent conversation history for a session
|
|
75
|
+
*/
|
|
76
|
+
history(sessionId: string, limit?: number): Promise<MemoryEntry[]>;
|
|
77
|
+
/**
|
|
78
|
+
* Forget (delete) memories
|
|
79
|
+
*/
|
|
80
|
+
forget(sessionId: string, options?: {
|
|
81
|
+
before?: Date;
|
|
82
|
+
id?: string;
|
|
83
|
+
}): Promise<number>;
|
|
84
|
+
/**
|
|
85
|
+
* Get memory statistics for a session
|
|
86
|
+
*/
|
|
87
|
+
stats(sessionId: string): Promise<{
|
|
88
|
+
total: number;
|
|
89
|
+
byRole: Record<string, number>;
|
|
90
|
+
oldest: Date | null;
|
|
91
|
+
newest: Date | null;
|
|
92
|
+
withEmbeddings: number;
|
|
93
|
+
}>;
|
|
94
|
+
/**
|
|
95
|
+
* Close the database connection
|
|
96
|
+
*/
|
|
97
|
+
close(): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
export default Engram;
|
package/dist/engram.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Engram = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
/**
|
|
6
|
+
* Engram - Persistent memory for AI assistants
|
|
7
|
+
*
|
|
8
|
+
* A lightweight, SQLite-backed memory system that gives your AI assistants
|
|
9
|
+
* the ability to remember conversations across sessions.
|
|
10
|
+
* v0.2 adds semantic search via Ollama embeddings (nomic-embed-text).
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* import { Engram } from '@cartisien/engram';
|
|
15
|
+
*
|
|
16
|
+
* const memory = new Engram({ dbPath: './memory.db' });
|
|
17
|
+
*
|
|
18
|
+
* // Store a memory
|
|
19
|
+
* await memory.remember('user_123', 'User prefers dark mode and TypeScript', 'user');
|
|
20
|
+
*
|
|
21
|
+
* // Retrieve relevant memories semantically
|
|
22
|
+
* const context = await memory.recall('user_123', 'What are this user\'s preferences?', 5);
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
class Engram {
|
|
26
|
+
constructor(config = {}) {
|
|
27
|
+
this.initialized = false;
|
|
28
|
+
this.dbPath = config.dbPath || ':memory:';
|
|
29
|
+
this.maxContextLength = config.maxContextLength || 4000;
|
|
30
|
+
this.embeddingUrl = config.embeddingUrl || 'http://192.168.68.73:11434';
|
|
31
|
+
this.embeddingModel = config.embeddingModel || 'nomic-embed-text';
|
|
32
|
+
this.semanticSearch = config.semanticSearch !== false;
|
|
33
|
+
}
|
|
34
|
+
async init() {
|
|
35
|
+
if (this.initialized)
|
|
36
|
+
return;
|
|
37
|
+
const sqlite3 = require('sqlite3').verbose();
|
|
38
|
+
const { open } = require('sqlite');
|
|
39
|
+
this.db = await open({
|
|
40
|
+
filename: this.dbPath,
|
|
41
|
+
driver: sqlite3.Database
|
|
42
|
+
});
|
|
43
|
+
await this.db.exec(`
|
|
44
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
45
|
+
id TEXT PRIMARY KEY,
|
|
46
|
+
session_id TEXT NOT NULL,
|
|
47
|
+
content TEXT NOT NULL,
|
|
48
|
+
role TEXT CHECK(role IN ('user', 'assistant', 'system')),
|
|
49
|
+
timestamp INTEGER NOT NULL,
|
|
50
|
+
metadata TEXT,
|
|
51
|
+
content_hash TEXT NOT NULL,
|
|
52
|
+
embedding TEXT
|
|
53
|
+
);
|
|
54
|
+
`);
|
|
55
|
+
// Add embedding column if upgrading from v0.1
|
|
56
|
+
try {
|
|
57
|
+
await this.db.exec(`ALTER TABLE memories ADD COLUMN embedding TEXT`);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Column already exists, ignore
|
|
61
|
+
}
|
|
62
|
+
await this.db.exec(`
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_session_timestamp
|
|
64
|
+
ON memories(session_id, timestamp DESC);
|
|
65
|
+
`);
|
|
66
|
+
await this.db.exec(`
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_content
|
|
68
|
+
ON memories(content);
|
|
69
|
+
`);
|
|
70
|
+
this.initialized = true;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Fetch embedding vector from Ollama
|
|
74
|
+
*/
|
|
75
|
+
async embed(text) {
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(`${this.embeddingUrl}/api/embeddings`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({ model: this.embeddingModel, prompt: text }),
|
|
81
|
+
signal: AbortSignal.timeout(5000)
|
|
82
|
+
});
|
|
83
|
+
if (!response.ok)
|
|
84
|
+
return null;
|
|
85
|
+
const data = await response.json();
|
|
86
|
+
return data.embedding ?? null;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Cosine similarity between two vectors
|
|
94
|
+
*/
|
|
95
|
+
cosineSimilarity(a, b) {
|
|
96
|
+
let dot = 0, magA = 0, magB = 0;
|
|
97
|
+
for (let i = 0; i < a.length; i++) {
|
|
98
|
+
dot += a[i] * b[i];
|
|
99
|
+
magA += a[i] * a[i];
|
|
100
|
+
magB += b[i] * b[i];
|
|
101
|
+
}
|
|
102
|
+
const denom = Math.sqrt(magA) * Math.sqrt(magB);
|
|
103
|
+
return denom === 0 ? 0 : dot / denom;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Store a memory entry
|
|
107
|
+
*/
|
|
108
|
+
async remember(sessionId, content, role = 'user', metadata) {
|
|
109
|
+
await this.init();
|
|
110
|
+
const id = (0, crypto_1.createHash)('sha256')
|
|
111
|
+
.update(`${sessionId}:${content}:${Date.now()}`)
|
|
112
|
+
.digest('hex')
|
|
113
|
+
.slice(0, 16);
|
|
114
|
+
const contentHash = (0, crypto_1.createHash)('sha256')
|
|
115
|
+
.update(content)
|
|
116
|
+
.digest('hex')
|
|
117
|
+
.slice(0, 16);
|
|
118
|
+
const truncated = content.slice(0, this.maxContextLength);
|
|
119
|
+
// Fetch embedding if semantic search enabled
|
|
120
|
+
let embeddingJson = null;
|
|
121
|
+
if (this.semanticSearch) {
|
|
122
|
+
const vector = await this.embed(truncated);
|
|
123
|
+
if (vector)
|
|
124
|
+
embeddingJson = JSON.stringify(vector);
|
|
125
|
+
}
|
|
126
|
+
const entry = {
|
|
127
|
+
id,
|
|
128
|
+
sessionId,
|
|
129
|
+
content: truncated,
|
|
130
|
+
role,
|
|
131
|
+
timestamp: new Date(),
|
|
132
|
+
metadata
|
|
133
|
+
};
|
|
134
|
+
await this.db.run(`INSERT INTO memories (id, session_id, content, role, timestamp, metadata, content_hash, embedding)
|
|
135
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
136
|
+
entry.id,
|
|
137
|
+
entry.sessionId,
|
|
138
|
+
entry.content,
|
|
139
|
+
entry.role,
|
|
140
|
+
entry.timestamp.getTime(),
|
|
141
|
+
metadata ? JSON.stringify(metadata) : null,
|
|
142
|
+
contentHash,
|
|
143
|
+
embeddingJson
|
|
144
|
+
]);
|
|
145
|
+
return entry;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Recall memories for a session.
|
|
149
|
+
*
|
|
150
|
+
* With semantic search enabled (default), embeds the query and ranks
|
|
151
|
+
* results by cosine similarity. Falls back to keyword search if Ollama
|
|
152
|
+
* is unreachable or no embeddings are stored.
|
|
153
|
+
*/
|
|
154
|
+
async recall(sessionId, query, limit = 10, options = {}) {
|
|
155
|
+
await this.init();
|
|
156
|
+
// Build base query
|
|
157
|
+
let sql = `
|
|
158
|
+
SELECT id, session_id, content, role, timestamp, metadata, embedding
|
|
159
|
+
FROM memories
|
|
160
|
+
WHERE session_id = ?
|
|
161
|
+
`;
|
|
162
|
+
const params = [sessionId];
|
|
163
|
+
if (options.role) {
|
|
164
|
+
sql += ` AND role = ?`;
|
|
165
|
+
params.push(options.role);
|
|
166
|
+
}
|
|
167
|
+
if (options.after) {
|
|
168
|
+
sql += ` AND timestamp >= ?`;
|
|
169
|
+
params.push(options.after.getTime());
|
|
170
|
+
}
|
|
171
|
+
if (options.before) {
|
|
172
|
+
sql += ` AND timestamp <= ?`;
|
|
173
|
+
params.push(options.before.getTime());
|
|
174
|
+
}
|
|
175
|
+
// Try semantic search
|
|
176
|
+
if (query && query.trim() && this.semanticSearch) {
|
|
177
|
+
const queryVector = await this.embed(query);
|
|
178
|
+
if (queryVector) {
|
|
179
|
+
sql += ` ORDER BY timestamp DESC`;
|
|
180
|
+
const rows = await this.db.all(sql, params);
|
|
181
|
+
// Score each row by cosine similarity
|
|
182
|
+
const scored = rows
|
|
183
|
+
.map((row) => {
|
|
184
|
+
let similarity = 0;
|
|
185
|
+
if (row.embedding) {
|
|
186
|
+
try {
|
|
187
|
+
const vec = JSON.parse(row.embedding);
|
|
188
|
+
similarity = this.cosineSimilarity(queryVector, vec);
|
|
189
|
+
}
|
|
190
|
+
catch { /* malformed, skip */ }
|
|
191
|
+
}
|
|
192
|
+
return { row, similarity };
|
|
193
|
+
})
|
|
194
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
195
|
+
.slice(0, limit);
|
|
196
|
+
return scored.map(({ row, similarity }) => ({
|
|
197
|
+
id: row.id,
|
|
198
|
+
sessionId: row.session_id,
|
|
199
|
+
content: row.content,
|
|
200
|
+
role: row.role,
|
|
201
|
+
timestamp: new Date(row.timestamp),
|
|
202
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
203
|
+
similarity
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
// Ollama unreachable — fall through to keyword search
|
|
207
|
+
}
|
|
208
|
+
// Keyword fallback
|
|
209
|
+
if (query && query.trim()) {
|
|
210
|
+
const keywords = query.toLowerCase().split(/\s+/).filter(k => k.length > 2);
|
|
211
|
+
if (keywords.length > 0) {
|
|
212
|
+
sql += ` AND (` + keywords.map(() => `LOWER(content) LIKE ?`).join(' OR ') + `)`;
|
|
213
|
+
params.push(...keywords.map(k => `%${k}%`));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
sql += ` ORDER BY timestamp DESC LIMIT ?`;
|
|
217
|
+
params.push(limit);
|
|
218
|
+
const rows = await this.db.all(sql, params);
|
|
219
|
+
return rows.map((row) => ({
|
|
220
|
+
id: row.id,
|
|
221
|
+
sessionId: row.session_id,
|
|
222
|
+
content: row.content,
|
|
223
|
+
role: row.role,
|
|
224
|
+
timestamp: new Date(row.timestamp),
|
|
225
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Get recent conversation history for a session
|
|
230
|
+
*/
|
|
231
|
+
async history(sessionId, limit = 20) {
|
|
232
|
+
return this.recall(sessionId, undefined, limit, {});
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Forget (delete) memories
|
|
236
|
+
*/
|
|
237
|
+
async forget(sessionId, options) {
|
|
238
|
+
await this.init();
|
|
239
|
+
if (options?.id) {
|
|
240
|
+
const result = await this.db.run('DELETE FROM memories WHERE session_id = ? AND id = ?', [sessionId, options.id]);
|
|
241
|
+
return result.changes || 0;
|
|
242
|
+
}
|
|
243
|
+
let sql = 'DELETE FROM memories WHERE session_id = ?';
|
|
244
|
+
const params = [sessionId];
|
|
245
|
+
if (options?.before) {
|
|
246
|
+
sql += ' AND timestamp < ?';
|
|
247
|
+
params.push(options.before.getTime());
|
|
248
|
+
}
|
|
249
|
+
const result = await this.db.run(sql, params);
|
|
250
|
+
return result.changes || 0;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Get memory statistics for a session
|
|
254
|
+
*/
|
|
255
|
+
async stats(sessionId) {
|
|
256
|
+
await this.init();
|
|
257
|
+
const totalRow = await this.db.get('SELECT COUNT(*) as count FROM memories WHERE session_id = ?', [sessionId]);
|
|
258
|
+
const total = totalRow?.count || 0;
|
|
259
|
+
const roleRows = await this.db.all('SELECT role, COUNT(*) as count FROM memories WHERE session_id = ? GROUP BY role', [sessionId]);
|
|
260
|
+
const byRole = {};
|
|
261
|
+
roleRows.forEach((row) => { byRole[row.role] = row.count; });
|
|
262
|
+
const range = await this.db.get('SELECT MIN(timestamp) as oldest, MAX(timestamp) as newest FROM memories WHERE session_id = ?', [sessionId]);
|
|
263
|
+
const embRow = await this.db.get('SELECT COUNT(*) as count FROM memories WHERE session_id = ? AND embedding IS NOT NULL', [sessionId]);
|
|
264
|
+
return {
|
|
265
|
+
total,
|
|
266
|
+
byRole,
|
|
267
|
+
oldest: range?.oldest ? new Date(range.oldest) : null,
|
|
268
|
+
newest: range?.newest ? new Date(range.newest) : null,
|
|
269
|
+
withEmbeddings: embRow?.count || 0
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Close the database connection
|
|
274
|
+
*/
|
|
275
|
+
async close() {
|
|
276
|
+
if (this.db) {
|
|
277
|
+
await this.db.close();
|
|
278
|
+
this.initialized = false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
exports.Engram = Engram;
|
|
283
|
+
exports.default = Engram;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
|
|
38
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
39
|
+
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
40
|
+
const engram_js_1 = require("../engram.js");
|
|
41
|
+
const os = __importStar(require("os"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
const DB_PATH = process.env.ENGRAM_DB || path.join(os.homedir(), '.engram', 'memory.db');
|
|
45
|
+
const EMBEDDING_URL = process.env.ENGRAM_EMBEDDING_URL || 'http://192.168.68.73:11434';
|
|
46
|
+
// Ensure DB directory exists
|
|
47
|
+
const dbDir = path.dirname(DB_PATH);
|
|
48
|
+
if (!fs.existsSync(dbDir))
|
|
49
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
50
|
+
const memory = new engram_js_1.Engram({
|
|
51
|
+
dbPath: DB_PATH,
|
|
52
|
+
embeddingUrl: EMBEDDING_URL,
|
|
53
|
+
semanticSearch: true,
|
|
54
|
+
});
|
|
55
|
+
const server = new index_js_1.Server({ name: 'engram', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
56
|
+
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
57
|
+
tools: [
|
|
58
|
+
{
|
|
59
|
+
name: 'remember',
|
|
60
|
+
description: 'Store a memory entry for a session. Embeddings are generated automatically for semantic recall.',
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
sessionId: { type: 'string', description: 'Session identifier (e.g. agent name or user ID)' },
|
|
65
|
+
content: { type: 'string', description: 'The memory content to store' },
|
|
66
|
+
role: { type: 'string', enum: ['user', 'assistant', 'system'], description: 'Role of the memory source', default: 'user' },
|
|
67
|
+
},
|
|
68
|
+
required: ['sessionId', 'content'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'recall',
|
|
73
|
+
description: 'Retrieve relevant memories using semantic search. Falls back to keyword search if embeddings unavailable.',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
78
|
+
query: { type: 'string', description: 'Search query — semantic similarity is used' },
|
|
79
|
+
limit: { type: 'number', description: 'Max results to return (default 10)', default: 10 },
|
|
80
|
+
},
|
|
81
|
+
required: ['sessionId', 'query'],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'history',
|
|
86
|
+
description: 'Get recent conversation history for a session in chronological order.',
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
91
|
+
limit: { type: 'number', description: 'Max entries to return (default 20)', default: 20 },
|
|
92
|
+
},
|
|
93
|
+
required: ['sessionId'],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'forget',
|
|
98
|
+
description: 'Delete memories for a session. Delete all, one by ID, or entries before a date.',
|
|
99
|
+
inputSchema: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
103
|
+
id: { type: 'string', description: 'Specific memory ID to delete (optional)' },
|
|
104
|
+
before: { type: 'string', description: 'ISO date string — delete entries before this date (optional)' },
|
|
105
|
+
},
|
|
106
|
+
required: ['sessionId'],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'stats',
|
|
111
|
+
description: 'Get memory statistics for a session.',
|
|
112
|
+
inputSchema: {
|
|
113
|
+
type: 'object',
|
|
114
|
+
properties: {
|
|
115
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
116
|
+
},
|
|
117
|
+
required: ['sessionId'],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
}));
|
|
122
|
+
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
123
|
+
const { name, arguments: args = {} } = request.params;
|
|
124
|
+
try {
|
|
125
|
+
switch (name) {
|
|
126
|
+
case 'remember': {
|
|
127
|
+
const entry = await memory.remember(args.sessionId, args.content, args.role || 'user');
|
|
128
|
+
return {
|
|
129
|
+
content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
case 'recall': {
|
|
133
|
+
const entries = await memory.recall(args.sessionId, args.query, args.limit || 10);
|
|
134
|
+
return {
|
|
135
|
+
content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
case 'history': {
|
|
139
|
+
const entries = await memory.history(args.sessionId, args.limit || 20);
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
case 'forget': {
|
|
145
|
+
const options = {};
|
|
146
|
+
if (args.id)
|
|
147
|
+
options.id = args.id;
|
|
148
|
+
if (args.before)
|
|
149
|
+
options.before = new Date(args.before);
|
|
150
|
+
const deleted = await memory.forget(args.sessionId, options);
|
|
151
|
+
return {
|
|
152
|
+
content: [{ type: 'text', text: `Deleted ${deleted} memor${deleted === 1 ? 'y' : 'ies'}.` }],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
case 'stats': {
|
|
156
|
+
const stats = await memory.stats(args.sessionId);
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
default:
|
|
162
|
+
return {
|
|
163
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
164
|
+
isError: true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
return {
|
|
170
|
+
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
171
|
+
isError: true,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
async function main() {
|
|
176
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
177
|
+
await server.connect(transport);
|
|
178
|
+
process.stderr.write('Engram MCP server running\n');
|
|
179
|
+
}
|
|
180
|
+
main().catch((err) => {
|
|
181
|
+
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
});
|
package/engram.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { open, Database as SQLiteDatabase } from 'sqlite';
|
|
3
|
+
|
|
4
|
+
export interface MemoryEntry {
|
|
5
|
+
id: string;
|
|
6
|
+
sessionId: string;
|
|
7
|
+
content: string;
|
|
8
|
+
role: 'user' | 'assistant' | 'system';
|
|
9
|
+
timestamp: Date;
|
|
10
|
+
metadata?: Record<string, unknown>;
|
|
11
|
+
similarity?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RecallOptions {
|
|
15
|
+
limit?: number;
|
|
16
|
+
before?: Date;
|
|
17
|
+
after?: Date;
|
|
18
|
+
role?: 'user' | 'assistant' | 'system';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EngramConfig {
|
|
22
|
+
dbPath?: string;
|
|
23
|
+
maxContextLength?: number;
|
|
24
|
+
embeddingUrl?: string;
|
|
25
|
+
embeddingModel?: string;
|
|
26
|
+
semanticSearch?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Engram - Persistent memory for AI assistants
|
|
31
|
+
*
|
|
32
|
+
* A lightweight, SQLite-backed memory system that gives your AI assistants
|
|
33
|
+
* the ability to remember conversations across sessions.
|
|
34
|
+
* v0.2 adds semantic search via Ollama embeddings (nomic-embed-text).
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* import { Engram } from '@cartisien/engram';
|
|
39
|
+
*
|
|
40
|
+
* const memory = new Engram({ dbPath: './memory.db' });
|
|
41
|
+
*
|
|
42
|
+
* // Store a memory
|
|
43
|
+
* await memory.remember('user_123', 'User prefers dark mode and TypeScript', 'user');
|
|
44
|
+
*
|
|
45
|
+
* // Retrieve relevant memories semantically
|
|
46
|
+
* const context = await memory.recall('user_123', 'What are this user\'s preferences?', 5);
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export class Engram {
|
|
50
|
+
private db!: SQLiteDatabase;
|
|
51
|
+
private maxContextLength: number;
|
|
52
|
+
private dbPath: string;
|
|
53
|
+
private initialized: boolean = false;
|
|
54
|
+
private embeddingUrl: string;
|
|
55
|
+
private embeddingModel: string;
|
|
56
|
+
private semanticSearch: boolean;
|
|
57
|
+
|
|
58
|
+
constructor(config: EngramConfig = {}) {
|
|
59
|
+
this.dbPath = config.dbPath || ':memory:';
|
|
60
|
+
this.maxContextLength = config.maxContextLength || 4000;
|
|
61
|
+
this.embeddingUrl = config.embeddingUrl || 'http://192.168.68.73:11434';
|
|
62
|
+
this.embeddingModel = config.embeddingModel || 'nomic-embed-text';
|
|
63
|
+
this.semanticSearch = config.semanticSearch !== false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async init(): Promise<void> {
|
|
67
|
+
if (this.initialized) return;
|
|
68
|
+
|
|
69
|
+
const sqlite3 = require('sqlite3').verbose();
|
|
70
|
+
const { open } = require('sqlite');
|
|
71
|
+
|
|
72
|
+
this.db = await open({
|
|
73
|
+
filename: this.dbPath,
|
|
74
|
+
driver: sqlite3.Database
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await this.db.exec(`
|
|
78
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
79
|
+
id TEXT PRIMARY KEY,
|
|
80
|
+
session_id TEXT NOT NULL,
|
|
81
|
+
content TEXT NOT NULL,
|
|
82
|
+
role TEXT CHECK(role IN ('user', 'assistant', 'system')),
|
|
83
|
+
timestamp INTEGER NOT NULL,
|
|
84
|
+
metadata TEXT,
|
|
85
|
+
content_hash TEXT NOT NULL,
|
|
86
|
+
embedding TEXT
|
|
87
|
+
);
|
|
88
|
+
`);
|
|
89
|
+
|
|
90
|
+
// Add embedding column if upgrading from v0.1
|
|
91
|
+
try {
|
|
92
|
+
await this.db.exec(`ALTER TABLE memories ADD COLUMN embedding TEXT`);
|
|
93
|
+
} catch {
|
|
94
|
+
// Column already exists, ignore
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await this.db.exec(`
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_session_timestamp
|
|
99
|
+
ON memories(session_id, timestamp DESC);
|
|
100
|
+
`);
|
|
101
|
+
|
|
102
|
+
await this.db.exec(`
|
|
103
|
+
CREATE INDEX IF NOT EXISTS idx_content
|
|
104
|
+
ON memories(content);
|
|
105
|
+
`);
|
|
106
|
+
|
|
107
|
+
this.initialized = true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fetch embedding vector from Ollama
|
|
112
|
+
*/
|
|
113
|
+
private async embed(text: string): Promise<number[] | null> {
|
|
114
|
+
try {
|
|
115
|
+
const response = await fetch(`${this.embeddingUrl}/api/embeddings`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({ model: this.embeddingModel, prompt: text }),
|
|
119
|
+
signal: AbortSignal.timeout(5000)
|
|
120
|
+
});
|
|
121
|
+
if (!response.ok) return null;
|
|
122
|
+
const data = await response.json() as { embedding: number[] };
|
|
123
|
+
return data.embedding ?? null;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Cosine similarity between two vectors
|
|
131
|
+
*/
|
|
132
|
+
private cosineSimilarity(a: number[], b: number[]): number {
|
|
133
|
+
let dot = 0, magA = 0, magB = 0;
|
|
134
|
+
for (let i = 0; i < a.length; i++) {
|
|
135
|
+
dot += a[i] * b[i];
|
|
136
|
+
magA += a[i] * a[i];
|
|
137
|
+
magB += b[i] * b[i];
|
|
138
|
+
}
|
|
139
|
+
const denom = Math.sqrt(magA) * Math.sqrt(magB);
|
|
140
|
+
return denom === 0 ? 0 : dot / denom;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Store a memory entry
|
|
145
|
+
*/
|
|
146
|
+
async remember(
|
|
147
|
+
sessionId: string,
|
|
148
|
+
content: string,
|
|
149
|
+
role: 'user' | 'assistant' | 'system' = 'user',
|
|
150
|
+
metadata?: Record<string, unknown>
|
|
151
|
+
): Promise<MemoryEntry> {
|
|
152
|
+
await this.init();
|
|
153
|
+
|
|
154
|
+
const id = createHash('sha256')
|
|
155
|
+
.update(`${sessionId}:${content}:${Date.now()}`)
|
|
156
|
+
.digest('hex')
|
|
157
|
+
.slice(0, 16);
|
|
158
|
+
|
|
159
|
+
const contentHash = createHash('sha256')
|
|
160
|
+
.update(content)
|
|
161
|
+
.digest('hex')
|
|
162
|
+
.slice(0, 16);
|
|
163
|
+
|
|
164
|
+
const truncated = content.slice(0, this.maxContextLength);
|
|
165
|
+
|
|
166
|
+
// Fetch embedding if semantic search enabled
|
|
167
|
+
let embeddingJson: string | null = null;
|
|
168
|
+
if (this.semanticSearch) {
|
|
169
|
+
const vector = await this.embed(truncated);
|
|
170
|
+
if (vector) embeddingJson = JSON.stringify(vector);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const entry: MemoryEntry = {
|
|
174
|
+
id,
|
|
175
|
+
sessionId,
|
|
176
|
+
content: truncated,
|
|
177
|
+
role,
|
|
178
|
+
timestamp: new Date(),
|
|
179
|
+
metadata
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
await this.db.run(
|
|
183
|
+
`INSERT INTO memories (id, session_id, content, role, timestamp, metadata, content_hash, embedding)
|
|
184
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
185
|
+
[
|
|
186
|
+
entry.id,
|
|
187
|
+
entry.sessionId,
|
|
188
|
+
entry.content,
|
|
189
|
+
entry.role,
|
|
190
|
+
entry.timestamp.getTime(),
|
|
191
|
+
metadata ? JSON.stringify(metadata) : null,
|
|
192
|
+
contentHash,
|
|
193
|
+
embeddingJson
|
|
194
|
+
]
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return entry;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Recall memories for a session.
|
|
202
|
+
*
|
|
203
|
+
* With semantic search enabled (default), embeds the query and ranks
|
|
204
|
+
* results by cosine similarity. Falls back to keyword search if Ollama
|
|
205
|
+
* is unreachable or no embeddings are stored.
|
|
206
|
+
*/
|
|
207
|
+
async recall(
|
|
208
|
+
sessionId: string,
|
|
209
|
+
query?: string,
|
|
210
|
+
limit: number = 10,
|
|
211
|
+
options: RecallOptions = {}
|
|
212
|
+
): Promise<MemoryEntry[]> {
|
|
213
|
+
await this.init();
|
|
214
|
+
|
|
215
|
+
// Build base query
|
|
216
|
+
let sql = `
|
|
217
|
+
SELECT id, session_id, content, role, timestamp, metadata, embedding
|
|
218
|
+
FROM memories
|
|
219
|
+
WHERE session_id = ?
|
|
220
|
+
`;
|
|
221
|
+
const params: (string | number)[] = [sessionId];
|
|
222
|
+
|
|
223
|
+
if (options.role) {
|
|
224
|
+
sql += ` AND role = ?`;
|
|
225
|
+
params.push(options.role);
|
|
226
|
+
}
|
|
227
|
+
if (options.after) {
|
|
228
|
+
sql += ` AND timestamp >= ?`;
|
|
229
|
+
params.push(options.after.getTime());
|
|
230
|
+
}
|
|
231
|
+
if (options.before) {
|
|
232
|
+
sql += ` AND timestamp <= ?`;
|
|
233
|
+
params.push(options.before.getTime());
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Try semantic search
|
|
237
|
+
if (query && query.trim() && this.semanticSearch) {
|
|
238
|
+
const queryVector = await this.embed(query);
|
|
239
|
+
if (queryVector) {
|
|
240
|
+
sql += ` ORDER BY timestamp DESC`;
|
|
241
|
+
const rows = await this.db.all(sql, params);
|
|
242
|
+
|
|
243
|
+
// Score each row by cosine similarity
|
|
244
|
+
const scored = rows
|
|
245
|
+
.map((row: any) => {
|
|
246
|
+
let similarity = 0;
|
|
247
|
+
if (row.embedding) {
|
|
248
|
+
try {
|
|
249
|
+
const vec: number[] = JSON.parse(row.embedding);
|
|
250
|
+
similarity = this.cosineSimilarity(queryVector, vec);
|
|
251
|
+
} catch { /* malformed, skip */ }
|
|
252
|
+
}
|
|
253
|
+
return { row, similarity };
|
|
254
|
+
})
|
|
255
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
256
|
+
.slice(0, limit);
|
|
257
|
+
|
|
258
|
+
return scored.map(({ row, similarity }) => ({
|
|
259
|
+
id: row.id,
|
|
260
|
+
sessionId: row.session_id,
|
|
261
|
+
content: row.content,
|
|
262
|
+
role: row.role,
|
|
263
|
+
timestamp: new Date(row.timestamp),
|
|
264
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
265
|
+
similarity
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
// Ollama unreachable — fall through to keyword search
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Keyword fallback
|
|
272
|
+
if (query && query.trim()) {
|
|
273
|
+
const keywords = query.toLowerCase().split(/\s+/).filter(k => k.length > 2);
|
|
274
|
+
if (keywords.length > 0) {
|
|
275
|
+
sql += ` AND (` + keywords.map(() => `LOWER(content) LIKE ?`).join(' OR ') + `)`;
|
|
276
|
+
params.push(...keywords.map(k => `%${k}%`));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
sql += ` ORDER BY timestamp DESC LIMIT ?`;
|
|
281
|
+
params.push(limit);
|
|
282
|
+
|
|
283
|
+
const rows = await this.db.all(sql, params);
|
|
284
|
+
return rows.map((row: any) => ({
|
|
285
|
+
id: row.id,
|
|
286
|
+
sessionId: row.session_id,
|
|
287
|
+
content: row.content,
|
|
288
|
+
role: row.role,
|
|
289
|
+
timestamp: new Date(row.timestamp),
|
|
290
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get recent conversation history for a session
|
|
296
|
+
*/
|
|
297
|
+
async history(sessionId: string, limit: number = 20): Promise<MemoryEntry[]> {
|
|
298
|
+
return this.recall(sessionId, undefined, limit, {});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Forget (delete) memories
|
|
303
|
+
*/
|
|
304
|
+
async forget(
|
|
305
|
+
sessionId: string,
|
|
306
|
+
options?: { before?: Date; id?: string }
|
|
307
|
+
): Promise<number> {
|
|
308
|
+
await this.init();
|
|
309
|
+
|
|
310
|
+
if (options?.id) {
|
|
311
|
+
const result = await this.db.run(
|
|
312
|
+
'DELETE FROM memories WHERE session_id = ? AND id = ?',
|
|
313
|
+
[sessionId, options.id]
|
|
314
|
+
);
|
|
315
|
+
return result.changes || 0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let sql = 'DELETE FROM memories WHERE session_id = ?';
|
|
319
|
+
const params: (string | number)[] = [sessionId];
|
|
320
|
+
|
|
321
|
+
if (options?.before) {
|
|
322
|
+
sql += ' AND timestamp < ?';
|
|
323
|
+
params.push(options.before.getTime());
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const result = await this.db.run(sql, params);
|
|
327
|
+
return result.changes || 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get memory statistics for a session
|
|
332
|
+
*/
|
|
333
|
+
async stats(sessionId: string): Promise<{
|
|
334
|
+
total: number;
|
|
335
|
+
byRole: Record<string, number>;
|
|
336
|
+
oldest: Date | null;
|
|
337
|
+
newest: Date | null;
|
|
338
|
+
withEmbeddings: number;
|
|
339
|
+
}> {
|
|
340
|
+
await this.init();
|
|
341
|
+
|
|
342
|
+
const totalRow = await this.db.get(
|
|
343
|
+
'SELECT COUNT(*) as count FROM memories WHERE session_id = ?',
|
|
344
|
+
[sessionId]
|
|
345
|
+
);
|
|
346
|
+
const total = totalRow?.count || 0;
|
|
347
|
+
|
|
348
|
+
const roleRows = await this.db.all(
|
|
349
|
+
'SELECT role, COUNT(*) as count FROM memories WHERE session_id = ? GROUP BY role',
|
|
350
|
+
[sessionId]
|
|
351
|
+
);
|
|
352
|
+
const byRole: Record<string, number> = {};
|
|
353
|
+
roleRows.forEach((row: any) => { byRole[row.role] = row.count; });
|
|
354
|
+
|
|
355
|
+
const range = await this.db.get(
|
|
356
|
+
'SELECT MIN(timestamp) as oldest, MAX(timestamp) as newest FROM memories WHERE session_id = ?',
|
|
357
|
+
[sessionId]
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const embRow = await this.db.get(
|
|
361
|
+
'SELECT COUNT(*) as count FROM memories WHERE session_id = ? AND embedding IS NOT NULL',
|
|
362
|
+
[sessionId]
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
total,
|
|
367
|
+
byRole,
|
|
368
|
+
oldest: range?.oldest ? new Date(range.oldest) : null,
|
|
369
|
+
newest: range?.newest ? new Date(range.newest) : null,
|
|
370
|
+
withEmbeddings: embRow?.count || 0
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Close the database connection
|
|
376
|
+
*/
|
|
377
|
+
async close(): Promise<void> {
|
|
378
|
+
if (this.db) {
|
|
379
|
+
await this.db.close();
|
|
380
|
+
this.initialized = false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export default Engram;
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cartisien/engram-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for @cartisien/engram \u2014 persistent semantic memory for AI agents",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"engram-mcp": "dist/src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"start": "node dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
15
|
+
"sqlite": "^5.1.1",
|
|
16
|
+
"sqlite3": "^5.1.7"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"typescript": "^5.0.0",
|
|
20
|
+
"@types/node": "^20.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import {
|
|
5
|
+
CallToolRequestSchema,
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
import { Engram } from '../engram.js';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
|
|
13
|
+
const DB_PATH = process.env.ENGRAM_DB || path.join(os.homedir(), '.engram', 'memory.db');
|
|
14
|
+
const EMBEDDING_URL = process.env.ENGRAM_EMBEDDING_URL || 'http://192.168.68.73:11434';
|
|
15
|
+
|
|
16
|
+
// Ensure DB directory exists
|
|
17
|
+
const dbDir = path.dirname(DB_PATH);
|
|
18
|
+
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
|
|
19
|
+
|
|
20
|
+
const memory = new Engram({
|
|
21
|
+
dbPath: DB_PATH,
|
|
22
|
+
embeddingUrl: EMBEDDING_URL,
|
|
23
|
+
semanticSearch: true,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const server = new Server(
|
|
27
|
+
{ name: 'engram', version: '0.1.0' },
|
|
28
|
+
{ capabilities: { tools: {} } }
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
32
|
+
tools: [
|
|
33
|
+
{
|
|
34
|
+
name: 'remember',
|
|
35
|
+
description: 'Store a memory entry for a session. Embeddings are generated automatically for semantic recall.',
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
sessionId: { type: 'string', description: 'Session identifier (e.g. agent name or user ID)' },
|
|
40
|
+
content: { type: 'string', description: 'The memory content to store' },
|
|
41
|
+
role: { type: 'string', enum: ['user', 'assistant', 'system'], description: 'Role of the memory source', default: 'user' },
|
|
42
|
+
},
|
|
43
|
+
required: ['sessionId', 'content'],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'recall',
|
|
48
|
+
description: 'Retrieve relevant memories using semantic search. Falls back to keyword search if embeddings unavailable.',
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
53
|
+
query: { type: 'string', description: 'Search query — semantic similarity is used' },
|
|
54
|
+
limit: { type: 'number', description: 'Max results to return (default 10)', default: 10 },
|
|
55
|
+
},
|
|
56
|
+
required: ['sessionId', 'query'],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'history',
|
|
61
|
+
description: 'Get recent conversation history for a session in chronological order.',
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
66
|
+
limit: { type: 'number', description: 'Max entries to return (default 20)', default: 20 },
|
|
67
|
+
},
|
|
68
|
+
required: ['sessionId'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'forget',
|
|
73
|
+
description: 'Delete memories for a session. Delete all, one by ID, or entries before a date.',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
78
|
+
id: { type: 'string', description: 'Specific memory ID to delete (optional)' },
|
|
79
|
+
before: { type: 'string', description: 'ISO date string — delete entries before this date (optional)' },
|
|
80
|
+
},
|
|
81
|
+
required: ['sessionId'],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'stats',
|
|
86
|
+
description: 'Get memory statistics for a session.',
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
91
|
+
},
|
|
92
|
+
required: ['sessionId'],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
99
|
+
const { name, arguments: args = {} } = request.params;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
switch (name) {
|
|
103
|
+
case 'remember': {
|
|
104
|
+
const entry = await memory.remember(
|
|
105
|
+
args.sessionId as string,
|
|
106
|
+
args.content as string,
|
|
107
|
+
(args.role as 'user' | 'assistant' | 'system') || 'user'
|
|
108
|
+
);
|
|
109
|
+
return {
|
|
110
|
+
content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
case 'recall': {
|
|
115
|
+
const entries = await memory.recall(
|
|
116
|
+
args.sessionId as string,
|
|
117
|
+
args.query as string,
|
|
118
|
+
(args.limit as number) || 10
|
|
119
|
+
);
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
case 'history': {
|
|
126
|
+
const entries = await memory.history(
|
|
127
|
+
args.sessionId as string,
|
|
128
|
+
(args.limit as number) || 20
|
|
129
|
+
);
|
|
130
|
+
return {
|
|
131
|
+
content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case 'forget': {
|
|
136
|
+
const options: { id?: string; before?: Date } = {};
|
|
137
|
+
if (args.id) options.id = args.id as string;
|
|
138
|
+
if (args.before) options.before = new Date(args.before as string);
|
|
139
|
+
const deleted = await memory.forget(args.sessionId as string, options);
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: 'text', text: `Deleted ${deleted} memor${deleted === 1 ? 'y' : 'ies'}.` }],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case 'stats': {
|
|
146
|
+
const stats = await memory.stats(args.sessionId as string);
|
|
147
|
+
return {
|
|
148
|
+
content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
default:
|
|
153
|
+
return {
|
|
154
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
155
|
+
isError: true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
} catch (err: any) {
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
161
|
+
isError: true,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
async function main() {
|
|
167
|
+
const transport = new StdioServerTransport();
|
|
168
|
+
await server.connect(transport);
|
|
169
|
+
process.stderr.write('Engram MCP server running\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
main().catch((err) => {
|
|
173
|
+
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "CommonJS",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": ".",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": [
|
|
14
|
+
"src/**/*",
|
|
15
|
+
"engram.ts"
|
|
16
|
+
]
|
|
17
|
+
}
|