@echomem/mcp 1.4.33 → 1.4.35

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
@@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import axios from "axios";
6
6
  import { ZodError } from "zod";
7
- import { canonicalToolNames, completeGroupPublicationSchema, createGroupInviteSchema, createGroupSchema, deleteMemorySchema, flagPublicationAttentionSchema, getGroupSessionSharingSchema, getByContextSchema, groupContextSchema, joinGroupSchema, keywordsSchema, listFriendsSchema, listToolSpecs, othersSchema, publishBatchToGroupSchema, publishToGroupSchema, prepareGroupPublicationSchema, publicMemorySchema, recordMemoryCitationsSchema, resolveCanonicalToolName, saveConversationSchema, searchMemoriesSchema, searchUsersSchema, sendFriendRequestSchema, timeRangeSchema, updateGroupProfileSchema, setGroupSessionSharingSchema, } from "./v1-contract.js";
7
+ import { canonicalToolNames, completeGroupPublicationSchema, createGroupInviteSchema, createGroupSchema, deleteMemorySchema, flagPublicationAttentionSchema, getGroupSessionSharingSchema, getByContextSchema, groupContextSchema, joinGroupSchema, keywordsSchema, listFriendsSchema, listToolSpecs, othersSchema, publishBatchToGroupSchema, publishToGroupSchema, prepareGroupPublicationSchema, publicMemorySchema, recordMemoryCitationsSchema, requestGroupSessionSharingSchema, resolveCanonicalToolName, saveConversationSchema, searchMemoriesSchema, searchUsersSchema, sendFriendRequestSchema, timeRangeSchema, updateGroupProfileSchema, setGroupSessionSharingSchema, } from "./v1-contract.js";
8
8
  import { KeyStore } from "./keystore.js";
9
9
  import { EventLogger, hashText } from "./events.js";
10
10
  import { buildReportText } from "./report.js";
