@echomem/mcp 1.4.2 → 1.4.4

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/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { createHash, randomUUID } from "node:crypto";
13
13
  import { fetchEncryptionConfig, decryptMemoryFields } from "./encryption.js";
14
14
  import { runCli } from "./setup.js";
15
15
  import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS } from "./package-metadata.js";
16
+ import { checkLatestUpdateStatus, formatUpdateNotice, formatUpdateStatusText, startBackgroundUpdateCheck, } from "./update-check.js";
16
17
  const ECHO_API_BASE_URL = process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app";
17
18
  const MEMORY_FEED_API_URL = process.env.MEMORY_FEED_API_URL || "https://memory-feed.vercel.app";
18
19
  /** Thrown when no API token is present yet — the model gets a "run login" nudge, not a hard error. */
@@ -800,6 +801,7 @@ class EchoMemMCPServer {
800
801
  mcpClientVersion;
801
802
  /** Whether the most recent ListTools response carried the memory map (per-session recall signal). */
802
803
  mapInjected = false;
804
+ updateStatus;
803
805
  constructor(store) {
804
806
  this.server = new Server({
805
807
  name: "echomem-mcp",
@@ -812,6 +814,9 @@ class EchoMemMCPServer {
812
814
  });
813
815
  this.client = new EchoMemApiClient(store);
814
816
  this.events = new EventLogger({ session_id: this.client.getSessionId(), app_version: SERVER_VERSION });
817
+ startBackgroundUpdateCheck((status) => {
818
+ this.updateStatus = status;
819
+ });
815
820
  this.setupToolHandlers();
816
821
  this.server.onerror = (error) => console.error("[MCP Error]", error);
817
822
  process.on("SIGINT", async () => {
@@ -851,7 +856,8 @@ class EchoMemMCPServer {
851
856
  ? await Promise.race([this.mapCache, new Promise((r) => setTimeout(() => r(undefined), 2500))])
852
857
  : undefined;
853
858
  this.mapInjected = !!map;
854
- return { tools: listToolSpecs({ map }) };
859
+ const updateNotice = formatUpdateNotice(this.updateStatus);
860
+ return { tools: listToolSpecs({ map, updateNotice }) };
855
861
  });
856
862
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
857
863
  const canonicalName = resolveCanonicalToolName(request.params.name);
@@ -883,6 +889,12 @@ class EchoMemMCPServer {
883
889
  if (canonicalName === canonicalToolNames.report) {
884
890
  return { content: [{ type: "text", text: await buildReportText(false) }] };
885
891
  }
892
+ if (canonicalName === canonicalToolNames.updateStatus) {
893
+ const force = isRecord(request.params.arguments) && request.params.arguments.force === true;
894
+ const status = await checkLatestUpdateStatus({ force });
895
+ this.updateStatus = status;
896
+ return { content: [{ type: "text", text: formatUpdateStatusText(status) }] };
897
+ }
886
898
  if (canonicalName === canonicalToolNames.contextHealth) {
887
899
  const client = isRecord(request.params.arguments) && typeof request.params.arguments.client === "string"
888
900
  ? request.params.arguments.client
@@ -994,6 +1006,7 @@ class EchoMemMCPServer {
994
1006
  rec.latency_ms = Date.now() - t0;
995
1007
  this.events.record(rec);
996
1008
  if (canonicalName !== canonicalToolNames.report &&
1009
+ canonicalName !== canonicalToolNames.updateStatus &&
997
1010
  canonicalName !== canonicalToolNames.contextHealth &&
998
1011
  canonicalName !== canonicalToolNames.recompose &&
999
1012
  canonicalName !== canonicalToolNames.save &&
@@ -22,11 +22,13 @@ export const MCP_PACKAGE_VERSION = stringOrFallback(packageJson.version, FALLBAC
22
22
  export const MCP_PACKAGE_DESCRIPTION = stringOrFallback(packageJson.description, FALLBACK_PACKAGE.description);
23
23
  export const MCP_PACKAGE_LABEL = `${MCP_PACKAGE_NAME}@${MCP_PACKAGE_VERSION}`;
24
24
  export const MCP_UPDATE_COMMAND = `npx -y ${MCP_PACKAGE_NAME}@latest update`;
25
+ export const MCP_UPDATE_ALL_COMMAND = `${MCP_UPDATE_COMMAND} --all`;
25
26
  export const MCP_SERVER_INSTRUCTIONS = [
26
27
  `${MCP_PACKAGE_DESCRIPTION} (${MCP_PACKAGE_LABEL}).`,
27
- `If the user or local config expects a newer EchoMem MCP version than ${MCP_PACKAGE_VERSION}, update once with \`${MCP_UPDATE_COMMAND}\` and start a new MCP session.`,
28
+ `If the user or local config expects a newer EchoMem MCP version than ${MCP_PACKAGE_VERSION}, update once with \`${MCP_UPDATE_ALL_COMMAND}\` and start a new MCP session.`,
29
+ "Use echomem_update_status to check whether npm has a newer bridge; it is cached and non-blocking during normal tool listing.",
28
30
  "Do not auto-update on every MCP startup; this bridge is intentionally stable between explicit updates.",
29
31
  ].join(" ");
30
32
  export function withMcpVersion(description) {
31
- return `${description}\n\nEchoMem MCP bridge: ${MCP_PACKAGE_LABEL}. If this version is stale, update once with \`${MCP_UPDATE_COMMAND}\` (add \`--client cursor|windsurf|claude-desktop|codex\` when needed), then start a new MCP session. Do not run updates repeatedly or on every startup.`;
33
+ return `${description}\n\nEchoMem MCP bridge: ${MCP_PACKAGE_LABEL}. If this version is stale, update once with \`${MCP_UPDATE_ALL_COMMAND}\` (or add \`--client cursor|windsurf|claude-desktop|claude-code|codex\` for a single client), then start a new MCP session. Do not run updates repeatedly or on every startup.`;
32
34
  }
package/dist/setup.js CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import http from "node:http";
16
16
  import { randomUUID } from "node:crypto";
17
- import { spawn } from "node:child_process";
17
+ import { execFileSync, spawn } from "node:child_process";
18
18
  import { Worker } from "node:worker_threads";
19
19
  import fs from "node:fs";
20
20
  import os from "node:os";
@@ -30,7 +30,8 @@ import { syncCodexUsage } from "./codex-sync.js";
30
30
  import { renderSetupPage } from "./setup-page.js";
31
31
  import { repoLabel } from "./forensics.js";
32
32
  import { installHooks } from "./hud/hooks.js";
33
- import { MCP_PACKAGE_LABEL, MCP_PACKAGE_VERSION, MCP_UPDATE_COMMAND } from "./package-metadata.js";
33
+ import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
34
+ import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
34
35
  // The hosted connect-device page is now only the account-auth/token courier. The dashboard itself is
35
36
  // served by this localhost bridge, where local logs and processed/unprocessed counts never leave the
36
37
  // device unless the user explicitly starts migration. Override the hosted auth origin with ECHO_WEB_URL.
@@ -142,6 +143,259 @@ export function writeJsonClientConfig(configPath, entry) {
142
143
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
143
144
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
144
145
  }
146
+ export function writeClaudeCodeConfig(entry) {
147
+ try {
148
+ execFileSync("claude", ["mcp", "add-json", "echomem", JSON.stringify(entry)], {
149
+ encoding: "utf8",
150
+ stdio: ["ignore", "pipe", "pipe"],
151
+ timeout: 10000,
152
+ });
153
+ return "wrote";
154
+ }
155
+ catch {
156
+ return "unavailable";
157
+ }
158
+ }
159
+ function readJsonClientEntry(configPath) {
160
+ try {
161
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
162
+ const servers = config.mcpServers;
163
+ if (!servers || typeof servers !== "object" || Array.isArray(servers))
164
+ return null;
165
+ const entry = servers.echomem;
166
+ return entry && typeof entry === "object" && !Array.isArray(entry) ? entry : null;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ function readCodexEntry(configPath) {
173
+ let content = "";
174
+ try {
175
+ content = fs.readFileSync(configPath, "utf8");
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ const lines = content.split("\n");
181
+ const start = lines.findIndex((line) => /^\s*\[mcp_servers\.echomem\]\s*$/.test(line));
182
+ if (start < 0)
183
+ return null;
184
+ let end = start + 1;
185
+ while (end < lines.length && !/^\s*\[/.test(lines[end]))
186
+ end++;
187
+ const block = lines.slice(start, end);
188
+ const command = parseTomlString(block.find((line) => /^\s*command\s*=/.test(line)));
189
+ const args = parseTomlStringArray(block.find((line) => /^\s*args\s*=/.test(line)));
190
+ return command ? { command, args } : null;
191
+ }
192
+ function parseTomlString(line) {
193
+ if (!line)
194
+ return undefined;
195
+ const match = line.match(/=\s*(".*")\s*$/);
196
+ if (!match)
197
+ return undefined;
198
+ try {
199
+ const value = JSON.parse(match[1]);
200
+ return typeof value === "string" ? value : undefined;
201
+ }
202
+ catch {
203
+ return undefined;
204
+ }
205
+ }
206
+ function parseTomlStringArray(line) {
207
+ if (!line)
208
+ return [];
209
+ const match = line.match(/=\s*(\[.*\])\s*$/);
210
+ if (!match)
211
+ return [];
212
+ try {
213
+ const value = JSON.parse(match[1]);
214
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
215
+ }
216
+ catch {
217
+ return [];
218
+ }
219
+ }
220
+ function entryArgs(entry) {
221
+ return Array.isArray(entry.args) ? entry.args.filter((arg) => typeof arg === "string") : [];
222
+ }
223
+ function entryCommand(entry) {
224
+ return typeof entry.command === "string" && entry.command.trim() ? entry.command.trim() : undefined;
225
+ }
226
+ function entryTarget(entry) {
227
+ const command = entryCommand(entry) ?? "(missing command)";
228
+ const args = entryArgs(entry);
229
+ return [command, ...args].join(" ");
230
+ }
231
+ function packageVersionFromPath(entryPath) {
232
+ let current = entryPath;
233
+ try {
234
+ current = fs.realpathSync(entryPath);
235
+ }
236
+ catch {
237
+ /* keep the original path; it may still be inside an existing package dir */
238
+ }
239
+ try {
240
+ if (fs.existsSync(current) && fs.statSync(current).isFile())
241
+ current = path.dirname(current);
242
+ }
243
+ catch {
244
+ current = path.dirname(current);
245
+ }
246
+ for (let i = 0; i < 12; i += 1) {
247
+ const packagePath = path.join(current, "package.json");
248
+ try {
249
+ const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"));
250
+ if (pkg.name === MCP_PACKAGE_NAME && typeof pkg.version === "string") {
251
+ return { version: pkg.version, packagePath };
252
+ }
253
+ }
254
+ catch {
255
+ /* keep walking */
256
+ }
257
+ const parent = path.dirname(current);
258
+ if (parent === current)
259
+ break;
260
+ current = parent;
261
+ }
262
+ return null;
263
+ }
264
+ export function resolveServerEntryVersion(entry) {
265
+ const command = entryCommand(entry);
266
+ const args = entryArgs(entry);
267
+ const npxPackageArg = args.find((arg) => arg === MCP_PACKAGE_NAME || arg.startsWith(`${MCP_PACKAGE_NAME}@`));
268
+ if (command && path.basename(command).replace(/\.(cmd|exe)$/i, "") === "npx" && npxPackageArg) {
269
+ const suffix = npxPackageArg.slice(MCP_PACKAGE_NAME.length);
270
+ if (suffix === "@latest")
271
+ return { runtime: "latest" };
272
+ if (suffix.startsWith("@") && /^\d+\.\d+\.\d+/.test(suffix.slice(1)))
273
+ return { version: suffix.slice(1) };
274
+ return { runtime: "dynamic" };
275
+ }
276
+ const candidates = [...args, command].filter((value) => typeof value === "string");
277
+ for (const candidate of candidates) {
278
+ // Direct local/dev entries often look like ".../packages/mcp-server/dist/index.js";
279
+ // they do not contain the published package name, but walking upward still finds package.json.
280
+ const resolved = packageVersionFromPath(candidate);
281
+ if (resolved)
282
+ return resolved;
283
+ }
284
+ return {};
285
+ }
286
+ function versionState(version, desiredVersion) {
287
+ if (!version || !desiredVersion)
288
+ return "unknown";
289
+ const cmp = compareSemver(version, desiredVersion);
290
+ if (cmp < 0)
291
+ return "stale";
292
+ if (cmp > 0)
293
+ return "newer";
294
+ return "ok";
295
+ }
296
+ function inspectClientConfig(client, desiredVersion) {
297
+ if (client.kind === "snippet")
298
+ return inspectClaudeCodeConfig(client, desiredVersion);
299
+ if (client.kind === "json" && !fs.existsSync(path.dirname(client.configPath)))
300
+ return null;
301
+ if (client.kind === "command" && !fs.existsSync(client.detectDir))
302
+ return null;
303
+ const entry = client.kind === "json" ? readJsonClientEntry(client.configPath) : readCodexEntry(client.configPath);
304
+ if (!entry) {
305
+ return {
306
+ id: client.id,
307
+ label: client.label,
308
+ configured: false,
309
+ detail: `no EchoMem MCP entry in ${client.configPath}`,
310
+ state: "missing",
311
+ command: `${MCP_UPDATE_COMMAND} --client ${client.id}`,
312
+ };
313
+ }
314
+ const resolved = resolveServerEntryVersion(entry);
315
+ const state = resolved.runtime === "latest" ? "ok" : versionState(resolved.version, desiredVersion);
316
+ return {
317
+ id: client.id,
318
+ label: client.label,
319
+ configured: true,
320
+ detail: entryTarget(entry),
321
+ version: resolved.version,
322
+ packagePath: resolved.packagePath,
323
+ runtime: resolved.runtime,
324
+ state,
325
+ command: state === "stale" || state === "missing" ? `${MCP_UPDATE_COMMAND} --client ${client.id}` : undefined,
326
+ };
327
+ }
328
+ function inspectClaudeCodeConfig(client, desiredVersion) {
329
+ if (!fs.existsSync(home(".claude")))
330
+ return null;
331
+ try {
332
+ const output = execFileSync("claude", ["mcp", "list"], {
333
+ encoding: "utf8",
334
+ stdio: ["ignore", "pipe", "ignore"],
335
+ timeout: 3000,
336
+ });
337
+ const line = output.split("\n").find((item) => item.toLowerCase().includes("echomem"));
338
+ if (!line) {
339
+ return {
340
+ id: client.id,
341
+ label: client.label,
342
+ configured: false,
343
+ detail: "Claude Code reports no EchoMem MCP server",
344
+ state: "missing",
345
+ command: `${MCP_UPDATE_COMMAND} --client ${client.id}`,
346
+ };
347
+ }
348
+ const match = line.match(new RegExp(`${MCP_PACKAGE_NAME.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}@(\\d+\\.\\d+\\.\\d+)`));
349
+ const version = match?.[1];
350
+ return {
351
+ id: client.id,
352
+ label: client.label,
353
+ configured: true,
354
+ detail: line.trim(),
355
+ version,
356
+ state: versionState(version, desiredVersion),
357
+ command: version && versionState(version, desiredVersion) === "stale" ? `${MCP_UPDATE_COMMAND} --client ${client.id}` : undefined,
358
+ };
359
+ }
360
+ catch {
361
+ return {
362
+ id: client.id,
363
+ label: client.label,
364
+ configured: false,
365
+ detail: "Claude Code CLI not available for `claude mcp list`; add EchoMem from Claude Code manually",
366
+ state: "unknown",
367
+ };
368
+ }
369
+ }
370
+ function inspectClientConfigs(desiredVersion) {
371
+ return knownClients()
372
+ .map((client) => inspectClientConfig(client, desiredVersion))
373
+ .filter((report) => report !== null);
374
+ }
375
+ function formatClientConfigReport(report) {
376
+ const lines = [];
377
+ const status = report.state === "ok"
378
+ ? "ok"
379
+ : report.state === "stale"
380
+ ? "stale"
381
+ : report.state === "newer"
382
+ ? "newer than this status command"
383
+ : report.state === "missing"
384
+ ? "missing"
385
+ : "unknown";
386
+ const version = report.runtime === "latest"
387
+ ? "latest at launch"
388
+ : report.runtime === "dynamic"
389
+ ? "dynamic npm resolution"
390
+ : report.version
391
+ ? `${MCP_PACKAGE_NAME}@${report.version}`
392
+ : "version unknown";
393
+ lines.push(` ${report.label}: ${report.configured ? "configured" : "not configured"} (${status}; ${version})`);
394
+ lines.push(` ${report.detail}`);
395
+ if (report.command)
396
+ lines.push(` Update: ${report.command}`);
397
+ return lines;
398
+ }
145
399
  // ---------------------------------------------------------------------------
146
400
  // Browser + localhost callback
147
401
  // ---------------------------------------------------------------------------
@@ -768,11 +1022,11 @@ function parseFlags(argv) {
768
1022
  async function cmdSetup(flags) {
769
1023
  const entry = buildServerEntry({ devEntryPath: typeof flags.dev === "string" ? flags.dev : undefined });
770
1024
  const requested = typeof flags.client === "string" ? flags.client : undefined;
771
- const targets = (requested ? knownClients().filter((c) => c.id === requested) : detectClients());
1025
+ const targets = selectSetupTargets(requested, Boolean(flags.all));
772
1026
  if (targets.length === 0) {
773
1027
  console.log("No client auto-detected. Add this MCP server entry manually:\n");
774
1028
  console.log(JSON.stringify({ echomem: entry }, null, 2));
775
- console.log("\n(Or re-run with --client cursor|windsurf|claude-desktop|codex.)");
1029
+ console.log("\n(Or re-run with --client cursor|windsurf|claude-desktop|claude-code|codex, or --all.)");
776
1030
  }
777
1031
  else {
778
1032
  for (const c of targets) {
@@ -788,7 +1042,13 @@ async function cmdSetup(flags) {
788
1042
  console.log(`✅ ${c.label} already has the EchoMem MCP entry: ${c.configPath}`);
789
1043
  }
790
1044
  else {
791
- console.log(`ℹ️ ${c.label}: ${c.note}\n entry: ${JSON.stringify(entry)}`);
1045
+ const result = c.id === "claude-code" ? writeClaudeCodeConfig(entry) : "unavailable";
1046
+ if (result === "wrote") {
1047
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
1048
+ }
1049
+ else {
1050
+ console.log(`ℹ️ ${c.label}: ${c.note}\n entry: ${JSON.stringify(entry)}`);
1051
+ }
792
1052
  }
793
1053
  }
794
1054
  }
@@ -806,6 +1066,18 @@ async function cmdUpdate(flags) {
806
1066
  await cmdSetup({ ...flags, "skip-login": true });
807
1067
  console.log(`Update config complete. Start a new MCP session to load ${MCP_PACKAGE_LABEL}.`);
808
1068
  }
1069
+ function selectSetupTargets(requested, all) {
1070
+ if (all || requested === "all") {
1071
+ return knownClients().filter((client) => {
1072
+ if (client.kind === "json")
1073
+ return fs.existsSync(path.dirname(client.configPath));
1074
+ if (client.kind === "command")
1075
+ return fs.existsSync(client.detectDir);
1076
+ return client.id === "claude-code" && fs.existsSync(home(".claude"));
1077
+ });
1078
+ }
1079
+ return requested ? knownClients().filter((client) => client.id === requested) : detectClients();
1080
+ }
809
1081
  async function cmdSetupHud(flags) {
810
1082
  const client = parseHudClient(flags["hud-client"] || "auto");
811
1083
  const hudCli = resolveHudCliPath();
@@ -1381,10 +1653,21 @@ async function cmdUnlock(flags) {
1381
1653
  store.saveKey(keyB64);
1382
1654
  console.log("✅ Vault unlocked. Reload your MCP client (or start a new session).");
1383
1655
  }
1384
- async function cmdStatus() {
1656
+ async function cmdStatus(flags = {}) {
1385
1657
  const store = new KeyStore();
1386
1658
  const token = store.getToken();
1387
1659
  console.log(`EchoMem MCP: ${MCP_PACKAGE_LABEL}`);
1660
+ const updateStatus = flags["no-network"] ? readCachedUpdateStatus() : await checkLatestUpdateStatus({ force: true });
1661
+ const latest = updateStatus?.latestVersion;
1662
+ if (latest) {
1663
+ console.log(`Published latest: ${MCP_PACKAGE_NAME}@${latest}`);
1664
+ if (compareSemver(MCP_PACKAGE_VERSION, latest) < 0) {
1665
+ console.log(`This status command is older than latest. Update client configs with: ${MCP_UPDATE_ALL_COMMAND}`);
1666
+ }
1667
+ }
1668
+ else if (updateStatus?.error) {
1669
+ console.log(`Published latest: unavailable (${updateStatus.error})`);
1670
+ }
1388
1671
  console.log(`Credentials file: ${store.path()}`);
1389
1672
  console.log(`API token: ${token ? "present" : "MISSING — run `echomem-mcp login`"}`);
1390
1673
  if (token) {
@@ -1408,6 +1691,14 @@ async function cmdStatus() {
1408
1691
  console.log(`Encryption key: ${store.getKey() ? "present" : store.isKeyExpired() ? "EXPIRED — run `echomem-mcp unlock`" : "not set"}`);
1409
1692
  const detected = detectClients();
1410
1693
  console.log(`Detected clients: ${detected.length ? detected.map((c) => c.label).join(", ") : "none auto-detected"}`);
1694
+ const reports = inspectClientConfigs(latest ?? MCP_PACKAGE_VERSION);
1695
+ if (reports.length) {
1696
+ console.log("Client MCP configs:");
1697
+ for (const report of reports) {
1698
+ for (const line of formatClientConfigReport(report))
1699
+ console.log(line);
1700
+ }
1701
+ }
1411
1702
  }
1412
1703
  function cmdLogout() {
1413
1704
  const store = new KeyStore();
@@ -1425,11 +1716,13 @@ Usage:
1425
1716
  echomem-mcp Run the MCP server (stdio; default — used by your editor)
1426
1717
  echomem-mcp setup [--client X] Detect editor, write its MCP config, then log in
1427
1718
  echomem-mcp setup --skip-login Write MCP config without opening login/browser
1428
- echomem-mcp update [--client X] Repoint MCP config to this installed bridge; no login/browser
1719
+ echomem-mcp update --all Repoint detected clients to this installed bridge; no login/browser
1720
+ echomem-mcp update --client X Repoint one MCP client; no login/browser
1429
1721
  echomem-mcp setup --with-hud Configure MCP, then launch the EchoMem context HUD
1430
1722
  echomem-mcp login Approve this device in the browser (or --token/--passphrase)
1431
1723
  echomem-mcp unlock Re-derive the encryption key after its TTL (or --passphrase)
1432
1724
  echomem-mcp status Show token/key/clients
1725
+ echomem-mcp doctor [--no-network] Diagnose configured client bridge versions
1433
1726
  echomem-mcp logout Remove stored credentials
1434
1727
  echomem-mcp report [--json] Your AI coding memory audit (local, no login, $0)
1435
1728
  echomem-mcp migrate [--since D] Import your existing Codex/Claude history into your memory
@@ -1443,7 +1736,8 @@ Usage:
1443
1736
 
1444
1737
  Manual / headless:
1445
1738
  echomem-mcp login --token ec_xxx [--passphrase <vault pass> | --key <base64>]
1446
- ${MCP_UPDATE_COMMAND} --client codex # one-shot latest update, no browser login
1739
+ ${MCP_UPDATE_ALL_COMMAND} # one-shot latest update for detected clients, no browser login
1740
+ ${MCP_UPDATE_COMMAND} --client codex # update one client only
1447
1741
  echomem-mcp setup --dev /abs/path/dist/index.js # point clients at a local checkout
1448
1742
  echomem-mcp setup --with-hud --install-hud-hooks --client codex [--hud-client auto]
1449
1743
  echomem-mcp sync-usage --days 7 --limit 50 --dry-run
@@ -1468,7 +1762,10 @@ export async function runCli(argv) {
1468
1762
  await cmdUnlock(flags);
1469
1763
  return true;
1470
1764
  case "status":
1471
- await cmdStatus();
1765
+ await cmdStatus(flags);
1766
+ return true;
1767
+ case "doctor":
1768
+ await cmdStatus(flags);
1472
1769
  return true;
1473
1770
  case "logout":
1474
1771
  cmdLogout();
@@ -0,0 +1,154 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { echoConfigDir } from "./keystore.js";
4
+ import { MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, } from "./package-metadata.js";
5
+ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
6
+ const UPDATE_CHECK_TIMEOUT_MS = 3500;
7
+ export function compareSemver(a, b) {
8
+ const av = a.split(/[.-]/).map((part) => Number(part));
9
+ const bv = b.split(/[.-]/).map((part) => Number(part));
10
+ for (let i = 0; i < Math.max(av.length, bv.length, 3); i += 1) {
11
+ const ai = Number.isFinite(av[i]) ? av[i] : 0;
12
+ const bi = Number.isFinite(bv[i]) ? bv[i] : 0;
13
+ if (ai !== bi)
14
+ return ai > bi ? 1 : -1;
15
+ }
16
+ return 0;
17
+ }
18
+ export function updateCheckCachePath() {
19
+ return path.join(echoConfigDir(), "mcp-update-check.json");
20
+ }
21
+ export function readCachedUpdateStatus(nowMs = Date.now()) {
22
+ let raw;
23
+ try {
24
+ raw = JSON.parse(fs.readFileSync(updateCheckCachePath(), "utf8"));
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ if (raw.packageName !== MCP_PACKAGE_NAME)
30
+ return undefined;
31
+ if (typeof raw.latestVersion !== "string" || typeof raw.checkedAt !== "string")
32
+ return undefined;
33
+ const checkedMs = Date.parse(raw.checkedAt);
34
+ if (!Number.isFinite(checkedMs))
35
+ return undefined;
36
+ return {
37
+ packageName: MCP_PACKAGE_NAME,
38
+ currentVersion: MCP_PACKAGE_VERSION,
39
+ latestVersion: raw.latestVersion,
40
+ checkedAt: raw.checkedAt,
41
+ updateAvailable: compareSemver(MCP_PACKAGE_VERSION, raw.latestVersion) < 0,
42
+ source: "cache",
43
+ command: MCP_UPDATE_ALL_COMMAND,
44
+ error: nowMs - checkedMs > UPDATE_CHECK_INTERVAL_MS ? "cached result is older than 24h" : undefined,
45
+ };
46
+ }
47
+ export function isUpdateCheckFresh(status, nowMs = Date.now()) {
48
+ if (!status?.checkedAt)
49
+ return false;
50
+ const checkedMs = Date.parse(status.checkedAt);
51
+ return Number.isFinite(checkedMs) && nowMs - checkedMs <= UPDATE_CHECK_INTERVAL_MS;
52
+ }
53
+ export async function checkLatestUpdateStatus(opts = {}) {
54
+ const nowMs = opts.nowMs ?? Date.now();
55
+ const cached = readCachedUpdateStatus(nowMs);
56
+ if (process.env.ECHO_DISABLE_UPDATE_CHECK === "1") {
57
+ return cached ?? baseStatus("disabled");
58
+ }
59
+ if (!opts.force && cached && isUpdateCheckFresh(cached, nowMs))
60
+ return cached;
61
+ const registryUrl = opts.registryUrl
62
+ || process.env.ECHO_MCP_UPDATE_REGISTRY_URL
63
+ || `https://registry.npmjs.org/${encodeURIComponent(MCP_PACKAGE_NAME)}/latest`;
64
+ try {
65
+ const signal = AbortSignal.timeout(opts.timeoutMs ?? UPDATE_CHECK_TIMEOUT_MS);
66
+ const res = await fetch(registryUrl, {
67
+ headers: { accept: "application/json" },
68
+ signal,
69
+ });
70
+ if (!res.ok)
71
+ throw new Error(`npm registry HTTP ${res.status}`);
72
+ const data = await res.json();
73
+ const latestVersion = typeof data.version === "string" ? data.version.trim() : "";
74
+ if (!latestVersion)
75
+ throw new Error("npm registry response missing version");
76
+ const checkedAt = new Date(nowMs).toISOString();
77
+ const status = {
78
+ packageName: MCP_PACKAGE_NAME,
79
+ currentVersion: MCP_PACKAGE_VERSION,
80
+ latestVersion,
81
+ checkedAt,
82
+ updateAvailable: compareSemver(MCP_PACKAGE_VERSION, latestVersion) < 0,
83
+ source: "network",
84
+ command: MCP_UPDATE_ALL_COMMAND,
85
+ };
86
+ writeUpdateCache(status);
87
+ return status;
88
+ }
89
+ catch (error) {
90
+ return {
91
+ ...(cached ?? baseStatus("none")),
92
+ error: error instanceof Error ? error.message : String(error),
93
+ };
94
+ }
95
+ }
96
+ export function startBackgroundUpdateCheck(onStatus) {
97
+ const cached = readCachedUpdateStatus();
98
+ if (cached)
99
+ onStatus(cached);
100
+ if (process.env.ECHO_DISABLE_UPDATE_CHECK === "1" || isUpdateCheckFresh(cached))
101
+ return cached;
102
+ void checkLatestUpdateStatus({ force: true }).then(onStatus).catch(() => {
103
+ /* Update checks are advisory; never affect MCP startup. */
104
+ });
105
+ return cached;
106
+ }
107
+ export function formatUpdateNotice(status) {
108
+ if (!status?.updateAvailable || !status.latestVersion)
109
+ return undefined;
110
+ return [
111
+ `EchoMem update available: installed ${status.currentVersion}, latest ${status.latestVersion}.`,
112
+ `Offer to update, then run \`${status.command}\` if the user agrees.`,
113
+ "After updating, start a new agent/MCP session.",
114
+ ].join(" ");
115
+ }
116
+ export function formatUpdateStatusText(status) {
117
+ const lines = [
118
+ `EchoMem MCP installed: ${status.packageName}@${status.currentVersion}`,
119
+ status.latestVersion ? `Latest published: ${status.packageName}@${status.latestVersion}` : "Latest published: unknown",
120
+ `Update available: ${status.updateAvailable ? "yes" : "no"}`,
121
+ ];
122
+ if (status.updateAvailable) {
123
+ lines.push(`Update command: ${status.command}`);
124
+ lines.push("After updating, start a new agent/MCP session.");
125
+ }
126
+ if (status.checkedAt)
127
+ lines.push(`Checked at: ${status.checkedAt} (${status.source})`);
128
+ else
129
+ lines.push(`Checked at: not checked (${status.source})`);
130
+ if (status.error)
131
+ lines.push(`Check note: ${status.error}`);
132
+ return lines.join("\n");
133
+ }
134
+ function baseStatus(source) {
135
+ return {
136
+ packageName: MCP_PACKAGE_NAME,
137
+ currentVersion: MCP_PACKAGE_VERSION,
138
+ updateAvailable: false,
139
+ source,
140
+ command: MCP_UPDATE_ALL_COMMAND,
141
+ };
142
+ }
143
+ function writeUpdateCache(status) {
144
+ if (!status.latestVersion || !status.checkedAt)
145
+ return;
146
+ const dir = echoConfigDir();
147
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
148
+ fs.writeFileSync(updateCheckCachePath(), JSON.stringify({
149
+ packageName: status.packageName,
150
+ currentVersion: status.currentVersion,
151
+ latestVersion: status.latestVersion,
152
+ checkedAt: status.checkedAt,
153
+ }, null, 2), { mode: 0o600 });
154
+ }
@@ -7,6 +7,7 @@ export const canonicalToolNames = {
7
7
  keywords: "search_memories_by_keywords",
8
8
  others: "search_others_memories",
9
9
  report: "echomem_usage_report",
10
+ updateStatus: "echomem_update_status",
10
11
  contextHealth: "echo_context_health",
11
12
  recompose: "echo_recompose",
12
13
  delete: "delete_memory",
@@ -75,13 +76,15 @@ export const getByContextSchema = z.object({
75
76
  export function listToolSpecs(opts = {}) {
76
77
  const currentTime = new Date().toISOString();
77
78
  const map = opts.map?.trim();
79
+ const updateNotice = opts.updateNotice?.trim();
80
+ const updateSection = updateNotice ? `\n\nUPDATE NOTICE: ${updateNotice}` : "";
78
81
  const mapSection = map
79
82
  ? `\n\nThis user's EchoMem currently covers these topics (a relevance guide — recall when the task relates to one of them):\n${map}\n`
80
83
  : "";
81
84
  return [
82
85
  {
83
86
  name: canonicalToolNames.search,
84
- description: withMcpVersion(`Recall the user's prior decisions, preferences, constraints, and project context from EchoMem — their long-term memory across ALL their AI tools (Claude.ai, ChatGPT, other agents), not just this session.${mapSection}\nCall this when the task plausibly relates to that remembered context — a topic above, or when the user refers to past work ("what did we decide", "like before", "the usual") — so you don't re-derive or re-ask what they already settled. Skip it for self-contained tasks with no link to their history (e.g. a generic algorithm question). By default this returns only the ranked memories retrieved for recall and skips EchoMem answer generation; set includeAnswer=true only when you explicitly need EchoMem's legacy synthesized recall. Current time: ${currentTime}.`),
87
+ description: withMcpVersion(`Recall the user's prior decisions, preferences, constraints, and project context from EchoMem — their long-term memory across ALL their AI tools (Claude.ai, ChatGPT, other agents), not just this session.${mapSection}\nCall this when the task plausibly relates to that remembered context — a topic above, or when the user refers to past work ("what did we decide", "like before", "the usual") — so you don't re-derive or re-ask what they already settled. Skip it for self-contained tasks with no link to their history (e.g. a generic algorithm question). By default this returns only the ranked memories retrieved for recall and skips EchoMem answer generation; set includeAnswer=true only when you explicitly need EchoMem's legacy synthesized recall. Current time: ${currentTime}.${updateSection}`),
85
88
  inputSchema: {
86
89
  type: "object",
87
90
  properties: {
@@ -254,6 +257,20 @@ export function listToolSpecs(opts = {}) {
254
257
  description: "Show the user a one-screen audit of THEIR OWN AI coding usage — computed locally from their Codex/Claude Code logs ($0, nothing uploaded): how many tokens their agents spent, how much was re-reading context, reads vs memory recalls, and what changes with EchoMem. Call this when the user asks about their usage, token spend, cost, how much they're wasting, or wants a summary of their agent activity — and you may offer it once right after EchoMem is first connected. Returns formatted text to show the user verbatim. Needs no login.",
255
258
  inputSchema: { type: "object", properties: {} },
256
259
  },
260
+ {
261
+ name: canonicalToolNames.updateStatus,
262
+ description: `Check whether this installed EchoMem MCP bridge is behind the latest published npm version. Works without login, uploads no user transcript, and normal background checks are cached so EchoMem does not hit npm on every startup. If it reports an update, tell the user and offer to run the returned update command; after updating, the user must start a new agent/MCP session.${updateSection}`,
263
+ inputSchema: {
264
+ type: "object",
265
+ properties: {
266
+ force: {
267
+ type: "boolean",
268
+ default: false,
269
+ description: "When true, refresh npm latest now instead of using the cached check.",
270
+ },
271
+ },
272
+ },
273
+ },
257
274
  {
258
275
  name: canonicalToolNames.contextHealth,
259
276
  description: "Show the current local Codex/Claude context-health score as markdown: clean percentage, tracked lower-bound dead-weight, redundant reads, source client, and token-count source. Use when the user asks about the context HUD, dirty context, context pollution, whether cleanup is worth it, or wants an in-chat fallback to the passive HUD. This reads local agent logs only and needs no login.",