@echomem/mcp 1.4.50 → 1.4.52

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/README.md CHANGED
@@ -54,7 +54,7 @@ npx -y @echomem/mcp@latest init
54
54
  Windows PowerShell:
55
55
 
56
56
  ```powershell
57
- npx.cmd -y @echomem/mcp@latest init
57
+ npx.cmd -y @echomem/mcp@latest connect
58
58
  ```
59
59
 
60
60
  The one-off `npx` command is the recommended install path; it stages a durable per-user runtime
@@ -63,7 +63,9 @@ Node if `npx.cmd` is not found.
63
63
 
64
64
  | Command | What it does |
65
65
  |---|---|
66
- | `npm i -g @echomem/mcp@latest && echomem-mcp init` | Legacy/headless setup for installed agents + login |
66
+ | `npx -y @echomem/mcp@latest connect` | Open Connect Echo for account readiness, per-host setup, Doctor, repair, and uninstall (use `npx.cmd` in Windows PowerShell) |
67
+ | `npx -y @echomem/mcp@latest init` | One-command setup for installed agents + login (use `npx.cmd` in Windows PowerShell) |
68
+ | `npm i -g @echomem/mcp@latest`, then `echomem-mcp init` | Optional global install for a persistent CLI command |
67
69
  | `npm i -g @echomem/mcp@latest && echomem-mcp setup` | Install the CLI globally and configure just the detected editor |
68
70
  | `npx -y @echomem/mcp@latest setup` | One-off setup without keeping a global CLI command |
69
71
  | `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config + log in |
@@ -75,8 +77,31 @@ Node if `npx.cmd` is not found.
75
77
  | `echomem-mcp lock` | Remove the local vault key while keeping the device login |
76
78
  | `echomem-mcp status` | Show token / key / detected clients, configured bridge versions, and update guidance |
77
79
  | `echomem-mcp doctor [--no-network]` | Diagnose configured client bridge versions |
80
+ | `echomem-mcp control` / `manage` | Compatibility aliases for the returning-user Connect Echo page |
81
+ | `echomem-mcp reconnect` | Clean-reinstall EchoMem's managed runtime, clear inactive runtime cache, and repair detected host entries |
82
+ | `echomem-mcp uninstall --confirm` | Remove EchoMem MCP host entries/runtime while preserving login, vault credentials, and cloud memories |
78
83
  | `echomem-mcp logout` | Remove stored credentials |
79
84
 
85
+ ### Windows Connect Echo (no desktop app required)
86
+
87
+ If you installed the optional global CLI, run this from PowerShell:
88
+
89
+ ```powershell
90
+ echomem-mcp connect
91
+ ```
92
+
93
+ For the recommended one-off install, launch the same local control page with:
94
+
95
+ ```powershell
96
+ npx.cmd -y @echomem/mcp@latest connect
97
+ ```
98
+
99
+ The page binds only to `127.0.0.1` and uses a per-launch nonce. It validates account/vault state,
100
+ shows each detected MCP host independently, and provides Connect, Repair, Disconnect, Doctor, and
101
+ clean Reconnect actions. **Uninstall MCP** stays under Advanced controls and removes only EchoMem-owned
102
+ host entries, hooks, guidance, skills, and managed runtime files; it does not remove the local
103
+ EchoMem credential store or any cloud memory. Restart each agent after configuration changes.
104
+
80
105
  The bridge reports its package version in MCP server instructions and in tool descriptions. It also
81
106
  checks npm for a newer published bridge using a cached, non-blocking check. Standalone installations
82
107
  stage compatible updates in the background under `~/.echomem/mcp-runtime` and atomically activate
@@ -0,0 +1,63 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ function isErrno(error, code) {
5
+ return error instanceof Error
6
+ && "code" in error
7
+ && error.code === code;
8
+ }
9
+ /**
10
+ * Read a user-owned JSON configuration without treating corruption as an empty file.
11
+ * Setup must fail closed here: replacing malformed JSON would destroy unrelated client settings.
12
+ */
13
+ export function readJsonObjectFile(file, label = "configuration") {
14
+ let raw;
15
+ try {
16
+ raw = fs.readFileSync(file, "utf8");
17
+ }
18
+ catch (error) {
19
+ if (isErrno(error, "ENOENT"))
20
+ return {};
21
+ throw new Error(`Could not read ${label} at ${file}: ${error instanceof Error ? error.message : String(error)}`);
22
+ }
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(raw);
26
+ }
27
+ catch (error) {
28
+ throw new Error(`Could not safely update ${label} at ${file} because the existing file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
29
+ }
30
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
31
+ throw new Error(`Could not safely update ${label} at ${file} because the existing JSON root is not an object.`);
32
+ }
33
+ return parsed;
34
+ }
35
+ /** Write beside the destination and rename only after the complete replacement is durable locally. */
36
+ export function atomicWriteTextFile(file, content) {
37
+ const directory = path.dirname(file);
38
+ fs.mkdirSync(directory, { recursive: true });
39
+ const temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
40
+ let mode;
41
+ try {
42
+ mode = fs.statSync(file).mode;
43
+ }
44
+ catch {
45
+ /* A fresh file uses the process default mode. */
46
+ }
47
+ try {
48
+ fs.writeFileSync(temporary, content, mode === undefined ? undefined : { mode });
49
+ fs.renameSync(temporary, file);
50
+ }
51
+ catch (error) {
52
+ try {
53
+ fs.rmSync(temporary, { force: true });
54
+ }
55
+ catch {
56
+ /* Preserve the original error. */
57
+ }
58
+ throw error;
59
+ }
60
+ }
61
+ export function atomicWriteJsonObject(file, value) {
62
+ atomicWriteTextFile(file, JSON.stringify(value, null, 2));
63
+ }
@@ -173,28 +173,79 @@ function finishStaging(staging, destination, version) {
173
173
  fs.renameSync(staging, destination);
174
174
  return activateRuntime(destination, version);
175
175
  }