@@ -18,6 +18,13 @@ import { checkLatestUpdateStatus, formatUpdateNotice, formatUpdateStatusText, st
18
18
  const ECHO_API_BASE_URL = process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app";
19
19
  const ECHO_PRICING_URL = process.env.ECHO_PRICING_URL || "https://echoknows.com/account";
20
20
  const ECHO_MEMORY_WEB_URL = (process.env.ECHO_MEMORY_WEB_URL || "https://echoknows.com/memory").replace(/\/$/, "");
21
+ const DESKTOP_MANAGED = process.env.ECHO_DESKTOP_MANAGED === "1";
22
+ const CONNECT_DEVICE_INSTRUCTION = DESKTOP_MANAGED
23
+ ? "Open Echo Desktop, sign in, and choose Connect MCP, then retry this action."
24
+ : "Run `echomem-mcp login` in a terminal to reconnect this device, then retry this action.";
25
+ const UNLOCK_VAULT_INSTRUCTION = DESKTOP_MANAGED
26
+ ? "Open Echo Desktop and unlock the vault there. Keep the passphrase out of chat."
27
+ : "Open Terminal and run `echomem-mcp unlock` yourself. Do not have the agent run this interactive command and do not send your passphrase in chat.";
21
28
  function memoryWebUrl(memoryId) {
22
29
  return `${ECHO_MEMORY_WEB_URL}/${encodeURIComponent(memoryId)}`;
23
30
  }
@@ -156,7 +163,7 @@ function formatReconnectRequiredResult(error) {
156
163
  return null;
157
164
  return [
158
165
  "🔌 EchoMem's saved login is no longer accepted (Unauthorized, HTTP 401).",
159
- "Action required from the user: run `echomem-mcp login` in a terminal to reconnect this device, then retry this action.",
166
+ `Action required from the user: ${CONNECT_DEVICE_INSTRUCTION}`,
160
167
  "No editor restart is needed. Keep all credentials out of chat.",
161
168
  ].join("\n");
162
169
  }
@@ -1013,7 +1020,12 @@ class EchoMemApiClient {
1013
1020
  async recordMemoryCitations(args) {
1014
1021
  const parsed = recordMemoryCitationsSchema.parse(args ?? {});
1015
1022
  try {
1016
- const response = await this.axios.post("/api/extension/social/memory-citations", { ...parsed, sessionKey: this.sessionId });
1023
+ const response = await this.axios.post("/api/extension/social/memory-citations",
1024
+ // A citation receipt belongs to one planned answer, not to the lifetime of the MCP stdio
1025
+ // process. Deriving the legacy API's sessionKey from the required per-answer receipt keeps
1026
+ // retries idempotent while avoiding the same process-vs-conversation coupling that broke
1027
+ // group sharing in long-lived Claude hosts.
1028
+ { ...parsed, sessionKey: `answer:${parsed.receiptId}` });
1017
1029
  return response.data;
1018
1030
  }
1019
1031
  catch (error) {
@@ -1152,9 +1164,11 @@ class EchoMemMCPServer {
1152
1164
  });
1153
1165
  this.client = new EchoMemApiClient(store);
1154
1166
  this.events = new EventLogger({ session_id: this.client.getSessionId(), app_version: SERVER_VERSION });
1155
- startBackgroundUpdateCheck((status) => {
1156
- this.updateStatus = status;
1157
- });
1167
+ if (!DESKTOP_MANAGED) {
1168
+ startBackgroundUpdateCheck((status) => {
1169
+ this.updateStatus = status;
1170
+ });
1171
+ }
1158
1172
  this.setupToolHandlers();
1159
1173
  this.server.onerror = (error) => console.error("[MCP Error]", error);
1160
1174
  process.on("SIGINT", async () => {
@@ -1202,7 +1216,7 @@ class EchoMemMCPServer {
1202
1216
  ]);
1203
1217
  this.mapInjected = !!map;
1204
1218
  this.groupMapInjected = !!groupMap;
1205
- const updateNotice = formatUpdateNotice(this.updateStatus);
1219
+ const updateNotice = DESKTOP_MANAGED ? undefined : formatUpdateNotice(this.updateStatus);
1206
1220
  return { tools: listToolSpecs({ map, groupMap, updateNotice }) };
1207
1221
  });
1208
1222
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -1238,6 +1252,14 @@ class EchoMemMCPServer {
1238
1252
  return { content: [{ type: "text", text: await buildReportText(false) }] };
1239
1253
  }
1240
1254
  if (canonicalName === canonicalToolNames.updateStatus) {
1255
+ if (DESKTOP_MANAGED) {
1256
+ return {
1257
+ content: [{
1258
+ type: "text",
1259
+ text: `Echo Desktop manages this MCP runtime (${MCP_PACKAGE_VERSION}). Install app updates from Echo Desktop, then start a new agent session.`,
1260
+ }],
1261
+ };
1262
+ }
1241
1263
  const force = isRecord(request.params.arguments) && request.params.arguments.force === true;
1242
1264
  const status = await checkLatestUpdateStatus({ force });
1243
1265
  this.updateStatus = status;
@@ -1320,6 +1342,8 @@ class EchoMemMCPServer {
1320
1342
  return await this.handleGroupContext(request.params.arguments);
1321
1343
  case canonicalToolNames.getGroupSessionSharing:
1322
1344
  return await this.handleGetGroupSessionSharing(request.params.arguments);
1345
+ case canonicalToolNames.requestGroupSessionSharing:
1346
+ return await this.handleRequestGroupSessionSharing(request.params.arguments);
1323
1347
  case canonicalToolNames.setGroupSessionSharing:
1324
1348
  return await this.handleSetGroupSessionSharing(request.params.arguments);
1325
1349
  case canonicalToolNames.createGroup:
@@ -1354,7 +1378,7 @@ class EchoMemMCPServer {
1354
1378
  content: [
1355
1379
  {
1356
1380
  type: "text",
1357
- text: "🔌 EchoMem isn't connected yet. Run `echomem-mcp login` in a terminal to connect this device, then retry — no editor restart needed.",
1381
+ text: `🔌 EchoMem isn't connected yet. ${CONNECT_DEVICE_INSTRUCTION} No editor restart is needed.`,
1358
1382
  },
1359
1383
  ],
1360
1384
  };
@@ -1368,8 +1392,10 @@ class EchoMemMCPServer {
1368
1392
  text: [
1369
1393
  "🔒 EchoMem vault is locked.",
1370
1394
  "This encrypted account has no usable local decryption key. Once unlocked, this trusted device stays unlocked until you explicitly lock it or log out.",
1371
- "Action required from the user: open Terminal and run `echomem-mcp unlock` yourself. Do not have the agent run this interactive command and do not send your passphrase in chat.",
1372
- "At `Vault passphrase (typing is hidden):`, type the passphrase and press Return. No characters will appear while you type; that is expected.",
1395
+ `Action required from the user: ${UNLOCK_VAULT_INSTRUCTION}`,
1396
+ ...(DESKTOP_MANAGED ? [] : [
1397
+ "At `Vault passphrase (typing is hidden):`, type the passphrase and press Return. No characters will appear while you type; that is expected.",
1398
+ ]),
1373
1399
  "After the success message, retry this EchoMem action in the current session — no editor restart is needed.",
1374
1400
  ].join("\n"),
1375
1401
  },
@@ -1607,7 +1633,7 @@ Details: ${m.details || "N/A"}`)
1607
1633
  syncedGroupNames.length
1608
1634
  ? `Saved privately first and synced to approved groups: ${syncedGroupNames.join(", ")}.`
1609
1635
  : "Saved to your private memory.",
1610
- `This user has multiple groups: ${availableGroups.map((group) => `${readString(group, "name") ?? "Unnamed group"} (${readString(group, "id") ?? "unknown id"})`).join(", ")}. Call get_group_session_sharing with this conversation scope and one groupId at a time; obtain a separate explicit Yes/No decision for each group.`,
1636
+ `This user has multiple groups: ${availableGroups.map((group) => `${readString(group, "name") ?? "Unnamed group"} (${readString(group, "id") ?? "unknown id"})`).join(", ")}. Call request_group_session_sharing with this conversation scope and one groupId at a time so supported hosts render a separate sharing choice for each group.`,
1611
1637
  failedSyncs.length
1612
1638
  ? `${failedSyncs.length} approved group sync(s) failed; private memories were preserved.`
1613
1639
  : "",
@@ -1616,7 +1642,7 @@ Details: ${m.details || "N/A"}`)
1616
1642
  : "",
1617
1643
  ].filter(Boolean).join(" ")
1618
1644
  : sharing?.decision === null || sharing?.decision === undefined
1619
- ? `Saved to your private memory. Ask: “Share memories saved from this conversation with ${readString(sharingGroup, "name") ?? "your current group"}?” Silence leaves the state unset, so ask again at a later qualifying checkpoint until the user explicitly answers Yes or No; do not repeat the prompt in the same response.`
1645
+ ? `Saved to your private memory. Call request_group_session_sharing with this conversation scope${readString(sharingGroup, "id") ? ` and groupId ${readString(sharingGroup, "id")}` : ""} so a supported host can show the sharing choice for ${readString(sharingGroup, "name") ?? "your current group"}. If the client cannot render it, relay that tool's fallback question. Decline, cancel, or silence leaves the state unset.`
1620
1646
  : sharing.decision === "private"
1621
1647
  ? "Saved to your private memory."
1622
1648
  : syncedGroupNames.length
@@ -1646,7 +1672,7 @@ Details: ${m.details || "N/A"}`)
1646
1672
  `Successfully ingested conversation. Extracted ${memoriesExtracted} memory distinct events.`,
1647
1673
  receipt,
1648
1674
  typeof groupSharingScopeId === "string" && groupSharingScopeId
1649
- ? `Conversation sharing scope: ${groupSharingScopeId}\nReuse this exact groupSharingScopeId only for get_group_session_sharing, set_group_session_sharing, and save_conversation calls in this conversation. Never reuse it in another conversation or save it as memory.`
1675
+ ? `Conversation sharing scope: ${groupSharingScopeId}\nReuse this exact groupSharingScopeId only for get_group_session_sharing, request_group_session_sharing, set_group_session_sharing, and save_conversation calls in this conversation. Never reuse it in another conversation or save it as memory.`
1650
1676
  : "",
1651
1677
  typeof memoriesDiscarded === "number" && memoriesDiscarded > 0
1652
1678
  ? `${memoriesDiscarded} additional memories were not stored because your active-memory limit was reached.`
@@ -2090,7 +2116,7 @@ Details: ${m.details || "N/A"}`;
2090
2116
  return {
2091
2117
  content: [{
2092
2118
  type: "text",
2093
- text: `${scopeText}\n\nThis user belongs to multiple groups: ${choices}. Reuse this same conversation scope and call get_group_session_sharing once per groupId. Ask for a separate explicit Yes/No sharing decision for each selected group.`,
2119
+ text: `${scopeText}\n\nThis user belongs to multiple groups: ${choices}. Reuse this same conversation scope and call request_group_session_sharing once per groupId so supported hosts render a separate sharing choice for each group.`,
2094
2120
  }],
2095
2121
  };
2096
2122
  }
@@ -2100,7 +2126,7 @@ Details: ${m.details || "N/A"}`;
2100
2126
  return {
2101
2127
  content: [{
2102
2128
  type: "text",
2103
- text: `${scopeText}\n\nNo sharing decision exists for this conversation. Ask: “Share memories saved from this conversation with ${readString(group, "name") ?? "your group"}?” Silence is not a No: leave the state unset and ask again at a later qualifying checkpoint until the user explicitly answers Yes or No, without repeating the prompt in the same response. Then call set_group_session_sharing with that explicit answer and the scope above; an explicit No stops later prompts for this conversation.`,
2129
+ text: `${scopeText}\n\nNo sharing decision exists for this conversation. Call request_group_session_sharing with the scope above${readString(group, "id") ? ` and groupId ${readString(group, "id")}` : ""}. Supported hosts will show a native Share with team / Keep private choice. If the client cannot render it, relay that tool's fallback question. Decline, cancel, or silence is not a No and leaves the state unset.`,
2104
2130
  }],
