cf-memory-mcp 3.47.0 → 3.49.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.
@@ -2612,6 +2612,66 @@ class CFMemoryMCP {
2612
2612
  }
2613
2613
  }
2614
2614
 
2615
+ /**
2616
+ * Path for the on-disk implicit-session cache (keyed by cwd hash).
2617
+ * Lets the bridge survive restarts without losing the implicit
2618
+ * session — otherwise a fresh process would create a duplicate
2619
+ * session for the same cwd.
2620
+ */
2621
+ getImplicitSessionDiskPath() {
2622
+ try {
2623
+ const cwd = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
2624
+ const crypto = require('crypto');
2625
+ const hash = crypto.createHash('sha256').update(cwd).digest('hex').slice(0, 16);
2626
+ return path.join(os.homedir(), '.cf-memory', `implicit-${hash}.json`);
2627
+ } catch (_) { return null; }
2628
+ }
2629
+
2630
+ /**
2631
+ * Load the disk-persisted implicit session for the current cwd, if any.
2632
+ * Stale entries (>1 day old) are ignored — sessions that haven't been
2633
+ * touched in a day are probably not "implicitly active" anymore.
2634
+ */
2635
+ loadImplicitSessionFromDisk() {
2636
+ try {
2637
+ const p = this.getImplicitSessionDiskPath();
2638
+ if (!p || !fs.existsSync(p)) return null;
2639
+ const entry = JSON.parse(fs.readFileSync(p, 'utf8'));
2640
+ const ageMs = Date.now() - new Date(entry.cached_at).getTime();
2641
+ if (ageMs > 24 * 60 * 60 * 1000) return null; // stale
2642
+ return entry.session_id || null;
2643
+ } catch (_) { return null; }
2644
+ }
2645
+
2646
+ /**
2647
+ * Persist the current implicit session id to disk. Called from
2648
+ * getOrCreateImplicitSession and cacheImplicitSessionFromResponse.
2649
+ * Atomic via .tmp + rename, same as the resume cache.
2650
+ */
2651
+ saveImplicitSessionToDisk(sessionId) {
2652
+ try {
2653
+ const p = this.getImplicitSessionDiskPath();
2654
+ if (!p) return;
2655
+ const dir = path.dirname(p);
2656
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
2657
+ const entry = {
2658
+ cached_at: new Date().toISOString(),
2659
+ cwd: process.env.CF_MEMORY_WATCH_PATH || process.cwd(),
2660
+ session_id: sessionId,
2661
+ };
2662
+ const tmp = `${p}.tmp.${process.pid}`;
2663
+ try {
2664
+ fs.writeFileSync(tmp, JSON.stringify(entry, null, 2));
2665
+ fs.renameSync(tmp, p);
2666
+ } catch (renameErr) {
2667
+ try { fs.unlinkSync(tmp); } catch (_) { /* ignore */ }
2668
+ fs.writeFileSync(p, JSON.stringify(entry, null, 2));
2669
+ }
2670
+ } catch (err) {
2671
+ this.logDebug(`saveImplicitSessionToDisk failed: ${err && err.message}`);
2672
+ }
2673
+ }
2674
+
2615
2675
  /**
2616
2676
  * Return the implicit session_id for the current cwd, creating one via
2617
2677
  * start_session when no session is active. Lets agents skip explicit
@@ -2621,6 +2681,7 @@ class CFMemoryMCP {
2621
2681
  * The cache is keyed by cwd so multiple bridge processes for different
2622
2682
  * repos don't share state. Sessions finalized via end_session WITHOUT
2623
2683
  * keep_open clear the implicit cache so the next call starts fresh.
2684
+ * Persists to disk so bridge restarts don't churn new sessions.
2624
2685
  */
