@crowley/rag-mcp 1.1.0 → 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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { summarizeInput, countResults, formatToolError, TRACKING_EXCLUDE, SESSION_TOOLS, TOOL_TIMEOUTS, } from '../tool-middleware.js';
3
+ describe('Tool Middleware', () => {
4
+ beforeEach(() => {
5
+ vi.resetAllMocks();
6
+ });
7
+ describe('summarizeInput', () => {
8
+ it('extracts query field', () => {
9
+ expect(summarizeInput('search', { query: 'find auth code' })).toBe('find auth code');
10
+ });
11
+ it('extracts question field', () => {
12
+ expect(summarizeInput('ask', { question: 'what is auth?' })).toBe('what is auth?');
13
+ });
14
+ it('extracts content field as fallback', () => {
15
+ expect(summarizeInput('remember', { content: 'important note' })).toBe('important note');
16
+ });
17
+ it('extracts file path as fallback', () => {
18
+ expect(summarizeInput('explain', { filePath: 'src/auth.ts' })).toBe('src/auth.ts');
19
+ });
20
+ it('truncates long strings to 200 chars', () => {
21
+ const long = 'a'.repeat(300);
22
+ expect(summarizeInput('search', { query: long }).length).toBeLessThanOrEqual(200);
23
+ });
24
+ it('returns tool name when no useful field', () => {
25
+ expect(summarizeInput('get_stats', {})).toBe('get_stats');
26
+ });
27
+ });
28
+ describe('countResults', () => {
29
+ it('returns 0 for "No results" messages', () => {
30
+ expect(countResults('No results found.')).toBe(0);
31
+ });
32
+ it('returns 0 for "not found" messages', () => {
33
+ expect(countResults('Memory not found')).toBe(0);
34
+ });
35
+ it('counts numbered items', () => {
36
+ const text = '1. First\n2. Second\n3. Third';
37
+ expect(countResults(text)).toBe(3);
38
+ });
39
+ it('counts bullet items', () => {
40
+ const text = '- item1\n- item2';
41
+ expect(countResults(text)).toBe(2);
42
+ });
43
+ it('returns 1 for generic content', () => {
44
+ expect(countResults('Some response text')).toBe(1);
45
+ });
46
+ });
47
+ describe('formatToolError', () => {
48
+ const ctx = {
49
+ api: { defaults: { baseURL: 'http://localhost:3100' } },
50
+ };
51
+ it('formats ECONNREFUSED error', () => {
52
+ const err = { code: 'ECONNREFUSED' };
53
+ const result = formatToolError(err, ctx);
54
+ expect(result).toContain('Cannot connect');
55
+ expect(result).toContain('localhost:3100');
56
+ });
57
+ it('formats API error with status', () => {
58
+ const err = { response: { status: 404, data: { error: 'not found' } } };
59
+ const result = formatToolError(err, ctx);
60
+ expect(result).toContain('404');
61
+ });
62
+ it('formats generic error message', () => {
63
+ const err = { message: 'Something broke' };
64
+ const result = formatToolError(err, ctx);
65
+ expect(result).toContain('Something broke');
66
+ });
67
+ });
68
+ describe('constants', () => {
69
+ it('TRACKING_EXCLUDE contains meta tools', () => {
70
+ expect(TRACKING_EXCLUDE.has('get_tool_analytics')).toBe(true);
71
+ expect(TRACKING_EXCLUDE.has('get_quality_metrics')).toBe(true);
72
+ });
73
+ it('SESSION_TOOLS contains session management', () => {
74
+ expect(SESSION_TOOLS.has('start_session')).toBe(true);
75
+ expect(SESSION_TOOLS.has('end_session')).toBe(true);
76
+ });
77
+ it('TOOL_TIMEOUTS has correct tiers', () => {
78
+ expect(TOOL_TIMEOUTS['index_codebase']).toBe(120_000);
79
+ expect(TOOL_TIMEOUTS['search_codebase']).toBe(15_000);
80
+ expect(TOOL_TIMEOUTS['recall']).toBe(10_000);
81
+ });
82
+ });
83
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { createMemoryTools } from '../../tools/memory';
3
+ function createMockCtx() {
4
+ return {
5
+ api: {
6
+ post: vi.fn(),
7
+ get: vi.fn(),
8
+ delete: vi.fn(),
9
+ patch: vi.fn(),
10
+ defaults: { baseURL: 'http://localhost:3100' },
11
+ },
12
+ projectName: 'testproject',
13
+ projectPath: '/tmp/testproject',
14
+ collectionPrefix: 'testproject',
15
+ enrichmentEnabled: false,
16
+ };
17
+ }
18
+ describe('Memory Tools', () => {
19
+ let tools;
20
+ let ctx;
21
+ beforeEach(() => {
22
+ vi.resetAllMocks();
23
+ tools = createMemoryTools('testproject');
24
+ ctx = createMockCtx();
25
+ });
26
+ function findTool(name) {
27
+ return tools.find(t => t.name === name);
28
+ }
29
+ describe('remember', () => {
30
+ it('stores memory and returns formatted result', async () => {
31
+ const mem = { id: 'mem-1', type: 'note', content: 'test note', createdAt: new Date().toISOString() };
32
+ ctx.api.post.mockResolvedValue({ data: { memory: mem } });
33
+ const result = await findTool('remember').handler({ content: 'test note', type: 'note', tags: ['tag1'] }, ctx);
34
+ expect(ctx.api.post).toHaveBeenCalledWith('/api/memory', expect.objectContaining({
35
+ projectName: 'testproject',
36
+ content: 'test note',
37
+ type: 'note',
38
+ }));
39
+ expect(result).toContain('Memory stored');
40
+ expect(result).toContain('mem-1');
41
+ });
42
+ });
43
+ describe('recall', () => {
44
+ it('returns formatted results', async () => {
45
+ ctx.api.post.mockResolvedValue({
46
+ data: {
47
+ results: [
48
+ { memory: { type: 'insight', content: 'found it', createdAt: new Date().toISOString(), tags: [] }, score: 0.85 },
49
+ ],
50
+ },
51
+ });
52
+ const result = await findTool('recall').handler({ query: 'find something', limit: 5 }, ctx);
53
+ expect(result).toContain('Recalled Memories');
54
+ });
55
+ it('returns empty message when no results', async () => {
56
+ ctx.api.post.mockResolvedValue({ data: { results: [] } });
57
+ const result = await findTool('recall').handler({ query: 'nothing' }, ctx);
58
+ expect(result).toContain('No memories found');
59
+ });
60
+ });
61
+ describe('forget', () => {
62
+ it('deletes by memoryId', async () => {
63
+ ctx.api.delete.mockResolvedValue({ data: { success: true } });
64
+ const result = await findTool('forget').handler({ memoryId: 'mem-1' }, ctx);
65
+ expect(ctx.api.delete).toHaveBeenCalledWith(expect.stringContaining('/api/memory/mem-1'));
66
+ expect(result).toContain('deleted');
67
+ });
68
+ it('deletes by type', async () => {
69
+ ctx.api.delete.mockResolvedValue({ data: {} });
70
+ const result = await findTool('forget').handler({ type: 'note' }, ctx);
71
+ expect(ctx.api.delete).toHaveBeenCalledWith(expect.stringContaining('/api/memory/type/note'));
72
+ expect(result).toContain('note');
73
+ });
74
+ it('deletes by olderThanDays', async () => {
75
+ ctx.api.post.mockResolvedValue({ data: { deleted: 10 } });
76
+ const result = await findTool('forget').handler({ olderThanDays: 30 }, ctx);
77
+ expect(ctx.api.post).toHaveBeenCalledWith('/api/memory/forget-older', expect.objectContaining({
78
+ olderThanDays: 30,
79
+ }));
80
+ expect(result).toContain('10');
81
+ });
82
+ it('returns message when nothing specified', async () => {
83
+ const result = await findTool('forget').handler({}, ctx);
84
+ expect(result).toContain('specify');
85
+ });
86
+ });
87
+ describe('promote_memory', () => {
88
+ it('promotes and returns formatted result', async () => {
89
+ const mem = { id: 'mem-1', type: 'insight', content: 'promoted' };
90
+ ctx.api.post.mockResolvedValue({ data: { memory: mem } });
91
+ const result = await findTool('promote_memory').handler({ memoryId: 'mem-1', reason: 'human_validated' }, ctx);
92
+ expect(result).toContain('promoted to durable');
93
+ expect(result).toContain('mem-1');
94
+ });
95
+ });
96
+ describe('memory_maintenance', () => {
97
+ it('formats maintenance results', async () => {
98
+ ctx.api.post.mockResolvedValue({
99
+ data: {
100
+ quarantine_cleanup: { rejected: ['q-1', 'q-2'], errors: [] },
101
+ feedback_maintenance: { promoted: ['f-1'], pruned: [], errors: [] },
102
+ },
103
+ });
104
+ const result = await findTool('memory_maintenance').handler({}, ctx);
105
+ expect(result).toContain('Maintenance Results');
106
+ expect(result).toContain('Quarantine Cleanup');
107
+ expect(result).toContain('Feedback Maintenance');
108
+ });
109
+ });
110
+ describe('batch_remember', () => {
111
+ it('stores multiple memories', async () => {
112
+ ctx.api.post.mockResolvedValue({
113
+ data: {
114
+ savedCount: 2,
115
+ memories: [
116
+ { id: 'b-1', type: 'note', content: 'first' },
117
+ { id: 'b-2', type: 'insight', content: 'second' },
118
+ ],
119
+ errors: [],
120
+ },
121
+ });
122
+ const result = await findTool('batch_remember').handler({ items: [{ content: 'first' }, { content: 'second', type: 'insight' }] }, ctx);
123
+ expect(result).toContain('Saved');
124
+ expect(result).toContain('2');
125
+ });
126
+ });
127
+ });
@@ -135,6 +135,7 @@ export function createMemoryTools(projectName) {
135
135
  handler: async (args, ctx) => {
136
136
  const memoryId = args.memoryId;
137
137
  const type = args.type;
138
+ const olderThanDays = args.olderThanDays;
138
139
  if (memoryId) {
139
140
  const response = await ctx.api.delete(`/api/memory/${memoryId}?projectName=${ctx.projectName}`);
140
141
  return response.data.success
@@ -145,7 +146,14 @@ export function createMemoryTools(projectName) {
145
146
  await ctx.api.delete(`/api/memory/type/${type}?projectName=${ctx.projectName}`);
146
147
  return `\u{1F5D1}\uFE0F Deleted all memories of type: ${type}`;
147
148
  }
148
- return "Please specify memoryId or type to delete.";
149
+ if (olderThanDays) {
150
+ const response = await ctx.api.post("/api/memory/forget-older", {
151
+ projectName: ctx.projectName,
152
+ olderThanDays,
153
+ });
154
+ return `\u{1F5D1}\uFE0F Deleted ${response.data.deleted} memories older than ${olderThanDays} days`;
155
+ }
156
+ return "Please specify memoryId, type, or olderThanDays to delete.";
149
157
  },
150
158
  },
151
159
  {
@@ -191,6 +199,7 @@ export function createMemoryTools(projectName) {
191
199
  handler: async (args, ctx) => {
192
200
  const items = args.items;
193
201
  const response = await ctx.api.post("/api/memory/batch", {
202
+ projectName: ctx.projectName,
194
203
  items,
195
204
  });
196
205
  const { savedCount, errors, memories } = response.data;
@@ -345,33 +354,81 @@ export function createMemoryTools(projectName) {
345
354
  },
346
355
  {
347
356
  name: "memory_maintenance",
348
- description: `Run feedback-driven memory maintenance for ${projectName}: auto-promote memories with 3+ positive feedback, auto-prune memories with 2+ incorrect feedback.`,
349
- schema: z.object({}),
357
+ description: `Run memory maintenance for ${projectName}: quarantine cleanup (expire old auto-memories), feedback-driven promote/prune, and compaction (merge similar durable memories).`,
358
+ schema: z.object({
359
+ operations: z.object({
360
+ quarantine_cleanup: z.boolean().optional().describe("Remove expired quarantine memories (default: true)"),
361
+ feedback_maintenance: z.boolean().optional().describe("Auto-promote/prune by feedback (default: true)"),
362
+ compaction: z.boolean().optional().describe("Merge similar durable memories (default: false)"),
363
+ compaction_dry_run: z.boolean().optional().describe("Preview compaction without changes (default: true)"),
364
+ }).optional().describe("Which operations to run (default: quarantine_cleanup + feedback_maintenance)"),
365
+ }),
350
366
  annotations: TOOL_ANNOTATIONS["memory_maintenance"],
351
- handler: async (_args, ctx) => {
367
+ handler: async (args, ctx) => {
368
+ const operations = args.operations;
352
369
  const response = await ctx.api.post("/api/memory/maintenance", {
353
370
  projectName: ctx.projectName,
371
+ operations,
354
372
  });
355
- const { promoted, pruned, errors } = response.data;
373
+ const data = response.data;
356
374
  let result = `# \u{1F9F9} Memory Maintenance Results\n\n`;
357
- if (promoted.length > 0) {
358
- result += `**Promoted** (${promoted.length}): memories with 3+ positive feedback moved to durable\n`;
359
- promoted.forEach((id) => { result += ` \u2705 ${id}\n`; });
375
+ // Quarantine cleanup section
376
+ if (data.quarantine_cleanup) {
377
+ const qc = data.quarantine_cleanup;
378
+ result += `## Quarantine Cleanup\n`;
379
+ if (qc.rejected.length > 0) {
380
+ result += `**Expired** (${qc.rejected.length}): removed from quarantine\n`;
381
+ qc.rejected.slice(0, 10).forEach((id) => { result += ` \u{1F5D1}\u{FE0F} ${id}\n`; });
382
+ if (qc.rejected.length > 10)
383
+ result += ` ... and ${qc.rejected.length - 10} more\n`;
384
+ }
385
+ else {
386
+ result += `No expired quarantine memories.\n`;
387
+ }
388
+ if (qc.errors.length > 0) {
389
+ qc.errors.forEach((e) => { result += ` \u26A0\u{FE0F} ${e}\n`; });
390
+ }
360
391
  result += `\n`;
361
392
  }
362
- if (pruned.length > 0) {
363
- result += `**Pruned** (${pruned.length}): memories with 2+ incorrect feedback removed\n`;
364
- pruned.forEach((id) => { result += ` \u{1F5D1}\u{FE0F} ${id}\n`; });
393
+ // Feedback maintenance section
394
+ if (data.feedback_maintenance) {
395
+ const fm = data.feedback_maintenance;
396
+ result += `## Feedback Maintenance\n`;
397
+ if (fm.promoted.length > 0) {
398
+ result += `**Promoted** (${fm.promoted.length}): moved to durable\n`;
399
+ fm.promoted.forEach((id) => { result += ` \u2705 ${id}\n`; });
400
+ }
401
+ if (fm.pruned.length > 0) {
402
+ result += `**Pruned** (${fm.pruned.length}): removed\n`;
403
+ fm.pruned.forEach((id) => { result += ` \u{1F5D1}\u{FE0F} ${id}\n`; });
404
+ }
405
+ if (fm.promoted.length === 0 && fm.pruned.length === 0) {
406
+ result += `No feedback-based actions needed.\n`;
407
+ }
408
+ if (fm.errors.length > 0) {
409
+ fm.errors.forEach((e) => { result += ` \u26A0\u{FE0F} ${e}\n`; });
410
+ }
365
411
  result += `\n`;
366
412
  }
367
- if (errors.length > 0) {
368
- result += `**Errors** (${errors.length}):\n`;
369
- errors.forEach((e) => { result += ` \u26A0\u{FE0F} ${e}\n`; });
413
+ // Compaction section
414
+ if (data.compaction) {
415
+ const cp = data.compaction;
416
+ result += `## Compaction${cp.dryRun ? ' (dry run)' : ''}\n`;
417
+ if (cp.clusters.length > 0) {
418
+ result += `**${cp.totalClusters} cluster(s)** of similar memories found\n\n`;
419
+ cp.clusters.slice(0, 5).forEach((c, i) => {
420
+ result += `${i + 1}. ${c.originalIds.length} memories → ${truncate(c.mergedContent, 120)}\n`;
421
+ if (c.mergedId)
422
+ result += ` Merged ID: \`${c.mergedId}\`\n`;
423
+ });
424
+ if (cp.clusters.length > 5)
425
+ result += `... and ${cp.clusters.length - 5} more clusters\n`;
426
+ }
427
+ else {
428
+ result += `No similar memory clusters found.\n`;
429
+ }
370
430
  result += `\n`;
371
431
  }
372
- if (promoted.length === 0 && pruned.length === 0) {
373
- result += `No memories needed maintenance. All feedback thresholds are below auto-action levels.\n`;
374
- }
375
432
  return result;
376
433
  },
377
434
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowley/rag-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Universal RAG MCP Server for any project",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,7 +14,9 @@
14
14
  "build": "tsc",
15
15
  "dev": "tsc -w",
16
16
  "start": "node dist/index.js",
17
- "prepublishOnly": "npm run build"
17
+ "prepublishOnly": "npm run build",
18
+ "test": "vitest run --passWithNoTests",
19
+ "test:watch": "vitest"
18
20
  },
19
21
  "keywords": [
20
22
  "mcp",
@@ -38,6 +40,7 @@
38
40
  },
39
41
  "devDependencies": {
40
42
  "@types/node": "^20.10.0",
41
- "typescript": "^5.3.0"
43
+ "typescript": "^5.3.0",
44
+ "vitest": "^4.0.18"
42
45
  }
43
46
  }