@asd412id/mcp-context-manager 1.0.11 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/prompts.js +20 -16
- package/dist/tools/context.d.ts +23 -0
- package/dist/tools/context.js +229 -0
- package/dist/tools/context.test.d.ts +1 -0
- package/dist/tools/context.test.js +44 -0
- package/dist/tools/memory.d.ts +14 -0
- package/dist/tools/memory.js +130 -0
- package/dist/tools/memory.test.d.ts +1 -0
- package/dist/tools/memory.test.js +27 -0
- package/dist/tools/session.js +31 -6
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -278,7 +278,7 @@ To specify a custom path for storing context data, add environment variables.
|
|
|
278
278
|
| `ctx-cleanup` | **Free up context window space and reduce RAM usage** |
|
|
279
279
|
| `ctx-handoff` | **Generate handoff document for new session** |
|
|
280
280
|
|
|
281
|
-
## Available Tools (
|
|
281
|
+
## Available Tools (33 tools)
|
|
282
282
|
|
|
283
283
|
### Session Management
|
|
284
284
|
|
|
@@ -300,6 +300,7 @@ To specify a custom path for storing context data, add environment variables.
|
|
|
300
300
|
| `memory_list` | List all memory keys |
|
|
301
301
|
| `memory_clear` | Clear memory (all/by tags) |
|
|
302
302
|
| `memory_cleanup` | Remove expired memory entries |
|
|
303
|
+
| `memory_capture_candidates` | Extract and optionally persist important memory candidates from text |
|
|
303
304
|
|
|
304
305
|
### Context Summarizer
|
|
305
306
|
|
|
@@ -310,6 +311,7 @@ To specify a custom path for storing context data, add environment variables.
|
|
|
310
311
|
| `context_list_summaries` | List all summaries |
|
|
311
312
|
| `context_merge_summaries` | Merge multiple summaries |
|
|
312
313
|
| `context_status` | Get storage stats and token usage estimate |
|
|
314
|
+
| `context_prune_smart` | Score context items, keep high-signal, summarize/prune low-signal |
|
|
313
315
|
| `store_health` | Check store integrity and recommendations |
|
|
314
316
|
|
|
315
317
|
### Project Tracker
|
package/dist/prompts.js
CHANGED
|
@@ -5,22 +5,24 @@ const CONTEXT_MANAGEMENT_INSTRUCTIONS = `
|
|
|
5
5
|
|
|
6
6
|
**To minimize context window usage and free RAM:**
|
|
7
7
|
|
|
8
|
-
1. **
|
|
9
|
-
2. **
|
|
10
|
-
3. **
|
|
11
|
-
4. **
|
|
8
|
+
1. **Recall before reading** - Use memory_search()/tracker_search() first, then read files only if needed
|
|
9
|
+
2. **Offload to persistent storage** - Use memory_set() or memory_capture_candidates() for important findings
|
|
10
|
+
3. **Read files efficiently** - Use file_smart_read(structureOnly:true) or file_smart_read(keywords:[...])
|
|
11
|
+
4. **Smart prune context** - Use context_prune_smart() to keep high-signal items and prune low-signal chatter
|
|
12
|
+
5. **Checkpoint regularly** - Save state every 10-15 messages with checkpoint_save()
|
|
12
13
|
|
|
13
14
|
**When to cleanup (check with context_status()):**
|
|
14
|
-
- Token usage >50% →
|
|
15
|
+
- Token usage >50% → Run context_prune_smart() and summarize verbose content
|
|
15
16
|
- Token usage >70% → Checkpoint and consider handoff
|
|
16
17
|
- Token usage >85% → MUST handoff to new session
|
|
17
18
|
|
|
18
19
|
**Cleanup workflow:**
|
|
19
20
|
1. context_status() → Check token usage
|
|
20
|
-
2.
|
|
21
|
-
3.
|
|
22
|
-
4.
|
|
23
|
-
5.
|
|
21
|
+
2. context_prune_smart(items, mode:"hybrid") → Score and prune low-signal context
|
|
22
|
+
3. context_summarize(longText) → Compress high-value verbose content
|
|
23
|
+
4. memory_capture_candidates(text, dryRun:false) → Persist important facts automatically
|
|
24
|
+
5. checkpoint_save() → Save state
|
|
25
|
+
6. session_handoff() → Generate handoff doc
|
|
24
26
|
`;
|
|
25
27
|
export function registerPrompts(server) {
|
|
26
28
|
// ctx-init - Load previous context with instructions
|
|
@@ -39,7 +41,8 @@ ${CONTEXT_MANAGEMENT_INSTRUCTIONS}
|
|
|
39
41
|
**After init, follow these rules:**
|
|
40
42
|
- Log decisions with tracker_log(type:"decision")
|
|
41
43
|
- Log file changes with tracker_log(type:"change")
|
|
42
|
-
-
|
|
44
|
+
- Capture important findings with memory_capture_candidates() or memory_set()
|
|
45
|
+
- Prune processed context with context_prune_smart()
|
|
43
46
|
- Checkpoint every 10-15 messages`
|
|
44
47
|
}
|
|
45
48
|
}]
|
|
@@ -130,7 +133,7 @@ ${CONTEXT_MANAGEMENT_INSTRUCTIONS}
|
|
|
130
133
|
role: 'user',
|
|
131
134
|
content: {
|
|
132
135
|
type: 'text',
|
|
133
|
-
text: `Context getting long.
|
|
136
|
+
text: `Context getting long. Run context_prune_smart() first, summarize with context_summarize(), persist key info via memory_capture_candidates(), then save checkpoint.`
|
|
134
137
|
}
|
|
135
138
|
}]
|
|
136
139
|
}));
|
|
@@ -166,15 +169,16 @@ ${CONTEXT_MANAGEMENT_INSTRUCTIONS}
|
|
|
166
169
|
**Execute this workflow:**
|
|
167
170
|
1. context_status() → Check current token usage estimate
|
|
168
171
|
2. Identify verbose content in conversation that can be summarized
|
|
169
|
-
3.
|
|
170
|
-
4.
|
|
171
|
-
5.
|
|
172
|
-
6.
|
|
172
|
+
3. context_prune_smart(items, mode:"hybrid") → Keep high-signal and prune low-signal context
|
|
173
|
+
4. context_summarize(verboseText, maxLength:1000) → Compress long content
|
|
174
|
+
5. memory_capture_candidates(text, dryRun:false) → Offload important data automatically
|
|
175
|
+
6. checkpoint_save(name, state) → Save current session state
|
|
176
|
+
7. If token usage >70%, run session_handoff() to prepare for new session
|
|
173
177
|
|
|
174
178
|
**Tips to reduce context:**
|
|
175
179
|
- Use file_smart_read(structureOnly:true) instead of reading full files
|
|
176
180
|
- Use file_smart_read(keywords:[...]) to read only relevant sections
|
|
177
|
-
- Store discovered info in memory_set() instead of keeping in context
|
|
181
|
+
- Store discovered info in memory_capture_candidates()/memory_set() instead of keeping in context
|
|
178
182
|
- Don't re-read files - use memory_get() for cached data`
|
|
179
183
|
}
|
|
180
184
|
}]
|
package/dist/tools/context.d.ts
CHANGED
|
@@ -1,2 +1,25 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
interface SmartContextItem {
|
|
3
|
+
id: string;
|
|
4
|
+
text: string;
|
|
5
|
+
source?: string;
|
|
6
|
+
timestamp?: string;
|
|
7
|
+
pinned?: boolean;
|
|
8
|
+
}
|
|
9
|
+
declare function summarizeForPrune(text: string, maxLength: number): string;
|
|
10
|
+
declare function scoreSmartContextItem(item: SmartContextItem, now: number): {
|
|
11
|
+
score: number;
|
|
12
|
+
signals: string[];
|
|
13
|
+
};
|
|
14
|
+
declare function extractPruneMemoryCandidates(text: string): Array<{
|
|
15
|
+
keyHint: string;
|
|
16
|
+
reason: string;
|
|
17
|
+
value: string;
|
|
18
|
+
}>;
|
|
19
|
+
export declare const __contextTestables: {
|
|
20
|
+
summarizeForPrune: typeof summarizeForPrune;
|
|
21
|
+
scoreSmartContextItem: typeof scoreSmartContextItem;
|
|
22
|
+
extractPruneMemoryCandidates: typeof extractPruneMemoryCandidates;
|
|
23
|
+
};
|
|
2
24
|
export declare function registerContextTools(server: McpServer): void;
|
|
25
|
+
export {};
|
package/dist/tools/context.js
CHANGED
|
@@ -78,6 +78,121 @@ async function getBackupStats(basePath) {
|
|
|
78
78
|
}
|
|
79
79
|
return stats;
|
|
80
80
|
}
|
|
81
|
+
function summarizeForPrune(text, maxLength) {
|
|
82
|
+
if (text.length <= maxLength)
|
|
83
|
+
return text;
|
|
84
|
+
const sentences = text.match(/[^.!?\n]+[.!?]?/g) || [text];
|
|
85
|
+
const important = [];
|
|
86
|
+
const normal = [];
|
|
87
|
+
for (const sentenceRaw of sentences) {
|
|
88
|
+
const sentence = sentenceRaw.trim();
|
|
89
|
+
if (!sentence)
|
|
90
|
+
continue;
|
|
91
|
+
const lower = sentence.toLowerCase();
|
|
92
|
+
if (lower.includes('important') ||
|
|
93
|
+
lower.includes('decision') ||
|
|
94
|
+
lower.includes('todo') ||
|
|
95
|
+
lower.includes('error') ||
|
|
96
|
+
lower.includes('must') ||
|
|
97
|
+
lower.includes('action')) {
|
|
98
|
+
important.push(sentence);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
normal.push(sentence);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const compact = [];
|
|
105
|
+
for (const sentence of [...important, ...normal]) {
|
|
106
|
+
const next = compact.length > 0 ? `${compact.join(' ')} ${sentence}` : sentence;
|
|
107
|
+
if (next.length > maxLength)
|
|
108
|
+
break;
|
|
109
|
+
compact.push(sentence);
|
|
110
|
+
}
|
|
111
|
+
const summary = compact.join(' ').trim();
|
|
112
|
+
return summary || `${text.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
113
|
+
}
|
|
114
|
+
function scoreSmartContextItem(item, now) {
|
|
115
|
+
const text = item.text || '';
|
|
116
|
+
const lower = text.toLowerCase();
|
|
117
|
+
let score = 0;
|
|
118
|
+
const signals = [];
|
|
119
|
+
if (item.pinned) {
|
|
120
|
+
score += 1000;
|
|
121
|
+
signals.push('pinned');
|
|
122
|
+
}
|
|
123
|
+
const importantKeywords = ['error', 'bug', 'decision', 'todo', 'must', 'important', 'action', 'requirement', 'blocked'];
|
|
124
|
+
const matchedKeywords = importantKeywords.filter((kw) => lower.includes(kw));
|
|
125
|
+
if (matchedKeywords.length > 0) {
|
|
126
|
+
score += Math.min(28, matchedKeywords.length * 4);
|
|
127
|
+
signals.push(`keywords:${matchedKeywords.join(',')}`);
|
|
128
|
+
}
|
|
129
|
+
if (item.source) {
|
|
130
|
+
const sourceLower = item.source.toLowerCase();
|
|
131
|
+
if (sourceLower.includes('user')) {
|
|
132
|
+
score += 10;
|
|
133
|
+
signals.push('source:user');
|
|
134
|
+
}
|
|
135
|
+
else if (sourceLower.includes('system')) {
|
|
136
|
+
score += 8;
|
|
137
|
+
signals.push('source:system');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (/([a-zA-Z]:\\|\/).+\.[a-z0-9]+/i.test(text)) {
|
|
141
|
+
score += 6;
|
|
142
|
+
signals.push('contains:path');
|
|
143
|
+
}
|
|
144
|
+
if (/\bhttps?:\/\//i.test(text)) {
|
|
145
|
+
score += 5;
|
|
146
|
+
signals.push('contains:url');
|
|
147
|
+
}
|
|
148
|
+
if (text.length > 400) {
|
|
149
|
+
score += 4;
|
|
150
|
+
signals.push('long-context');
|
|
151
|
+
}
|
|
152
|
+
else if (text.length < 24) {
|
|
153
|
+
score -= 4;
|
|
154
|
+
signals.push('very-short');
|
|
155
|
+
}
|
|
156
|
+
if (item.timestamp) {
|
|
157
|
+
const ts = Date.parse(item.timestamp);
|
|
158
|
+
if (!Number.isNaN(ts)) {
|
|
159
|
+
const ageHours = Math.max(0, (now - ts) / (1000 * 60 * 60));
|
|
160
|
+
const recency = Math.max(0, 20 - Math.floor(ageHours / 2));
|
|
161
|
+
score += recency;
|
|
162
|
+
signals.push(`recency:+${recency}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (/(^|\s)(ok|thanks|noted|done)(\s|$)/i.test(lower)) {
|
|
166
|
+
score -= 3;
|
|
167
|
+
signals.push('low-signal-chat');
|
|
168
|
+
}
|
|
169
|
+
return { score, signals };
|
|
170
|
+
}
|
|
171
|
+
function extractPruneMemoryCandidates(text) {
|
|
172
|
+
const candidates = [];
|
|
173
|
+
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
174
|
+
for (const line of lines) {
|
|
175
|
+
const lower = line.toLowerCase();
|
|
176
|
+
if (lower.includes('decision:') || lower.includes('decided to')) {
|
|
177
|
+
candidates.push({ keyHint: 'decision.auto', reason: 'decision-signal', value: line });
|
|
178
|
+
}
|
|
179
|
+
if (lower.includes('todo:') || lower.includes('action:') || lower.includes('must ')) {
|
|
180
|
+
candidates.push({ keyHint: 'todo.auto', reason: 'action-signal', value: line });
|
|
181
|
+
}
|
|
182
|
+
if (lower.includes('error') || lower.includes('failed') || lower.includes('exception')) {
|
|
183
|
+
candidates.push({ keyHint: 'error.auto', reason: 'error-signal', value: line });
|
|
184
|
+
}
|
|
185
|
+
if (/\bhttps?:\/\//i.test(line)) {
|
|
186
|
+
candidates.push({ keyHint: 'reference.url', reason: 'url-signal', value: line });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return candidates.slice(0, 8);
|
|
190
|
+
}
|
|
191
|
+
export const __contextTestables = {
|
|
192
|
+
summarizeForPrune,
|
|
193
|
+
scoreSmartContextItem,
|
|
194
|
+
extractPruneMemoryCandidates
|
|
195
|
+
};
|
|
81
196
|
export function registerContextTools(server) {
|
|
82
197
|
server.registerTool('context_status', {
|
|
83
198
|
title: 'Context Status',
|
|
@@ -140,6 +255,120 @@ WHEN TO USE:
|
|
|
140
255
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
141
256
|
};
|
|
142
257
|
});
|
|
258
|
+
server.registerTool('context_prune_smart', {
|
|
259
|
+
title: 'Smart Context Prune',
|
|
260
|
+
description: `Smart context pruning with relevance scoring, optional summarization, and memory candidate extraction.
|
|
261
|
+
WHEN TO USE:
|
|
262
|
+
- After processing large batches of context/tool output
|
|
263
|
+
- When token pressure is rising and you need to keep only high-signal context
|
|
264
|
+
- To generate compact summaries before pruning noisy context`,
|
|
265
|
+
inputSchema: {
|
|
266
|
+
items: z.array(z.object({
|
|
267
|
+
id: z.string().describe('Unique context item identifier'),
|
|
268
|
+
text: z.string().describe('Context content text'),
|
|
269
|
+
source: z.string().optional().describe('Context source (user/tool/system/assistant)'),
|
|
270
|
+
timestamp: z.string().optional().describe('ISO timestamp for recency scoring'),
|
|
271
|
+
pinned: z.boolean().optional().describe('Pinned items are always kept')
|
|
272
|
+
})).describe('Context items to evaluate'),
|
|
273
|
+
mode: z.enum(['hybrid', 'aggressive', 'conservative']).optional().describe('Prune strategy mode (default: hybrid)'),
|
|
274
|
+
maxKeep: z.number().optional().describe('Maximum number of items to keep (default: 8)'),
|
|
275
|
+
summaryMaxLength: z.number().optional().describe('Maximum summary length for compressed entries (default: 240)'),
|
|
276
|
+
memoryCandidateLimit: z.number().optional().describe('Maximum extracted memory candidates (default: 8)')
|
|
277
|
+
}
|
|
278
|
+
}, async ({ items, mode = 'hybrid', maxKeep = 8, summaryMaxLength = 240, memoryCandidateLimit = 8 }) => {
|
|
279
|
+
if (items.length === 0) {
|
|
280
|
+
return {
|
|
281
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
282
|
+
mode,
|
|
283
|
+
totalItems: 0,
|
|
284
|
+
keep: [],
|
|
285
|
+
prune: [],
|
|
286
|
+
summaries: [],
|
|
287
|
+
memoryCandidates: []
|
|
288
|
+
}, null, 2) }]
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const now = Date.now();
|
|
292
|
+
const normalizedItems = items.map((item) => ({
|
|
293
|
+
id: item.id,
|
|
294
|
+
text: item.text,
|
|
295
|
+
source: item.source,
|
|
296
|
+
timestamp: item.timestamp,
|
|
297
|
+
pinned: item.pinned
|
|
298
|
+
}));
|
|
299
|
+
const scored = normalizedItems.map((item) => {
|
|
300
|
+
const { score, signals } = scoreSmartContextItem(item, now);
|
|
301
|
+
return { item, score, signals };
|
|
302
|
+
}).sort((a, b) => b.score - a.score);
|
|
303
|
+
const pinnedItems = scored.filter((entry) => entry.item.pinned);
|
|
304
|
+
const keepBaseByMode = mode === 'aggressive'
|
|
305
|
+
? Math.max(3, Math.ceil(scored.length * 0.25))
|
|
306
|
+
: mode === 'conservative'
|
|
307
|
+
? Math.max(5, Math.ceil(scored.length * 0.7))
|
|
308
|
+
: Math.max(4, Math.ceil(scored.length * 0.45));
|
|
309
|
+
const keepTarget = Math.min(scored.length, Math.max(pinnedItems.length, Math.min(maxKeep, keepBaseByMode)));
|
|
310
|
+
const keepSet = new Set();
|
|
311
|
+
for (const pinned of pinnedItems) {
|
|
312
|
+
keepSet.add(pinned.item.id);
|
|
313
|
+
}
|
|
314
|
+
for (const entry of scored) {
|
|
315
|
+
if (keepSet.size >= keepTarget)
|
|
316
|
+
break;
|
|
317
|
+
keepSet.add(entry.item.id);
|
|
318
|
+
}
|
|
319
|
+
const keep = scored
|
|
320
|
+
.filter((entry) => keepSet.has(entry.item.id))
|
|
321
|
+
.map((entry) => ({
|
|
322
|
+
id: entry.item.id,
|
|
323
|
+
source: entry.item.source,
|
|
324
|
+
score: entry.score,
|
|
325
|
+
pinned: !!entry.item.pinned,
|
|
326
|
+
signals: entry.signals,
|
|
327
|
+
textPreview: summarizeForPrune(entry.item.text, 180)
|
|
328
|
+
}));
|
|
329
|
+
const prunedScored = scored
|
|
330
|
+
.filter((entry) => !keepSet.has(entry.item.id));
|
|
331
|
+
const prune = prunedScored
|
|
332
|
+
.map((entry) => ({
|
|
333
|
+
id: entry.item.id,
|
|
334
|
+
source: entry.item.source,
|
|
335
|
+
score: entry.score,
|
|
336
|
+
signals: entry.signals,
|
|
337
|
+
reason: entry.score < 8 ? 'low-signal' : 'lower-priority',
|
|
338
|
+
textPreview: summarizeForPrune(entry.item.text, 120)
|
|
339
|
+
}));
|
|
340
|
+
const summaries = prunedScored
|
|
341
|
+
.filter((entry) => entry.item.text.length > summaryMaxLength)
|
|
342
|
+
.slice(0, 12)
|
|
343
|
+
.map((entry) => ({
|
|
344
|
+
id: entry.item.id,
|
|
345
|
+
source: entry.item.source,
|
|
346
|
+
originalLength: entry.item.text.length,
|
|
347
|
+
summary: summarizeForPrune(entry.item.text, summaryMaxLength)
|
|
348
|
+
}));
|
|
349
|
+
const memoryCandidates = prunedScored
|
|
350
|
+
.flatMap((entry) => extractPruneMemoryCandidates(entry.item.text))
|
|
351
|
+
.slice(0, memoryCandidateLimit);
|
|
352
|
+
const output = {
|
|
353
|
+
mode,
|
|
354
|
+
strategy: {
|
|
355
|
+
totalItems: scored.length,
|
|
356
|
+
keepTarget,
|
|
357
|
+
kept: keep.length,
|
|
358
|
+
pruned: prune.length
|
|
359
|
+
},
|
|
360
|
+
keep,
|
|
361
|
+
prune,
|
|
362
|
+
summaries,
|
|
363
|
+
memoryCandidates,
|
|
364
|
+
recommendation: prune.length > 0
|
|
365
|
+
? 'Prune listed low-signal items and persist memoryCandidates via memory_set or memory_capture_candidates'
|
|
366
|
+
: 'No prune needed; current context is already high-signal'
|
|
367
|
+
};
|
|
368
|
+
return {
|
|
369
|
+
content: [{ type: 'text', text: JSON.stringify(output, null, 2) }]
|
|
370
|
+
};
|
|
371
|
+
});
|
|
143
372
|
server.registerTool('store_health', {
|
|
144
373
|
title: 'Store Health',
|
|
145
374
|
description: `Check health of the context store - file integrity, backup status, and recommendations.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { __contextTestables } from './context.js';
|
|
4
|
+
test('summarizeForPrune respects max length and keeps key sentence', () => {
|
|
5
|
+
const text = [
|
|
6
|
+
'This is regular background sentence.',
|
|
7
|
+
'Decision: use smart prune scoring for noisy outputs.',
|
|
8
|
+
'Another long detail to fill space and trigger compression behavior.'
|
|
9
|
+
].join(' ');
|
|
10
|
+
const summary = __contextTestables.summarizeForPrune(text, 80);
|
|
11
|
+
assert.ok(summary.length <= 80);
|
|
12
|
+
assert.ok(summary.toLowerCase().includes('decision'));
|
|
13
|
+
});
|
|
14
|
+
test('scoreSmartContextItem gives higher score for pinned/important content', () => {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
const high = __contextTestables.scoreSmartContextItem({
|
|
17
|
+
id: 'a',
|
|
18
|
+
text: 'Important decision: must fix error in src/tools/context.ts',
|
|
19
|
+
source: 'user',
|
|
20
|
+
timestamp: new Date(now).toISOString(),
|
|
21
|
+
pinned: true
|
|
22
|
+
}, now);
|
|
23
|
+
const low = __contextTestables.scoreSmartContextItem({
|
|
24
|
+
id: 'b',
|
|
25
|
+
text: 'ok thanks',
|
|
26
|
+
source: 'assistant',
|
|
27
|
+
timestamp: new Date(now - 1000 * 60 * 60 * 48).toISOString()
|
|
28
|
+
}, now);
|
|
29
|
+
assert.ok(high.score > low.score);
|
|
30
|
+
assert.ok(high.signals.includes('pinned'));
|
|
31
|
+
});
|
|
32
|
+
test('extractPruneMemoryCandidates extracts decision/todo/error/url signals', () => {
|
|
33
|
+
const candidates = __contextTestables.extractPruneMemoryCandidates([
|
|
34
|
+
'Decision: Use hybrid mode for prune.',
|
|
35
|
+
'TODO: add tests for memory capture.',
|
|
36
|
+
'Error: build failed in CI.',
|
|
37
|
+
'Reference: https://example.com/docs'
|
|
38
|
+
].join('\n'));
|
|
39
|
+
const reasons = candidates.map((c) => c.reason);
|
|
40
|
+
assert.ok(reasons.includes('decision-signal'));
|
|
41
|
+
assert.ok(reasons.includes('action-signal'));
|
|
42
|
+
assert.ok(reasons.includes('error-signal'));
|
|
43
|
+
assert.ok(reasons.includes('url-signal'));
|
|
44
|
+
});
|
package/dist/tools/memory.d.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
export declare function cleanupExpiredMemories(): Promise<number>;
|
|
3
|
+
interface MemoryCandidate {
|
|
4
|
+
key: string;
|
|
5
|
+
value: string;
|
|
6
|
+
tags: string[];
|
|
7
|
+
confidence: number;
|
|
8
|
+
reason: string;
|
|
9
|
+
}
|
|
10
|
+
declare function slugify(value: string): string;
|
|
11
|
+
declare function buildMemoryCandidates(text: string, source: string, baseTags: string[]): MemoryCandidate[];
|
|
12
|
+
export declare const __memoryTestables: {
|
|
13
|
+
slugify: typeof slugify;
|
|
14
|
+
buildMemoryCandidates: typeof buildMemoryCandidates;
|
|
15
|
+
};
|
|
3
16
|
export declare function registerMemoryTools(server: McpServer): void;
|
|
17
|
+
export {};
|
package/dist/tools/memory.js
CHANGED
|
@@ -79,6 +79,80 @@ function deepMerge(target, source) {
|
|
|
79
79
|
}
|
|
80
80
|
return result;
|
|
81
81
|
}
|
|
82
|
+
function slugify(value) {
|
|
83
|
+
return value
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
86
|
+
.replace(/^-+|-+$/g, '')
|
|
87
|
+
.slice(0, 36) || 'item';
|
|
88
|
+
}
|
|
89
|
+
function buildMemoryCandidates(text, source, baseTags) {
|
|
90
|
+
const candidates = [];
|
|
91
|
+
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
const lower = line.toLowerCase();
|
|
94
|
+
if (lower.includes('decision:') || lower.includes('decided to')) {
|
|
95
|
+
const subject = line.replace(/^.*?(decision:\s*|decided to\s*)/i, '').trim() || line;
|
|
96
|
+
candidates.push({
|
|
97
|
+
key: `decision.${slugify(subject)}`,
|
|
98
|
+
value: line,
|
|
99
|
+
tags: [...baseTags, source, 'decision'],
|
|
100
|
+
confidence: 0.92,
|
|
101
|
+
reason: 'decision signal'
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (lower.includes('todo:') || lower.includes('action:') || lower.includes('must ')) {
|
|
105
|
+
const subject = line.replace(/^.*?(todo:\s*|action:\s*|must\s*)/i, '').trim() || line;
|
|
106
|
+
candidates.push({
|
|
107
|
+
key: `todo.${slugify(subject)}`,
|
|
108
|
+
value: line,
|
|
109
|
+
tags: [...baseTags, source, 'todo'],
|
|
110
|
+
confidence: 0.86,
|
|
111
|
+
reason: 'actionable item signal'
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (lower.includes('error') || lower.includes('failed') || lower.includes('exception')) {
|
|
115
|
+
candidates.push({
|
|
116
|
+
key: `error.${slugify(line)}`,
|
|
117
|
+
value: line,
|
|
118
|
+
tags: [...baseTags, source, 'error'],
|
|
119
|
+
confidence: 0.9,
|
|
120
|
+
reason: 'error signal'
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (/\bhttps?:\/\//i.test(line)) {
|
|
124
|
+
candidates.push({
|
|
125
|
+
key: `reference.url.${slugify(line)}`,
|
|
126
|
+
value: line,
|
|
127
|
+
tags: [...baseTags, source, 'reference'],
|
|
128
|
+
confidence: 0.8,
|
|
129
|
+
reason: 'url reference signal'
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const configMatch = line.match(/\b([A-Z][A-Z0-9_]{2,})\s*[=:]\s*(.+)$/);
|
|
133
|
+
if (configMatch) {
|
|
134
|
+
candidates.push({
|
|
135
|
+
key: `config.${slugify(configMatch[1])}`,
|
|
136
|
+
value: line,
|
|
137
|
+
tags: [...baseTags, source, 'config'],
|
|
138
|
+
confidence: 0.84,
|
|
139
|
+
reason: 'config/env signal'
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const dedup = new Map();
|
|
144
|
+
for (const candidate of candidates) {
|
|
145
|
+
const existing = dedup.get(candidate.key);
|
|
146
|
+
if (!existing || candidate.confidence > existing.confidence) {
|
|
147
|
+
dedup.set(candidate.key, candidate);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return Array.from(dedup.values());
|
|
151
|
+
}
|
|
152
|
+
export const __memoryTestables = {
|
|
153
|
+
slugify,
|
|
154
|
+
buildMemoryCandidates
|
|
155
|
+
};
|
|
82
156
|
export function registerMemoryTools(server) {
|
|
83
157
|
server.registerTool('memory_set', {
|
|
84
158
|
title: 'Memory Set',
|
|
@@ -197,6 +271,62 @@ WHEN TO USE:
|
|
|
197
271
|
}]
|
|
198
272
|
};
|
|
199
273
|
});
|
|
274
|
+
server.registerTool('memory_capture_candidates', {
|
|
275
|
+
title: 'Memory Capture Candidates',
|
|
276
|
+
description: `Extract and optionally persist important memory candidates from raw text.
|
|
277
|
+
WHEN TO USE:
|
|
278
|
+
- After long tool outputs to store decisions/errors/todos automatically
|
|
279
|
+
- Before pruning context to avoid losing important details
|
|
280
|
+
- For proactive memory capture workflows`,
|
|
281
|
+
inputSchema: {
|
|
282
|
+
text: z.string().describe('Raw text to analyze for memory candidates'),
|
|
283
|
+
source: z.string().optional().describe('Source label for extracted candidates (default: llm)'),
|
|
284
|
+
autoTags: z.array(z.string()).optional().describe('Additional tags to include on all candidates'),
|
|
285
|
+
maxCandidates: z.number().optional().describe('Maximum number of candidates to return/store (default: 10)'),
|
|
286
|
+
dryRun: z.boolean().optional().describe('If true, only preview candidates without persisting')
|
|
287
|
+
}
|
|
288
|
+
}, async ({ text, source = 'llm', autoTags = [], maxCandidates = 10, dryRun = true }) => {
|
|
289
|
+
const candidates = buildMemoryCandidates(text, source, autoTags).slice(0, maxCandidates);
|
|
290
|
+
if (candidates.length === 0) {
|
|
291
|
+
return {
|
|
292
|
+
content: [{ type: 'text', text: 'No important memory candidates detected' }]
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (dryRun) {
|
|
296
|
+
return {
|
|
297
|
+
content: [{
|
|
298
|
+
type: 'text',
|
|
299
|
+
text: JSON.stringify({ dryRun: true, count: candidates.length, candidates }, null, 2)
|
|
300
|
+
}]
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const memStore = await getMemoryStore();
|
|
304
|
+
const now = new Date().toISOString();
|
|
305
|
+
const savedKeys = [];
|
|
306
|
+
for (const candidate of candidates) {
|
|
307
|
+
const existing = memStore.entries[candidate.key];
|
|
308
|
+
memStore.entries[candidate.key] = {
|
|
309
|
+
key: candidate.key,
|
|
310
|
+
value: candidate.value,
|
|
311
|
+
tags: candidate.tags,
|
|
312
|
+
createdAt: existing?.createdAt || now,
|
|
313
|
+
updatedAt: now
|
|
314
|
+
};
|
|
315
|
+
savedKeys.push(candidate.key);
|
|
316
|
+
}
|
|
317
|
+
await saveMemoryStore(memStore);
|
|
318
|
+
return {
|
|
319
|
+
content: [{
|
|
320
|
+
type: 'text',
|
|
321
|
+
text: JSON.stringify({
|
|
322
|
+
dryRun: false,
|
|
323
|
+
savedCount: savedKeys.length,
|
|
324
|
+
savedKeys,
|
|
325
|
+
candidates
|
|
326
|
+
}, null, 2)
|
|
327
|
+
}]
|
|
328
|
+
};
|
|
329
|
+
});
|
|
200
330
|
server.registerTool('memory_delete', {
|
|
201
331
|
title: 'Memory Delete',
|
|
202
332
|
description: 'Delete a memory entry by key.',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { __memoryTestables } from './memory.js';
|
|
4
|
+
test('slugify normalizes and trims tokens safely', () => {
|
|
5
|
+
const result = __memoryTestables.slugify(' Decision: Add New Feature! ');
|
|
6
|
+
assert.equal(result, 'decision-add-new-feature');
|
|
7
|
+
});
|
|
8
|
+
test('buildMemoryCandidates extracts high-signal candidates with tags', () => {
|
|
9
|
+
const text = [
|
|
10
|
+
'Decision: use memory capture as default.',
|
|
11
|
+
'TODO: improve context pruning.',
|
|
12
|
+
'ERROR: request failed with timeout.',
|
|
13
|
+
'MCP_CONTEXT_PATH=/tmp/context',
|
|
14
|
+
'See docs: https://example.dev/mcp'
|
|
15
|
+
].join('\n');
|
|
16
|
+
const candidates = __memoryTestables.buildMemoryCandidates(text, 'llm', ['test']);
|
|
17
|
+
const keys = candidates.map((item) => item.key);
|
|
18
|
+
assert.ok(keys.some((key) => key.startsWith('decision.')));
|
|
19
|
+
assert.ok(keys.some((key) => key.startsWith('todo.')));
|
|
20
|
+
assert.ok(keys.some((key) => key.startsWith('error.')));
|
|
21
|
+
assert.ok(keys.some((key) => key.startsWith('config.')));
|
|
22
|
+
assert.ok(keys.some((key) => key.startsWith('reference.url.')));
|
|
23
|
+
for (const candidate of candidates) {
|
|
24
|
+
assert.ok(candidate.tags.includes('test'));
|
|
25
|
+
assert.ok(candidate.tags.includes('llm'));
|
|
26
|
+
}
|
|
27
|
+
});
|
package/dist/tools/session.js
CHANGED
|
@@ -311,9 +311,10 @@ WHEN TO USE: Call this ONCE at the START of every session/conversation.
|
|
|
311
311
|
Returns: latest checkpoint, tracker status (todos/decisions), all memories, and auto-detected project info.
|
|
312
312
|
This replaces calling checkpoint_load(), tracker_status(), and memory_list() separately.`,
|
|
313
313
|
inputSchema: {
|
|
314
|
-
cwd: z.string().optional().describe('Current working directory for project detection (defaults to process.cwd())')
|
|
314
|
+
cwd: z.string().optional().describe('Current working directory for project detection (defaults to process.cwd())'),
|
|
315
|
+
verbose: z.boolean().optional().describe('Include full checkpoint/tracker/memory payload (default: false)')
|
|
315
316
|
}
|
|
316
|
-
}, async ({ cwd }) => {
|
|
317
|
+
}, async ({ cwd, verbose = false }) => {
|
|
317
318
|
const workingDir = cwd || process.cwd();
|
|
318
319
|
// Cleanup expired memories first
|
|
319
320
|
const cleanedUp = await cleanupExpiredMemories();
|
|
@@ -345,13 +346,37 @@ This replaces calling checkpoint_load(), tracker_status(), and memory_list() sep
|
|
|
345
346
|
memoriesCount: Array.isArray(memories) ? memories.length : 0,
|
|
346
347
|
cleanedUpExpiredMemories: cleanedUp
|
|
347
348
|
};
|
|
349
|
+
const output = verbose
|
|
350
|
+
? { summary, ...state }
|
|
351
|
+
: {
|
|
352
|
+
summary,
|
|
353
|
+
checkpoint: checkpoint
|
|
354
|
+
? {
|
|
355
|
+
id: checkpoint.id,
|
|
356
|
+
name: checkpoint.name,
|
|
357
|
+
createdAt: checkpoint.createdAt,
|
|
358
|
+
files: checkpoint.files || []
|
|
359
|
+
}
|
|
360
|
+
: null,
|
|
361
|
+
tracker: {
|
|
362
|
+
projectName: tracker.projectName,
|
|
363
|
+
pendingTodos: tracker.pendingTodos || [],
|
|
364
|
+
recentChanges: tracker.recentChanges || [],
|
|
365
|
+
decisions: tracker.decisions || []
|
|
366
|
+
},
|
|
367
|
+
memories: Array.isArray(memories)
|
|
368
|
+
? memories.map((m) => ({
|
|
369
|
+
key: m.key,
|
|
370
|
+
tags: m.tags || [],
|
|
371
|
+
updatedAt: m.updatedAt
|
|
372
|
+
}))
|
|
373
|
+
: [],
|
|
374
|
+
project
|
|
375
|
+
};
|
|
348
376
|
return {
|
|
349
377
|
content: [{
|
|
350
378
|
type: 'text',
|
|
351
|
-
text: JSON.stringify(
|
|
352
|
-
summary,
|
|
353
|
-
...state
|
|
354
|
-
}, null, 2)
|
|
379
|
+
text: JSON.stringify(output, null, 2)
|
|
355
380
|
}]
|
|
356
381
|
};
|
|
357
382
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asd412id/mcp-context-manager",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"description": "MCP tools for context management - summarizer, memory store, project tracker, checkpoints, and smart file loader",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"build": "tsc",
|
|
16
16
|
"dev": "tsc --watch",
|
|
17
17
|
"start": "node dist/index.js",
|
|
18
|
+
"test": "npm run build && node --test dist/**/*.test.js",
|
|
18
19
|
"prepublishOnly": "npm run build"
|
|
19
20
|
},
|
|
20
21
|
"keywords": [
|