engram-mcp-server 1.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.
Files changed (44) hide show
  1. package/README.md +645 -0
  2. package/dist/constants.d.ts +21 -0
  3. package/dist/constants.js +81 -0
  4. package/dist/database.d.ts +30 -0
  5. package/dist/database.js +134 -0
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.js +67 -0
  8. package/dist/migrations.d.ts +4 -0
  9. package/dist/migrations.js +342 -0
  10. package/dist/scripts/install-hooks.d.ts +3 -0
  11. package/dist/scripts/install-hooks.js +89 -0
  12. package/dist/tools/intelligence.d.ts +3 -0
  13. package/dist/tools/intelligence.js +427 -0
  14. package/dist/tools/maintenance.d.ts +3 -0
  15. package/dist/tools/maintenance.js +646 -0
  16. package/dist/tools/memory.d.ts +3 -0
  17. package/dist/tools/memory.js +446 -0
  18. package/dist/tools/scheduler.d.ts +3 -0
  19. package/dist/tools/scheduler.js +363 -0
  20. package/dist/tools/sessions.d.ts +3 -0
  21. package/dist/tools/sessions.js +355 -0
  22. package/dist/tools/tasks.d.ts +3 -0
  23. package/dist/tools/tasks.js +206 -0
  24. package/dist/types.d.ts +170 -0
  25. package/dist/types.js +5 -0
  26. package/dist/utils.d.ts +58 -0
  27. package/dist/utils.js +190 -0
  28. package/docs/scheduled-events.md +150 -0
  29. package/package.json +43 -0
  30. package/scripts/install-mcp.js +175 -0
  31. package/src/constants.ts +86 -0
  32. package/src/database.ts +162 -0
  33. package/src/index.ts +79 -0
  34. package/src/migrations.ts +367 -0
  35. package/src/scripts/install-hooks.ts +96 -0
  36. package/src/tools/intelligence.ts +469 -0
  37. package/src/tools/maintenance.ts +783 -0
  38. package/src/tools/memory.ts +543 -0
  39. package/src/tools/scheduler.ts +413 -0
  40. package/src/tools/sessions.ts +430 -0
  41. package/src/tools/tasks.ts +215 -0
  42. package/src/types.ts +267 -0
  43. package/src/utils.ts +216 -0
  44. package/tsconfig.json +19 -0