2625
2686
  async getOrCreateImplicitSession() {
2626
2687
  try {
@@ -2628,6 +2689,13 @@ class CFMemoryMCP {
2628
2689
  if (!this._implicitSessionByCwd) this._implicitSessionByCwd = new Map();
2629
2690
  const cached = this._implicitSessionByCwd.get(cwd);
2630
2691
  if (cached) return cached;
2692
+ // Try disk cache (survives bridge restarts).
2693
+ const fromDisk = this.loadImplicitSessionFromDisk();
2694
+ if (fromDisk) {
2695
+ this._implicitSessionByCwd.set(cwd, fromDisk);
2696
+ _mcpTrace('IMPLICIT_SESSION', `restored implicit session=${fromDisk} from disk for cwd=${cwd}`);
2697
+ return fromDisk;
2698
+ }
2631
2699
 
2632
2700
  // Lazily start a session, tagged with the same repo metadata as
2633
2701
  // start_session would auto-attach if called explicitly.
@@ -2652,6 +2720,7 @@ class CFMemoryMCP {
2652
2720
  } catch (_) { /* malformed response */ }
2653
2721
  if (sessionId) {
2654
2722
  this._implicitSessionByCwd.set(cwd, sessionId);
2723
+ this.saveImplicitSessionToDisk(sessionId);
2655
2724
  _mcpTrace('IMPLICIT_SESSION', `created implicit session=${sessionId} for cwd=${cwd}`);
2656
2725
  }
2657
2726
  return sessionId;
@@ -2678,6 +2747,7 @@ class CFMemoryMCP {
2678
2747
  const cwd = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
2679
2748
  if (!this._implicitSessionByCwd) this._implicitSessionByCwd = new Map();
2680
2749
  this._implicitSessionByCwd.set(cwd, sessionId);
2750
+ this.saveImplicitSessionToDisk(sessionId);
2681
2751
  _mcpTrace('IMPLICIT_SESSION', `cached implicit session=${sessionId} for cwd=${cwd}`);
2682
2752
  } catch (err) {
2683
2753
  this.logDebug(`cacheImplicitSessionFromResponse failed: ${err && err.message}`);
@@ -2708,6 +2778,12 @@ class CFMemoryMCP {
2708
2778
  this._implicitSessionByCwd.delete(cwd);
2709
2779
  _mcpTrace('IMPLICIT_SESSION', `cleared implicit session for cwd=${cwd}`);
2710
2780
  }
2781
+ // Also drop the on-disk persistent cache so a future restart
2782
+ // doesn't pick the just-finalized session back up.
2783
+ try {
2784
+ const p = this.getImplicitSessionDiskPath();
2785
+ if (p && fs.existsSync(p)) fs.unlinkSync(p);
2786
+ } catch (_) { /* ignore */ }
2711
2787
  } catch (err) {
2712
2788
  this.logDebug(`clearImplicitSessionIfFinalized failed: ${err && err.message}`);
2713
2789
  }
@@ -3981,6 +4057,14 @@ function parseCliArgs(rest) {
3981
4057
  flags.branch_only = true;
3982
4058
  } else if (a === '--repo-only') {
3983
4059
  flags.repo_only = true;
4060
+ } else if (a === '--fields') {
4061
+ flags.fields = rest[++i];
4062
+ } else if (a.startsWith('--fields=')) {
4063
+ flags.fields = a.slice('--fields='.length);
4064
+ } else if (a === '--diff') {
4065
+ flags.diff = rest[++i];
4066
+ } else if (a.startsWith('--diff=')) {
4067
+ flags.diff = a.slice('--diff='.length);
3984
4068
  } else if (a === '--older-than') {
3985
4069
  // Accept "7d" / "30d" / "12h" / raw number (days).
3986
4070
  const raw = rest[++i] || '';
@@ -4175,6 +4259,43 @@ async function runResumeCli() {
4175
4259
  process.exit(0);
4176
4260
  }
4177
4261
 
4262
+ // --fields a,b,c: multi-field extract. Print each field on its
4263
+ // own line (or as `name=value` for shell scripting). Supports
4264
+ // any top-level handoff field plus envelope metadata.
4265
+ if (flags.fields) {
4266
+ const envelope = payload.resume_handoff;
4267
+ if (!envelope) process.exit(3);
4268
+ const handoff = envelope.handoff || {};
4269
+ const fieldLookup = (name) => {
4270
+ switch (name) {
4271
+ case 'session_id': return envelope.session_id;
4272
+ case 'age_minutes': return envelope.handoff_age_minutes;
4273
+ case 'quality_score': return envelope.quality_score;
4274
+ case 'match_confidence': return envelope.match_confidence;
4275
+ case 'match_reason': return envelope.match_reason;
4276
+ case 'stale': return envelope.stale ? 'true' : 'false';
4277
+ default: return handoff[name];
4278
+ }
4279
+ };
4280
+ const wantNames = flags.fields.split(',').map(s => s.trim()).filter(Boolean);
4281
+ if (wantNames.length === 0) process.exit(3);
4282
+ const lines = [];
4283
+ for (const name of wantNames) {
4284
+ const v = fieldLookup(name);
4285
+ if (v === undefined || v === null) continue;
4286
+ if (Array.isArray(v)) {
4287
+ lines.push(`${name}=${v.length} item${v.length === 1 ? '' : 's'}`);
4288
+ } else if (typeof v === 'object') {
4289
+ lines.push(`${name}=${JSON.stringify(v)}`);
4290
+ } else {
4291
+ lines.push(`${name}=${v}`);
4292
+ }
4293
+ }
4294
+ if (lines.length === 0) process.exit(3);
4295
+ process.stdout.write(lines.join('\n') + '\n');
4296
+ process.exit(0);
4297
+ }
4298
+
4178
4299
  // --age: print just handoff_age_minutes (single integer).
4179
4300
  if (flags.age) {
4180
4301
  const ageMin = payload.resume_handoff?.handoff_age_minutes;
@@ -4275,6 +4396,65 @@ async function runResumeCli() {
4275
4396
  process.exit(0);
4276
4397
  }
4277
4398
 
4399
+ // --diff <other-id>: compare two handoffs. Shows added/removed
4400
+ // next_steps, branch shift, status change, files diff. Useful
4401
+ // for "what changed between yesterday's session and today's?".
4402
+ if (flags.diff) {
4403
+ if (!found) {
4404
+ process.stderr.write('No source handoff available to diff against.\n');
4405
+ process.exit(3);
4406
+ }
4407
+ const otherRes = await server.makeRequest({
4408
+ jsonrpc: '2.0', id: `cli-diff-${Date.now()}`,
4409
+ method: 'tools/call',
4410
+ params: { name: 'get_context_bootstrap', arguments: { resume: true, session_id_hint: flags.diff } },
4411
+ });
4412
+ const otherText = otherRes?.result?.content?.[0]?.text;
4413
+ const otherPayload = JSON.parse(otherText || '{}');
4414
+ const otherHandoff = otherPayload.resume_handoff?.handoff;
4415
+ if (!otherHandoff) {
4416
+ process.stderr.write(`Could not fetch handoff "${flags.diff}" to diff against.\n`);
4417
+ process.exit(3);
4418
+ }
4419
+ const h = payload.resume_handoff.handoff;
4420
+ const setOf = (arr, key) =>
4421
+ new Set((arr || []).map(x => typeof x === 'string' ? x : x[key]).filter(Boolean));
4422
+ const diffArr = (label, a, b, key) => {
4423
+ const A = setOf(a, key); const B = setOf(b, key);
4424
+ const added = [...A].filter(x => !B.has(x));
4425
+ const removed = [...B].filter(x => !A.has(x));
4426
+ const lines = [];
4427
+ if (added.length) lines.push(` + ${label}: ${added.join(', ')}`);
4428
+ if (removed.length) lines.push(` - ${label}: ${removed.join(', ')}`);
4429
+ return lines.join('\n');
4430
+ };
4431
+
4432
+ const aShort = (payload.resume_handoff.session_id || '').slice(0, 8);
4433
+ const bShort = (otherPayload.resume_handoff.session_id || '').slice(0, 8);
4434
+ const lines = [`Diff: ${aShort} (this) vs ${bShort} (other)`, ''];
4435
+ // Scalar fields
4436
+ const scalars = ['goal', 'status', 'branch', 'repo_path'];
4437
+ for (const f of scalars) {
4438
+ if (h[f] !== otherHandoff[f]) {
4439
+ lines.push(` ~ ${f}: "${otherHandoff[f] || ''}" → "${h[f] || ''}"`);
4440
+ }
4441
+ }
4442
+ // Array fields
4443
+ const arrDiff = diffArr('next_steps', h.next_steps, otherHandoff.next_steps);
4444
+ if (arrDiff) lines.push(arrDiff);
4445
+ const blockerDiff = diffArr('blockers', h.blockers, otherHandoff.blockers);
4446
+ if (blockerDiff) lines.push(blockerDiff);
4447
+ const decisionDiff = diffArr('decisions', h.decisions, otherHandoff.decisions);
4448
+ if (decisionDiff) lines.push(decisionDiff);
4449
+ const fileDiff = diffArr('files_touched', h.files_touched, otherHandoff.files_touched, 'path');
4450
+ if (fileDiff) lines.push(fileDiff);
4451
+ const anchorDiff = diffArr('code_anchors', h.code_anchors, otherHandoff.code_anchors, 'file_path');
4452
+ if (anchorDiff) lines.push(anchorDiff);
4453
+ if (lines.length === 2) lines.push(' (no differences)');
4454
+ process.stdout.write(lines.join('\n') + '\n');
4455
+ process.exit(0);
4456
+ }
4457
+
4278
4458
  // --validate: verify the handoff's file references still exist
4279
4459
  // on disk. Resolves each path relative to the handoff's repo_path
4280
4460
  // (or cwd). Reports per-path status. Useful before resuming to
@@ -4981,6 +5161,7 @@ const PER_COMMAND_HELP = {
4981
5161
  --decisions-only decisions, one per line.
4982
5162
  --blockers-only open blockers, one per line.
4983
5163
  --chain Walk parent_session_id back; show the thread history.
5164
+ --diff <other-id> Compare this handoff against another (added/removed/changed fields).
4984
5165
  --validate Check that files_touched + code_anchors still exist locally.
4985
5166
  --raw Print just the raw handoff JSON (no envelope metadata).
4986
5167
  --age Print handoff_age_minutes (single integer).
@@ -4989,6 +5170,9 @@ const PER_COMMAND_HELP = {
4989
5170
  --status-only Print just the status string.
4990
5171
  --branch-only Print just the branch.
4991
5172
  --repo-only Print just the repo_path.
5173
+ --fields <list> Print specified fields as "name=value", comma-sep.
5174
+ Supports any handoff field plus session_id, age_minutes,
5175
+ quality_score, match_confidence, match_reason, stale.
4992
5176
  --json, -j Full bootstrap payload as JSON.
4993
5177
  Exit codes: 0 = found, 3 = no handoff / no data, 4 = --validate found missing files.`,
4994
5178
  list: `cf-memory-mcp list [--status S] [--since ISO] [--repo PATH] [--limit N] [--json]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cf-memory-mcp",
3
- "version": "3.47.0",
3
+ "version": "3.49.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": {