176
- export function installHeadlessRuntimeSync(targetVersion = MCP_PACKAGE_VERSION) {
176
+ function pruneInactiveRuntimeVersions(activeVersion) {
177
+ const removed = [];
178
+ let entries = [];
179
+ try {
180
+ entries = fs.readdirSync(versionsRoot(), { withFileTypes: true });
181
+ }
182
+ catch {
183
+ return removed;
184
+ }
185
+ for (const entry of entries) {
186
+ // A dot-prefixed directory is another install's staging area. Never prune in-flight work.
187
+ if (entry.name === activeVersion || entry.name.startsWith("."))
188
+ continue;
189
+ try {
190
+ safeVersion(entry.name);
191
+ }
192
+ catch {
193
+ continue;
194
+ }
195
+ const target = path.join(versionsRoot(), entry.name);
196
+ try {
197
+ fs.rmSync(target, { recursive: true, force: true });
198
+ removed.push(entry.name);
199
+ }
200
+ catch {
201
+ /* Cache pruning is best effort; the active clean runtime is already safe. */
202
+ }
203
+ }
204
+ return removed;
205
+ }
206
+ export function installHeadlessRuntimeSync(targetVersion = MCP_PACKAGE_VERSION, options = {}) {
177
207
  const version = safeVersion(targetVersion);
178
208
  const existing = readHeadlessRuntimeInstallation();
179
- if (existing?.version === version)
209
+ if (!options.force && existing?.version === version)
180
210
  return existing;
181
211
  const destination = path.join(versionsRoot(), version);
182
- if (readPackageVersion(destination) === version && fs.existsSync(packageEntryForPrefix(destination))) {
183
- return activateRuntime(destination, version);
212
+ if (!options.force && readPackageVersion(destination) === version && fs.existsSync(packageEntryForPrefix(destination))) {
213
+ const installation = activateRuntime(destination, version);
214
+ if (options.prune)
215
+ pruneInactiveRuntimeVersions(version);
216
+ return installation;
184
217
  }
185
- const { staging } = prepareStaging(version);
186
- const npm = resolveNpmInvocation();
218
+ const releaseLock = acquireUpdateLock();
219
+ if (!releaseLock)
220
+ throw new Error("Another EchoMem runtime update is already in progress. Retry reconnect in a moment.");
221
+ let staging = "";
187
222
  try {
223
+ staging = prepareStaging(version).staging;
224
+ const npm = resolveNpmInvocation();
188
225
  execFileSync(npm.command, [...npm.prefixArgs, ...npmInstallArguments(staging, version)], {
189
226
  stdio: "inherit",
190
227
  windowsHide: true,
191
228
  });
192
- return finishStaging(staging, destination, version);
229
+ const installation = finishStaging(staging, destination, version);
230
+ if (options.prune)
231
+ pruneInactiveRuntimeVersions(version);
232
+ return installation;
193
233
  }
194
234
  catch (error) {
195
- fs.rmSync(staging, { recursive: true, force: true });
235
+ if (staging)
236
+ fs.rmSync(staging, { recursive: true, force: true });
196
237
  throw new Error(`Could not stage ${MCP_PACKAGE_NAME}@${version}: ${error instanceof Error ? error.message : String(error)}`);
197
238
  }
239
+ finally {
240
+ releaseLock();
241
+ }
242
+ }
243
+ /** Remove only EchoMem's staged standalone runtime. Credentials and cloud data live elsewhere. */
244
+ export function removeHeadlessRuntime() {
245
+ const root = headlessRuntimeRoot();
246
+ const removed = fs.existsSync(root);
247
+ fs.rmSync(root, { recursive: true, force: true });
248
+ return { removed, path: root };
198
249
  }
199
250
  function acquireUpdateLock() {
200
251
  const lock = path.join(headlessRuntimeRoot(), "update.lock");
package/dist/hud/hooks.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { atomicWriteJsonObject, readJsonObjectFile } from "../config-files.js";
4
5
  import { resolveDurableDistPath } from "../durable-entry.js";
5
6
  /**
6
7
  * Hook commands are persisted into another program's settings and must keep working long after this
@@ -45,17 +46,59 @@ export function installSourceSessionHooks(mode) {
45
46
  }
46
47
  return written;
47
48
  }
49
+ /** Remove only EchoMem-owned lifecycle hook groups and preserve every unrelated user hook. */
50
+ export function removeLifecycleHooks(mode) {
51
+ const files = [];
52
+ let removedGroups = 0;
53
+ const targets = [];
54
+ if (mode === "codex" || mode === "both" || mode === "auto") {
55
+ targets.push(path.join(profileDirectory("CODEX_HOME", ".codex"), "hooks.json"));
56
+ }
57
+ if (mode === "claude-code" || mode === "both" || mode === "auto") {
58
+ targets.push(path.join(profileDirectory("CLAUDE_CONFIG_DIR", ".claude"), "settings.json"));
59
+ }
60
+ for (const file of targets) {
61
+ if (!fs.existsSync(file))
62
+ continue;
63
+ const content = readHooksFile(file);
64
+ const hooks = content.hooks;
65
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
66
+ continue;
67
+ let changed = false;
68
+ for (const [event, value] of Object.entries(hooks)) {
69
+ if (!Array.isArray(value))
70
+ continue;
71
+ const kept = value.filter((group) => !isEchoLifecycleGroup(group));
72
+ const removed = value.length - kept.length;
73
+ if (removed === 0)
74
+ continue;
75
+ removedGroups += removed;
76
+ changed = true;
77
+ if (kept.length > 0)
78
+ hooks[event] = kept;
79
+ else
80
+ delete hooks[event];
81
+ }
82
+ if (!changed)
83
+ continue;
84
+ if (Object.keys(hooks).length === 0)
85
+ delete content.hooks;
86
+ atomicWriteJsonObject(file, content);
87
+ files.push(file);
88
+ }
89
+ return { files, removedGroups };
90
+ }
48
91
  function installCodexHooks() {
49
- const dir = path.join(os.homedir(), ".codex");
92
+ const dir = profileDirectory("CODEX_HOME", ".codex");
50
93
  const file = path.join(dir, "hooks.json");
51
94
  fs.mkdirSync(dir, { recursive: true });
52
95
  const hookCommand = hookCommandFor("index.js", "summary --client codex --json");
53
96
  const content = readHooksFile(file);
54
97
  content.hooks = content.hooks || {};
55
- content.hooks.PostToolUse = mergeHookGroup(content.hooks.PostToolUse, { matcher: "*", hooks: [{ type: "command", command: hookCommand, timeout: 5 }] });
56
- content.hooks.PostCompact = mergeHookGroup(content.hooks.PostCompact, { hooks: [{ type: "command", command: hookCommand, timeout: 5 }] });
57
- content.hooks.Stop = mergeHookGroup(content.hooks.Stop, { hooks: [{ type: "command", command: hookCommand, timeout: 5 }] });
58
- fs.writeFileSync(file, JSON.stringify(content, null, 2));
98
+ content.hooks.PostToolUse = mergeHookGroup(content.hooks.PostToolUse, { matcher: "*", hooks: [codexCommandHook(hookCommand, 5)] });
99
+ content.hooks.PostCompact = mergeHookGroup(content.hooks.PostCompact, { hooks: [codexCommandHook(hookCommand, 5)] });
100
+ content.hooks.Stop = mergeHookGroup(content.hooks.Stop, { hooks: [codexCommandHook(hookCommand, 5)] });
101
+ atomicWriteJsonObject(file, content);
59
102
  return file;
60
103
  }
61
104
  function saveCheckpointCommand() {
@@ -65,24 +108,19 @@ function sourceSessionCommand() {
65
108
  return hookCommandFor(path.join("hud", "cli.js"), "bind-source-session");
66
109
  }
67
110
  function installCodexSourceSessionHook() {
68
- const dir = path.join(os.homedir(), ".codex");
111
+ const dir = profileDirectory("CODEX_HOME", ".codex");
69
112
  const file = path.join(dir, "hooks.json");
70
113
  fs.mkdirSync(dir, { recursive: true });
71
114
  const content = readHooksFile(file);
72
115
  content.hooks = content.hooks || {};
73
116
  content.hooks.SessionStart = mergeSourceSessionGroup(content.hooks.SessionStart, {
74
- hooks: [{
75
- type: "command",
76
- command: sourceSessionCommand(),
77
- timeout: 5,
78
- statusMessage: "Binding EchoMem to this conversation",
79
- }],
117
+ hooks: [codexCommandHook(sourceSessionCommand(), 5, "Binding EchoMem to this conversation")],
80
118
  });
81
- fs.writeFileSync(file, JSON.stringify(content, null, 2));
119
+ atomicWriteJsonObject(file, content);
82
120
  return file;
83
121
  }
84
122
  function installClaudeCodeSourceSessionHook() {
85
- const dir = path.join(os.homedir(), ".claude");
123
+ const dir = profileDirectory("CLAUDE_CONFIG_DIR", ".claude");
86
124
  const file = path.join(dir, "settings.json");
87
125
  fs.mkdirSync(dir, { recursive: true });
88
126
  const content = readHooksFile(file);
@@ -95,28 +133,23 @@ function installClaudeCodeSourceSessionHook() {
95
133
  statusMessage: "Binding EchoMem to this conversation",
96
134
  }],
97
135
  });
98
- fs.writeFileSync(file, JSON.stringify(content, null, 2));
136
+ atomicWriteJsonObject(file, content);
99
137
  return file;
100
138
  }
101
139
  function installCodexSaveCheckpointHook() {
102
- const dir = path.join(os.homedir(), ".codex");
140
+ const dir = profileDirectory("CODEX_HOME", ".codex");
103
141
  const file = path.join(dir, "hooks.json");
104
142
  fs.mkdirSync(dir, { recursive: true });
105
143
  const content = readHooksFile(file);
106
144
  content.hooks = content.hooks || {};
107
145
  content.hooks.Stop = mergeSaveCheckpointGroup(content.hooks.Stop, {
108
- hooks: [{
109
- type: "command",
110
- command: saveCheckpointCommand(),
111
- timeout: 10,
112
- statusMessage: "Checking whether completed work should be remembered",
113
- }],
146
+ hooks: [codexCommandHook(saveCheckpointCommand(), 10, "Checking whether completed work should be remembered")],
114
147
  });
115
- fs.writeFileSync(file, JSON.stringify(content, null, 2));
148
+ atomicWriteJsonObject(file, content);
116
149
  return file;
117
150
  }
118
151
  function installClaudeCodeSaveCheckpointHook() {
119
- const dir = path.join(os.homedir(), ".claude");
152
+ const dir = profileDirectory("CLAUDE_CONFIG_DIR", ".claude");
120
153
  const file = path.join(dir, "settings.json");
121
154
  fs.mkdirSync(dir, { recursive: true });
122
155
  const content = readHooksFile(file);
@@ -129,22 +162,40 @@ function installClaudeCodeSaveCheckpointHook() {
129
162
  statusMessage: "Checking whether completed work should be remembered",
130
163
  }],
131
164
  });