2105
2131
  };
2106
2132
  }
@@ -2116,13 +2142,23 @@ Details: ${m.details || "N/A"}`;
2116
2142
  }],
2117
2143
  };
2118
2144
  }
2119
- async handleSetGroupSessionSharing(args) {
2120
- const parsed = setGroupSessionSharingSchema.parse(args ?? {});
2121
- const payload = await this.client.setGroupSessionSharing(parsed);
2122
- const sync = isRecord(payload?.sync) ? payload.sync : null;
2145
+ sharingScopeText(groupSharingScopeId) {
2146
+ return groupSharingScopeId
2147
+ ? `Conversation sharing scope: ${groupSharingScopeId}\nReuse this exact groupSharingScopeId only in this conversation for later get/request/set/save calls. Never persist it as memory.`
2148
+ : "";
2149
+ }
2150
+ sharingFallbackText(scopeText, groupName) {
2151
+ return [
2152
+ scopeText,
2153
+ `This client cannot show EchoMem's sharing choice UI. Ask: “Share eligible memories saved from this conversation with ${groupName}?” Only an explicit Yes or No may be passed to set_group_session_sharing. Decline, cancel, or silence leaves the state unset.`,
2154
+ ].filter(Boolean).join("\n\n");
2155
+ }
2156
+ formatGroupSessionSharingUpdate(share, payload, scopeText = "") {
2157
+ const data = isRecord(payload) ? payload : {};
2158
+ const sync = isRecord(data.sync) ? data.sync : null;
2123
2159
  const protectedIds = Array.isArray(sync?.protectedMemoryIds) ? sync.protectedMemoryIds : [];
2124
- const group = isRecord(payload?.group) ? payload.group : {};
2125
- const receipt = parsed.share
2160
+ const group = isRecord(data.group) ? data.group : {};
2161
+ const receipt = share
2126
2162
  ? sync?.synced === false
2127
2163
  ? "Conversation sharing is enabled, but the initial group sync failed. Private memories were preserved; retry before claiming publication."
2128
2164
  : `Conversation sharing is enabled for ${readString(group, "name") ?? "the current group"}. Existing eligible memories in this scope were synced and later saves carrying the same scope will sync automatically.`
@@ -2130,10 +2166,127 @@ Details: ${m.details || "N/A"}`;
2130
2166
  return {
2131
2167
  content: [{
2132
2168
  type: "text",
2133
- text: `${receipt}${protectedIds.length ? ` ${protectedIds.length} flagged ${protectedIds.length === 1 ? "memory was" : "memories were"} protected and kept private.` : ""}`,
2169
+ text: [
2170
+ scopeText,
2171
+ `${receipt}${protectedIds.length ? ` ${protectedIds.length} flagged ${protectedIds.length === 1 ? "memory was" : "memories were"} protected and kept private.` : ""}`,
2172
+ ].filter(Boolean).join("\n\n"),
2134
2173
  }],
2135
2174
  };
2136
2175
  }
2176
+ async handleRequestGroupSessionSharing(args) {
2177
+ const parsed = requestGroupSessionSharingSchema.parse(args ?? {});
2178
+ const payload = await this.client.getGroupSessionSharing(parsed);
2179
+ const groupSharingScopeId = readString(payload ?? {}, "groupSharingScopeId");
2180
+ const scopeText = this.sharingScopeText(groupSharingScopeId);
2181
+ if (!groupSharingScopeId) {
2182
+ return {
2183
+ content: [{
2184
+ type: "text",
2185
+ text: "EchoMem could not establish a conversation sharing scope, so no decision was recorded and the checkpoint remains private. Retry request_group_session_sharing before offering team sharing.",
2186
+ }],
2187
+ };
2188
+ }
2189
+ if (payload?.hasGroup !== true) {
2190
+ return {
2191
+ content: [{
2192
+ type: "text",
2193
+ text: "No company group is configured for this user. Saves remain private; do not ask about conversation sharing.",
2194
+ }],
2195
+ };
2196
+ }
2197
+ if (payload?.requiresGroupSelection === true) {
2198
+ const availableGroups = Array.isArray(payload?.availableGroups)
2199
+ ? payload.availableGroups.filter(isRecord)
2200
+ : [];
2201
+ const choices = availableGroups
2202
+ .map((group) => `${readString(group, "name") ?? "Unnamed group"} (${readString(group, "id") ?? "unknown id"})`)
2203
+ .join(", ");
2204
+ return {
2205
+ content: [{
2206
+ type: "text",
2207
+ text: `${scopeText}\n\nChoose one group and call request_group_session_sharing again with the same scope and its groupId: ${choices}. Each group has an independent decision.`,
2208
+ }],
2209
+ };
2210
+ }
2211
+ const group = isRecord(payload?.group) ? payload.group : {};
2212
+ const groupName = readString(group, "name") ?? "your group";
2213
+ const existingDecision = typeof payload?.decision === "string" ? payload.decision : null;
2214
+ if (existingDecision === "share" || existingDecision === "private") {
2215
+ return {
2216
+ content: [{
2217
+ type: "text",
2218
+ text: [
2219
+ scopeText,
2220
+ existingDecision === "share"
2221
+ ? `This conversation is already approved for ${groupName}. Eligible memories sync automatically; flagged memories stay private.`
2222
+ : `This conversation is already private for ${groupName}. No sharing prompt was shown.`,
2223
+ ].filter(Boolean).join("\n\n"),
2224
+ }],
2225
+ };
2226
+ }
2227
+ const formElicitation = this.server.getClientCapabilities()?.elicitation?.form;
2228
+ if (!formElicitation) {
2229
+ return {
2230
+ content: [{ type: "text", text: this.sharingFallbackText(scopeText, groupName) }],
2231
+ };
2232
+ }
2233
+ let elicitation;
2234
+ try {
2235
+ elicitation = await this.server.elicitInput({
2236
+ mode: "form",
2237
+ message: `EchoMem saved this checkpoint privately. Choose whether eligible memories from this conversation should also be shared with ${groupName}. Flagged memories always remain private.`,
2238
+ requestedSchema: {
2239
+ type: "object",
2240
+ properties: {
2241
+ sharingDecision: {
2242
+ type: "string",
2243
+ title: "Team sharing",
2244
+ description: `Choose whether eligible conversation memories may be shared with ${groupName}.`,
2245
+ oneOf: [
2246
+ { const: "share", title: "Share with team" },
2247
+ { const: "private", title: "Keep private" },
2248
+ ],
2249
+ },
2250
+ },
2251
+ required: ["sharingDecision"],
2252
+ },
2253
+ });
2254
+ }
2255
+ catch {
2256
+ return {
2257
+ content: [{ type: "text", text: this.sharingFallbackText(scopeText, groupName) }],
2258
+ };
2259
+ }
2260
+ if (elicitation.action !== "accept") {
2261
+ return {
2262
+ content: [{
2263
+ type: "text",
2264
+ text: [
2265
+ scopeText,
2266
+ `No sharing decision was recorded for ${groupName}. The checkpoint remains private. Ask again at a later qualifying checkpoint; never interpret decline or cancel as No.`,
2267
+ ].filter(Boolean).join("\n\n"),
2268
+ }],
2269
+ };
2270
+ }
2271
+ const sharingDecision = readString(elicitation.content ?? {}, "sharingDecision");
2272
+ if (sharingDecision !== "share" && sharingDecision !== "private") {
2273
+ return {
2274
+ content: [{ type: "text", text: this.sharingFallbackText(scopeText, groupName) }],
2275
+ };
2276
+ }
2277
+ const updatePayload = await this.client.setGroupSessionSharing({
2278
+ ...parsed,
2279
+ groupSharingScopeId,
2280
+ share: sharingDecision === "share",
2281
+ confirmed: true,
2282
+ });
2283
+ return this.formatGroupSessionSharingUpdate(sharingDecision === "share", updatePayload, scopeText);
2284
+ }
2285
+ async handleSetGroupSessionSharing(args) {
2286
+ const parsed = setGroupSessionSharingSchema.parse(args ?? {});
2287
+ const payload = await this.client.setGroupSessionSharing(parsed);
2288
+ return this.formatGroupSessionSharingUpdate(parsed.share, payload);
2289
+ }
2137
2290
  async handlePublishToGroup(args) {
2138
2291
  const parsed = publishToGroupSchema.parse(args ?? {});
2139
2292
  const payload = await this.client.publishMemoryToGroup(args);
@@ -2182,8 +2335,8 @@ Details: ${m.details || "N/A"}`;
2182
2335
  content: [{
2183
2336
  type: "text",
2184
2337
  text: payload?.alreadyMember
2185
- ? `You are already a member of ${payload?.group?.name ?? "this group"}. No memories were published. Use prepare_group_publication to review memories and propose any missing title or responsibility fields, then call get_group_session_sharing and, if unset, ask again at later qualifying checkpoints until the user explicitly answers Yes or No.`
2186
- : `Joined ${payload?.group?.name ?? "the company group"}. No memories were published. Next use prepare_group_publication to review candidates, infer a proposed title and responsibility summary, and ask the user to confirm that profile together with the publication preview. Also call get_group_session_sharing without a scope to mint one for this conversation; if unset, ask whether memories saved from this conversation should be shared and ask again at a later qualifying checkpoint after silence until an explicit Yes or No.`,
2338
+ ? `You are already a member of ${payload?.group?.name ?? "this group"}. No memories were published. Use prepare_group_publication to review memories and propose any missing title or responsibility fields, then call request_group_session_sharing so supported hosts render the native sharing choice. Decline or cancel stays unset for a later checkpoint.`
2339
+ : `Joined ${payload?.group?.name ?? "the company group"}. No memories were published. Next use prepare_group_publication to review candidates, infer a proposed title and responsibility summary, and ask the user to confirm that profile together with the publication preview. Also call request_group_session_sharing without a scope so supported hosts show the native team-sharing choice. Decline or cancel leaves consent unset for a later checkpoint.`,
2187
2340
  }],
2188
2341
  };
