@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.
@@ -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
- // Auto-analyze project if MCFAST_TOKEN is set
271
- await this.autoAnalyzeProject();
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;