cf-memory-mcp 3.61.0 → 3.63.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.
@@ -2672,6 +2672,42 @@ class CFMemoryMCP {
2672
2672
  }
2673
2673
  }
2674
2674
 
2675
+ /**
2676
+ * Drop the implicit-session disk cache iff the cached session id is
2677
+ * in `deletedSessionIds`. Called from the delete CLI so a subsequent
2678
+ * end_session against the implicit cache won't write to a session
2679
+ * that no longer exists on the server. Also clears the in-memory map
2680
+ * for the matching cwd. Returns true when something was dropped.
2681
+ */
2682
+ invalidateImplicitSessionIfMatches(deletedSessionIds) {
2683
+ if (!deletedSessionIds || !deletedSessionIds.length) return false;
2684
+ const matchSet = new Set(deletedSessionIds);
2685
+ let dropped = false;
2686
+ try {
2687
+ const implicitPath = this.getImplicitSessionDiskPath();
2688
+ if (implicitPath && fs.existsSync(implicitPath)) {
2689
+ const entry = JSON.parse(fs.readFileSync(implicitPath, 'utf8'));
2690
+ if (matchSet.has(entry.session_id)) {
2691
+ fs.unlinkSync(implicitPath);
2692
+ dropped = true;
2693
+ }
2694
+ }
2695
+ } catch (_) { /* non-fatal */ }
2696
+ // Also evict in-memory entries so a long-lived bridge stops
2697
+ // auto-filling the deleted id.
2698
+ try {
2699
+ if (this._implicitSessionByCwd && this._implicitSessionByCwd.size) {
2700
+ for (const [cwd, sid] of this._implicitSessionByCwd.entries()) {
2701
+ if (matchSet.has(sid)) {
2702
+ this._implicitSessionByCwd.delete(cwd);
2703
+ dropped = true;
2704
+ }
2705
+ }
2706
+ }
2707
+ } catch (_) { /* non-fatal */ }
2708
+ return dropped;
2709
+ }
2710
+
2675
2711
  /**
2676
2712
  * Return the implicit session_id for the current cwd, creating one via
2677
2713
  * start_session when no session is active. Lets agents skip explicit
@@ -3919,7 +3955,7 @@ if (process.argv.includes('--version') || process.argv.includes('-v')) {
3919
3955
 
3920
3956
  // Global --help only when no subcommand is present. With a subcommand, fall
3921
3957
  // through to the per-command help dispatch below.
3922
- const SUBCOMMANDS = new Set(['resume', 'list', 'checkpoint', 'status', 'clean', 'export', 'import', 'doctor', 'completion', 'delete', 'env', 'history', 'link', 'explain', 'gha']);
3958
+ const SUBCOMMANDS = new Set(['resume', 'list', 'search', 'checkpoint', 'status', 'clean', 'export', 'import', 'doctor', 'completion', 'delete', 'env', 'history', 'link', 'explain', 'gha']);
3923
3959
  const hasSubcommand = process.argv[2] && SUBCOMMANDS.has(process.argv[2]);
3924
3960
 
3925
3961
  if (!hasSubcommand && (process.argv.includes('--help') || process.argv.includes('-h'))) {
@@ -3932,6 +3968,7 @@ Usage:
3932
3968
  npx cf-memory-mcp Start the MCP server
3933
3969
  npx cf-memory-mcp resume [id] Print the prior resume handoff (markdown)
3934
3970
  npx cf-memory-mcp list List recent handoffs for cwd (status-ranked)
3971
+ npx cf-memory-mcp search <query> Find handoffs by goal/notes/decisions text (cross-repo default)
3935
3972
  npx cf-memory-mcp history List all handoffs for cwd, chronological
3936
3973
  npx cf-memory-mcp checkpoint ["<goal>"] Snapshot current state (keep_open)
3937
3974
  npx cf-memory-mcp link <child> --parent <id> Retroactively link a child session to a parent
@@ -4122,6 +4159,10 @@ function parseCliArgs(rest) {
4122
4159
  flags.since = a.slice('--since='.length);
4123
4160
  } else if (a === '--yes' || a === '-y') {
4124
4161
  flags.yes = true;
4162
+ } else if (a === '--ids-only') {
4163
+ flags.ids_only = true;
4164
+ } else if (a === '--here') {
4165
+ flags.here = true;
4125
4166
  } else positional.push(a);
4126
4167
  }
4127
4168
  return { positional, flags };
@@ -4959,6 +5000,116 @@ async function runListCli() {
4959
5000
  }
4960
5001
  }
4961
5002
 
5003
+ async function runSearchCli() {
5004
+ if (!API_KEY) {
5005
+ console.error('Error: CF_MEMORY_API_KEY environment variable is required');
5006
+ process.exit(1);
5007
+ }
5008
+ const { positional, flags } = parseCliArgs(process.argv.slice(3));
5009
+ const query = positional[0];
5010
+ if (!query) {
5011
+ console.error('Usage: cf-memory-mcp search <query> [--repo <path>] [--project-id <id>] [--status <s>] [--since <iso>] [--limit N] [--json] [--ids-only]');
5012
+ console.error('Searches across goal/next_steps/notes/decisions/blockers (case-insensitive substring).');
5013
+ console.error('Defaults to ALL your handoffs (cross-repo). Use --repo or --project-id to scope.');
5014
+ process.exit(1);
5015
+ }
5016
+ const server = new CFMemoryMCP();
5017
+ server.logDebug = () => {};
5018
+ server.logError = (...a) => process.stderr.write(a.join(' ') + '\n');
5019
+ try {
5020
+ // Server-side substring filter via goal_contains (which actually
5021
+ // matches the whole serialized handoff JSON — goal, next_steps,
5022
+ // notes, decisions, blockers, code_anchors, files_touched).
5023
+ const args = { resume: true, goal_contains: query };
5024
+ // Default: NO repo/project scoping — search is meant to be global.
5025
+ // Opt-in via --repo or --project-id (or --here for cwd's repo).
5026
+ if (flags.repo_path) args.repo_path = flags.repo_path;
5027
+ if (flags.project_id) args.project_id = flags.project_id;
5028
+ if (flags.here) {
5029
+ const meta = server.getRepoMetadata();
5030
+ if (meta.repo_path) args.repo_path = meta.repo_path;
5031
+ if (meta.branch) args.branch = meta.branch;
5032
+ }
5033
+ if (flags.status) args.status_filter = flags.status;
5034
+ if (flags.since) args.since = flags.since;
5035
+
5036
+ const response = await server.makeRequest({
5037
+ jsonrpc: '2.0',
5038
+ id: `cli-search-${Date.now()}`,
5039
+ method: 'tools/call',
5040
+ params: { name: 'get_context_bootstrap', arguments: args },
5041
+ });
5042
+ const text = response?.result?.content?.[0]?.text;
5043
+ const payload = JSON.parse(text || '{}');
5044
+ const allList = Array.isArray(payload.recent_handoffs) ? payload.recent_handoffs : [];
5045
+ const list = flags.limit ? allList.slice(0, flags.limit) : allList;
5046
+
5047
+ if (flags.ids_only) {
5048
+ for (const h of list) process.stdout.write(`${h.session_id || ''}\n`);
5049
+ process.exit(list.length === 0 ? 3 : 0);
5050
+ }
5051
+ if (flags.json) {
5052
+ process.stdout.write(JSON.stringify({
5053
+ query,
5054
+ scope: args.repo_path ? 'repo' : args.project_id ? 'project' : 'global',
5055
+ repo_path: args.repo_path,
5056
+ project_id: args.project_id,
5057
+ status_filter: flags.status,
5058
+ since: flags.since,
5059
+ count: list.length,
5060
+ handoffs: list,
5061
+ }, null, 2) + '\n');
5062
+ process.exit(list.length === 0 ? 3 : 0);
5063
+ }
5064
+ if (list.length === 0) {
5065
+ process.stderr.write(`No handoffs match "${query}".\n`);
5066
+ process.exit(3);
5067
+ }
5068
+ // Render with terminal highlight (ANSI bold) when stdout is a TTY.
5069
+ const tty = process.stdout.isTTY;
5070
+ const lcQuery = query.toLowerCase();
5071
+ const highlight = (str) => {
5072
+ if (!str) return str;
5073
+ if (!tty) return str;
5074
+ const idx = str.toLowerCase().indexOf(lcQuery);
5075
+ if (idx < 0) return str;
5076
+ const before = str.slice(0, idx);
5077
+ const match = str.slice(idx, idx + query.length);
5078
+ const after = str.slice(idx + query.length);
5079
+ return `${before}\x1b[1;33m${match}\x1b[0m${after}`;
5080
+ };
5081
+ const fmtAge = (iso) => {
5082
+ if (!iso) return '?';
5083
+ const min = Math.round((Date.now() - new Date(iso).getTime()) / 60000);
5084
+ if (min < 60) return `${min}m`;
5085
+ if (min < 1440) return `${Math.round(min/60)}h`;
5086
+ return `${Math.round(min/1440)}d`;
5087
+ };
5088
+ const scopeLabel = args.repo_path ? `in repo ${args.repo_path}` : args.project_id ? `in project ${args.project_id}` : 'across all your handoffs';
5089
+ process.stdout.write(`Found ${list.length} handoff${list.length === 1 ? '' : 's'} matching "${query}" ${scopeLabel}:\n\n`);
5090
+ for (const h of list) {
5091
+ const shortId = (h.session_id || '').slice(0, 8);
5092
+ const age = fmtAge(h.ended_at || h.started_at);
5093
+ const status = (h.status || 'unknown').padEnd(11);
5094
+ const branch = h.branch ? `[${h.branch}] ` : '';
5095
+ const goal = highlight((h.goal || '(no goal)').slice(0, 100));
5096
+ const q = typeof h.quality_score === 'number' ? ` q=${h.quality_score.toFixed(2)}` : '';
5097
+ process.stdout.write(` ${shortId} ${status} ${age.padStart(4)} ago${q} ${branch}${goal}\n`);
5098
+ if (h.next_step) process.stdout.write(` next: ${highlight(h.next_step.slice(0, 100))}\n`);
5099
+ // If the repo differs from the user's cwd, surface it so they
5100
+ // know which project the handoff belongs to.
5101
+ if (h.repo_path && !args.repo_path) {
5102
+ process.stdout.write(` repo: ${h.repo_path}\n`);
5103
+ }
5104
+ }
5105
+ process.stdout.write(`\n(Run "npx cf-memory-mcp resume <id>" to load one, or pipe to "xargs -I{} npx cf-memory-mcp resume {}" with --ids-only)\n`);
5106
+ process.exit(0);
5107
+ } catch (err) {
5108
+ console.error('search command failed:', err.message);
5109
+ process.exit(1);
5110
+ }
5111
+ }
5112
+
4962
5113
  async function runLinkCli() {
4963
5114
  if (!API_KEY) {
4964
5115
  console.error('Error: CF_MEMORY_API_KEY environment variable is required');
@@ -5215,6 +5366,12 @@ async function runDeleteCli() {
5215
5366
  });
5216
5367
  const deleteText = deleteRes?.result?.content?.[0]?.text;
5217
5368
  const payload = JSON.parse(deleteText || '{}');
5369
+ // Implicit-cache invalidation: if any deleted id matches the
5370
+ // current cwd's implicit session, drop the cache so the next
5371
+ // end_session creates a fresh one.
5372
+ if (payload.deleted) {
5373
+ server.invalidateImplicitSessionIfMatches(payload.deleted_session_ids || []);
5374
+ }
5218
5375
  if (flags.json) {
5219
5376
  process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
5220
5377
  process.exit(payload.deleted ? 0 : 3);
@@ -5254,6 +5411,12 @@ async function runDeleteCli() {
5254
5411
  });
5255
5412
  const deleteText = deleteRes?.result?.content?.[0]?.text;
5256
5413
  const payload = JSON.parse(deleteText || '{}');
5414
+ // Drop the implicit-session disk cache if the deleted id matches
5415
+ // it. Otherwise a subsequent end_session would try to write to a
5416
+ // session that no longer exists.
5417
+ if (payload.deleted) {
5418
+ server.invalidateImplicitSessionIfMatches([fullId]);
5419
+ }
5257
5420
  if (flags.json) {
5258
5421
  process.stdout.write(JSON.stringify({ ...payload, session_id: fullId }, null, 2) + '\n');
5259
5422
  process.exit(payload.deleted ? 0 : 3);
@@ -5341,7 +5504,7 @@ function runCompletionCli() {
5341
5504
  process.stderr.write(`\nDone. Installed completion for ${installed} shell${installed === 1 ? '' : 's'}.\n`);
5342
5505
  process.exit(installed > 0 ? 0 : 1);
5343
5506
  }
5344
- const commands = ['resume', 'list', 'history', 'checkpoint', 'link', 'explain', 'gha', 'status', 'clean', 'export', 'import', 'delete', 'doctor', 'env', 'completion'];
5507
+ const commands = ['resume', 'list', 'search', 'history', 'checkpoint', 'link', 'explain', 'gha', 'status', 'clean', 'export', 'import', 'delete', 'doctor', 'env', 'completion'];
5345
5508
  const flags = ['--json', '-j', '--limit', '-n', '--md', '--all', '--force', '-f', '--version', '-v', '--help', '-h', '--diagnose'];
5346
5509
 
5347
5510
  // Determine the install target for each shell. Use user-local paths
@@ -5545,6 +5708,23 @@ const PER_COMMAND_HELP = {
5545
5708
  --repo PATH Override the cwd's repo (peek at another repo).
5546
5709
  --limit N, -n N Max number of entries (default 5, max 50).
5547
5710
  --json, -j Emit a JSON array for scripts.`,
5711
+ search: `cf-memory-mcp search <query> [--here | --repo PATH | --project-id ID] [--status S] [--since ISO] [--limit N] [--json|--ids-only]
5712
+ Find handoffs matching <query> across goal, next_steps, notes,
5713
+ decisions, blockers, code_anchors, and files_touched (case-insensitive
5714
+ substring). Default scope is ALL your handoffs (cross-repo). Highlights
5715
+ the matched substring in terminal output.
5716
+ <query> Required substring to search for.
5717
+ --here Scope to cwd's repo (otherwise: global).
5718
+ --repo PATH Scope to a specific repo path.
5719
+ --project-id ID Scope to a specific project.
5720
+ --status S Filter by status (in_progress, completed, ...).
5721
+ --since ISO Lower bound on handoff timestamp (ISO 8601).
5722
+ --limit N Max entries to render (server may cap at 50).
5723
+ --ids-only Print only session_ids (xargs-friendly).
5724
+ --json, -j Emit JSON for scripts.
5725
+ Exit codes: 0 = matches, 3 = no matches.
5726
+ Example: cf-memory-mcp search "OAuth" --status in_progress
5727
+ cf-memory-mcp search "migration" --ids-only | xargs -I{} cfm resume {}`,
5548
5728
  checkpoint: `cf-memory-mcp checkpoint ["<goal>"] [--force] [--json]
5549
5729
  Snapshot current state to a fresh handoff with keep_open:true. The
5550
5730
  session stays active for future checkpoints. The final end_session (or
@@ -6097,11 +6277,15 @@ async function runCleanCli() {
6097
6277
  const removed = [];
6098
6278
  try {
6099
6279
  if (all) {
6100
- // Clear ALL disk caches in ~/.cf-memory/.
6280
+ // Clear ALL disk caches in ~/.cf-memory/: handoff-*.json
6281
+ // (offline resume cache) AND implicit-*.json (persistent
6282
+ // implicit session id per cwd).
6101
6283
  const dir = path.join(os.homedir(), '.cf-memory');
6102
6284
  if (fs.existsSync(dir)) {
6103
6285
  for (const entry of fs.readdirSync(dir)) {
6104
- if (entry.startsWith('handoff-') && entry.endsWith('.json')) {
6286
+ const isHandoff = entry.startsWith('handoff-') && entry.endsWith('.json');
6287
+ const isImplicit = entry.startsWith('implicit-') && entry.endsWith('.json');
6288
+ if (isHandoff || isImplicit) {
6105
6289
  const full = path.join(dir, entry);
6106
6290
  fs.unlinkSync(full);
6107
6291
  removed.push(full);
@@ -6109,10 +6293,17 @@ async function runCleanCli() {
6109
6293
  }
6110
6294
  }
6111
6295
  } else {
6112
- const p = server.getDiskCachePath();
6113
- if (p && fs.existsSync(p)) {
6114
- fs.unlinkSync(p);
6115
- removed.push(p);
6296
+ // cwd-only mode: drop both the handoff cache AND the
6297
+ // implicit session cache for this cwd.
6298
+ const handoffPath = server.getDiskCachePath();
6299
+ if (handoffPath && fs.existsSync(handoffPath)) {
6300
+ fs.unlinkSync(handoffPath);
6301
+ removed.push(handoffPath);
6302
+ }
6303
+ const implicitPath = server.getImplicitSessionDiskPath();
6304
+ if (implicitPath && fs.existsSync(implicitPath)) {
6305
+ fs.unlinkSync(implicitPath);
6306
+ removed.push(implicitPath);
6116
6307
  }
6117
6308
  }
6118
6309
  if (flags.json) {
@@ -6369,6 +6560,11 @@ if (process.argv[2] === 'list') {
6369
6560
  return;
6370
6561
  }
6371
6562
 
6563
+ if (process.argv[2] === 'search') {
6564
+ runSearchCli();
6565
+ return;
6566
+ }
6567
+
6372
6568
  if (process.argv[2] === 'checkpoint') {
6373
6569
  runCheckpointCli();
6374
6570
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cf-memory-mcp",
3
- "version": "3.61.0",
3
+ "version": "3.63.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": {