@@ -0,0 +1,446 @@
1
+ // ============================================================================
2
+ // Engram MCP Server — Core Memory Tools
3
+ // ============================================================================
4
+ import { z } from "zod";
5
+ import { getDb, now, getCurrentSessionId } from "../database.js";
6
+ import { TOOL_PREFIX } from "../constants.js";
7
+ export function registerMemoryTools(server) {
8
+ // ═══════════════════════════════════════════════════════════════════════
9
+ // CHANGE TRACKING
10
+ // ═══════════════════════════════════════════════════════════════════════
11
+ server.registerTool(`${TOOL_PREFIX}_record_change`, {
12
+ title: "Record Change",
13
+ description: `Record a file change so future sessions know what happened and why. Call this after making significant modifications. Bulk recording is supported — pass multiple changes at once.
14
+
15
+ Args:
16
+ - changes (array): Array of change objects, each with:
17
+ - file_path (string): Relative path to the changed file
18
+ - change_type: "created" | "modified" | "deleted" | "refactored" | "renamed" | "moved" | "config_changed"
19
+ - description (string): What was changed and why
20
+ - diff_summary (string, optional): Brief summary of the diff
21
+ - impact_scope: "local" | "module" | "cross_module" | "global" (default: "local")
22
+
23
+ Returns:
24
+ Confirmation with number of changes recorded.`,
25
+ inputSchema: {
26
+ changes: z.array(z.object({
27
+ file_path: z.string().describe("Relative path to the changed file"),
28
+ change_type: z.enum(["created", "modified", "deleted", "refactored", "renamed", "moved", "config_changed"]),
29
+ description: z.string().describe("What was changed and why"),
30
+ diff_summary: z.string().optional().describe("Brief diff summary"),
31
+ impact_scope: z.enum(["local", "module", "cross_module", "global"]).default("local"),
32
+ })).min(1).describe("Array of changes to record"),
33
+ },
34
+ annotations: {
35
+ readOnlyHint: false,
36
+ destructiveHint: false,
37
+ idempotentHint: false,
38
+ openWorldHint: false,
39
+ },
40
+ }, async ({ changes }) => {
41
+ const db = getDb();
42
+ const timestamp = now();
43
+ const sessionId = getCurrentSessionId();
44
+ const insert = db.prepare("INSERT INTO changes (session_id, timestamp, file_path, change_type, description, diff_summary, impact_scope) VALUES (?, ?, ?, ?, ?, ?, ?)");
45
+ const transaction = db.transaction(() => {
46
+ for (const c of changes) {
47
+ insert.run(sessionId, timestamp, c.file_path, c.change_type, c.description, c.diff_summary || null, c.impact_scope);
48
+ // Auto-update file_notes last_modified_session
49
+ if (sessionId) {
50
+ db.prepare("UPDATE file_notes SET last_modified_session = ? WHERE file_path = ?").run(sessionId, c.file_path);
51
+ }
52
+ }
53
+ });
54
+ transaction();
55
+ return {
56
+ content: [{
57
+ type: "text",
58
+ text: `Recorded ${changes.length} change(s) in session #${sessionId ?? "none"}.`,
59
+ }],
60
+ };
61
+ });
62
+ server.registerTool(`${TOOL_PREFIX}_get_file_history`, {
63
+ title: "Get File History",
64
+ description: `Get the complete change history for a specific file — all recorded modifications, related decisions, and file notes.
65
+
66
+ Args:
67
+ - file_path (string): Path to the file
68
+ - limit (number, optional): Max changes to return (default 20)
69
+
70
+ Returns:
71
+ File notes, change history, and related decisions.`,
72
+ inputSchema: {
73
+ file_path: z.string().describe("Relative path to the file"),
74
+ limit: z.number().int().min(1).max(100).default(20).describe("Max changes to return"),
75
+ },
76
+ annotations: {
77
+ readOnlyHint: true,
78
+ destructiveHint: false,
79
+ idempotentHint: true,
80
+ openWorldHint: false,
81
+ },
82
+ }, async ({ file_path, limit }) => {
83
+ const db = getDb();
84
+ const notes = db.prepare("SELECT * FROM file_notes WHERE file_path = ?").get(file_path);
85
+ const changes = db.prepare("SELECT * FROM changes WHERE file_path = ? ORDER BY timestamp DESC LIMIT ?").all(file_path, limit);
86
+ const decisions = db.prepare("SELECT * FROM decisions WHERE affected_files LIKE ? AND status = 'active' ORDER BY timestamp DESC").all(`%${file_path}%`);
87
+ return {
88
+ content: [{
89
+ type: "text",
90
+ text: JSON.stringify({
91
+ file_path,
92
+ notes: notes || null,
93
+ change_count: changes.length,
94
+ changes,
95
+ related_decisions: decisions,
96
+ }, null, 2),
97
+ }],
98
+ };
99
+ });
100
+ // ═══════════════════════════════════════════════════════════════════════
101
+ // ARCHITECTURAL DECISIONS
102
+ // ═══════════════════════════════════════════════════════════════════════
103
+ server.registerTool(`${TOOL_PREFIX}_record_decision`, {
104
+ title: "Record Decision",
105
+ description: `Record an architectural or design decision with its rationale. These persist across all future sessions and are surfaced during start_session. Use this for any choice that future agents or sessions need to respect.
106
+
107
+ Args:
108
+ - decision (string): The decision that was made
109
+ - rationale (string, optional): Why this decision was made — context, tradeoffs, alternatives considered
110
+ - affected_files (array of strings, optional): Files impacted by this decision
111
+ - tags (array of strings, optional): Categorization tags (e.g., "architecture", "database", "ui", "api")
112
+ - status: "active" | "experimental" (default: "active")
113
+ - supersedes (number, optional): ID of a previous decision this replaces
114
+
115
+ Returns:
116
+ Decision ID and confirmation.`,
117
+ inputSchema: {
118
+ decision: z.string().min(5).describe("The decision that was made"),
119
+ rationale: z.string().optional().describe("Why — context, tradeoffs, alternatives considered"),
120
+ affected_files: z.array(z.string()).optional().describe("Files impacted by this decision"),
121
+ tags: z.array(z.string()).optional().describe("Tags for categorization"),
122
+ status: z.enum(["active", "experimental"]).default("active"),
123
+ supersedes: z.number().int().optional().describe("ID of a previous decision this replaces"),
124
+ },
125
+ annotations: {
126
+ readOnlyHint: false,
127
+ destructiveHint: false,
128
+ idempotentHint: false,
129
+ openWorldHint: false,
130
+ },
131
+ }, async ({ decision, rationale, affected_files, tags, status, supersedes }) => {
132
+ const db = getDb();
133
+ const timestamp = now();
134
+ const sessionId = getCurrentSessionId();
135
+ const result = db.prepare("INSERT INTO decisions (session_id, timestamp, decision, rationale, affected_files, tags, status, superseded_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(sessionId, timestamp, decision, rationale || null, affected_files ? JSON.stringify(affected_files) : null, tags ? JSON.stringify(tags) : null, status, supersedes || null);
136
+ const newDecisionId = result.lastInsertRowid;
137
+ // If superseding, mark old decision with the new decision's ID
138
+ if (supersedes) {
139
+ db.prepare("UPDATE decisions SET status = 'superseded', superseded_by = ? WHERE id = ?")
140
+ .run(newDecisionId, supersedes);
141
+ }
142
+ return {
143
+ content: [{
144
+ type: "text",
145
+ text: JSON.stringify({
146
+ decision_id: newDecisionId,
147
+ message: `Decision #${newDecisionId} recorded${supersedes ? ` (supersedes #${supersedes})` : ""}.`,
148
+ decision,
149
+ }, null, 2),
150
+ }],
151
+ };
152
+ });
153
+ server.registerTool(`${TOOL_PREFIX}_get_decisions`, {
154
+ title: "Get Decisions",
155
+ description: `Retrieve recorded architectural decisions. Filter by status, tags, or affected files.
156
+
157
+ Args:
158
+ - status (string, optional): Filter by status — "active", "superseded", "deprecated", "experimental"
159
+ - tag (string, optional): Filter by tag
160
+ - file_path (string, optional): Find decisions affecting a specific file
161
+ - limit (number, optional): Max results (default 20)
162
+
163
+ Returns:
164
+ Array of decisions with rationale and metadata.`,
165
+ inputSchema: {
166
+ status: z.enum(["active", "superseded", "deprecated", "experimental"]).optional(),
167
+ tag: z.string().optional().describe("Filter by tag"),
168
+ file_path: z.string().optional().describe("Find decisions affecting this file"),
169
+ limit: z.number().int().min(1).max(100).default(20),
170
+ },
171
+ annotations: {
172
+ readOnlyHint: true,
173
+ destructiveHint: false,
174
+ idempotentHint: true,
175
+ openWorldHint: false,
176
+ },
177
+ }, async ({ status, tag, file_path, limit }) => {
178
+ const db = getDb();
179
+ let query = "SELECT * FROM decisions WHERE 1=1";
180
+ const params = [];
181
+ if (status) {
182
+ query += " AND status = ?";
183
+ params.push(status);
184
+ }
185
+ if (tag) {
186
+ query += " AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)";
187
+ params.push(tag);
188
+ }
189
+ if (file_path) {
190
+ query += " AND EXISTS (SELECT 1 FROM json_each(affected_files) WHERE value = ?)";
191
+ params.push(file_path);
192
+ }
193
+ query += " ORDER BY timestamp DESC LIMIT ?";
194
+ params.push(limit);
195
+ const decisions = db.prepare(query).all(...params);
196
+ return {
197
+ content: [{
198
+ type: "text",
199
+ text: JSON.stringify({ count: decisions.length, decisions }, null, 2),
200
+ }],
201
+ };
202
+ });
203
+ server.registerTool(`${TOOL_PREFIX}_update_decision`, {
204
+ title: "Update Decision Status",
205
+ description: `Update the status of an existing decision. Use to deprecate, supersede, or reactivate decisions.
206
+
207
+ Args:
208
+ - id (number): Decision ID to update
209
+ - status: "active" | "superseded" | "deprecated" | "experimental"
210
+
211
+ Returns:
212
+ Confirmation.`,
213
+ inputSchema: {
214
+ id: z.number().int().describe("Decision ID"),
215
+ status: z.enum(["active", "superseded", "deprecated", "experimental"]),
216
+ },
217
+ annotations: {
218
+ readOnlyHint: false,
219
+ destructiveHint: false,
220
+ idempotentHint: true,
221
+ openWorldHint: false,
222
+ },
223
+ }, async ({ id, status }) => {
224
+ const db = getDb();
225
+ const result = db.prepare("UPDATE decisions SET status = ? WHERE id = ?").run(status, id);
226
+ if (result.changes === 0) {
227
+ return { isError: true, content: [{ type: "text", text: `Decision #${id} not found.` }] };
228
+ }
229
+ return { content: [{ type: "text", text: `Decision #${id} status updated to "${status}".` }] };
230
+ });
231
+ // ═══════════════════════════════════════════════════════════════════════
232
+ // FILE NOTES
233
+ // ═══════════════════════════════════════════════════════════════════════
234
+ server.registerTool(`${TOOL_PREFIX}_set_file_notes`, {
235
+ title: "Set File Notes",
236
+ description: `Store persistent notes about a file: its purpose, dependencies, architectural layer, complexity, and any important details. This creates a knowledge base that eliminates the need to re-read and re-analyze files across sessions.
237
+
238
+ Args:
239
+ - file_path (string): Relative path to the file
240
+ - purpose (string, optional): What this file does — its responsibility
241
+ - dependencies (array, optional): Files this file depends on
242
+ - dependents (array, optional): Files that depend on this file
243
+ - layer: "ui" | "viewmodel" | "domain" | "data" | "network" | "database" | "di" | "util" | "test" | "config" | "build" | "other"
244
+ - complexity: "trivial" | "simple" | "moderate" | "complex" | "critical"
245
+ - notes (string, optional): Any important context, gotchas, or warnings
246
+
247
+ Returns:
248
+ Confirmation.`,
249
+ inputSchema: {
250
+ file_path: z.string().describe("Relative path to the file"),
251
+ purpose: z.string().optional().describe("What this file does"),
252
+ dependencies: z.array(z.string()).optional().describe("Files this depends on"),
253
+ dependents: z.array(z.string()).optional().describe("Files that depend on this"),
254
+ layer: z.enum(["ui", "viewmodel", "domain", "data", "network", "database", "di", "util", "test", "config", "build", "other"]).optional(),
255
+ complexity: z.enum(["trivial", "simple", "moderate", "complex", "critical"]).optional(),
256
+ notes: z.string().optional().describe("Important context, gotchas, warnings"),
257
+ },
258
+ annotations: {
259
+ readOnlyHint: false,
260
+ destructiveHint: false,
261
+ idempotentHint: true,
262
+ openWorldHint: false,
263
+ },
264
+ }, async ({ file_path, purpose, dependencies, dependents, layer, complexity, notes }) => {
265
+ const db = getDb();
266
+ const timestamp = now();
267
+ const sessionId = getCurrentSessionId();
268
+ db.prepare(`
269
+ INSERT INTO file_notes (file_path, purpose, dependencies, dependents, layer, last_reviewed, last_modified_session, notes, complexity)
270
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
271
+ ON CONFLICT(file_path) DO UPDATE SET
272
+ purpose = COALESCE(?, purpose),
273
+ dependencies = COALESCE(?, dependencies),
274
+ dependents = COALESCE(?, dependents),
275
+ layer = COALESCE(?, layer),
276
+ last_reviewed = ?,
277
+ last_modified_session = COALESCE(?, last_modified_session),
278
+ notes = COALESCE(?, notes),
279
+ complexity = COALESCE(?, complexity)
280
+ `).run(file_path, purpose || null, dependencies ? JSON.stringify(dependencies) : null, dependents ? JSON.stringify(dependents) : null, layer || null, timestamp, sessionId, notes || null, complexity || null,
281
+ // Update values
282
+ purpose || null, dependencies ? JSON.stringify(dependencies) : null, dependents ? JSON.stringify(dependents) : null, layer || null, timestamp, sessionId, notes || null, complexity || null);
283
+ return {
284
+ content: [{ type: "text", text: `File notes saved for ${file_path}.` }],
285
+ };
286
+ });
287
+ server.registerTool(`${TOOL_PREFIX}_get_file_notes`, {
288
+ title: "Get File Notes",
289
+ description: `Retrieve stored notes for one or more files. Use to quickly understand a file's purpose and context without reading it.
290
+
291
+ Args:
292
+ - file_path (string, optional): Specific file to query
293
+ - layer (string, optional): Filter by architectural layer
294
+ - complexity (string, optional): Filter by complexity level
295
+
296
+ Returns:
297
+ File notes with purpose, dependencies, layer, and complexity.`,
298
+ inputSchema: {
299
+ file_path: z.string().optional().describe("Specific file to query"),
300
+ layer: z.enum(["ui", "viewmodel", "domain", "data", "network", "database", "di", "util", "test", "config", "build", "other"]).optional(),
301
+ complexity: z.enum(["trivial", "simple", "moderate", "complex", "critical"]).optional(),
302
+ },
303
+ annotations: {
304
+ readOnlyHint: true,
305
+ destructiveHint: false,
306
+ idempotentHint: true,
307
+ openWorldHint: false,
308
+ },
309
+ }, async ({ file_path, layer, complexity }) => {
310
+ const db = getDb();
311
+ if (file_path) {
312
+ const note = db.prepare("SELECT * FROM file_notes WHERE file_path = ?").get(file_path);
313
+ return { content: [{ type: "text", text: JSON.stringify(note || { message: "No notes found for this file." }, null, 2) }] };
314
+ }
315
+ let query = "SELECT * FROM file_notes WHERE 1=1";
316
+ const params = [];
317
+ if (layer) {
318
+ query += " AND layer = ?";
319
+ params.push(layer);
320
+ }
321
+ if (complexity) {
322
+ query += " AND complexity = ?";
323
+ params.push(complexity);
324
+ }
325
+ query += " ORDER BY file_path";
326
+ const notes = db.prepare(query).all(...params);
327
+ return { content: [{ type: "text", text: JSON.stringify({ count: notes.length, files: notes }, null, 2) }] };
328
+ });
329
+ // ═══════════════════════════════════════════════════════════════════════
330
+ // CONVENTIONS
331
+ // ═══════════════════════════════════════════════════════════════════════
332
+ server.registerTool(`${TOOL_PREFIX}_add_convention`, {
333
+ title: "Add Convention",
334
+ description: `Record a project convention that the agent should always follow. Conventions are surfaced during start_session and serve as persistent rules.
335
+
336
+ Args:
337
+ - category: "naming" | "architecture" | "styling" | "testing" | "git" | "documentation" | "error_handling" | "performance" | "security" | "other"
338
+ - rule (string): The convention rule in clear, actionable language
339
+ - examples (array of strings, optional): Code or usage examples
340
+
341
+ Returns:
342
+ Convention ID and confirmation.`,
343
+ inputSchema: {
344
+ category: z.enum(["naming", "architecture", "styling", "testing", "git", "documentation", "error_handling", "performance", "security", "other"]),
345
+ rule: z.string().min(5).describe("The convention rule"),
346
+ examples: z.array(z.string()).optional().describe("Examples of the convention in use"),
347
+ },
348
+ annotations: {
349
+ readOnlyHint: false,
350
+ destructiveHint: false,
351
+ idempotentHint: false,
352
+ openWorldHint: false,
353
+ },
354
+ }, async ({ category, rule, examples }) => {
355
+ const db = getDb();
356
+ const timestamp = now();
357
+ const sessionId = getCurrentSessionId();
358
+ const result = db.prepare("INSERT INTO conventions (session_id, timestamp, category, rule, examples) VALUES (?, ?, ?, ?, ?)").run(sessionId, timestamp, category, rule, examples ? JSON.stringify(examples) : null);
359
+ return {
360
+ content: [{
361
+ type: "text",
362
+ text: JSON.stringify({
363
+ convention_id: result.lastInsertRowid,
364
+ message: `Convention #${result.lastInsertRowid} added to [${category}].`,
365
+ rule,
366
+ }, null, 2),
367
+ }],
368
+ };
369
+ });
370
+ server.registerTool(`${TOOL_PREFIX}_get_conventions`, {
371
+ title: "Get Conventions",
372
+ description: `Retrieve all active project conventions. Optionally filter by category.
373
+
374
+ Args:
375
+ - category (string, optional): Filter by convention category
376
+ - include_disabled (boolean, optional): Include unenforced conventions (default: false)
377
+
378
+ Returns:
379
+ Array of conventions grouped by category.`,
380
+ inputSchema: {
381
+ category: z.enum(["naming", "architecture", "styling", "testing", "git", "documentation", "error_handling", "performance", "security", "other"]).optional(),
382
+ include_disabled: z.boolean().default(false),
383
+ },
384
+ annotations: {
385
+ readOnlyHint: true,
386
+ destructiveHint: false,
387
+ idempotentHint: true,
388
+ openWorldHint: false,
389
+ },
390
+ }, async ({ category, include_disabled }) => {
391
+ const db = getDb();
392
+ let query = "SELECT * FROM conventions WHERE 1=1";
393
+ const params = [];
394
+ if (!include_disabled) {
395
+ query += " AND enforced = 1";
396
+ }
397
+ if (category) {
398
+ query += " AND category = ?";
399
+ params.push(category);
400
+ }
401
+ query += " ORDER BY category, id";
402
+ const conventions = db.prepare(query).all(...params);
403
+ // Group by category
404
+ const grouped = {};
405
+ for (const c of conventions) {
406
+ if (!grouped[c.category])
407
+ grouped[c.category] = [];
408
+ grouped[c.category].push(c);
409
+ }
410
+ return {
411
+ content: [{
412
+ type: "text",
413
+ text: JSON.stringify({ total: conventions.length, by_category: grouped }, null, 2),
414
+ }],
415
+ };
416
+ });
417
+ server.registerTool(`${TOOL_PREFIX}_toggle_convention`, {
418
+ title: "Toggle Convention",
419
+ description: `Enable or disable a convention. Disabled conventions are not surfaced during start_session.
420
+
421
+ Args:
422
+ - id (number): Convention ID
423
+ - enforced (boolean): Whether the convention should be enforced
424
+
425
+ Returns:
426
+ Confirmation.`,
427
+ inputSchema: {
428
+ id: z.number().int().describe("Convention ID"),
429
+ enforced: z.boolean().describe("Enable or disable"),
430
+ },
431
+ annotations: {
432
+ readOnlyHint: false,
433
+ destructiveHint: false,
434
+ idempotentHint: true,
435
+ openWorldHint: false,
436
+ },
437
+ }, async ({ id, enforced }) => {
438
+ const db = getDb();
439
+ const result = db.prepare("UPDATE conventions SET enforced = ? WHERE id = ?").run(enforced ? 1 : 0, id);
440
+ if (result.changes === 0) {
441
+ return { isError: true, content: [{ type: "text", text: `Convention #${id} not found.` }] };
442
+ }
443
+ return { content: [{ type: "text", text: `Convention #${id} ${enforced ? "enabled" : "disabled"}.` }] };
444
+ });
445
+ }
446
+ //# sourceMappingURL=memory.js.map
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerSchedulerTools(server: McpServer): void;
3
+ //# sourceMappingURL=scheduler.d.ts.map