cf-memory-mcp 3.47.0 → 3.48.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,10 @@ 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);
3984
4064
  } else if (a === '--older-than') {
3985
4065
  // Accept "7d" / "30d" / "12h" / raw number (days).
3986
4066
  const raw = rest[++i] || '';
@@ -4175,6 +4255,43 @@ async function runResumeCli() {
4175
4255
  process.exit(0);
4176
4256
  }
4177
4257
 
4258
+ // --fields a,b,c: multi-field extract. Print each field on its
4259
+ // own line (or as `name=value` for shell scripting). Supports
4260
+ // any top-level handoff field plus envelope metadata.
4261
+ if (flags.fields) {
4262
+ const envelope = payload.resume_handoff;
4263
+ if (!envelope) process.exit(3);
4264
+ const handoff = envelope.handoff || {};
4265
+ const fieldLookup = (name) => {
4266
+ switch (name) {
4267
+ case 'session_id': return envelope.session_id;
4268
+ case 'age_minutes': return envelope.handoff_age_minutes;
4269
+ case 'quality_score': return envelope.quality_score;
4270
+ case 'match_confidence': return envelope.match_confidence;
4271
+ case 'match_reason': return envelope.match_reason;
4272
+ case 'stale': return envelope.stale ? 'true' : 'false';
4273
+ default: return handoff[name];
4274
+ }
4275
+ };
4276
+ const wantNames = flags.fields.split(',').map(s => s.trim()).filter(Boolean);
4277
+ if (wantNames.length === 0) process.exit(3);
4278
+ const lines = [];
4279
+ for (const name of wantNames) {
4280
+ const v = fieldLookup(name);
4281
+ if (v === undefined || v === null) continue;
4282
+ if (Array.isArray(v)) {
4283
+ lines.push(`${name}=${v.length} item${v.length === 1 ? '' : 's'}`);
4284
+ } else if (typeof v === 'object') {
4285
+ lines.push(`${name}=${JSON.stringify(v)}`);
4286
+ } else {
4287
+ lines.push(`${name}=${v}`);
4288
+ }
4289
+ }
4290
+ if (lines.length === 0) process.exit(3);
4291
+ process.stdout.write(lines.join('\n') + '\n');
4292
+ process.exit(0);
4293
+ }
4294
+
4178
4295
  // --age: print just handoff_age_minutes (single integer).
4179
4296
  if (flags.age) {
4180
4297
  const ageMin = payload.resume_handoff?.handoff_age_minutes;
@@ -4989,6 +5106,9 @@ const PER_COMMAND_HELP = {
4989
5106
  --status-only Print just the status string.
4990
5107
  --branch-only Print just the branch.
4991
5108
  --repo-only Print just the repo_path.
5109
+ --fields <list> Print specified fields as "name=value", comma-sep.
5110
+ Supports any handoff field plus session_id, age_minutes,
5111
+ quality_score, match_confidence, match_reason, stale.
4992
5112
  --json, -j Full bootstrap payload as JSON.
4993
5113
  Exit codes: 0 = found, 3 = no handoff / no data, 4 = --validate found missing files.`,
4994
5114
  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.48.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": {