mcp-learning-memory 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/tools.js ADDED
@@ -0,0 +1,559 @@
1
+ /**
2
+ * Tool implementations for MCP server
3
+ */
4
+ import { loadMemory, saveMemory } from './storage.js';
5
+ import { validateInsightInput, normalizeTags, generateId, levenshteinDistance, validateListIndexParams, validateSearchQuery, } from './validation.js';
6
+ import { log } from './logger.js';
7
+ // Simple mutex to prevent concurrent read-modify-write races
8
+ class Mutex {
9
+ locked = false;
10
+ queue = [];
11
+ async acquire() {
12
+ if (!this.locked) {
13
+ this.locked = true;
14
+ return;
15
+ }
16
+ await new Promise(resolve => this.queue.push(resolve));
17
+ }
18
+ release() {
19
+ const next = this.queue.shift();
20
+ if (next) {
21
+ next();
22
+ }
23
+ else {
24
+ this.locked = false;
25
+ }
26
+ }
27
+ async runExclusive(fn) {
28
+ await this.acquire();
29
+ try {
30
+ return await fn();
31
+ }
32
+ finally {
33
+ this.release();
34
+ }
35
+ }
36
+ }
37
+ const memoryMutex = new Mutex();
38
+ /**
39
+ * Calculate trust score based on helpful and harmful feedback
40
+ * Formula: (helpful - harmful * 2) / (helpful + harmful + 1)
41
+ */
42
+ function calculateTrustScore(helpful, harmful) {
43
+ return Number(((helpful - harmful * 2) / (helpful + harmful + 1)).toFixed(2));
44
+ }
45
+ export async function addInsight(title, tags, content, overwrite = false, sourceUrl) {
46
+ try {
47
+ // Validate input
48
+ const validationError = validateInsightInput(title, tags, content, sourceUrl);
49
+ if (validationError) {
50
+ return {
51
+ success: false,
52
+ message: validationError,
53
+ };
54
+ }
55
+ // Normalize tags and title
56
+ const normalizedTags = normalizeTags(tags);
57
+ const trimmedTitle = title.trim();
58
+ let resultId = '';
59
+ let wasOverwritten = false;
60
+ // Load, update, and save memory with mutex protection
61
+ await memoryMutex.runExclusive(async () => {
62
+ const memory = await loadMemory();
63
+ // Check for duplicate or similar title using Levenshtein distance
64
+ let existingIndex = -1;
65
+ let similarTitle = '';
66
+ for (let i = 0; i < memory.insights.length; i++) {
67
+ const insight = memory.insights[i];
68
+ const isExactMatch = insight.title.toLowerCase() === trimmedTitle.toLowerCase();
69
+ // For very short titles (< 3 chars), only check exact match to avoid false positives
70
+ // For longer titles (>= 3 chars), use Levenshtein distance < 3 (catches typos)
71
+ // Examples: "Bug Fix" → "Bux Fix" (distance=2), "Test" → "Fest" (distance=1)
72
+ const minLength = Math.min(insight.title.length, trimmedTitle.length);
73
+ let isSimilar = false;
74
+ if (isExactMatch) {
75
+ isSimilar = true;
76
+ }
77
+ else if (minLength >= 3) {
78
+ const distance = levenshteinDistance(insight.title, trimmedTitle);
79
+ isSimilar = distance < 3;
80
+ }
81
+ if (isSimilar) {
82
+ existingIndex = i;
83
+ similarTitle = insight.title;
84
+ break;
85
+ }
86
+ }
87
+ if (existingIndex !== -1) {
88
+ if (!overwrite) {
89
+ const error = new Error(`Similar title found: "${similarTitle}" is too similar to "${trimmedTitle}". Use overwrite=true to replace.`);
90
+ error.duplicate = true;
91
+ error.existingId = memory.insights[existingIndex].id;
92
+ error.existingTitle = similarTitle;
93
+ throw error;
94
+ }
95
+ // Overwrite: keep ID and created date, update everything else
96
+ const existing = memory.insights[existingIndex];
97
+ existing.title = trimmedTitle;
98
+ existing.tags = normalizedTags;
99
+ existing.content = content.trim();
100
+ existing.lastUsed = new Date().toISOString();
101
+ existing.sourceUrl = sourceUrl?.trim();
102
+ resultId = existing.id;
103
+ wasOverwritten = true;
104
+ }
105
+ else {
106
+ // Calculate next ID number
107
+ let maxNum = 0;
108
+ for (const insight of memory.insights) {
109
+ const match = insight.id.match(/^ace-(\d+)$/);
110
+ if (match) {
111
+ const num = parseInt(match[1], 10);
112
+ if (num > maxNum)
113
+ maxNum = num;
114
+ }
115
+ }
116
+ const nextNum = maxNum + 1;
117
+ // Create new insight
118
+ const now = new Date().toISOString();
119
+ const insight = {
120
+ id: generateId(nextNum),
121
+ title: trimmedTitle,
122
+ tags: normalizedTags,
123
+ content: content.trim(),
124
+ helpful: 0,
125
+ harmful: 0,
126
+ created: now,
127
+ lastUsed: now,
128
+ sourceUrl: sourceUrl?.trim(),
129
+ };
130
+ memory.insights.push(insight);
131
+ resultId = insight.id;
132
+ }
133
+ await saveMemory(memory);
134
+ });
135
+ log.info(`${wasOverwritten ? 'Updated' : 'Added'} insight: ${resultId}`);
136
+ return {
137
+ success: true,
138
+ message: wasOverwritten
139
+ ? `Insight updated successfully (ID: ${resultId})`
140
+ : `Insight added successfully with ID: ${resultId}`,
141
+ data: { id: resultId, overwritten: wasOverwritten },
142
+ };
143
+ }
144
+ catch (error) {
145
+ log.error('Error adding insight:', error);
146
+ // Check if this is a duplicate error
147
+ const err = error;
148
+ if (err.duplicate) {
149
+ return {
150
+ success: false,
151
+ message: err.message,
152
+ data: {
153
+ duplicate: true,
154
+ existingId: err.existingId,
155
+ existingTitle: err.existingTitle,
156
+ },
157
+ };
158
+ }
159
+ return {
160
+ success: false,
161
+ message: `Failed to add insight: ${err.message}`,
162
+ };
163
+ }
164
+ }
165
+ export async function listIndex(params = {}) {
166
+ try {
167
+ // Validate parameters
168
+ const validationError = validateListIndexParams(params);
169
+ if (validationError) {
170
+ return {
171
+ success: false,
172
+ message: validationError,
173
+ };
174
+ }
175
+ const memory = await loadMemory();
176
+ // Normalize filter tags to lowercase for consistent matching
177
+ const filterTags = params.tags ? normalizeTags(params.tags) : undefined;
178
+ const minHelpful = params.minHelpful ?? 0;
179
+ const sortBy = params.sortBy ?? 'lastUsed';
180
+ const limit = params.limit ?? 50;
181
+ const PREVIEW_LIMIT = 100;
182
+ let index = memory.insights.map(insight => {
183
+ const trustScore = calculateTrustScore(insight.helpful, insight.harmful);
184
+ const entry = {
185
+ id: insight.id,
186
+ title: insight.title,
187
+ tags: insight.tags,
188
+ helpful: insight.helpful,
189
+ harmful: insight.harmful,
190
+ created: insight.created,
191
+ lastUsed: insight.lastUsed,
192
+ contentPreview: insight.content.substring(0, PREVIEW_LIMIT),
193
+ contentTruncated: insight.content.length > PREVIEW_LIMIT,
194
+ sourceUrl: insight.sourceUrl,
195
+ trustScore,
196
+ };
197
+ if (trustScore < 0) {
198
+ entry.warning = 'Low trust score';
199
+ }
200
+ return entry;
201
+ });
202
+ // Apply tag filters (AND logic - insight must have ALL filter tags)
203
+ if (filterTags && filterTags.length > 0) {
204
+ index = index.filter(insight => filterTags.every(filterTag => insight.tags.some(insightTag => insightTag === filterTag)));
205
+ }
206
+ // Apply minHelpful filter
207
+ if (minHelpful > 0) {
208
+ index = index.filter(insight => insight.helpful >= minHelpful);
209
+ }
210
+ // Sort by specified field
211
+ if (sortBy === 'created') {
212
+ index.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
213
+ }
214
+ else if (sortBy === 'helpful') {
215
+ index.sort((a, b) => b.helpful - a.helpful);
216
+ }
217
+ else {
218
+ // Default: sort by lastUsed
219
+ index.sort((a, b) => new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime());
220
+ }
221
+ // Apply limit
222
+ const totalResults = index.length;
223
+ index = index.slice(0, limit);
224
+ const isFiltered = filterTags !== undefined || minHelpful > 0;
225
+ const isLimited = totalResults > index.length;
226
+ let message;
227
+ if (isFiltered && isLimited) {
228
+ message = `Found ${totalResults} insights (filtered, showing ${index.length})`;
229
+ }
230
+ else if (isFiltered) {
231
+ message = `Found ${totalResults} insights (filtered)`;
232
+ }
233
+ else if (isLimited) {
234
+ message = `Found ${totalResults} insights (showing ${index.length})`;
235
+ }
236
+ else {
237
+ message = `Found ${index.length} insights`;
238
+ }
239
+ return {
240
+ success: true,
241
+ message,
242
+ data: index,
243
+ };
244
+ }
245
+ catch (error) {
246
+ log.error('Error listing index:', error);
247
+ return {
248
+ success: false,
249
+ message: `Failed to list index: ${error.message}`,
250
+ };
251
+ }
252
+ }
253
+ export async function getDetail(id) {
254
+ try {
255
+ let insightData;
256
+ // Load, update, and save memory with mutex protection
257
+ await memoryMutex.runExclusive(async () => {
258
+ const memory = await loadMemory();
259
+ const insight = memory.insights.find(i => i.id === id);
260
+ if (!insight) {
261
+ return;
262
+ }
263
+ // Update lastUsed timestamp on every access
264
+ // NOTE: This writes to disk on every getDetail call. For current requirements
265
+ // (< 10 ops/min, single user) this is acceptable. For high-frequency access,
266
+ // consider adding debounce or batching lastUsed updates.
267
+ insight.lastUsed = new Date().toISOString();
268
+ await saveMemory(memory);
269
+ insightData = insight;
270
+ });
271
+ if (!insightData) {
272
+ return {
273
+ success: false,
274
+ message: `Insight not found: ${id}`,
275
+ };
276
+ }
277
+ return {
278
+ success: true,
279
+ message: 'Insight retrieved successfully',
280
+ data: insightData,
281
+ };
282
+ }
283
+ catch (error) {
284
+ log.error('Error getting detail:', error);
285
+ return {
286
+ success: false,
287
+ message: `Failed to get detail: ${error.message}`,
288
+ };
289
+ }
290
+ }
291
+ export async function markHelpful(id) {
292
+ try {
293
+ let helpfulCount = 0;
294
+ let found = false;
295
+ // Load, update, and save memory with mutex protection
296
+ await memoryMutex.runExclusive(async () => {
297
+ const memory = await loadMemory();
298
+ const insight = memory.insights.find(i => i.id === id);
299
+ if (!insight) {
300
+ return;
301
+ }
302
+ insight.helpful += 1;
303
+ // Update lastUsed on feedback (acceptable for current load)
304
+ insight.lastUsed = new Date().toISOString();
305
+ await saveMemory(memory);
306
+ helpfulCount = insight.helpful;
307
+ found = true;
308
+ });
309
+ if (!found) {
310
+ return {
311
+ success: false,
312
+ message: `Insight not found: ${id}`,
313
+ };
314
+ }
315
+ log.info(`Marked helpful: ${id}`);
316
+ return {
317
+ success: true,
318
+ message: `Insight marked as helpful (count: ${helpfulCount})`,
319
+ data: { helpful: helpfulCount },
320
+ };
321
+ }
322
+ catch (error) {
323
+ log.error('Error marking helpful:', error);
324
+ return {
325
+ success: false,
326
+ message: `Failed to mark helpful: ${error.message}`,
327
+ };
328
+ }
329
+ }
330
+ export async function markHarmful(id) {
331
+ try {
332
+ let harmfulCount = 0;
333
+ let found = false;
334
+ // Load, update, and save memory with mutex protection
335
+ await memoryMutex.runExclusive(async () => {
336
+ const memory = await loadMemory();
337
+ const insight = memory.insights.find(i => i.id === id);
338
+ if (!insight) {
339
+ return;
340
+ }
341
+ insight.harmful += 1;
342
+ // Update lastUsed on feedback (acceptable for current load)
343
+ insight.lastUsed = new Date().toISOString();
344
+ await saveMemory(memory);
345
+ harmfulCount = insight.harmful;
346
+ found = true;
347
+ });
348
+ if (!found) {
349
+ return {
350
+ success: false,
351
+ message: `Insight not found: ${id}`,
352
+ };
353
+ }
354
+ log.info(`Marked harmful: ${id}`);
355
+ return {
356
+ success: true,
357
+ message: `Insight marked as harmful (count: ${harmfulCount})`,
358
+ data: { harmful: harmfulCount },
359
+ };
360
+ }
361
+ catch (error) {
362
+ log.error('Error marking harmful:', error);
363
+ return {
364
+ success: false,
365
+ message: `Failed to mark harmful: ${error.message}`,
366
+ };
367
+ }
368
+ }
369
+ export async function searchInsights(query, limit = 5) {
370
+ try {
371
+ // Validate query
372
+ const validationError = validateSearchQuery(query);
373
+ if (validationError) {
374
+ return {
375
+ success: false,
376
+ message: validationError,
377
+ };
378
+ }
379
+ const memory = await loadMemory();
380
+ // Normalize query
381
+ const normalizedQuery = query.trim().toLowerCase();
382
+ // Score each insight
383
+ const PREVIEW_LIMIT = 100;
384
+ const scoredInsights = memory.insights.map(insight => {
385
+ let baseScore = 0;
386
+ const normalizedTitle = insight.title.toLowerCase();
387
+ const normalizedContent = insight.content.toLowerCase();
388
+ // Title scoring
389
+ if (normalizedTitle === normalizedQuery) {
390
+ baseScore += 10; // Exact match in title
391
+ }
392
+ else if (normalizedTitle.includes(normalizedQuery)) {
393
+ baseScore += 5; // Partial match in title
394
+ }
395
+ // Content scoring
396
+ if (normalizedContent.includes(normalizedQuery)) {
397
+ baseScore += 3; // Match in content
398
+ }
399
+ // Tags scoring
400
+ // Note: tags are already normalized to lowercase during storage
401
+ for (const tag of insight.tags) {
402
+ if (tag.includes(normalizedQuery)) {
403
+ baseScore += 2; // Match in tag
404
+ }
405
+ }
406
+ // Helpful count bonus
407
+ baseScore += insight.helpful;
408
+ // Recent usage bonus (within 7 days)
409
+ const lastUsedDate = new Date(insight.lastUsed);
410
+ const now = new Date();
411
+ const daysDiff = (now.getTime() - lastUsedDate.getTime()) / (1000 * 60 * 60 * 24);
412
+ if (daysDiff >= 0 && daysDiff <= 7) {
413
+ baseScore += 3;
414
+ }
415
+ // Calculate trust score and add to ranking
416
+ const trustScore = calculateTrustScore(insight.helpful, insight.harmful);
417
+ baseScore += trustScore * 5;
418
+ // Apply decay factor based on days since last used
419
+ // Decay from 1.0 to 0.1 over 90 days
420
+ const daysSinceUsed = Math.max(0, daysDiff);
421
+ const decayFactor = Math.max(0.1, 1 - daysSinceUsed / 90);
422
+ const effectiveScore = baseScore * decayFactor;
423
+ const entry = {
424
+ id: insight.id,
425
+ title: insight.title,
426
+ tags: insight.tags,
427
+ helpful: insight.helpful,
428
+ harmful: insight.harmful,
429
+ created: insight.created,
430
+ lastUsed: insight.lastUsed,
431
+ contentPreview: insight.content.substring(0, PREVIEW_LIMIT),
432
+ contentTruncated: insight.content.length > PREVIEW_LIMIT,
433
+ sourceUrl: insight.sourceUrl,
434
+ trustScore,
435
+ score: effectiveScore,
436
+ };
437
+ if (trustScore < 0) {
438
+ entry.warning = 'Low trust score';
439
+ }
440
+ return entry;
441
+ });
442
+ // Filter insights with score > 0
443
+ const matchedInsights = scoredInsights.filter(insight => insight.score > 0);
444
+ // Sort by score descending
445
+ matchedInsights.sort((a, b) => b.score - a.score);
446
+ // Apply limit
447
+ const limitedInsights = matchedInsights.slice(0, limit);
448
+ // Remove score field before returning
449
+ const results = limitedInsights.map(({ score, ...rest }) => rest);
450
+ if (results.length === 0) {
451
+ return {
452
+ success: true,
453
+ message: `No insights found matching query: "${query}"`,
454
+ data: [],
455
+ };
456
+ }
457
+ return {
458
+ success: true,
459
+ message: `Found ${results.length} insights matching query: "${query}"`,
460
+ data: results,
461
+ };
462
+ }
463
+ catch (error) {
464
+ log.error('Error searching insights:', error);
465
+ return {
466
+ success: false,
467
+ message: `Failed to search insights: ${error.message}`,
468
+ };
469
+ }
470
+ }
471
+ export async function deleteInsight(id) {
472
+ try {
473
+ let found = false;
474
+ // Load, update, and save memory with mutex protection
475
+ await memoryMutex.runExclusive(async () => {
476
+ const memory = await loadMemory();
477
+ const index = memory.insights.findIndex(i => i.id === id);
478
+ if (index === -1) {
479
+ return;
480
+ }
481
+ memory.insights.splice(index, 1);
482
+ await saveMemory(memory);
483
+ found = true;
484
+ });
485
+ if (!found) {
486
+ return {
487
+ success: false,
488
+ message: 'Insight not found',
489
+ };
490
+ }
491
+ log.info(`Deleted insight: ${id}`);
492
+ return {
493
+ success: true,
494
+ message: `Deleted ${id}`,
495
+ };
496
+ }
497
+ catch (error) {
498
+ log.error('Error deleting insight:', error);
499
+ return {
500
+ success: false,
501
+ message: `Failed to delete insight: ${error.message}`,
502
+ };
503
+ }
504
+ }
505
+ export async function exportMemory(format = 'json') {
506
+ try {
507
+ const memory = await loadMemory();
508
+ let data;
509
+ if (format === 'markdown') {
510
+ // Format as readable markdown
511
+ const lines = [
512
+ '# Learning Memory Export',
513
+ '',
514
+ `> Exported: ${new Date().toISOString()}`,
515
+ `> Total insights: ${memory.insights.length}`,
516
+ '',
517
+ '---',
518
+ '',
519
+ ];
520
+ for (const insight of memory.insights) {
521
+ const trustScore = calculateTrustScore(insight.helpful, insight.harmful);
522
+ lines.push(`## ${insight.title}`);
523
+ lines.push('');
524
+ lines.push(`**ID:** ${insight.id}`);
525
+ lines.push(`**Tags:** ${insight.tags.join(', ')}`);
526
+ lines.push(`**Stats:** Helpful ${insight.helpful} / Harmful ${insight.harmful} (Trust: ${trustScore})`);
527
+ lines.push(`**Created:** ${insight.created}`);
528
+ lines.push(`**Last Used:** ${insight.lastUsed}`);
529
+ if (insight.sourceUrl) {
530
+ lines.push(`**Source:** ${insight.sourceUrl}`);
531
+ }
532
+ lines.push('');
533
+ lines.push('### Content');
534
+ lines.push('');
535
+ lines.push(insight.content);
536
+ lines.push('');
537
+ lines.push('---');
538
+ lines.push('');
539
+ }
540
+ data = lines.join('\n');
541
+ }
542
+ else {
543
+ // Format as JSON
544
+ data = JSON.stringify(memory, null, 2);
545
+ }
546
+ return {
547
+ success: true,
548
+ message: `Exported ${memory.insights.length} insights as ${format}`,
549
+ data: { data, count: memory.insights.length },
550
+ };
551
+ }
552
+ catch (error) {
553
+ log.error('Error exporting memory:', error);
554
+ return {
555
+ success: false,
556
+ message: `Failed to export memory: ${error.message}`,
557
+ };
558
+ }
559
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Core data structures for ACE Flash Memory MCP
3
+ */
4
+ export interface Insight {
5
+ id: string;
6
+ title: string;
7
+ tags: string[];
8
+ content: string;
9
+ helpful: number;
10
+ harmful: number;
11
+ created: string;
12
+ lastUsed: string;
13
+ sourceUrl?: string;
14
+ }
15
+ export interface Memory {
16
+ insights: Insight[];
17
+ }
18
+ export interface IndexEntry {
19
+ id: string;
20
+ title: string;
21
+ tags: string[];
22
+ helpful: number;
23
+ harmful: number;
24
+ created: string;
25
+ lastUsed: string;
26
+ contentPreview: string;
27
+ contentTruncated: boolean;
28
+ sourceUrl?: string;
29
+ trustScore: number;
30
+ warning?: string;
31
+ }
32
+ export interface ToolResponse {
33
+ success: boolean;
34
+ message: string;
35
+ data?: unknown;
36
+ }
37
+ export interface ListIndexParams {
38
+ tags?: string[];
39
+ minHelpful?: number;
40
+ sortBy?: 'created' | 'lastUsed' | 'helpful';
41
+ limit?: number;
42
+ }
43
+ export interface SearchInsightsParams {
44
+ query: string;
45
+ limit?: number;
46
+ }
47
+ export interface ScoredInsight extends IndexEntry {
48
+ score: number;
49
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Core data structures for ACE Flash Memory MCP
3
+ */
4
+ export {};
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Input validation and normalization
3
+ */
4
+ export declare const LIMITS: {
5
+ readonly TITLE_MAX: 200;
6
+ readonly TAGS_MAX: 10;
7
+ readonly TAG_LENGTH_MAX: 50;
8
+ readonly CONTENT_MAX: 10000;
9
+ readonly SOURCE_URL_MAX: 2000;
10
+ };
11
+ export declare function validateInsightInput(title: string, tags: string[], content: string, sourceUrl?: string): string | null;
12
+ export declare function normalizeTags(tags: string[]): string[];
13
+ export declare function generateId(nextNumber: number): string;
14
+ /**
15
+ * Calculate Levenshtein distance between two strings (case-insensitive)
16
+ * Used for detecting similar titles in auto-deduplication
17
+ */
18
+ export declare function levenshteinDistance(str1: string, str2: string): number;
19
+ /**
20
+ * Validate parameters for list_index tool
21
+ */
22
+ export declare function validateListIndexParams(params: {
23
+ tags?: string[];
24
+ minHelpful?: number;
25
+ sortBy?: string;
26
+ limit?: number;
27
+ }): string | null;
28
+ /**
29
+ * Validate search query for search_insights tool
30
+ */
31
+ export declare function validateSearchQuery(query: string): string | null;