@mrxkun/mcfast-mcp 4.1.15 → 4.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/README.md +13 -1
- package/package.json +1 -1
- package/src/index.js +67 -68
- package/src/memory/bootstrap/project-scanner.js +328 -0
- package/src/memory/memory-engine.js +123 -2
- package/src/memory/utils/chunker.js +5 -4
- package/src/memory/utils/compaction.js +281 -0
- package/src/memory/watchers/file-watcher.js +59 -44
- package/src/tools/memory_get.js +113 -11
|
@@ -25,6 +25,8 @@ import { CuratedMemory } from './layers/curated-memory.js';
|
|
|
25
25
|
|
|
26
26
|
// Bootstrap
|
|
27
27
|
import { AgentsMdBootstrap } from './bootstrap/agents-md.js';
|
|
28
|
+
import { ProjectScanner } from './bootstrap/project-scanner.js';
|
|
29
|
+
import { MemoryCompactor } from './utils/compaction.js';
|
|
28
30
|
|
|
29
31
|
// Project Analysis (AI-powered)
|
|
30
32
|
import { execute as projectAnalyzeExecute } from '../tools/project_analyze.js';
|
|
@@ -260,6 +262,14 @@ export class MemoryEngine {
|
|
|
260
262
|
// Initialize intelligence
|
|
261
263
|
await this.initializeIntelligence();
|
|
262
264
|
|
|
265
|
+
// Initialize compactor
|
|
266
|
+
this.compactor = new MemoryCompactor({
|
|
267
|
+
memoryPath: this.memoryPath,
|
|
268
|
+
softThresholdKB: 500,
|
|
269
|
+
hardThresholdKB: 1024,
|
|
270
|
+
keepDays: 7
|
|
271
|
+
});
|
|
272
|
+
|
|
263
273
|
this.isInitialized = true;
|
|
264
274
|
|
|
265
275
|
console.error(`[MemoryEngine] ✅ Initialized successfully`);
|
|
@@ -267,8 +277,18 @@ export class MemoryEngine {
|
|
|
267
277
|
console.error(`[MemoryEngine] Smart Routing: ${this.smartRoutingEnabled ? 'ENABLED' : 'DISABLED'}`);
|
|
268
278
|
console.error(`[MemoryEngine] Intelligence: ${this.intelligenceEnabled ? 'ENABLED' : 'DISABLED'}`);
|
|
269
279
|
|
|
270
|
-
//
|
|
271
|
-
|
|
280
|
+
// Bootstrap: local scan FIRST (no internet needed), AI scan if token present
|
|
281
|
+
this.bootstrapProjectContext().catch(err => {
|
|
282
|
+
console.error('[MemoryEngine] Bootstrap error (non-fatal):', err.message);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Periodic compaction (every 30 min, non-blocking)
|
|
286
|
+
const _compactTimer = setInterval(() => {
|
|
287
|
+
this.compactor.maybeCompact().then(r => {
|
|
288
|
+
if (r.ran) console.error('[MemoryEngine] Auto-compaction ran, compacted:', r.compacted);
|
|
289
|
+
}).catch(() => { });
|
|
290
|
+
}, 30 * 60 * 1000);
|
|
291
|
+
if (_compactTimer.unref) _compactTimer.unref();
|
|
272
292
|
|
|
273
293
|
// Log initialization to daily logs
|
|
274
294
|
await this.dailyLogs.log('Memory Engine Initialized', `Project: ${projectPath}`, {
|
|
@@ -276,6 +296,107 @@ export class MemoryEngine {
|
|
|
276
296
|
});
|
|
277
297
|
}
|
|
278
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Bootstrap project context based on bootstrap_mode setting.
|
|
301
|
+
* Modes:
|
|
302
|
+
* - local: only runs ProjectScanner (no internet needed)
|
|
303
|
+
* - cloud: only runs projectAnalyzeExecute (via Mercury API)
|
|
304
|
+
* - hybrid: runs ProjectScanner, then enriches via AI
|
|
305
|
+
*/
|
|
306
|
+
async bootstrapProjectContext() {
|
|
307
|
+
// Determine bootstrap mode
|
|
308
|
+
let mode = process.env.MCFAST_BOOTSTRAP_MODE || 'hybrid';
|
|
309
|
+
if (this.dashboardClient && this.dashboardClient.apiKey) {
|
|
310
|
+
try {
|
|
311
|
+
const config = await this.dashboardClient.getSearchConfig();
|
|
312
|
+
if (config && config.bootstrap_mode) {
|
|
313
|
+
mode = config.bootstrap_mode;
|
|
314
|
+
}
|
|
315
|
+
} catch (e) {
|
|
316
|
+
console.error('[MemoryEngine] Failed to fetch bootstrap mode, defaulting to hybrid');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Skip if already has curated (non-auto-generated) context
|
|
321
|
+
try {
|
|
322
|
+
const memContent = await this.curatedMemory.read();
|
|
323
|
+
if (memContent &&
|
|
324
|
+
memContent.includes('## Project Context') &&
|
|
325
|
+
!memContent.includes('<!-- Add project context here -->') &&
|
|
326
|
+
!memContent.includes('Auto-generated by mcfast local scanner')) {
|
|
327
|
+
console.error('[MemoryEngine] Project Context already curated, skipping auto-scan');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
} catch { }
|
|
331
|
+
|
|
332
|
+
// Step 1: Local Scan (for 'local' or 'hybrid' modes)
|
|
333
|
+
if (mode === 'local' || mode === 'hybrid') {
|
|
334
|
+
try {
|
|
335
|
+
console.error(`[MemoryEngine] 🔍 Local project scan starting... (mode: ${mode})`);
|
|
336
|
+
const scanner = new ProjectScanner(this.projectPath);
|
|
337
|
+
const scanResult = await scanner.scan();
|
|
338
|
+
const contextSection = scanner.buildMemorySection(scanResult);
|
|
339
|
+
await this._updateMemorySection('Project Context', contextSection);
|
|
340
|
+
console.error(`[MemoryEngine] ✅ Local scan: ${scanResult.name} (${scanResult.type})`);
|
|
341
|
+
await this.dailyLogs.log(
|
|
342
|
+
'Project Scanned',
|
|
343
|
+
`${scanResult.name} | ${scanResult.type} | routes: ${scanResult.apiRoutes.length}`,
|
|
344
|
+
{ name: scanResult.name, type: scanResult.type }
|
|
345
|
+
);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
console.error('[MemoryEngine] Local scan error:', err.message);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Step 2: AI enrichment (background, optional, for 'cloud' or 'hybrid' modes)
|
|
352
|
+
if ((mode === 'cloud' || mode === 'hybrid') && process.env.MCFAST_TOKEN) {
|
|
353
|
+
console.error(`[MemoryEngine] 🔄 AI analysis starting in background... (mode: ${mode})`);
|
|
354
|
+
projectAnalyzeExecute({ force: false, updateMemory: true })
|
|
355
|
+
.then(r => { if (r?.metadata?.memoryUpdated) console.error('[MemoryEngine] ✅ AI analysis complete'); })
|
|
356
|
+
.catch(err => console.error('[MemoryEngine] ⚠️ AI analysis failed:', err.message));
|
|
357
|
+
} else if (mode === 'cloud' && !process.env.MCFAST_TOKEN) {
|
|
358
|
+
console.error('[MemoryEngine] ⚠️ Cloud bootstrap mode requires MCFAST_TOKEN, but none was provided.');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Update or insert a named ## section in MEMORY.md
|
|
364
|
+
*/
|
|
365
|
+
async _updateMemorySection(sectionName, newContent) {
|
|
366
|
+
let content = '';
|
|
367
|
+
try { content = await this.curatedMemory.read() || ''; } catch { }
|
|
368
|
+
if (!content) {
|
|
369
|
+
content = `# Long-term Memory\n\n---\n*Last updated: ${new Date().toISOString()}*\n`;
|
|
370
|
+
}
|
|
371
|
+
const re = new RegExp(`(## ${sectionName}\n)([\\s\\S]*?)(?=\n## |$)`, 's');
|
|
372
|
+
const replacement = `$1\n${newContent}\n\n`;
|
|
373
|
+
if (re.test(content)) {
|
|
374
|
+
content = content.replace(re, replacement);
|
|
375
|
+
} else {
|
|
376
|
+
const footerIdx = content.lastIndexOf('---');
|
|
377
|
+
const insertAt = footerIdx > 0 ? footerIdx : content.length;
|
|
378
|
+
content = content.slice(0, insertAt) +
|
|
379
|
+
`\n## ${sectionName}\n\n${newContent}\n\n` +
|
|
380
|
+
content.slice(insertAt);
|
|
381
|
+
}
|
|
382
|
+
content = content.replace(/\*Last updated:.*\*/, `*Last updated: ${new Date().toISOString()}*`);
|
|
383
|
+
const memFile = path.join(this.memoryPath, 'MEMORY.md');
|
|
384
|
+
await fs.mkdir(path.dirname(memFile), { recursive: true });
|
|
385
|
+
await fs.writeFile(memFile, content, 'utf-8');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Get compaction statistics */
|
|
389
|
+
async getCompactionStats() {
|
|
390
|
+
if (!this.compactor) return null;
|
|
391
|
+
return this.compactor.getStats();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Manually trigger memory compaction */
|
|
395
|
+
async triggerCompaction(force = false) {
|
|
396
|
+
if (!this.compactor) throw new Error('Compactor not initialized');
|
|
397
|
+
return force ? this.compactor.forceCompact() : this.compactor.maybeCompact();
|
|
398
|
+
}
|
|
399
|
+
|
|
279
400
|
async initializeIntelligence() {
|
|
280
401
|
if (!this.intelligenceEnabled) return;
|
|
281
402
|
|
|
@@ -29,7 +29,7 @@ export class Chunker {
|
|
|
29
29
|
|
|
30
30
|
if (currentTokens + lineTokens > this.chunkSize && currentChunk.length > 0) {
|
|
31
31
|
chunks.push(this.createChunk(currentChunk, chunkStartLine, i, metadata));
|
|
32
|
-
|
|
32
|
+
|
|
33
33
|
const overlapLines = this.calculateOverlap(currentChunk);
|
|
34
34
|
currentChunk = [...overlapLines, line];
|
|
35
35
|
currentTokens = overlapLines.reduce((sum, l) => sum + this.estimateTokens(l), 0) + lineTokens;
|
|
@@ -54,7 +54,7 @@ export class Chunker {
|
|
|
54
54
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
55
55
|
const line = lines[i];
|
|
56
56
|
const lineTokens = this.estimateTokens(line);
|
|
57
|
-
|
|
57
|
+
|
|
58
58
|
if (overlapTokens + lineTokens <= this.overlap) {
|
|
59
59
|
overlapLines.unshift(line);
|
|
60
60
|
overlapTokens += lineTokens;
|
|
@@ -69,7 +69,7 @@ export class Chunker {
|
|
|
69
69
|
createChunk(lines, startLine, endLine, metadata) {
|
|
70
70
|
const content = lines.join('\n');
|
|
71
71
|
const tokens = this.estimateTokens(content);
|
|
72
|
-
|
|
72
|
+
|
|
73
73
|
// Handle both string (filePath) and object metadata
|
|
74
74
|
const filePath = typeof metadata === 'string' ? metadata : (metadata.filePath || '');
|
|
75
75
|
|
|
@@ -80,7 +80,8 @@ export class Chunker {
|
|
|
80
80
|
content_hash: crypto.createHash('md5').update(content).digest('hex'),
|
|
81
81
|
start_line: startLine,
|
|
82
82
|
end_line: endLine,
|
|
83
|
-
token_count: tokens
|
|
83
|
+
token_count: tokens,
|
|
84
|
+
chunk_type: (typeof metadata === 'object' && metadata.chunkType) || 'code'
|
|
84
85
|
};
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Compaction Engine
|
|
3
|
+
*
|
|
4
|
+
* Inspired by OpenClaw's "automatic memory flush (pre-compaction ping)" design.
|
|
5
|
+
* https://docs.openclaw.ai/concepts/memory#automatic-memory-flush-pre-compaction-ping
|
|
6
|
+
*
|
|
7
|
+
* Strategy:
|
|
8
|
+
* 1. Monitor MEMORY.md + daily logs total size
|
|
9
|
+
* 2. When total > softThresholdKB: compact old daily logs (>= keepDays)
|
|
10
|
+
* into MEMORY.md "Archived Sessions" section, then delete them
|
|
11
|
+
* 3. When MEMORY.md itself > hardThresholdKB: trim old "Archived Sessions"
|
|
12
|
+
* entries (keep only the compressed summary lines)
|
|
13
|
+
* 4. Run silently in background; report stats on request
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs/promises';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import crypto from 'crypto';
|
|
19
|
+
|
|
20
|
+
export class MemoryCompactor {
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
this.memoryPath = options.memoryPath; // e.g. .mcfast/
|
|
23
|
+
this.softThresholdKB = options.softThresholdKB || 500; // compact old logs
|
|
24
|
+
this.hardThresholdKB = options.hardThresholdKB || 1024; // trim MEMORY.md
|
|
25
|
+
this.keepDays = options.keepDays || 7; // keep last N daily logs
|
|
26
|
+
this.archiveSectionName = 'Archived Sessions';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get memoryDir() { return path.join(this.memoryPath, 'memory'); }
|
|
30
|
+
get curatedFile() { return path.join(this.memoryPath, 'MEMORY.md'); }
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if compaction is needed and run it if so.
|
|
34
|
+
* Returns an object with what was done.
|
|
35
|
+
*/
|
|
36
|
+
async maybeCompact() {
|
|
37
|
+
const totalKB = await this.getTotalSizeKB();
|
|
38
|
+
const report = { totalKB, softThreshold: this.softThresholdKB, ran: false };
|
|
39
|
+
|
|
40
|
+
if (totalKB < this.softThresholdKB) {
|
|
41
|
+
return report;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
report.ran = true;
|
|
45
|
+
report.compacted = await this.compactOldLogs();
|
|
46
|
+
|
|
47
|
+
// If MEMORY.md still huge, trim archive section
|
|
48
|
+
const curatedKB = await this.getFileSizeKB(this.curatedFile);
|
|
49
|
+
if (curatedKB > this.hardThresholdKB) {
|
|
50
|
+
report.trimmed = await this.trimArchivedSessions();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return report;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Force compaction regardless of threshold.
|
|
58
|
+
*/
|
|
59
|
+
async forceCompact() {
|
|
60
|
+
const compacted = await this.compactOldLogs();
|
|
61
|
+
const trimmed = await this.trimArchivedSessions();
|
|
62
|
+
return { compacted, trimmed };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get total size (in KB) of MEMORY.md + all daily logs
|
|
67
|
+
*/
|
|
68
|
+
async getTotalSizeKB() {
|
|
69
|
+
let total = 0;
|
|
70
|
+
total += await this.getFileSizeKB(this.curatedFile);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const files = await fs.readdir(this.memoryDir);
|
|
74
|
+
for (const f of files) {
|
|
75
|
+
if (f.endsWith('.md')) {
|
|
76
|
+
total += await this.getFileSizeKB(path.join(this.memoryDir, f));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch { }
|
|
80
|
+
|
|
81
|
+
return total;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async getFileSizeKB(filePath) {
|
|
85
|
+
try {
|
|
86
|
+
const stat = await fs.stat(filePath);
|
|
87
|
+
return stat.size / 1024;
|
|
88
|
+
} catch {
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compact daily logs older than keepDays into MEMORY.md archive section,
|
|
95
|
+
* then delete them.
|
|
96
|
+
* Returns number of files compacted.
|
|
97
|
+
*/
|
|
98
|
+
async compactOldLogs() {
|
|
99
|
+
let compacted = 0;
|
|
100
|
+
const cutoffDate = new Date();
|
|
101
|
+
cutoffDate.setDate(cutoffDate.getDate() - this.keepDays);
|
|
102
|
+
|
|
103
|
+
let files = [];
|
|
104
|
+
try {
|
|
105
|
+
files = await fs.readdir(this.memoryDir);
|
|
106
|
+
} catch {
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const oldLogs = files
|
|
111
|
+
.filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
|
|
112
|
+
.filter(f => {
|
|
113
|
+
const fileDate = new Date(f.replace('.md', ''));
|
|
114
|
+
return fileDate < cutoffDate;
|
|
115
|
+
})
|
|
116
|
+
.sort();
|
|
117
|
+
|
|
118
|
+
if (oldLogs.length === 0) return 0;
|
|
119
|
+
|
|
120
|
+
// Read all old logs and build archive entries
|
|
121
|
+
const archiveLines = [];
|
|
122
|
+
for (const logFile of oldLogs) {
|
|
123
|
+
const filePath = path.join(this.memoryDir, logFile);
|
|
124
|
+
try {
|
|
125
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
126
|
+
const dateStr = logFile.replace('.md', '');
|
|
127
|
+
const summary = this.summarizeLog(content, dateStr);
|
|
128
|
+
archiveLines.push(summary);
|
|
129
|
+
await fs.unlink(filePath);
|
|
130
|
+
compacted++;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error(`[MemoryCompactor] Failed to compact ${logFile}:`, err.message);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Append to MEMORY.md archive section
|
|
137
|
+
if (archiveLines.length > 0) {
|
|
138
|
+
await this.appendToArchiveSection(archiveLines.join('\n\n'));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.error(`[MemoryCompactor] Compacted ${compacted} old daily logs into ${this.archiveSectionName}`);
|
|
142
|
+
return compacted;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Summarize a daily log into a compact bullet list
|
|
147
|
+
*/
|
|
148
|
+
summarizeLog(content, dateStr) {
|
|
149
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
150
|
+
// Extract headers as key events
|
|
151
|
+
const events = lines
|
|
152
|
+
.filter(l => l.startsWith('## ') || l.startsWith('### '))
|
|
153
|
+
.map(l => l.replace(/^#+\s*/, '').trim())
|
|
154
|
+
.slice(0, 5);
|
|
155
|
+
|
|
156
|
+
if (events.length === 0) {
|
|
157
|
+
// Fallback: take first meaningful lines
|
|
158
|
+
const nonEmpty = lines.filter(l => l.length > 10 && !l.startsWith('#')).slice(0, 3);
|
|
159
|
+
events.push(...nonEmpty.map(l => l.substring(0, 120)));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const bulletList = events.map(e => ` - ${e}`).join('\n');
|
|
163
|
+
return `### ${dateStr}\n${bulletList}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Append content to the "Archived Sessions" section in MEMORY.md
|
|
168
|
+
*/
|
|
169
|
+
async appendToArchiveSection(newContent) {
|
|
170
|
+
let content = '';
|
|
171
|
+
try {
|
|
172
|
+
content = await fs.readFile(this.curatedFile, 'utf-8');
|
|
173
|
+
} catch {
|
|
174
|
+
content = this.getDefaultTemplate();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const sectionHeader = `## ${this.archiveSectionName}`;
|
|
178
|
+
if (content.includes(sectionHeader)) {
|
|
179
|
+
// Append inside existing section
|
|
180
|
+
content = content.replace(
|
|
181
|
+
new RegExp(`(## ${this.archiveSectionName}[\\s\\S]*?)(?=\\n## |$)`, 's'),
|
|
182
|
+
(match) => match.trimEnd() + '\n\n' + newContent + '\n'
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
// Add section before the footer
|
|
186
|
+
content = content.replace(
|
|
187
|
+
/(---\n\*Last updated:.*\*)/,
|
|
188
|
+
`\n${sectionHeader}\n\n${newContent}\n\n$1`
|
|
189
|
+
);
|
|
190
|
+
if (!content.includes(sectionHeader)) {
|
|
191
|
+
content += `\n\n${sectionHeader}\n\n${newContent}\n`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Update timestamp
|
|
196
|
+
content = content.replace(
|
|
197
|
+
/\*Last updated:.*\*/,
|
|
198
|
+
`*Last updated: ${new Date().toISOString()}*`
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
await fs.mkdir(path.dirname(this.curatedFile), { recursive: true });
|
|
202
|
+
await fs.writeFile(this.curatedFile, content);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Trim old archive entries when MEMORY.md is too large.
|
|
207
|
+
* Keeps the last 30 entries in "Archived Sessions", discards older ones.
|
|
208
|
+
*/
|
|
209
|
+
async trimArchivedSessions() {
|
|
210
|
+
let content = '';
|
|
211
|
+
try {
|
|
212
|
+
content = await fs.readFile(this.curatedFile, 'utf-8');
|
|
213
|
+
} catch {
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const sectionHeader = `## ${this.archiveSectionName}`;
|
|
218
|
+
const sectionMatch = content.match(
|
|
219
|
+
new RegExp(`## ${this.archiveSectionName}([\\s\\S]*?)(?=\\n## |$)`, 's')
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
if (!sectionMatch) return 0;
|
|
223
|
+
|
|
224
|
+
const sectionBody = sectionMatch[1];
|
|
225
|
+
// Split into individual entries (each starts with ### YYYY-MM-DD)
|
|
226
|
+
const entries = sectionBody.split(/(?=###\s+\d{4}-\d{2}-\d{2})/g).filter(e => e.trim());
|
|
227
|
+
|
|
228
|
+
if (entries.length <= 30) return 0; // no need to trim
|
|
229
|
+
|
|
230
|
+
const KEEP = 30;
|
|
231
|
+
const trimmed = entries.length - KEEP;
|
|
232
|
+
const kept = entries.slice(-KEEP);
|
|
233
|
+
|
|
234
|
+
const newSection = `${sectionHeader}\n\n<!-- ${trimmed} older entries trimmed for size -->\n\n${kept.join('\n\n')}`;
|
|
235
|
+
content = content.replace(
|
|
236
|
+
new RegExp(`## ${this.archiveSectionName}[\\s\\S]*?(?=\\n## |$)`, 's'),
|
|
237
|
+
newSection
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
await fs.writeFile(this.curatedFile, content);
|
|
241
|
+
console.error(`[MemoryCompactor] Trimmed ${trimmed} old archive entries`);
|
|
242
|
+
return trimmed;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get compaction stats without running compaction
|
|
247
|
+
*/
|
|
248
|
+
async getStats() {
|
|
249
|
+
const totalKB = await this.getTotalSizeKB();
|
|
250
|
+
const curatedKB = await this.getFileSizeKB(this.curatedFile);
|
|
251
|
+
|
|
252
|
+
let dailyLogsCount = 0;
|
|
253
|
+
let oldLogsCount = 0;
|
|
254
|
+
const cutoffDate = new Date();
|
|
255
|
+
cutoffDate.setDate(cutoffDate.getDate() - this.keepDays);
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const files = await fs.readdir(this.memoryDir);
|
|
259
|
+
const logs = files.filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f));
|
|
260
|
+
dailyLogsCount = logs.length;
|
|
261
|
+
oldLogsCount = logs.filter(f => new Date(f.replace('.md', '')) < cutoffDate).length;
|
|
262
|
+
} catch { }
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
totalKB: Math.round(totalKB * 10) / 10,
|
|
266
|
+
curatedKB: Math.round(curatedKB * 10) / 10,
|
|
267
|
+
dailyLogsCount,
|
|
268
|
+
oldLogsCount,
|
|
269
|
+
softThresholdKB: this.softThresholdKB,
|
|
270
|
+
hardThresholdKB: this.hardThresholdKB,
|
|
271
|
+
keepDays: this.keepDays,
|
|
272
|
+
needsCompaction: totalKB >= this.softThresholdKB
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
getDefaultTemplate() {
|
|
277
|
+
return `# Long-term Memory\n\n---\n*Last updated: ${new Date().toISOString()}*\n`;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default MemoryCompactor;
|