132
- fs.writeFileSync(file, JSON.stringify(content, null, 2));
165
+ atomicWriteJsonObject(file, content);
133
166
  return file;
134
167
  }
135
168
  function installClaudeCodeSnippet() {
136
- const dir = path.join(os.homedir(), ".claude", "echo-ctx");
169
+ const dir = path.join(profileDirectory("CLAUDE_CONFIG_DIR", ".claude"), "echo-ctx");
137
170
  fs.mkdirSync(dir, { recursive: true });
138
171
  return dir;
139
172
  }
140
173
  function readHooksFile(file) {
141
- try {
142
- const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
143
- return typeof parsed === "object" && parsed !== null ? parsed : {};
174
+ return readJsonObjectFile(file, "agent hooks");
175
+ }
176
+ function profileDirectory(envKey, fallbackName) {
177
+ const home = os.homedir();
178
+ const configured = process.env[envKey]?.trim();
179
+ if (!configured)
180
+ return path.join(home, fallbackName);
181
+ if (configured === "~")
182
+ return home;
183
+ if (configured.startsWith("~/") || configured.startsWith("~\\")) {
184
+ return path.join(home, configured.slice(2));
144
185
  }
145
- catch {
146
- return {};
186
+ if (!path.isAbsolute(configured)) {
187
+ throw new Error(`${envKey} must be an absolute path or start with ~/`);
147
188
  }
189
+ return path.normalize(configured);
190
+ }
191
+ function codexCommandHook(command, timeout, statusMessage) {
192
+ return {
193
+ type: "command",
194
+ command,
195
+ commandWindows: command,
196
+ timeout,
197
+ ...(statusMessage ? { statusMessage } : {}),
198
+ };
148
199
  }
149
200
  function mergeHookGroup(existing, group) {
150
201
  const groups = Array.isArray(existing) ? existing.filter((item) => !isEchoHudGroup(item)) : [];
@@ -176,3 +227,6 @@ function isEchoSaveCheckpointGroup(value) {
176
227
  return false;
177
228
  return JSON.stringify(value).includes("save-checkpoint");
178
229
  }
230
+ function isEchoLifecycleGroup(value) {
231
+ return isEchoHudGroup(value) || isEchoSourceSessionGroup(value) || isEchoSaveCheckpointGroup(value);
232
+ }