cf-memory-mcp 3.17.0 → 3.19.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/bin/cf-memory-mcp.js +178 -8
- package/package.json +1 -1
package/bin/cf-memory-mcp.js
CHANGED
|
@@ -213,8 +213,10 @@ const TOOLS_LIST = [
|
|
|
213
213
|
repo_path: { type: 'string', description: 'Absolute project root path. Bridge auto-fills from cwd if omitted.' },
|
|
214
214
|
project_id: { type: 'string', description: 'Canonical project id (proj_...). Used before repo_path when set.' },
|
|
215
215
|
branch: { type: 'string', description: 'Current git branch. Mismatch downgrades match_confidence but does not hide. Bridge auto-fills from git.' },
|
|
216
|
-
session_id_hint: { type: 'string', description: 'Resume a specific session by id (overrides repo/branch matching). Useful after seeing recent_handoffs.' },
|
|
217
|
-
max_age_minutes: { type: 'number', description: 'Hard cutoff: hide handoffs older than this many minutes. Without it, older handoffs surface with reduced confidence.' }
|
|
216
|
+
session_id_hint: { type: 'string', description: 'Resume a specific session by id (overrides repo/branch matching). Full UUID or short prefix (>=8 chars). Useful after seeing recent_handoffs.' },
|
|
217
|
+
max_age_minutes: { type: 'number', description: 'Hard cutoff: hide handoffs older than this many minutes. Without it, older handoffs surface with reduced confidence.' },
|
|
218
|
+
goal_contains: { type: 'string', description: 'Substring filter on handoff content (goal/notes/decisions). Case-insensitive. Lets agents find a specific thread by what it was about.' },
|
|
219
|
+
since: { type: 'string', description: 'Lower bound on handoff timestamp (ISO 8601). Scope resume to a time window.' }
|
|
218
220
|
}
|
|
219
221
|
}
|
|
220
222
|
},
|
|
@@ -944,7 +946,27 @@ class CFMemoryMCP {
|
|
|
944
946
|
// (try/catch'd), and the agent's hand-written list always wins.
|
|
945
947
|
this.trackToolActivity(message);
|
|
946
948
|
|
|
947
|
-
let response
|
|
949
|
+
let response;
|
|
950
|
+
try {
|
|
951
|
+
response = await this.makeRequest(message);
|
|
952
|
+
} catch (err) {
|
|
953
|
+
// Network failure / worker unreachable. For resume bootstrap
|
|
954
|
+
// calls, serve from local disk so the agent isn't stuck
|
|
955
|
+
// when Cloudflare is down or the user is offline.
|
|
956
|
+
if (message.method === 'tools/call' &&
|
|
957
|
+
message.params?.name === 'get_context_bootstrap' &&
|
|
958
|
+
message.params.arguments?.resume) {
|
|
959
|
+
const fallback = this.loadResumeFromDisk(message.id);
|
|
960
|
+
if (fallback) {
|
|
961
|
+
_mcpTrace('DISPATCH_DONE', `id=${message.id} method=${message.method} fallback=disk elapsed=${Date.now()-_t0}ms`);
|
|
962
|
+
process.stdout.write(JSON.stringify(fallback) + '\n');
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
// Not a resume call, or no disk fallback available: surface
|
|
967
|
+
// the original error to the client.
|
|
968
|
+
throw err;
|
|
969
|
+
}
|
|
948
970
|
_mcpTrace('DISPATCH_DONE', `id=${message.id} method=${message.method} elapsed=${Date.now()-_t0}ms`);
|
|
949
971
|
|
|
950
972
|
// Annotate retrieve_context results with local staleness when
|
|
@@ -987,14 +1009,21 @@ class CFMemoryMCP {
|
|
|
987
1009
|
this.cacheImplicitSessionFromResponse(response);
|
|
988
1010
|
}
|
|
989
1011
|
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
// without having to call git themselves.
|
|
1012
|
+
// Resume bootstrap: if the server returned an error envelope
|
|
1013
|
+
// (not just a fetch failure), try the disk fallback. Then
|
|
1014
|
+
// run the normal diff injection + disk persist for success.
|
|
994
1015
|
if (message.method === 'tools/call' &&
|
|
995
1016
|
message.params?.name === 'get_context_bootstrap' &&
|
|
996
1017
|
message.params.arguments?.resume) {
|
|
1018
|
+
if (response?.error) {
|
|
1019
|
+
const fallback = this.loadResumeFromDisk(message.id);
|
|
1020
|
+
if (fallback) {
|
|
1021
|
+
response = fallback;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
997
1024
|
this.injectRepoDiffIntoResume(response);
|
|
1025
|
+
// Persist to disk for offline fallback on the next call.
|
|
1026
|
+
this.saveResumeToDisk(response);
|
|
998
1027
|
}
|
|
999
1028
|
|
|
1000
1029
|
// Send response to stdout
|
|
@@ -2637,10 +2666,63 @@ class CFMemoryMCP {
|
|
|
2637
2666
|
// sees the recent trajectory ("what was I doing").
|
|
2638
2667
|
const timelineNote = this.formatActivityTimeline();
|
|
2639
2668
|
const baseNote = `Auto-synthesized by cf-memory-mcp bridge. Agent did not provide a handoff. Tool calls observed: ${this._toolCallCount || 0}. Set CF_MEMORY_AUTO_HANDOFF=off to disable.`;
|
|
2640
|
-
|
|
2669
|
+
|
|
2670
|
+
// Workspace state: uncommitted changes + recent commits give the
|
|
2671
|
+
// next chat real signal about what's been happening in the repo,
|
|
2672
|
+
// not just what tools the agent called.
|
|
2673
|
+
const workspaceState = this.getGitWorkspaceState();
|
|
2674
|
+
const sections = [baseNote];
|
|
2675
|
+
if (timelineNote) sections.push(`Recent activity:\n${timelineNote}`);
|
|
2676
|
+
if (workspaceState) sections.push(`Workspace state:\n${workspaceState}`);
|
|
2677
|
+
handoff.notes = sections.join('\n\n');
|
|
2641
2678
|
return handoff;
|
|
2642
2679
|
}
|
|
2643
2680
|
|
|
2681
|
+
/**
|
|
2682
|
+
* Snapshot the local git workspace: uncommitted file count, top
|
|
2683
|
+
* changed files, last 3 commit messages. Used by auto-synthesized
|
|
2684
|
+
* handoffs so the next chat sees real workspace state, not just
|
|
2685
|
+
* tool-call activity. Best-effort: returns "" when git unavailable.
|
|
2686
|
+
*/
|
|
2687
|
+
getGitWorkspaceState() {
|
|
2688
|
+
try {
|
|
2689
|
+
const meta = this.getRepoMetadata();
|
|
2690
|
+
if (!meta.repo_path) return '';
|
|
2691
|
+
const { execSync } = require('child_process');
|
|
2692
|
+
const run = (cmd) => execSync(cmd, {
|
|
2693
|
+
cwd: meta.repo_path,
|
|
2694
|
+
encoding: 'utf8',
|
|
2695
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
2696
|
+
timeout: 500,
|
|
2697
|
+
});
|
|
2698
|
+
const lines = [];
|
|
2699
|
+
// Uncommitted: `git status --porcelain` is one line per changed file.
|
|
2700
|
+
try {
|
|
2701
|
+
const status = run('git status --porcelain').trim();
|
|
2702
|
+
if (status) {
|
|
2703
|
+
const files = status.split('\n').slice(0, 8);
|
|
2704
|
+
const more = status.split('\n').length > 8 ? ` (+ ${status.split('\n').length - 8} more)` : '';
|
|
2705
|
+
lines.push(` Uncommitted changes:${more}`);
|
|
2706
|
+
files.forEach(f => lines.push(` ${f}`));
|
|
2707
|
+
} else {
|
|
2708
|
+
lines.push(' Working tree clean.');
|
|
2709
|
+
}
|
|
2710
|
+
} catch (_) { /* git status failed */ }
|
|
2711
|
+
// Last 3 commits — short hash + subject only.
|
|
2712
|
+
try {
|
|
2713
|
+
const log = run('git log -3 --pretty=format:%h\\ %s').trim();
|
|
2714
|
+
if (log) {
|
|
2715
|
+
lines.push(' Recent commits:');
|
|
2716
|
+
log.split('\n').forEach(c => lines.push(` ${c}`));
|
|
2717
|
+
}
|
|
2718
|
+
} catch (_) { /* git log failed */ }
|
|
2719
|
+
return lines.join('\n');
|
|
2720
|
+
} catch (err) {
|
|
2721
|
+
this.logDebug(`getGitWorkspaceState failed: ${err && err.message}`);
|
|
2722
|
+
return '';
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2644
2726
|
/**
|
|
2645
2727
|
* Condense an MCP tool call into a single-line summary. Used for the
|
|
2646
2728
|
* activity timeline so the synthesized handoff includes "what was I
|
|
@@ -2690,6 +2772,92 @@ class CFMemoryMCP {
|
|
|
2690
2772
|
.join('\n');
|
|
2691
2773
|
}
|
|
2692
2774
|
|
|
2775
|
+
/**
|
|
2776
|
+
* Local-disk cache for the resume packet. Path is
|
|
2777
|
+
* ~/.cf-memory/handoff-<sha256-prefix-of-cwd>.json
|
|
2778
|
+
* Used as a fallback when the worker is unreachable (network down,
|
|
2779
|
+
* Cloudflare outage). Refreshed on every successful resume fetch.
|
|
2780
|
+
*/
|
|
2781
|
+
getDiskCachePath() {
|
|
2782
|
+
try {
|
|
2783
|
+
const cwd = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
|
|
2784
|
+
const crypto = require('crypto');
|
|
2785
|
+
const hash = crypto.createHash('sha256').update(cwd).digest('hex').slice(0, 16);
|
|
2786
|
+
const dir = path.join(os.homedir(), '.cf-memory');
|
|
2787
|
+
return path.join(dir, `handoff-${hash}.json`);
|
|
2788
|
+
} catch (_) { return null; }
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
/**
|
|
2792
|
+
* Persist a resume bootstrap response to local disk. Called after
|
|
2793
|
+
* a successful resume fetch (prewarm or live). Fire-and-forget;
|
|
2794
|
+
* disk errors are non-fatal.
|
|
2795
|
+
*/
|
|
2796
|
+
saveResumeToDisk(response) {
|
|
2797
|
+
try {
|
|
2798
|
+
const optOut = process.env.CF_MEMORY_DISK_CACHE;
|
|
2799
|
+
if (optOut === '0' || optOut === 'false' || optOut === 'off') return;
|
|
2800
|
+
const p = this.getDiskCachePath();
|
|
2801
|
+
if (!p) return;
|
|
2802
|
+
const text = response?.result?.content?.[0]?.text;
|
|
2803
|
+
if (!text) return;
|
|
2804
|
+
// Only persist when there's actually a handoff to cache.
|
|
2805
|
+
try {
|
|
2806
|
+
const parsed = JSON.parse(text);
|
|
2807
|
+
if (!parsed?.resume_handoff) return;
|
|
2808
|
+
} catch (_) { return; }
|
|
2809
|
+
const dir = path.dirname(p);
|
|
2810
|
+
if (!fs.existsSync(dir)) {
|
|
2811
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2812
|
+
}
|
|
2813
|
+
const entry = {
|
|
2814
|
+
cached_at: new Date().toISOString(),
|
|
2815
|
+
cwd: process.env.CF_MEMORY_WATCH_PATH || process.cwd(),
|
|
2816
|
+
response_text: text,
|
|
2817
|
+
};
|
|
2818
|
+
fs.writeFileSync(p, JSON.stringify(entry, null, 2));
|
|
2819
|
+
_mcpTrace('DISK_CACHE', `saved resume packet to ${p}`);
|
|
2820
|
+
} catch (err) {
|
|
2821
|
+
this.logDebug(`saveResumeToDisk failed: ${err && err.message}`);
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
/**
|
|
2826
|
+
* Load the most-recent disk-cached resume packet for the current cwd.
|
|
2827
|
+
* Returns a response-shaped object the bridge can write to stdout, or
|
|
2828
|
+
* null when nothing cached / disk unreadable. The returned envelope
|
|
2829
|
+
* is tagged with a `from_disk_cache:true` marker + `disk_cache_age`
|
|
2830
|
+
* so the agent knows this is a fallback, not a fresh fetch.
|
|
2831
|
+
*/
|
|
2832
|
+
loadResumeFromDisk(messageId) {
|
|
2833
|
+
try {
|
|
2834
|
+
const p = this.getDiskCachePath();
|
|
2835
|
+
if (!p || !fs.existsSync(p)) return null;
|
|
2836
|
+
const entry = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
2837
|
+
const parsed = JSON.parse(entry.response_text);
|
|
2838
|
+
const cachedAt = entry.cached_at;
|
|
2839
|
+
const ageMinutes = Math.round((Date.now() - new Date(cachedAt).getTime()) / 60000);
|
|
2840
|
+
// Annotate the envelope so the agent knows it's fallback.
|
|
2841
|
+
if (parsed.resume_handoff) {
|
|
2842
|
+
parsed.resume_handoff.from_disk_cache = true;
|
|
2843
|
+
parsed.resume_handoff.disk_cache_age_minutes = ageMinutes;
|
|
2844
|
+
parsed.resume_handoff.disk_cache_cached_at = cachedAt;
|
|
2845
|
+
}
|
|
2846
|
+
// Replace empty_hint with one that explains the fallback.
|
|
2847
|
+
parsed.empty_hint = `Worker unreachable; served from local disk cache (cached ${ageMinutes} min ago at ${cachedAt}). Refresh by calling get_context_bootstrap when the worker is back online.`;
|
|
2848
|
+
const response = {
|
|
2849
|
+
jsonrpc: '2.0',
|
|
2850
|
+
id: messageId,
|
|
2851
|
+
result: { content: [{ type: 'text', text: JSON.stringify(parsed) }] },
|
|
2852
|
+
};
|
|
2853
|
+
_mcpTrace('DISK_CACHE', `served resume from disk (age=${ageMinutes}min)`);
|
|
2854
|
+
return response;
|
|
2855
|
+
} catch (err) {
|
|
2856
|
+
this.logDebug(`loadResumeFromDisk failed: ${err && err.message}`);
|
|
2857
|
+
return null;
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2693
2861
|
/**
|
|
2694
2862
|
* Pre-fetch the resume handoff for the current cwd at bridge startup.
|
|
2695
2863
|
* The result is cached and surfaced when the agent's first call is
|
|
@@ -2731,6 +2899,8 @@ class CFMemoryMCP {
|
|
|
2731
2899
|
const parsed = JSON.parse(text || '{}');
|
|
2732
2900
|
handoff_present = !!parsed.resume_handoff;
|
|
2733
2901
|
} catch (_) { /* ignore */ }
|
|
2902
|
+
// Persist to disk for offline fallback. Best-effort.
|
|
2903
|
+
if (handoff_present) this.saveResumeToDisk(response);
|
|
2734
2904
|
_mcpTrace('RESUME_PREWARM', `elapsed=${elapsed}ms handoff_present=${handoff_present}`);
|
|
2735
2905
|
} catch (err) {
|
|
2736
2906
|
this.logDebug(`prewarmResumeContext failed: ${err && err.message}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cf-memory-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"description": "Cloudflare-hosted MCP server for code indexing, retrieval, and assistant memory with a direct remote MCP endpoint and local stdio bridge.",
|
|
5
5
|
"main": "bin/cf-memory-mcp.js",
|
|
6
6
|
"bin": {
|