@cartisien/engram-mcp 0.1.0 → 0.2.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/dist/src/index.js +176 -37
- package/package.json +9 -7
- package/src/index.ts +202 -41
- package/tests/mcp.test.ts +274 -0
- package/tsconfig.test.json +5 -0
package/dist/src/index.js
CHANGED
|
@@ -37,46 +37,54 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
37
37
|
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
|
|
38
38
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
39
39
|
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
40
|
-
const
|
|
40
|
+
const engram_1 = require("@cartisien/engram");
|
|
41
41
|
const os = __importStar(require("os"));
|
|
42
42
|
const path = __importStar(require("path"));
|
|
43
43
|
const fs = __importStar(require("fs"));
|
|
44
44
|
const DB_PATH = process.env.ENGRAM_DB || path.join(os.homedir(), '.engram', 'memory.db');
|
|
45
45
|
const EMBEDDING_URL = process.env.ENGRAM_EMBEDDING_URL || 'http://192.168.68.73:11434';
|
|
46
|
-
|
|
46
|
+
const GRAPH_MODEL = process.env.ENGRAM_GRAPH_MODEL || 'qwen2.5:32b';
|
|
47
|
+
const CONSOLIDATE_MODEL = process.env.ENGRAM_CONSOLIDATE_MODEL || 'qwen2.5:32b';
|
|
47
48
|
const dbDir = path.dirname(DB_PATH);
|
|
48
49
|
if (!fs.existsSync(dbDir))
|
|
49
50
|
fs.mkdirSync(dbDir, { recursive: true });
|
|
50
|
-
const memory = new
|
|
51
|
+
const memory = new engram_1.Engram({
|
|
51
52
|
dbPath: DB_PATH,
|
|
52
53
|
embeddingUrl: EMBEDDING_URL,
|
|
53
54
|
semanticSearch: true,
|
|
55
|
+
graphMemory: process.env.ENGRAM_GRAPH === '1',
|
|
56
|
+
graphModel: GRAPH_MODEL,
|
|
57
|
+
consolidateModel: CONSOLIDATE_MODEL,
|
|
54
58
|
});
|
|
55
|
-
const server = new index_js_1.Server({ name: 'engram', version: '0.
|
|
59
|
+
const server = new index_js_1.Server({ name: 'engram', version: '0.2.0' }, { capabilities: { tools: {} } });
|
|
56
60
|
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
57
61
|
tools: [
|
|
62
|
+
// ── Session memory ──────────────────────────────────────────────────
|
|
58
63
|
{
|
|
59
64
|
name: 'remember',
|
|
60
|
-
description: 'Store a memory entry for a session. Embeddings
|
|
65
|
+
description: 'Store a memory entry for a session. Embeddings and graph extraction happen automatically.',
|
|
61
66
|
inputSchema: {
|
|
62
67
|
type: 'object',
|
|
63
68
|
properties: {
|
|
64
|
-
sessionId: { type: 'string', description: 'Session identifier
|
|
65
|
-
content: { type: 'string', description: '
|
|
66
|
-
role: { type: 'string', enum: ['user', 'assistant', 'system'],
|
|
69
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
70
|
+
content: { type: 'string', description: 'Memory content to store' },
|
|
71
|
+
role: { type: 'string', enum: ['user', 'assistant', 'system'], default: 'user' },
|
|
72
|
+
metadata: { type: 'object', description: 'Optional key-value metadata' },
|
|
67
73
|
},
|
|
68
74
|
required: ['sessionId', 'content'],
|
|
69
75
|
},
|
|
70
76
|
},
|
|
71
77
|
{
|
|
72
78
|
name: 'recall',
|
|
73
|
-
description: 'Retrieve relevant memories
|
|
79
|
+
description: 'Retrieve relevant memories via semantic search (falls back to keyword). Searches working + long_term tiers. Pass userId to blend in cross-session user facts.',
|
|
74
80
|
inputSchema: {
|
|
75
81
|
type: 'object',
|
|
76
82
|
properties: {
|
|
77
83
|
sessionId: { type: 'string', description: 'Session identifier' },
|
|
78
|
-
query: { type: 'string', description: 'Search query
|
|
79
|
-
limit: { type: 'number', description: 'Max results
|
|
84
|
+
query: { type: 'string', description: 'Search query' },
|
|
85
|
+
limit: { type: 'number', description: 'Max results (default 10)', default: 10 },
|
|
86
|
+
userId: { type: 'string', description: 'Optional: also blend in this user\'s cross-session memories' },
|
|
87
|
+
tiers: { type: 'string', description: 'Comma-separated tiers to search: working,long_term,archived (default: working,long_term)' },
|
|
80
88
|
},
|
|
81
89
|
required: ['sessionId', 'query'],
|
|
82
90
|
},
|
|
@@ -88,75 +96,206 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
|
88
96
|
type: 'object',
|
|
89
97
|
properties: {
|
|
90
98
|
sessionId: { type: 'string', description: 'Session identifier' },
|
|
91
|
-
limit: { type: 'number', description: 'Max entries
|
|
99
|
+
limit: { type: 'number', description: 'Max entries (default 20)', default: 20 },
|
|
92
100
|
},
|
|
93
101
|
required: ['sessionId'],
|
|
94
102
|
},
|
|
95
103
|
},
|
|
96
104
|
{
|
|
97
105
|
name: 'forget',
|
|
98
|
-
description: 'Delete memories
|
|
106
|
+
description: 'Delete session memories. Delete all, one by ID, or entries before a date.',
|
|
99
107
|
inputSchema: {
|
|
100
108
|
type: 'object',
|
|
101
109
|
properties: {
|
|
102
110
|
sessionId: { type: 'string', description: 'Session identifier' },
|
|
103
111
|
id: { type: 'string', description: 'Specific memory ID to delete (optional)' },
|
|
104
|
-
before: { type: 'string', description: 'ISO date
|
|
112
|
+
before: { type: 'string', description: 'ISO date — delete entries before this date (optional)' },
|
|
105
113
|
},
|
|
106
114
|
required: ['sessionId'],
|
|
107
115
|
},
|
|
108
116
|
},
|
|
109
117
|
{
|
|
110
118
|
name: 'stats',
|
|
111
|
-
description: '
|
|
119
|
+
description: 'Memory statistics for a session — total, by role, by tier (working/long_term/archived), graph counts.',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
124
|
+
},
|
|
125
|
+
required: ['sessionId'],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'consolidate',
|
|
130
|
+
description: 'Consolidate old working memories into dense long-term summaries via LLM. Archives originals.',
|
|
112
131
|
inputSchema: {
|
|
113
132
|
type: 'object',
|
|
114
133
|
properties: {
|
|
115
134
|
sessionId: { type: 'string', description: 'Session identifier' },
|
|
135
|
+
batch: { type: 'number', description: 'Number of memories to consolidate (default 50)' },
|
|
136
|
+
keep: { type: 'number', description: 'Most recent N to leave untouched (default 20)' },
|
|
137
|
+
dryRun: { type: 'boolean', description: 'Preview summaries without writing (default false)' },
|
|
116
138
|
},
|
|
117
139
|
required: ['sessionId'],
|
|
118
140
|
},
|
|
119
141
|
},
|
|
142
|
+
{
|
|
143
|
+
name: 'graph',
|
|
144
|
+
description: 'Query the knowledge graph for an entity — returns relationships and source memories. Requires ENGRAM_GRAPH=1.',
|
|
145
|
+
inputSchema: {
|
|
146
|
+
type: 'object',
|
|
147
|
+
properties: {
|
|
148
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
149
|
+
entity: { type: 'string', description: 'Entity name to look up (case-insensitive)' },
|
|
150
|
+
},
|
|
151
|
+
required: ['sessionId', 'entity'],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
// ── User-scoped (cross-session) memory ──────────────────────────────
|
|
155
|
+
{
|
|
156
|
+
name: 'remember_user',
|
|
157
|
+
description: 'Store a user-scoped memory that persists across all sessions. Use for preferences, identity, long-term facts.',
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {
|
|
161
|
+
userId: { type: 'string', description: 'User identifier' },
|
|
162
|
+
content: { type: 'string', description: 'Memory content to store' },
|
|
163
|
+
role: { type: 'string', enum: ['user', 'assistant', 'system'], default: 'user' },
|
|
164
|
+
metadata: { type: 'object', description: 'Optional key-value metadata' },
|
|
165
|
+
},
|
|
166
|
+
required: ['userId', 'content'],
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'recall_user',
|
|
171
|
+
description: 'Recall user-scoped memories — works from any session context.',
|
|
172
|
+
inputSchema: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: {
|
|
175
|
+
userId: { type: 'string', description: 'User identifier' },
|
|
176
|
+
query: { type: 'string', description: 'Search query (optional — returns all if omitted)' },
|
|
177
|
+
limit: { type: 'number', description: 'Max results (default 10)', default: 10 },
|
|
178
|
+
},
|
|
179
|
+
required: ['userId'],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: 'forget_user',
|
|
184
|
+
description: 'Delete user-scoped memories.',
|
|
185
|
+
inputSchema: {
|
|
186
|
+
type: 'object',
|
|
187
|
+
properties: {
|
|
188
|
+
userId: { type: 'string', description: 'User identifier' },
|
|
189
|
+
id: { type: 'string', description: 'Specific memory ID (optional)' },
|
|
190
|
+
before: { type: 'string', description: 'ISO date — delete entries before this date (optional)' },
|
|
191
|
+
},
|
|
192
|
+
required: ['userId'],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: 'consolidate_user',
|
|
197
|
+
description: 'Consolidate user-scoped working memories into long-term summaries.',
|
|
198
|
+
inputSchema: {
|
|
199
|
+
type: 'object',
|
|
200
|
+
properties: {
|
|
201
|
+
userId: { type: 'string', description: 'User identifier' },
|
|
202
|
+
batch: { type: 'number', description: 'Number of memories to consolidate (default 50)' },
|
|
203
|
+
keep: { type: 'number', description: 'Most recent N to leave untouched (default 20)' },
|
|
204
|
+
dryRun: { type: 'boolean', description: 'Preview without writing (default false)' },
|
|
205
|
+
},
|
|
206
|
+
required: ['userId'],
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: 'user_stats',
|
|
211
|
+
description: 'Memory statistics for a user — total, by role, by tier.',
|
|
212
|
+
inputSchema: {
|
|
213
|
+
type: 'object',
|
|
214
|
+
properties: {
|
|
215
|
+
userId: { type: 'string', description: 'User identifier' },
|
|
216
|
+
},
|
|
217
|
+
required: ['userId'],
|
|
218
|
+
},
|
|
219
|
+
},
|
|
120
220
|
],
|
|
121
221
|
}));
|
|
122
222
|
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
123
223
|
const { name, arguments: args = {} } = request.params;
|
|
124
224
|
try {
|
|
125
225
|
switch (name) {
|
|
226
|
+
// ── Session ──────────────────────────────────────────────────────
|
|
126
227
|
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
|
-
};
|
|
228
|
+
const entry = await memory.remember(args.sessionId, args.content, args.role || 'user', args.metadata);
|
|
229
|
+
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
|
|
131
230
|
}
|
|
132
231
|
case 'recall': {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
232
|
+
const tiers = args.tiers
|
|
233
|
+
? args.tiers.split(',').map(t => t.trim())
|
|
234
|
+
: undefined;
|
|
235
|
+
const entries = await memory.recall(args.sessionId, args.query, args.limit || 10, {
|
|
236
|
+
tiers,
|
|
237
|
+
userId: args.userId,
|
|
238
|
+
});
|
|
239
|
+
return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
|
|
137
240
|
}
|
|
138
241
|
case 'history': {
|
|
139
242
|
const entries = await memory.history(args.sessionId, args.limit || 20);
|
|
140
|
-
return {
|
|
141
|
-
content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }],
|
|
142
|
-
};
|
|
243
|
+
return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
|
|
143
244
|
}
|
|
144
245
|
case 'forget': {
|
|
145
|
-
const
|
|
246
|
+
const opts = {};
|
|
146
247
|
if (args.id)
|
|
147
|
-
|
|
248
|
+
opts.id = args.id;
|
|
148
249
|
if (args.before)
|
|
149
|
-
|
|
150
|
-
const deleted = await memory.forget(args.sessionId,
|
|
151
|
-
return {
|
|
152
|
-
content: [{ type: 'text', text: `Deleted ${deleted} memor${deleted === 1 ? 'y' : 'ies'}.` }],
|
|
153
|
-
};
|
|
250
|
+
opts.before = new Date(args.before);
|
|
251
|
+
const deleted = await memory.forget(args.sessionId, opts);
|
|
252
|
+
return { content: [{ type: 'text', text: `Deleted ${deleted} memor${deleted === 1 ? 'y' : 'ies'}.` }] };
|
|
154
253
|
}
|
|
155
254
|
case 'stats': {
|
|
156
255
|
const stats = await memory.stats(args.sessionId);
|
|
157
|
-
return {
|
|
158
|
-
|
|
159
|
-
|
|
256
|
+
return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
|
|
257
|
+
}
|
|
258
|
+
case 'consolidate': {
|
|
259
|
+
const result = await memory.consolidate(args.sessionId, {
|
|
260
|
+
batch: args.batch,
|
|
261
|
+
keep: args.keep,
|
|
262
|
+
dryRun: args.dryRun,
|
|
263
|
+
});
|
|
264
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
265
|
+
}
|
|
266
|
+
case 'graph': {
|
|
267
|
+
const result = await memory.graph(args.sessionId, args.entity);
|
|
268
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
269
|
+
}
|
|
270
|
+
// ── User-scoped ───────────────────────────────────────────────────
|
|
271
|
+
case 'remember_user': {
|
|
272
|
+
const entry = await memory.rememberUser(args.userId, args.content, args.role || 'user', args.metadata);
|
|
273
|
+
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
|
|
274
|
+
}
|
|
275
|
+
case 'recall_user': {
|
|
276
|
+
const entries = await memory.recallUser(args.userId, args.query, args.limit || 10);
|
|
277
|
+
return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
|
|
278
|
+
}
|
|
279
|
+
case 'forget_user': {
|
|
280
|
+
const opts = {};
|
|
281
|
+
if (args.id)
|
|
282
|
+
opts.id = args.id;
|
|
283
|
+
if (args.before)
|
|
284
|
+
opts.before = new Date(args.before);
|
|
285
|
+
const deleted = await memory.forgetUser(args.userId, opts);
|
|
286
|
+
return { content: [{ type: 'text', text: `Deleted ${deleted} user memor${deleted === 1 ? 'y' : 'ies'}.` }] };
|
|
287
|
+
}
|
|
288
|
+
case 'consolidate_user': {
|
|
289
|
+
const result = await memory.consolidateUser(args.userId, {
|
|
290
|
+
batch: args.batch,
|
|
291
|
+
keep: args.keep,
|
|
292
|
+
dryRun: args.dryRun,
|
|
293
|
+
});
|
|
294
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
295
|
+
}
|
|
296
|
+
case 'user_stats': {
|
|
297
|
+
const stats = await memory.userStats(args.userId);
|
|
298
|
+
return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
|
|
160
299
|
}
|
|
161
300
|
default:
|
|
162
301
|
return {
|
|
@@ -175,7 +314,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
175
314
|
async function main() {
|
|
176
315
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
177
316
|
await server.connect(transport);
|
|
178
|
-
process.stderr.write('Engram MCP server running\n');
|
|
317
|
+
process.stderr.write('Engram MCP server v0.2.0 running\n');
|
|
179
318
|
}
|
|
180
319
|
main().catch((err) => {
|
|
181
320
|
process.stderr.write(`Fatal: ${err.message}\n`);
|
package/package.json
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cartisien/engram-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for @cartisien/engram
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server for @cartisien/engram — persistent semantic memory for AI agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/src/index.js",
|
|
6
7
|
"bin": {
|
|
7
8
|
"engram-mcp": "dist/src/index.js"
|
|
8
9
|
},
|
|
9
10
|
"scripts": {
|
|
10
11
|
"build": "tsc",
|
|
11
|
-
"start": "node dist/index.js"
|
|
12
|
+
"start": "node dist/src/index.js"
|
|
12
13
|
},
|
|
13
14
|
"dependencies": {
|
|
15
|
+
"@cartisien/engram": "^0.6.1",
|
|
14
16
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
15
|
-
"sqlite": "^5.1.1",
|
|
16
17
|
"sqlite3": "^5.1.7"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
20
|
+
"@types/node": "^20.0.0",
|
|
19
21
|
"typescript": "^5.0.0",
|
|
20
|
-
"
|
|
22
|
+
"vitest": "^2.0.0"
|
|
21
23
|
}
|
|
22
|
-
}
|
|
24
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,15 +5,16 @@ import {
|
|
|
5
5
|
CallToolRequestSchema,
|
|
6
6
|
ListToolsRequestSchema,
|
|
7
7
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
-
import { Engram } from '
|
|
8
|
+
import { Engram } from '@cartisien/engram';
|
|
9
9
|
import * as os from 'os';
|
|
10
10
|
import * as path from 'path';
|
|
11
11
|
import * as fs from 'fs';
|
|
12
12
|
|
|
13
13
|
const DB_PATH = process.env.ENGRAM_DB || path.join(os.homedir(), '.engram', 'memory.db');
|
|
14
14
|
const EMBEDDING_URL = process.env.ENGRAM_EMBEDDING_URL || 'http://192.168.68.73:11434';
|
|
15
|
+
const GRAPH_MODEL = process.env.ENGRAM_GRAPH_MODEL || 'qwen2.5:32b';
|
|
16
|
+
const CONSOLIDATE_MODEL = process.env.ENGRAM_CONSOLIDATE_MODEL || 'qwen2.5:32b';
|
|
15
17
|
|
|
16
|
-
// Ensure DB directory exists
|
|
17
18
|
const dbDir = path.dirname(DB_PATH);
|
|
18
19
|
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
|
|
19
20
|
|
|
@@ -21,37 +22,44 @@ const memory = new Engram({
|
|
|
21
22
|
dbPath: DB_PATH,
|
|
22
23
|
embeddingUrl: EMBEDDING_URL,
|
|
23
24
|
semanticSearch: true,
|
|
25
|
+
graphMemory: process.env.ENGRAM_GRAPH === '1',
|
|
26
|
+
graphModel: GRAPH_MODEL,
|
|
27
|
+
consolidateModel: CONSOLIDATE_MODEL,
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
const server = new Server(
|
|
27
|
-
{ name: 'engram', version: '0.
|
|
31
|
+
{ name: 'engram', version: '0.2.0' },
|
|
28
32
|
{ capabilities: { tools: {} } }
|
|
29
33
|
);
|
|
30
34
|
|
|
31
35
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
32
36
|
tools: [
|
|
37
|
+
// ── Session memory ──────────────────────────────────────────────────
|
|
33
38
|
{
|
|
34
39
|
name: 'remember',
|
|
35
|
-
description: 'Store a memory entry for a session. Embeddings
|
|
40
|
+
description: 'Store a memory entry for a session. Embeddings and graph extraction happen automatically.',
|
|
36
41
|
inputSchema: {
|
|
37
42
|
type: 'object',
|
|
38
43
|
properties: {
|
|
39
|
-
sessionId:
|
|
40
|
-
content:
|
|
41
|
-
role:
|
|
44
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
45
|
+
content: { type: 'string', description: 'Memory content to store' },
|
|
46
|
+
role: { type: 'string', enum: ['user', 'assistant', 'system'], default: 'user' },
|
|
47
|
+
metadata: { type: 'object', description: 'Optional key-value metadata' },
|
|
42
48
|
},
|
|
43
49
|
required: ['sessionId', 'content'],
|
|
44
50
|
},
|
|
45
51
|
},
|
|
46
52
|
{
|
|
47
53
|
name: 'recall',
|
|
48
|
-
description: 'Retrieve relevant memories
|
|
54
|
+
description: 'Retrieve relevant memories via semantic search (falls back to keyword). Searches working + long_term tiers. Pass userId to blend in cross-session user facts.',
|
|
49
55
|
inputSchema: {
|
|
50
56
|
type: 'object',
|
|
51
57
|
properties: {
|
|
52
|
-
sessionId:
|
|
53
|
-
query:
|
|
54
|
-
limit:
|
|
58
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
59
|
+
query: { type: 'string', description: 'Search query' },
|
|
60
|
+
limit: { type: 'number', description: 'Max results (default 10)', default: 10 },
|
|
61
|
+
userId: { type: 'string', description: 'Optional: also blend in this user\'s cross-session memories' },
|
|
62
|
+
tiers: { type: 'string', description: 'Comma-separated tiers to search: working,long_term,archived (default: working,long_term)' },
|
|
55
63
|
},
|
|
56
64
|
required: ['sessionId', 'query'],
|
|
57
65
|
},
|
|
@@ -62,36 +70,128 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
62
70
|
inputSchema: {
|
|
63
71
|
type: 'object',
|
|
64
72
|
properties: {
|
|
65
|
-
sessionId:
|
|
66
|
-
limit:
|
|
73
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
74
|
+
limit: { type: 'number', description: 'Max entries (default 20)', default: 20 },
|
|
67
75
|
},
|
|
68
76
|
required: ['sessionId'],
|
|
69
77
|
},
|
|
70
78
|
},
|
|
71
79
|
{
|
|
72
80
|
name: 'forget',
|
|
73
|
-
description: 'Delete memories
|
|
81
|
+
description: 'Delete session memories. Delete all, one by ID, or entries before a date.',
|
|
74
82
|
inputSchema: {
|
|
75
83
|
type: 'object',
|
|
76
84
|
properties: {
|
|
77
|
-
sessionId:
|
|
78
|
-
id:
|
|
79
|
-
before:
|
|
85
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
86
|
+
id: { type: 'string', description: 'Specific memory ID to delete (optional)' },
|
|
87
|
+
before: { type: 'string', description: 'ISO date — delete entries before this date (optional)' },
|
|
80
88
|
},
|
|
81
89
|
required: ['sessionId'],
|
|
82
90
|
},
|
|
83
91
|
},
|
|
84
92
|
{
|
|
85
93
|
name: 'stats',
|
|
86
|
-
description: '
|
|
94
|
+
description: 'Memory statistics for a session — total, by role, by tier (working/long_term/archived), graph counts.',
|
|
87
95
|
inputSchema: {
|
|
88
96
|
type: 'object',
|
|
89
97
|
properties: {
|
|
90
|
-
sessionId:
|
|
98
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
91
99
|
},
|
|
92
100
|
required: ['sessionId'],
|
|
93
101
|
},
|
|
94
102
|
},
|
|
103
|
+
{
|
|
104
|
+
name: 'consolidate',
|
|
105
|
+
description: 'Consolidate old working memories into dense long-term summaries via LLM. Archives originals.',
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
110
|
+
batch: { type: 'number', description: 'Number of memories to consolidate (default 50)' },
|
|
111
|
+
keep: { type: 'number', description: 'Most recent N to leave untouched (default 20)' },
|
|
112
|
+
dryRun: { type: 'boolean', description: 'Preview summaries without writing (default false)' },
|
|
113
|
+
},
|
|
114
|
+
required: ['sessionId'],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'graph',
|
|
119
|
+
description: 'Query the knowledge graph for an entity — returns relationships and source memories. Requires ENGRAM_GRAPH=1.',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
sessionId: { type: 'string', description: 'Session identifier' },
|
|
124
|
+
entity: { type: 'string', description: 'Entity name to look up (case-insensitive)' },
|
|
125
|
+
},
|
|
126
|
+
required: ['sessionId', 'entity'],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
// ── User-scoped (cross-session) memory ──────────────────────────────
|
|
130
|
+
{
|
|
131
|
+
name: 'remember_user',
|
|
132
|
+
description: 'Store a user-scoped memory that persists across all sessions. Use for preferences, identity, long-term facts.',
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
userId: { type: 'string', description: 'User identifier' },
|
|
137
|
+
content: { type: 'string', description: 'Memory content to store' },
|
|
138
|
+
role: { type: 'string', enum: ['user', 'assistant', 'system'], default: 'user' },
|
|
139
|
+
metadata: { type: 'object', description: 'Optional key-value metadata' },
|
|
140
|
+
},
|
|
141
|
+
required: ['userId', 'content'],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'recall_user',
|
|
146
|
+
description: 'Recall user-scoped memories — works from any session context.',
|
|
147
|
+
inputSchema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
userId: { type: 'string', description: 'User identifier' },
|
|
151
|
+
query: { type: 'string', description: 'Search query (optional — returns all if omitted)' },
|
|
152
|
+
limit: { type: 'number', description: 'Max results (default 10)', default: 10 },
|
|
153
|
+
},
|
|
154
|
+
required: ['userId'],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: 'forget_user',
|
|
159
|
+
description: 'Delete user-scoped memories.',
|
|
160
|
+
inputSchema: {
|
|
161
|
+
type: 'object',
|
|
162
|
+
properties: {
|
|
163
|
+
userId: { type: 'string', description: 'User identifier' },
|
|
164
|
+
id: { type: 'string', description: 'Specific memory ID (optional)' },
|
|
165
|
+
before: { type: 'string', description: 'ISO date — delete entries before this date (optional)' },
|
|
166
|
+
},
|
|
167
|
+
required: ['userId'],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: 'consolidate_user',
|
|
172
|
+
description: 'Consolidate user-scoped working memories into long-term summaries.',
|
|
173
|
+
inputSchema: {
|
|
174
|
+
type: 'object',
|
|
175
|
+
properties: {
|
|
176
|
+
userId: { type: 'string', description: 'User identifier' },
|
|
177
|
+
batch: { type: 'number', description: 'Number of memories to consolidate (default 50)' },
|
|
178
|
+
keep: { type: 'number', description: 'Most recent N to leave untouched (default 20)' },
|
|
179
|
+
dryRun: { type: 'boolean', description: 'Preview without writing (default false)' },
|
|
180
|
+
},
|
|
181
|
+
required: ['userId'],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'user_stats',
|
|
186
|
+
description: 'Memory statistics for a user — total, by role, by tier.',
|
|
187
|
+
inputSchema: {
|
|
188
|
+
type: 'object',
|
|
189
|
+
properties: {
|
|
190
|
+
userId: { type: 'string', description: 'User identifier' },
|
|
191
|
+
},
|
|
192
|
+
required: ['userId'],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
95
195
|
],
|
|
96
196
|
}));
|
|
97
197
|
|
|
@@ -100,26 +200,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
100
200
|
|
|
101
201
|
try {
|
|
102
202
|
switch (name) {
|
|
203
|
+
|
|
204
|
+
// ── Session ──────────────────────────────────────────────────────
|
|
205
|
+
|
|
103
206
|
case 'remember': {
|
|
104
207
|
const entry = await memory.remember(
|
|
105
208
|
args.sessionId as string,
|
|
106
209
|
args.content as string,
|
|
107
|
-
(args.role as 'user' | 'assistant' | 'system') || 'user'
|
|
210
|
+
(args.role as 'user' | 'assistant' | 'system') || 'user',
|
|
211
|
+
args.metadata as Record<string, unknown> | undefined
|
|
108
212
|
);
|
|
109
|
-
return {
|
|
110
|
-
content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }],
|
|
111
|
-
};
|
|
213
|
+
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
|
|
112
214
|
}
|
|
113
215
|
|
|
114
216
|
case 'recall': {
|
|
217
|
+
const tiers = args.tiers
|
|
218
|
+
? (args.tiers as string).split(',').map(t => t.trim()) as any
|
|
219
|
+
: undefined;
|
|
115
220
|
const entries = await memory.recall(
|
|
116
221
|
args.sessionId as string,
|
|
117
222
|
args.query as string,
|
|
118
|
-
(args.limit as number) || 10
|
|
223
|
+
(args.limit as number) || 10,
|
|
224
|
+
{
|
|
225
|
+
tiers,
|
|
226
|
+
userId: args.userId as string | undefined,
|
|
227
|
+
}
|
|
119
228
|
);
|
|
120
|
-
return {
|
|
121
|
-
content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }],
|
|
122
|
-
};
|
|
229
|
+
return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
|
|
123
230
|
}
|
|
124
231
|
|
|
125
232
|
case 'history': {
|
|
@@ -127,26 +234,80 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
127
234
|
args.sessionId as string,
|
|
128
235
|
(args.limit as number) || 20
|
|
129
236
|
);
|
|
130
|
-
return {
|
|
131
|
-
content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }],
|
|
132
|
-
};
|
|
237
|
+
return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
|
|
133
238
|
}
|
|
134
239
|
|
|
135
240
|
case 'forget': {
|
|
136
|
-
const
|
|
137
|
-
if (args.id)
|
|
138
|
-
if (args.before)
|
|
139
|
-
const deleted = await memory.forget(args.sessionId as string,
|
|
140
|
-
return {
|
|
141
|
-
content: [{ type: 'text', text: `Deleted ${deleted} memor${deleted === 1 ? 'y' : 'ies'}.` }],
|
|
142
|
-
};
|
|
241
|
+
const opts: Parameters<typeof memory.forget>[1] = {};
|
|
242
|
+
if (args.id) opts.id = args.id as string;
|
|
243
|
+
if (args.before) opts.before = new Date(args.before as string);
|
|
244
|
+
const deleted = await memory.forget(args.sessionId as string, opts);
|
|
245
|
+
return { content: [{ type: 'text', text: `Deleted ${deleted} memor${deleted === 1 ? 'y' : 'ies'}.` }] };
|
|
143
246
|
}
|
|
144
247
|
|
|
145
248
|
case 'stats': {
|
|
146
249
|
const stats = await memory.stats(args.sessionId as string);
|
|
147
|
-
return {
|
|
148
|
-
|
|
149
|
-
|
|
250
|
+
return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
case 'consolidate': {
|
|
254
|
+
const result = await memory.consolidate(args.sessionId as string, {
|
|
255
|
+
batch: args.batch as number | undefined,
|
|
256
|
+
keep: args.keep as number | undefined,
|
|
257
|
+
dryRun: args.dryRun as boolean | undefined,
|
|
258
|
+
});
|
|
259
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
case 'graph': {
|
|
263
|
+
const result = await memory.graph(
|
|
264
|
+
args.sessionId as string,
|
|
265
|
+
args.entity as string
|
|
266
|
+
);
|
|
267
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── User-scoped ───────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
case 'remember_user': {
|
|
273
|
+
const entry = await memory.rememberUser(
|
|
274
|
+
args.userId as string,
|
|
275
|
+
args.content as string,
|
|
276
|
+
(args.role as 'user' | 'assistant' | 'system') || 'user',
|
|
277
|
+
args.metadata as Record<string, unknown> | undefined
|
|
278
|
+
);
|
|
279
|
+
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
case 'recall_user': {
|
|
283
|
+
const entries = await memory.recallUser(
|
|
284
|
+
args.userId as string,
|
|
285
|
+
args.query as string | undefined,
|
|
286
|
+
(args.limit as number) || 10
|
|
287
|
+
);
|
|
288
|
+
return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case 'forget_user': {
|
|
292
|
+
const opts: Parameters<typeof memory.forgetUser>[1] = {};
|
|
293
|
+
if (args.id) opts.id = args.id as string;
|
|
294
|
+
if (args.before) opts.before = new Date(args.before as string);
|
|
295
|
+
const deleted = await memory.forgetUser(args.userId as string, opts);
|
|
296
|
+
return { content: [{ type: 'text', text: `Deleted ${deleted} user memor${deleted === 1 ? 'y' : 'ies'}.` }] };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case 'consolidate_user': {
|
|
300
|
+
const result = await memory.consolidateUser(args.userId as string, {
|
|
301
|
+
batch: args.batch as number | undefined,
|
|
302
|
+
keep: args.keep as number | undefined,
|
|
303
|
+
dryRun: args.dryRun as boolean | undefined,
|
|
304
|
+
});
|
|
305
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
case 'user_stats': {
|
|
309
|
+
const stats = await memory.userStats(args.userId as string);
|
|
310
|
+
return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
|
|
150
311
|
}
|
|
151
312
|
|
|
152
313
|
default:
|
|
@@ -166,7 +327,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
166
327
|
async function main() {
|
|
167
328
|
const transport = new StdioServerTransport();
|
|
168
329
|
await server.connect(transport);
|
|
169
|
-
process.stderr.write('Engram MCP server running\n');
|
|
330
|
+
process.stderr.write('Engram MCP server v0.2.0 running\n');
|
|
170
331
|
}
|
|
171
332
|
|
|
172
333
|
main().catch((err) => {
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server tool coverage tests.
|
|
3
|
+
* We test the tool definitions (schema) and the handler dispatch logic
|
|
4
|
+
* by exercising the Engram SDK directly — the same code paths the MCP
|
|
5
|
+
* server uses. This avoids needing a running MCP transport in CI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import { Engram } from '@cartisien/engram';
|
|
10
|
+
|
|
11
|
+
// Mirror the handler logic so we can test every case without transport
|
|
12
|
+
async function dispatch(memory: Engram, name: string, args: Record<string, unknown>) {
|
|
13
|
+
switch (name) {
|
|
14
|
+
case 'remember':
|
|
15
|
+
return memory.remember(
|
|
16
|
+
args.sessionId as string,
|
|
17
|
+
args.content as string,
|
|
18
|
+
(args.role as any) || 'user',
|
|
19
|
+
args.metadata as any
|
|
20
|
+
);
|
|
21
|
+
case 'recall':
|
|
22
|
+
return memory.recall(
|
|
23
|
+
args.sessionId as string,
|
|
24
|
+
args.query as string,
|
|
25
|
+
(args.limit as number) || 10,
|
|
26
|
+
{ tiers: args.tiers ? (args.tiers as string).split(',') as any : undefined,
|
|
27
|
+
userId: args.userId as string | undefined }
|
|
28
|
+
);
|
|
29
|
+
case 'history':
|
|
30
|
+
return memory.history(args.sessionId as string, (args.limit as number) || 20);
|
|
31
|
+
case 'forget': {
|
|
32
|
+
const opts: any = {};
|
|
33
|
+
if (args.id) opts.id = args.id;
|
|
34
|
+
if (args.before) opts.before = new Date(args.before as string);
|
|
35
|
+
return memory.forget(args.sessionId as string, opts);
|
|
36
|
+
}
|
|
37
|
+
case 'stats':
|
|
38
|
+
return memory.stats(args.sessionId as string);
|
|
39
|
+
case 'consolidate':
|
|
40
|
+
return memory.consolidate(args.sessionId as string, {
|
|
41
|
+
batch: args.batch as any, keep: args.keep as any, dryRun: args.dryRun as any
|
|
42
|
+
});
|
|
43
|
+
case 'graph':
|
|
44
|
+
return memory.graph(args.sessionId as string, args.entity as string);
|
|
45
|
+
case 'remember_user':
|
|
46
|
+
return memory.rememberUser(args.userId as string, args.content as string, (args.role as any) || 'user', args.metadata as any);
|
|
47
|
+
case 'recall_user':
|
|
48
|
+
return memory.recallUser(args.userId as string, args.query as string | undefined, (args.limit as number) || 10);
|
|
49
|
+
case 'forget_user': {
|
|
50
|
+
const opts: any = {};
|
|
51
|
+
if (args.id) opts.id = args.id;
|
|
52
|
+
if (args.before) opts.before = new Date(args.before as string);
|
|
53
|
+
return memory.forgetUser(args.userId as string, opts);
|
|
54
|
+
}
|
|
55
|
+
case 'consolidate_user':
|
|
56
|
+
return memory.consolidateUser(args.userId as string, {
|
|
57
|
+
batch: args.batch as any, keep: args.keep as any, dryRun: args.dryRun as any
|
|
58
|
+
});
|
|
59
|
+
case 'user_stats':
|
|
60
|
+
return memory.userStats(args.userId as string);
|
|
61
|
+
default:
|
|
62
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('MCP tool handlers — all 12 tools', () => {
|
|
67
|
+
let memory: Engram;
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
memory = new Engram({ dbPath: ':memory:', semanticSearch: false });
|
|
71
|
+
});
|
|
72
|
+
afterEach(async () => { await memory.close(); });
|
|
73
|
+
|
|
74
|
+
// ── remember ─────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
it('remember: stores entry and returns id', async () => {
|
|
77
|
+
const entry: any = await dispatch(memory, 'remember', {
|
|
78
|
+
sessionId: 's1', content: 'Test fact', role: 'user'
|
|
79
|
+
});
|
|
80
|
+
expect(entry.id).toBeTruthy();
|
|
81
|
+
expect(entry.content).toBe('Test fact');
|
|
82
|
+
expect(entry.tier).toBe('working');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('remember: stores with metadata', async () => {
|
|
86
|
+
const entry: any = await dispatch(memory, 'remember', {
|
|
87
|
+
sessionId: 's1', content: 'Fact with meta', metadata: { source: 'test' }
|
|
88
|
+
});
|
|
89
|
+
expect(entry.id).toBeTruthy();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── recall ────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
it('recall: returns stored memories', async () => {
|
|
95
|
+
await dispatch(memory, 'remember', { sessionId: 's1', content: 'GovScout uses React' });
|
|
96
|
+
const results: any = await dispatch(memory, 'recall', { sessionId: 's1', query: 'GovScout' });
|
|
97
|
+
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
98
|
+
expect(results[0].content).toContain('GovScout');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('recall: returns empty for unknown session', async () => {
|
|
102
|
+
const results: any = await dispatch(memory, 'recall', { sessionId: 'nobody', query: 'anything' });
|
|
103
|
+
expect(results).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('recall: blends user memories when userId provided', async () => {
|
|
107
|
+
await dispatch(memory, 'remember', { sessionId: 's1', content: 'Session fact' });
|
|
108
|
+
await dispatch(memory, 'remember_user', { userId: 'u1', content: 'User fact' });
|
|
109
|
+
const results: any = await dispatch(memory, 'recall', { sessionId: 's1', query: 'fact', userId: 'u1' });
|
|
110
|
+
const contents = results.map((r: any) => r.content);
|
|
111
|
+
expect(contents).toContain('Session fact');
|
|
112
|
+
expect(contents).toContain('User fact');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('recall: tier filter works', async () => {
|
|
116
|
+
await dispatch(memory, 'remember', { sessionId: 's1', content: 'Working tier fact' });
|
|
117
|
+
const results: any = await dispatch(memory, 'recall', {
|
|
118
|
+
sessionId: 's1', query: 'fact', tiers: 'working'
|
|
119
|
+
});
|
|
120
|
+
expect(results.every((r: any) => r.tier === 'working')).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── history ───────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
it('history: returns chronological entries', async () => {
|
|
126
|
+
await dispatch(memory, 'remember', { sessionId: 's1', content: 'First' });
|
|
127
|
+
await new Promise(r => setTimeout(r, 5));
|
|
128
|
+
await dispatch(memory, 'remember', { sessionId: 's1', content: 'Second' });
|
|
129
|
+
const entries: any = await dispatch(memory, 'history', { sessionId: 's1' });
|
|
130
|
+
expect(entries[0].content).toBe('First');
|
|
131
|
+
expect(entries[1].content).toBe('Second');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── forget ────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
it('forget: deletes all session memories', async () => {
|
|
137
|
+
await dispatch(memory, 'remember', { sessionId: 's1', content: 'To delete' });
|
|
138
|
+
const deleted: any = await dispatch(memory, 'forget', { sessionId: 's1' });
|
|
139
|
+
expect(deleted).toBe(1);
|
|
140
|
+
const results: any = await dispatch(memory, 'recall', { sessionId: 's1', query: 'delete' });
|
|
141
|
+
expect(results).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('forget: deletes by id', async () => {
|
|
145
|
+
const e: any = await dispatch(memory, 'remember', { sessionId: 's1', content: 'Target' });
|
|
146
|
+
await dispatch(memory, 'remember', { sessionId: 's1', content: 'Keep' });
|
|
147
|
+
const deleted: any = await dispatch(memory, 'forget', { sessionId: 's1', id: e.id });
|
|
148
|
+
expect(deleted).toBe(1);
|
|
149
|
+
const left: any = await dispatch(memory, 'history', { sessionId: 's1' });
|
|
150
|
+
expect(left.map((r: any) => r.content)).not.toContain('Target');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── stats ─────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
it('stats: returns total, byRole, byTier', async () => {
|
|
156
|
+
await dispatch(memory, 'remember', { sessionId: 's1', content: 'f1', role: 'user' });
|
|
157
|
+
await dispatch(memory, 'remember', { sessionId: 's1', content: 'f2', role: 'assistant' });
|
|
158
|
+
const stats: any = await dispatch(memory, 'stats', { sessionId: 's1' });
|
|
159
|
+
expect(stats.total).toBe(2);
|
|
160
|
+
expect(stats.byRole.user).toBe(1);
|
|
161
|
+
expect(stats.byRole.assistant).toBe(1);
|
|
162
|
+
expect(stats.byTier.working).toBe(2);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── consolidate ───────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
it('consolidate: dry run returns summarized count', async () => {
|
|
168
|
+
const result: any = await dispatch(memory, 'consolidate', {
|
|
169
|
+
sessionId: 's1', dryRun: true
|
|
170
|
+
});
|
|
171
|
+
expect(typeof result.summarized).toBe('number');
|
|
172
|
+
expect(result.created).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('consolidate: returns zeros for empty session', async () => {
|
|
176
|
+
const result: any = await dispatch(memory, 'consolidate', { sessionId: 'empty' });
|
|
177
|
+
expect(result.summarized).toBe(0);
|
|
178
|
+
expect(result.created).toBe(0);
|
|
179
|
+
expect(result.archived).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── graph ─────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
it('graph: returns entity + relationships + memories', async () => {
|
|
185
|
+
const result: any = await dispatch(memory, 'graph', {
|
|
186
|
+
sessionId: 's1', entity: 'govscout'
|
|
187
|
+
});
|
|
188
|
+
expect(result.entity).toBe('govscout');
|
|
189
|
+
expect(Array.isArray(result.relationships)).toBe(true);
|
|
190
|
+
expect(Array.isArray(result.relatedMemories)).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── remember_user ─────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
it('remember_user: stores and returns user entry', async () => {
|
|
196
|
+
const entry: any = await dispatch(memory, 'remember_user', {
|
|
197
|
+
userId: 'u1', content: 'Prefers TypeScript'
|
|
198
|
+
});
|
|
199
|
+
expect(entry.userId).toBe('u1');
|
|
200
|
+
expect(entry.content).toBe('Prefers TypeScript');
|
|
201
|
+
expect(entry.tier).toBe('working');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ── recall_user ───────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
it('recall_user: retrieves user memories', async () => {
|
|
207
|
+
await dispatch(memory, 'remember_user', { userId: 'u1', content: 'Loves dark mode' });
|
|
208
|
+
const results: any = await dispatch(memory, 'recall_user', { userId: 'u1' });
|
|
209
|
+
expect(results.length).toBe(1);
|
|
210
|
+
expect(results[0].content).toBe('Loves dark mode');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('recall_user: isolated between users', async () => {
|
|
214
|
+
await dispatch(memory, 'remember_user', { userId: 'u1', content: 'U1 fact' });
|
|
215
|
+
await dispatch(memory, 'remember_user', { userId: 'u2', content: 'U2 fact' });
|
|
216
|
+
const u1: any = await dispatch(memory, 'recall_user', { userId: 'u1' });
|
|
217
|
+
expect(u1.map((r: any) => r.content)).not.toContain('U2 fact');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('recall_user: empty for unknown user', async () => {
|
|
221
|
+
const results: any = await dispatch(memory, 'recall_user', { userId: 'nobody' });
|
|
222
|
+
expect(results).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ── forget_user ───────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
it('forget_user: deletes all user memories', async () => {
|
|
228
|
+
await dispatch(memory, 'remember_user', { userId: 'u1', content: 'To delete' });
|
|
229
|
+
const deleted: any = await dispatch(memory, 'forget_user', { userId: 'u1' });
|
|
230
|
+
expect(deleted).toBe(1);
|
|
231
|
+
const left: any = await dispatch(memory, 'recall_user', { userId: 'u1' });
|
|
232
|
+
expect(left).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('forget_user: deletes by id', async () => {
|
|
236
|
+
const e: any = await dispatch(memory, 'remember_user', { userId: 'u1', content: 'Target' });
|
|
237
|
+
await dispatch(memory, 'remember_user', { userId: 'u1', content: 'Keep' });
|
|
238
|
+
await dispatch(memory, 'forget_user', { userId: 'u1', id: e.id });
|
|
239
|
+
const left: any = await dispatch(memory, 'recall_user', { userId: 'u1' });
|
|
240
|
+
expect(left.map((r: any) => r.content)).not.toContain('Target');
|
|
241
|
+
expect(left.map((r: any) => r.content)).toContain('Keep');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ── consolidate_user ──────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
it('consolidate_user: dry run on empty user', async () => {
|
|
247
|
+
const result: any = await dispatch(memory, 'consolidate_user', {
|
|
248
|
+
userId: 'nobody', dryRun: true
|
|
249
|
+
});
|
|
250
|
+
expect(result.summarized).toBe(0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ── user_stats ────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
it('user_stats: returns counts', async () => {
|
|
256
|
+
await dispatch(memory, 'remember_user', { userId: 'u1', content: 'f1', role: 'user' });
|
|
257
|
+
await dispatch(memory, 'remember_user', { userId: 'u1', content: 'f2', role: 'assistant' });
|
|
258
|
+
const stats: any = await dispatch(memory, 'user_stats', { userId: 'u1' });
|
|
259
|
+
expect(stats.total).toBe(2);
|
|
260
|
+
expect(stats.byTier.working).toBe(2);
|
|
261
|
+
expect(stats.byRole.user).toBe(1);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('user_stats: zeros for unknown user', async () => {
|
|
265
|
+
const stats: any = await dispatch(memory, 'user_stats', { userId: 'nobody' });
|
|
266
|
+
expect(stats.total).toBe(0);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ── unknown tool ──────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
it('unknown tool: throws', async () => {
|
|
272
|
+
await expect(dispatch(memory, 'nonexistent', {})).rejects.toThrow('Unknown tool');
|
|
273
|
+
});
|
|
274
|
+
});
|