cf-memory-mcp 3.15.0 → 3.17.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.
Files changed (2) hide show
  1. package/bin/cf-memory-mcp.js +318 -11
  2. package/package.json +1 -1
@@ -688,7 +688,12 @@ class CFMemoryMCP {
688
688
  result: {
689
689
  protocolVersion: respondVersion,
690
690
  capabilities: {
691
- tools: {}
691
+ tools: {},
692
+ // Resources capability exposes the resume handoff at
693
+ // cfm://resume/current and recent handoffs at
694
+ // cfm://resume/recent for clients that prefer the
695
+ // resources protocol over tool calls.
696
+ resources: {}
692
697
  },
693
698
  serverInfo: {
694
699
  name: 'cf-memory-mcp-simplified',
@@ -726,12 +731,93 @@ class CFMemoryMCP {
726
731
  return;
727
732
  }
728
733
 
729
- // Handle resources/list locally (we have none)
734
+ // Handle resources/list locally expose the resume handoff
735
+ // and recent handoffs as MCP resources for clients that prefer
736
+ // the resources capability over tool calls.
730
737
  if (message.method === 'resources/list') {
738
+ const meta = this.getRepoMetadata();
739
+ const repoPath = meta.repo_path || 'unknown';
740
+ const branch = meta.branch || 'unknown';
741
+ const resources = [
742
+ {
743
+ uri: 'cfm://resume/current',
744
+ name: 'Current resume handoff',
745
+ description: `The most-recent matching handoff for ${repoPath} (branch=${branch}). Returns the same payload as get_context_bootstrap({resume:true}).`,
746
+ mimeType: 'application/json',
747
+ },
748
+ {
749
+ uri: 'cfm://resume/recent',
750
+ name: 'Recent handoffs (top 5)',
751
+ description: `The 5 most-recent handoffs for ${repoPath}, status-ranked (in_progress > completed). Useful for picking a specific session via session_id_hint.`,
752
+ mimeType: 'application/json',
753
+ },
754
+ ];
755
+ const response = {
756
+ jsonrpc: '2.0',
757
+ id: message.id,
758
+ result: { resources },
759
+ };
760
+ process.stdout.write(JSON.stringify(response) + '\n');
761
+ return;
762
+ }
763
+
764
+ // Handle resources/read — serve the resume packet as if the
765
+ // agent had called get_context_bootstrap({resume:true}). Lets
766
+ // MCP clients that use the resources capability (instead of
767
+ // tools) recover prior chat state.
768
+ if (message.method === 'resources/read') {
769
+ const uri = message.params?.uri;
770
+ if (uri === 'cfm://resume/current' || uri === 'cfm://resume/recent') {
771
+ try {
772
+ const meta = this.getRepoMetadata();
773
+ const args = { resume: true };
774
+ if (meta.repo_path) args.repo_path = meta.repo_path;
775
+ if (meta.branch) args.branch = meta.branch;
776
+ const fake = { params: { name: 'retrieve_context', arguments: {} } };
777
+ await this.maybeFillProjectId(fake);
778
+ if (fake.params.arguments.project_id) args.project_id = fake.params.arguments.project_id;
779
+ const bootstrap = await this.makeRequest({
780
+ jsonrpc: '2.0',
781
+ id: `resource-read-${Date.now()}`,
782
+ method: 'tools/call',
783
+ params: { name: 'get_context_bootstrap', arguments: args },
784
+ });
785
+ const text = bootstrap?.result?.content?.[0]?.text || '{}';
786
+ // For cfm://resume/recent, surface only the recent_handoffs summaries.
787
+ let payload = text;
788
+ if (uri === 'cfm://resume/recent') {
789
+ try {
790
+ const parsed = JSON.parse(text);
791
+ payload = JSON.stringify({
792
+ recent_handoffs: parsed.recent_handoffs || [],
793
+ }, null, 2);
794
+ } catch (_) { /* fall through with raw text */ }
795
+ }
796
+ const response = {
797
+ jsonrpc: '2.0',
798
+ id: message.id,
799
+ result: { contents: [{ uri, mimeType: 'application/json', text: payload }] },
800
+ };
801
+ process.stdout.write(JSON.stringify(response) + '\n');
802
+ return;
803
+ } catch (err) {
804
+ const response = {
805
+ jsonrpc: '2.0',
806
+ id: message.id,
807
+ error: { code: -32603, message: `Failed to read ${uri}: ${err && err.message}` },
808
+ };
809
+ process.stdout.write(JSON.stringify(response) + '\n');
810
+ return;
811
+ }
812
+ }
813
+ // Unknown resource URI
731
814
  const response = {
732
815
  jsonrpc: '2.0',
733
816
  id: message.id,
734
- result: { resources: [] }
817
+ error: {
818
+ code: -32602,
819
+ message: `Unknown resource URI: ${uri}. Supported: cfm://resume/current, cfm://resume/recent`,
820
+ },
735
821
  };
736
822
  process.stdout.write(JSON.stringify(response) + '\n');
737
823
  return;
@@ -846,6 +932,7 @@ class CFMemoryMCP {
846
932
  message.params.arguments?.resume) {
847
933
  const cached = this.maybeServePrewarmedResume(message);
848
934
  if (cached) {
935
+ this.injectRepoDiffIntoResume(cached);
849
936
  _mcpTrace('RESUME_PREWARM', `served get_context_bootstrap id=${message.id} from cache`);
850
937
  process.stdout.write(JSON.stringify(cached) + '\n');
851
938
  return;
@@ -900,6 +987,16 @@ class CFMemoryMCP {
900
987
  this.cacheImplicitSessionFromResponse(response);
901
988
  }
902
989
 
990
+ // Inject bridge-side git diff into resume responses. Lets the
991
+ // agent see what's changed in the repo since the handoff was
992
+ // written — files modified, commits added, branch moves —
993
+ // without having to call git themselves.
994
+ if (message.method === 'tools/call' &&
995
+ message.params?.name === 'get_context_bootstrap' &&
996
+ message.params.arguments?.resume) {
997
+ this.injectRepoDiffIntoResume(response);
998
+ }
999
+
903
1000
  // Send response to stdout
904
1001
  process.stdout.write(JSON.stringify(response) + '\n');
905
1002
 
@@ -2392,18 +2489,128 @@ class CFMemoryMCP {
2392
2489
  // Tool-call counter for auto-checkpoint safety net. Counts all
2393
2490
  // tool/call messages; auto-checkpoint fires every N calls.
2394
2491
  this._toolCallCount = (this._toolCallCount || 0) + 1;
2492
+
2493
+ // Activity timeline: keep the last 15 user-meaningful tool calls
2494
+ // in a ring buffer so synthesized handoffs include a "what was
2495
+ // I doing" trail. Excludes housekeeping (health_check, list_*)
2496
+ // and the internal auto-* calls themselves.
2497
+ const TIMELINE_TOOLS = new Set([
2498
+ 'retrieve_context', 'get_file_content', 'get_file_outline',
2499
+ 'get_related_code', 'refresh_files', 'refresh_stale',
2500
+ 'index_project', 'index_github', 'store_memory',
2501
+ 'find_stale_files',
2502
+ ]);
2503
+ if (TIMELINE_TOOLS.has(toolName)) {
2504
+ if (!this._activityTimeline) this._activityTimeline = [];
2505
+ const summary = this.summarizeToolCall(toolName, args);
2506
+ this._activityTimeline.push({ tool: toolName, summary, at: Date.now() });
2507
+ if (this._activityTimeline.length > 15) {
2508
+ this._activityTimeline.shift();
2509
+ }
2510
+ }
2395
2511
  } catch (err) {
2396
2512
  this.logDebug(`trackToolActivity failed: ${err && err.message}`);
2397
2513
  }
2398
2514
  }
2399
2515
 
2516
+ /**
2517
+ * Mutates a get_context_bootstrap response to attach a `repo_diff`
2518
+ * field describing what's changed in the local repo since the
2519
+ * handoff. Bridge-only — server has no git access. Best-effort, silent
2520
+ * on failure.
2521
+ *
2522
+ * Opt out via CF_MEMORY_RESUME_DIFF=off.
2523
+ */
2524
+ injectRepoDiffIntoResume(response) {
2525
+ try {
2526
+ const opt = process.env.CF_MEMORY_RESUME_DIFF;
2527
+ if (opt === '0' || opt === 'false' || opt === 'off') return;
2528
+ const text = response?.result?.content?.[0]?.text;
2529
+ if (!text) return;
2530
+ let parsed;
2531
+ try { parsed = JSON.parse(text); } catch (_) { return; }
2532
+ const envelope = parsed?.resume_handoff;
2533
+ if (!envelope) return;
2534
+
2535
+ const meta = this.getRepoMetadata();
2536
+ const repoRoot = meta.repo_path;
2537
+ if (!repoRoot) return;
2538
+ // Reference point: prefer ended_at, fall back to started_at.
2539
+ const since = envelope.ended_at || envelope.started_at;
2540
+ if (!since) return;
2541
+
2542
+ const diff = this.computeRepoDiff(repoRoot, since, envelope.handoff?.branch || meta.branch);
2543
+ if (diff) {
2544
+ envelope.repo_diff = diff;
2545
+ // Re-serialize the modified envelope back into the response.
2546
+ response.result.content[0].text = JSON.stringify(parsed);
2547
+ }
2548
+ } catch (err) {
2549
+ this.logDebug(`injectRepoDiffIntoResume failed: ${err && err.message}`);
2550
+ }
2551
+ }
2552
+
2553
+ /**
2554
+ * Run a bounded git query in the repo root to summarize changes since
2555
+ * the handoff was written. Returns { commits, files_changed,
2556
+ * branch_now, branch_at_handoff } or null if git isn't available.
2557
+ */
2558
+ computeRepoDiff(repoRoot, sinceIso, handoffBranch) {
2559
+ try {
2560
+ const { execSync } = require('child_process');
2561
+ const run = (cmd) => execSync(cmd, {
2562
+ cwd: repoRoot,
2563
+ encoding: 'utf8',
2564
+ stdio: ['ignore', 'pipe', 'ignore'],
2565
+ timeout: 1000,
2566
+ });
2567
+ // Git accepts ISO timestamps via --since
2568
+ const commitsOut = run(`git log --since='${sinceIso}' --pretty=format:'%h %s' -n 25`);
2569
+ const commits = commitsOut.split('\n').filter(Boolean).map(line => {
2570
+ const [hash, ...rest] = line.split(' ');
2571
+ return { hash, subject: rest.join(' ').slice(0, 120) };
2572
+ });
2573
+
2574
+ // Files changed since the handoff. Compare HEAD against the
2575
+ // commit closest to the handoff time. If no commits since,
2576
+ // diff vs. HEAD~0 returns nothing.
2577
+ let filesChanged = [];
2578
+ try {
2579
+ const namesOut = run(`git log --since='${sinceIso}' --name-only --pretty=format:''`);
2580
+ const names = new Set(namesOut.split('\n').map(s => s.trim()).filter(Boolean));
2581
+ filesChanged = Array.from(names).slice(0, 50);
2582
+ } catch (_) { /* empty diff is fine */ }
2583
+
2584
+ // Branch information.
2585
+ let branchNow = null;
2586
+ try {
2587
+ branchNow = run('git symbolic-ref --short HEAD').trim();
2588
+ } catch (_) { /* detached HEAD */ }
2589
+
2590
+ const diff = {
2591
+ commits_since_handoff: commits,
2592
+ files_changed_count: filesChanged.length,
2593
+ files_changed: filesChanged,
2594
+ };
2595
+ if (branchNow) diff.branch_now = branchNow;
2596
+ if (handoffBranch) diff.branch_at_handoff = handoffBranch;
2597
+ if (handoffBranch && branchNow && handoffBranch !== branchNow) {
2598
+ diff.branch_changed = true;
2599
+ }
2600
+ return diff;
2601
+ } catch (err) {
2602
+ this.logDebug(`computeRepoDiff failed: ${err && err.message}`);
2603
+ return null;
2604
+ }
2605
+ }
2606
+
2400
2607
  /**
2401
2608
  * Build a minimal handoff packet from observed bridge activity. Used by
2402
- * end_session auto-synthesis and the periodic auto-checkpoint. Returns
2403
- * just the fields the bridge can credibly populate — agent-supplied
2404
- * fields like decisions/blockers are intentionally omitted so a future
2405
- * resume reader can tell the difference between "agent wrote this" and
2406
- * "bridge synthesized this".
2609
+ * end_session auto-synthesis, the periodic auto-checkpoint, and the
2610
+ * SIGINT/SIGTERM safety net. Returns just the fields the bridge can
2611
+ * credibly populate — agent-supplied fields like decisions/blockers
2612
+ * are intentionally omitted so a future resume reader can tell the
2613
+ * difference between "agent wrote this" and "bridge synthesized this".
2407
2614
  */
2408
2615
  synthesizeMinimalHandoff() {
2409
2616
  const handoff = {
@@ -2424,10 +2631,65 @@ class CFMemoryMCP {
2424
2631
  } else {
2425
2632
  handoff.goal = 'Bridge-synthesized handoff (no explicit goal)';
2426
2633
  }
2427
- handoff.notes = `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.`;
2634
+
2635
+ // Activity timeline: last 15 meaningful tool calls, condensed to
2636
+ // a single line each. Goes in handoff.notes so the next chat
2637
+ // sees the recent trajectory ("what was I doing").
2638
+ const timelineNote = this.formatActivityTimeline();
2639
+ 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
+ handoff.notes = timelineNote ? `${baseNote}\n\nRecent activity:\n${timelineNote}` : baseNote;
2428
2641
  return handoff;
2429
2642
  }
2430
2643
 
2644
+ /**
2645
+ * Condense an MCP tool call into a single-line summary. Used for the
2646
+ * activity timeline so the synthesized handoff includes "what was I
2647
+ * doing" context. Keeps each line under 120 chars.
2648
+ */
2649
+ summarizeToolCall(toolName, args) {
2650
+ try {
2651
+ const trim = (s, n) => typeof s === 'string' ? s.slice(0, n) : '';
2652
+ switch (toolName) {
2653
+ case 'retrieve_context':
2654
+ return `retrieve_context: "${trim(args.query, 80)}"`;
2655
+ case 'get_file_content':
2656
+ return `get_file_content: ${trim(args.file_path, 80)}`;
2657
+ case 'get_file_outline':
2658
+ return `get_file_outline: ${trim(args.file_path, 80)}`;
2659
+ case 'get_related_code':
2660
+ return `get_related_code: ${trim(args.chunk_name || args.chunk_id, 80)}`;
2661
+ case 'refresh_files':
2662
+ return `refresh_files: ${(args.file_paths || []).slice(0, 3).join(', ')}${(args.file_paths || []).length > 3 ? '…' : ''}`;
2663
+ case 'refresh_stale':
2664
+ return `refresh_stale${args.project_id ? ' '+args.project_id : ''}`;
2665
+ case 'index_project':
2666
+ return `index_project: ${trim(args.project_path, 80)}`;
2667
+ case 'index_github':
2668
+ return `index_github: ${trim(args.repo_url, 80)}`;
2669
+ case 'store_memory':
2670
+ return `store_memory: "${trim(args.content, 80)}"`;
2671
+ case 'find_stale_files':
2672
+ return `find_stale_files`;
2673
+ default:
2674
+ return toolName;
2675
+ }
2676
+ } catch (_) {
2677
+ return toolName;
2678
+ }
2679
+ }
2680
+
2681
+ /**
2682
+ * Format the activity timeline as a compact bullet list. Returns ""
2683
+ * when no timeline exists.
2684
+ */
2685
+ formatActivityTimeline() {
2686
+ if (!this._activityTimeline || this._activityTimeline.length === 0) return '';
2687
+ return this._activityTimeline
2688
+ .slice(-10) // last 10 of the up-to-15 buffer
2689
+ .map(e => ` - ${e.summary}`)
2690
+ .join('\n');
2691
+ }
2692
+
2431
2693
  /**
2432
2694
  * Pre-fetch the resume handoff for the current cwd at bridge startup.
2433
2695
  * The result is cached and surfaced when the agent's first call is
@@ -3061,11 +3323,56 @@ class CFMemoryMCP {
3061
3323
  }
3062
3324
 
3063
3325
  /**
3064
- * Graceful shutdown
3326
+ * Graceful shutdown. Best-effort: tries to flush a synthesized handoff
3327
+ * to the active implicit session so the resume state survives the
3328
+ * shutdown. Uses keep_open so the session can still be properly closed
3329
+ * later if the agent reconnects. Bounded by a 2s timeout so we never
3330
+ * hang the exit on a slow network round-trip.
3331
+ *
3332
+ * Opt out via CF_MEMORY_SHUTDOWN_HANDOFF=off.
3065
3333
  */
3066
3334
  shutdown(reason = 'UNKNOWN') {
3067
3335
  this.logDebug(`Shutting down (reason: ${reason})`);
3068
- process.exit(0);
3336
+ const optOut = process.env.CF_MEMORY_SHUTDOWN_HANDOFF;
3337
+ const enabled = !(optOut === '0' || optOut === 'false' || optOut === 'off');
3338
+ if (!enabled) {
3339
+ process.exit(0);
3340
+ return;
3341
+ }
3342
+ // Resolve the implicit session WITHOUT creating a new one — we
3343
+ // don't want to leave an empty session row behind if the agent
3344
+ // never did anything. Only flush when there's tracked activity.
3345
+ const cwd = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
3346
+ const sessionId = this._implicitSessionByCwd?.get(cwd);
3347
+ const hasActivity = this._activityFiles && this._activityFiles.size > 0;
3348
+ if (!sessionId || !hasActivity) {
3349
+ this.logDebug(`shutdown handoff skipped: session=${sessionId||'(none)'} activity=${this._activityFiles?.size||0}`);
3350
+ process.exit(0);
3351
+ return;
3352
+ }
3353
+
3354
+ const handoff = this.synthesizeMinimalHandoff();
3355
+ const meta = this.getRepoMetadata();
3356
+ if (meta.repo_path) handoff.repo_path = meta.repo_path;
3357
+ if (meta.branch) handoff.branch = meta.branch;
3358
+
3359
+ const args = { session_id: sessionId, keep_open: true, handoff };
3360
+ const message = {
3361
+ jsonrpc: '2.0',
3362
+ id: `shutdown-${Date.now()}`,
3363
+ method: 'tools/call',
3364
+ params: { name: 'end_session', arguments: args },
3365
+ };
3366
+ _mcpTrace('SHUTDOWN_HANDOFF', `session=${sessionId} reason=${reason}`);
3367
+
3368
+ // Race the request against a 2s deadline so we never hang exit.
3369
+ const deadline = new Promise(resolve => setTimeout(resolve, 2000));
3370
+ Promise.race([
3371
+ this.makeRequest(message).catch(err => {
3372
+ this.logDebug(`shutdown handoff request failed: ${err && err.message}`);
3373
+ }),
3374
+ deadline,
3375
+ ]).finally(() => process.exit(0));
3069
3376
  }
3070
3377
  }
3071
3378
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cf-memory-mcp",
3
- "version": "3.15.0",
3
+ "version": "3.17.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": {