2189
2342
  }
@@ -21,26 +21,29 @@ export const MCP_PACKAGE_NAME = stringOrFallback(packageJson.name, FALLBACK_PACK
21
21
  export const MCP_PACKAGE_VERSION = stringOrFallback(packageJson.version, FALLBACK_PACKAGE.version);
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
+ export const MCP_DESKTOP_MANAGED = process.env.ECHO_DESKTOP_MANAGED === "1";
24
25
  export const MCP_UPDATE_COMMAND = `npx -y ${MCP_PACKAGE_NAME}@latest update`;
25
26
  export const MCP_UPDATE_ALL_COMMAND = `${MCP_UPDATE_COMMAND} --all`;
27
+ export const MCP_VAULT_UNLOCK_INSTRUCTION = MCP_DESKTOP_MANAGED
28
+ ? "open Echo Desktop and unlock the vault there"
29
+ : "run `echomem-mcp unlock` locally";
30
+ const MCP_UPDATE_INSTRUCTION = MCP_DESKTOP_MANAGED
31
+ ? "Echo Desktop manages this MCP runtime; install an Echo Desktop update when one is offered"
32
+ : `update once with \`${MCP_UPDATE_ALL_COMMAND}\``;
26
33
  export const MEMORY_CITATION_INSTRUCTION = 'If the user-facing answer materially relies on one or more EchoMem memories, end it with a compact "EchoMem sources:" list containing only the memories actually used. For memories owned by teammates or accepted friends, call record_memory_citations immediately before the final answer with those exact Memory IDs. Do not cite memories that were merely retrieved. Use each memory key as the Markdown label and its canonical https://echoknows.com/memory/<memory-id> URL. Omit the sources section and citation receipt when no memory informed the answer.';
27
34
  export const SAVED_MEMORY_RECEIPT_INSTRUCTION = 'After save_conversation succeeds and returns one or more saved memory IDs, make the save visible in the final user-facing answer with a compact "EchoMem saved:" list containing every memory created by that call. Use each memory key as the Markdown label and its canonical https://echoknows.com/memory/<memory-id> URL. This save receipt is separate from "EchoMem sources:" and does not imply that the newly saved memories informed the answer.';
28
35
  export const MCP_SERVER_INSTRUCTIONS = [
29
36
  `${MCP_PACKAGE_DESCRIPTION} (${MCP_PACKAGE_LABEL}).`,
30
- `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.`,
31
- "Use echomem_update_status to check whether npm has a newer bridge; it is cached and non-blocking during normal tool listing.",
32
- "Do not auto-update on every MCP startup; this bridge is intentionally stable between explicit updates.",
33
- "Before the final response for work that produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone, call save_conversation. This is a private checkpoint and does not require a separate user request. Skip secrets, incidental chatter, and trivial read-only work. If an encrypted vault is locked, tell the user to run echomem-mcp unlock rather than silently skipping the checkpoint.",
34
- "For a user with a company group, call get_group_session_sharing near conversation start or after a qualifying private save. On the first call in a conversation, omit groupSharingScopeId so EchoMem mints a fresh opaque scope. Reuse that exact scope only in this conversation for later get/set/save calls, and never persist it as memory. If multiple groups are returned, reuse the same scope and check one groupId at a time. Each group requires its own explicit Yes/No decision. Silence leaves that group's state unset; never infer an answer or repeat the prompt in the same response. Store only explicit answers with set_group_session_sharing, the same scope, and the selected groupId. Saves sync eligible memories to every approved group; a No keeps them private for that group. Flagged memories stay private.",
35
- "For company-group sharing, use get_group_context for orientation; create_memory_group/create_group_invite/join_memory_group for membership; and prepare_group_publication as a no-publication preview.",
36
- "After joining or when profile fields are missing, use candidate memory evidence to propose a title and responsibility summary. Ask the user to confirm that proposal together with the publication preview, then call update_group_profile and complete_group_publication.",
37
- "Use one canonical https://echoknows.com/memory/<memory-id> link for private, group, and friend evidence. Label it with the memory key; the site resolves the authorized representation.",
38
- "Each search result is one memory: preserve its Memory ID and canonical echoknows.com link when citing it.",
39
- MEMORY_CITATION_INSTRUCTION,
40
- SAVED_MEMORY_RECEIPT_INSTRUCTION,
41
- "During a publication preview, if an unflagged candidate appears sensitive, proactively ask whether the user wants to mark its exact ID for publication attention first. Explain that marking does not publish or change encryption; it means the agent will call it out and ask for detailed confirmation whenever a later publication includes it. Never auto-flag inferred sensitivity. For sensitive-topic flags, search and preview exact owned memories before confirmed flag_memories_for_publication_attention. Separate already-flagged candidates, state that nothing has been published yet, and offer to exclude them, review them separately, or first search for and mark similar sensitive owned memories.",
42
- "Never save an inferred group profile. Manual prepared publication requires explicit preview confirmation; flagged memories still require separate exact-memory confirmation. Never store or log an echo_grp_ invite code.",
37
+ `If this bridge is stale, ${MCP_UPDATE_INSTRUCTION} and start a new MCP session; never auto-update at startup.`,
38
+ "Before re-deriving prior decisions or preferences, use search_memories.",
39
+ `Before the final response for a durable decision, implementation, fix, commit, passing verification, release, or milestone, call save_conversation. Skip secrets and trivial work. If the encrypted vault is locked, tell the user to ${MCP_VAULT_UNLOCK_INSTRUCTION}.`,
40
+ "After a successful save, show every memory created by that call in a compact EchoMem saved: list with canonical links; this is separate from \"EchoMem sources:\".",
41
+ "For company groups, call request_group_session_sharing near conversation start or after a qualifying save. It renders a native choice on clients with MCP elicitation and returns a text fallback otherwise. Omit groupSharingScopeId only on the first call, then reuse the returned scope only in this conversation. Each group needs an explicit choice; decline, cancel, or silence stays unset. Never infer consent. Flagged memories stay private.",
42
+ "EchoMem credential identity is authoritative over Claude profiles, host accounts, git identity, or inference. Use get_group_context before group-orientation answers and never re-filter owners returned by search_others_memories.",
43
+ "When a final answer materially uses teammate or friend memories, call record_memory_citations with only the exact used Memory IDs and a unique per-answer receiptId.",
44
+ "End memory-informed answers with a compact EchoMem sources: list. Use each memory key as the label and https://echoknows.com/memory/<memory-id> as its canonical link.",
45
+ "Group profiles, conversation sharing, publication, sensitive-memory flags, and deletion require explicit user confirmation. Never store or log an echo_grp_ invite code.",
43
46
  ].join(" ");
44
47
  export function withMcpVersion(description) {
45
- 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.`;
48
+ return `${description}\n\nEchoMem MCP bridge: ${MCP_PACKAGE_LABEL}. If this version is stale, ${MCP_UPDATE_INSTRUCTION}, then start a new MCP session. Do not run updates repeatedly or on every startup.`;
46
49
  }
@@ -7,10 +7,10 @@ const CHECKPOINT_REASON = [
7
7
  "Run EchoMem's private completion checkpoint before finishing this turn.",
8
8
  "Review the work that just completed. If it produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone, call `save_conversation` now with a concise structured summary of the outcome, reasoning, relevant files, and constraints.",
9
9
  "Do not save secrets or incidental chatter. If the work was not durable, do not call the tool and finish normally.",
10
- "Private persistence happens first. For a user with a company group, call `get_group_session_sharing` near conversation start or after a qualifying private save.",
11
- "On the first get_group_session_sharing call in a conversation, omit groupSharingScopeId so EchoMem mints a fresh opaque scope. Reuse that exact scope only in this conversation for later get/set/save calls, and never persist it as memory. If multiple groups are returned, reuse the same scope and check one groupId at a time. Each group needs its own explicit Yes/No decision. Silence leaves that group's state unset; do not infer an answer or repeat the prompt in the same response. A Yes decision syncs eligible memories from later saves carrying the same scope to that group; saves automatically sync to every approved group. A No decision keeps them private for that group.",
10
+ "Private persistence happens first. For a user with a company group, call `request_group_session_sharing` near conversation start or after a qualifying private save.",
11
+ "On the first request_group_session_sharing call in a conversation, omit groupSharingScopeId so EchoMem mints a fresh opaque scope. Reuse that exact scope only in this conversation for later get/request/set/save calls, and never persist it as memory. If multiple groups are returned, reuse the same scope and call once per groupId. Supported clients render a native Share with team / Keep private choice; if the tool returns a text fallback, relay its exact question and call set_group_session_sharing only after an explicit Yes/No. Decline, cancel, or silence leaves that group's state unset. A Share decision syncs eligible memories from later saves carrying the same scope to that group; saves automatically sync to every approved group. Keep private keeps them private for that group.",
12
12
  "Flagged memories are withheld from automatic conversation sync and remain private.",
13
- "If EchoMem reports that the encrypted vault is locked, tell the user to run `echomem-mcp unlock`; never silently skip a qualifying checkpoint.",
13
+ "If EchoMem reports that the encrypted vault is locked, tell the user to open Echo Desktop and unlock the vault there; on a headless system, use `echomem-mcp unlock`. Never silently skip a qualifying checkpoint.",
14
14
  ].join(" ");
15
15
  function currentTurnSlice(transcript) {
16
16
  const lines = transcript.split(/\r?\n/);
@@ -38,7 +38,13 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
38
38
  }
39
39
  return envelope;
40
40
  }
41
- if (!gs || !Number.isFinite(sessions) || sessions <= 0 || canonicalSessions !== sessions || canonicalInput <= 0 || skipped !== 0) {
41
+ if (
42
+ !gs ||
43
+ !Number.isFinite(sessions) || sessions <= 0 ||
44
+ !Number.isFinite(canonicalSessions) || canonicalSessions <= 0 || canonicalSessions > sessions ||
45
+ !Number.isFinite(skipped) || skipped < 0 || canonicalSessions + skipped !== sessions ||
46
+ !Number.isFinite(canonicalInput) || canonicalInput <= 0
47
+ ) {
42
48
  throw new Error("Canonical analysis is incomplete or does not match the provider session cohort.");
43
49
  }
44
50
  if (!Array.isArray(r.repos) || r.repos.length === 0) {
@@ -52,15 +58,19 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
52
58
  report = null;
53
59
  reportEnvelope = null;
54
60
  resetReportSurface();
61
+ var canContinue = String(code || "").indexOf("REPORT_") === 0 || code === "RENDER_FAILED";
55
62
  setHead("We couldn't finish this scan", "Needs attention");
56
63
  app.className = "reportMessageStage";
57
64
  app.innerHTML =
58
65
  '<section class="reportMessage" data-report-state="failed">' +
59
66
  '<h2>We couldn’t finish this scan.</h2>' +
60
67
  '<p>Your coding history is unchanged.</p>' +
61
- '<p>Close this tab and run <code>echomem-mcp init</code> again.</p>' +
68
+ (canContinue
69
+ ? '<p>This optional report can be retried later.</p><div class="actions"><button type="button" class="primary" data-connect-echo>Continue without report</button></div>'
70
+ : '<p>Close this tab and run <code>echomem-mcp init</code> again.</p>') +
62
71
  '<details class="reportTechnical"><summary>Technical details</summary><code>' + esc(code || "REPORT_FAILED") + '</code></details>' +
63
72
  '</section>';
73
+ if (canContinue) bindConnect();
64
74
  }
65
75
  function renderEmptyReport() {
66
76
  report = null;
@@ -71,8 +81,10 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
71
81
  app.innerHTML =
72
82
  '<section class="reportMessage" data-report-state="empty">' +
73
83
  '<h2>No coding history found.</h2>' +
74
- '<p>Start a Codex or Claude Code session, then run <code>echomem-mcp init</code> again.</p>' +
84
+ '<p>You can continue now and import local coding history later.</p>' +
85
+ '<div class="actions"><button type="button" class="primary" data-connect-echo>Continue setup</button></div>' +
75
86
  '</section>';
87
+ bindConnect();
76
88
  }
77
89
  function renderBridgeIssue() {
78
90
  renderReportIssue("BRIDGE_UNREACHABLE", "The local bridge stopped answering before your report was ready. Your terminal may have closed or the process may have stopped.");
@@ -203,6 +215,9 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
203
215
  var hasPendingCount = typeof pending === "number";
204
216
  var discovery = stats && stats.discovery ? stats.discovery : {};
205
217
  var isPartial = !!(stats && stats.partial);
218
+ var optionalDiagnostics = stats && stats.optionalDiagnostics ? stats.optionalDiagnostics : {};
219
+ var optionalStatsDegraded = optionalDiagnostics.degraded === true;
220
+ var degradedWithoutCounts = optionalStatsDegraded && optionalDiagnostics.countsTrusted !== true;
206
221
  var localScanReady = !isPartial || discovery.phase === "exact" || discovery.phase === "full";
207
222
  var skippedActive = typeof migratable.skippedActive === "number" ? migratable.skippedActive : 0;
208
223
  var sessions = stats && stats.sessions ? stats.sessions : {};
@@ -210,8 +225,8 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
210
225
  // already imported elsewhere, so it over-counts (e.g. 10). The count is only trustworthy after the
211
226
  // account check (phase "account"/"exact"/"full"), which corrects it (e.g. 3). Until then we show a
212
227
  // "counting" state with no number, so the user never sees the count jump down.
213
- var pendingTrusted = hasPendingCount && !!discovery.phase && discovery.phase !== "quick";
214
- var knownDone = pendingTrusted && pending === 0;
228
+ var pendingTrusted = !degradedWithoutCounts && hasPendingCount && !!discovery.phase && discovery.phase !== "quick";
229
+ var knownDone = pendingTrusted && pending === 0 && !optionalStatsDegraded;
215
230
  var canExtract = pendingTrusted && pending > 0;
216
231
  var pendN = hasPendingCount ? pending : 0;
217
232
  var pendingCodex = typeof migratable.pendingCodex === "number" ? migratable.pendingCodex : null;
@@ -227,15 +242,19 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
227
242
  var currentPlanLabel = paidRecallPlan(currentPlan)
228
243
  ? currentPlan.charAt(0).toUpperCase() + currentPlan.slice(1) + " Echo"
229
244
  : "Original Echo";
230
- setHead("Turn coding history into memory", pendingTrusted ? (knownDone ? "Done" : "Ready") : (localScanReady ? "Ready" : "Scanning"));
231
- var headlineHtml = !pendingTrusted
245
+ setHead("Turn coding history into memory", degradedWithoutCounts ? "Ready" : (pendingTrusted ? (knownDone ? "Done" : "Ready") : (localScanReady ? "Ready" : "Scanning")));
246
+ var headlineHtml = degradedWithoutCounts
247
+ ? "Local history counting <strong>couldn’t finish.</strong>"
248
+ : !pendingTrusted
232
249
  ? "Counting your <strong>new conversations…</strong>"
233
250
  : (knownDone
234
251
  ? "You are <strong>all caught up.</strong>"
235
252
  : (planLimited
236
253
  ? "<strong>" + esc(number(pendN)) + " sessions</strong> found."
237
254
  : "<strong>" + esc(number(pendN)) + " sessions</strong> are ready to review."));
238
- var sub = !pendingTrusted
255
+ var sub = degradedWithoutCounts
256
+ ? "You can finish setup now. Your conversations stay on this Mac, and you can retry the history import later."
257
+ : !pendingTrusted
239
258
  ? "Echo is matching your local history against what is already in memory."
240
259
  : (knownDone
241
260
  ? (skippedActive ? number(skippedActive) + " active conversation" + (skippedActive === 1 ? " is" : "s are") + " still changing, so Echo will pick them up later." : "Your history is already in EchoMem. Nothing new to extract.")
@@ -287,6 +306,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
287
306
  }
288
307
  document.getElementById("exBanner").innerHTML =
289
308
  (statsSlow && !stats ? '<div class="warning">Local counts are taking longer than expected. You can still ask Echo to extract anything unprocessed.</div>' : '') +
309
+ (optionalStatsDegraded && !degradedWithoutCounts ? '<div class="warning">The optional usage summary was skipped. Your conversation list is still ready.</div>' : '') +
290
310
  (error ? '<div class="error">' + esc(error) + '</div>' : '');
291
311
  document.getElementById("exHeadline").innerHTML = headlineHtml;
292
312
  document.getElementById("exSub").textContent = sub;
@@ -297,7 +317,9 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
297
317
  var asset = id === "claude-desktop" ? "claude" : "codex";
298
318
  return '<span class="pfIcon"><img src="/hud-assets/' + asset + '.svg" alt="" onerror="this.style.display=&quot;none&quot;;this.nextElementSibling.style.display=&quot;grid&quot;;" /><span class="pfFallback">' + fallback + '</span></span>';
299
319
  };
300
- document.getElementById("exSources").innerHTML = !pendingTrusted
320
+ document.getElementById("exSources").innerHTML = degradedWithoutCounts
321
+ ? '<span class="pfNote">Optional local-history count skipped.</span>'
322
+ : !pendingTrusted
301
323
  ? '<span class="pfNote">Scanning local history&hellip;</span>'
302
324
  : (canExtract
303
325
  ? '<span class="pf">' + srcIcon("codex", "CX") + '<strong>' + esc(number(codexN)) + '</strong> Codex</span>' +
@@ -306,7 +328,9 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
306
328
  '<span class="pfNote">found on this Mac</span>'
307
329
  : "");
308
330
  // Reassurances live at the moment of commitment — right under the button.
309
- document.getElementById("exEta").innerHTML = pendingTrusted
331
+ document.getElementById("exEta").innerHTML = degradedWithoutCounts
332
+ ? 'Setup can continue without this optional count.'
333
+ : pendingTrusted
310
334
  ? (canExtract
311
335
  ? ''
312
336
  : 'Nothing new to extract right now.')
@@ -317,7 +341,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
317
341
  // While still counting, hide the primary button entirely; the headline + source split
318
342
  // already say it is working, and the ready state should present one clear action.
319
343
  var migrateBtn = document.getElementById("migrate");
320
- if (pendingTrusted) {
344
+ if (pendingTrusted || degradedWithoutCounts) {
321
345
  migrateBtn.style.display = "";
322
346
  var candidatesReady = !canExtract || candidateSessions().length > 0;
323
347
  if (canExtract && candidatesReady) ensureSessionSelection();
@@ -1,6 +1,6 @@
1
1
  import { renderSetupPageDocument } from "./setup-page/document.js";
2
2
  import { renderSetupPreviewBootstrap } from "./setup-preview.js";
3
- export { SETUP_PREVIEW_STATES } from "./setup-preview.js";
3
+ export { SETUP_PREVIEW_REPORT, SETUP_PREVIEW_STATES } from "./setup-preview.js";
4
4
  /**
5
5
  * Setup page entry point. The implementation is split by product phase under ./setup-page/:
6
6
  * core utilities, local report, post-auth extraction, and lifecycle polling.
@@ -3,8 +3,11 @@ export const SETUP_PREVIEW_STATES = [
3
3
  "consent-required",
4
4
  "scan",
5
5
  "scan-error",
6
+ "bridge-error",
6
7
  "report",
7
8
  "extract-counting",
9
+ "extract-degraded",
10
+ "extract-degraded-exact",
8
11
  "extract-ready",
9
12
  "extract-free-selected",
10
13
  "extract-free-trial-used",
@@ -35,7 +38,7 @@ export const SETUP_PREVIEW_STATES = [
35
38
  export function parseSetupPreviewState(value) {
36
39
  return SETUP_PREVIEW_STATES.includes(value) ? value : null;
37
40
  }
38
- const previewReport = {
41
+ export const SETUP_PREVIEW_REPORT = {
39
42
  schemaVersion: 1,
40
43
  dataOrigin: "design-preview",
41
44
  generatedFrom: [],
@@ -307,8 +310,12 @@ export function renderSetupPreviewBootstrap(state) {
307
310
  return `${watermark}
308
311
  renderReportIssue("REPORT_CANONICAL_INVALID", "Preview-only canonical reconciliation failure.");`;
309
312
  }
313
+ if (state === "bridge-error") {
314
+ return `${watermark}
315
+ renderBridgeIssue();`;
316
+ }
310
317
  if (state === "report") {
311
- const reportJson = JSON.stringify(previewReport);
318
+ const reportJson = JSON.stringify(SETUP_PREVIEW_REPORT);
312
319
  return `${watermark}
313
320
  localHistoryConsentGranted = true;
314
321
  report = ${reportJson};
@@ -320,6 +327,22 @@ export function renderSetupPreviewBootstrap(state) {
320
327
  stats = null;
321
328
  renderDashboard();`;
322
329
  }
330
+ if (state === "extract-degraded")
331
+ return `${watermark}${extractionPreviewBootstrap({
332
+ plan: "free", paid: false, trialAvailable: true, trialUsed: false,
333
+ quotaLimit: 100, quotaRemaining: 100, candidateCount: 7, selectFree: true,
334
+ })}
335
+ stats.discovery = { phase: "quick", exact: false };
336
+ stats.optionalDiagnostics = { degraded: true, reason: "EXACT_DISCOVERY_FAILED", countsTrusted: false };
337
+ renderDashboard();`;
338
+ if (state === "extract-degraded-exact")
339
+ return `${watermark}${extractionPreviewBootstrap({
340
+ plan: "free", paid: false, trialAvailable: true, trialUsed: false,
341
+ quotaLimit: 100, quotaRemaining: 100, candidateCount: 7, selectFree: true,
342
+ })}
343
+ stats.discovery = { phase: "exact", exact: true };
344
+ stats.optionalDiagnostics = { degraded: true, reason: "FULL_STATS_FAILED", countsTrusted: true };
345
+ renderDashboard();`;
323
346
  if (state === "extract-ready")
324
347
  return `${watermark}${extractionPreviewBootstrap({
325
348
  plan: "free", paid: false, trialAvailable: true, trialUsed: false,