@echomem/mcp 1.4.41 → 1.4.43

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
@@ -613,6 +613,8 @@ function toolEventName(canonicalName, status) {
613
613
  class EchoMemApiClient {
614
614
  store;
615
615
  axios;
616
+ activeToken;
617
+ accountGeneration = 0;
616
618
  boundSourceSession = null;
617
619
  requestSourceSession = new AsyncLocalStorage();
618
620
  sourceSessionsByCanonicalKey = new Map();
@@ -623,6 +625,7 @@ class EchoMemApiClient {
623
625
  deleteConfirmations = new Map();
624
626
  constructor(store) {
625
627
  this.store = store;
628
+ this.activeToken = store.getToken();
626
629
  this.axios = axios.create({
627
630
  baseURL: ECHO_API_BASE_URL.replace(/\/$/, ""),
628
631
  headers: {
@@ -632,7 +635,7 @@ class EchoMemApiClient {
632
635
  // Read the token fresh on EVERY request (not baked in at construction) so a `login` that runs
633
636
  // after the editor already launched the bridge is picked up on the next call — no restart.
634
637
  this.axios.interceptors.request.use((config) => {
635
- const token = this.store.getToken();
638
+ const token = this.synchronizeAccountContext();
636
639
  if (token)
637
640
  config.headers.set("Authorization", `Bearer ${token}`);
638
641
  const sourceSession = this.getBoundSourceSession();
@@ -642,21 +645,46 @@ class EchoMemApiClient {
642
645
  return config;
643
646
  });
644
647
  }
648
+ /**
649
+ * Account-scoped state must move atomically with the credential file. The token is intentionally
650
+ * read fresh, but encryption, identity, source-session, and confirmation caches belong to the
651
+ * token that populated them and cannot survive an in-process account switch.
652
+ */
653
+ synchronizeAccountContext() {
654
+ const token = this.store.getToken();
655
+ if (token === this.activeToken)
656
+ return token;
657
+ this.activeToken = token;
658
+ this.accountGeneration += 1;
659
+ this.encConfigPromise = null;
660
+ this.whoamiCache = null;
661
+ this.boundSourceSession = null;
662
+ this.sourceSessionsByCanonicalKey.clear();
663
+ this.deleteConfirmations.clear();
664
+ return token;
665
+ }
666
+ /** Lets the MCP server invalidate its account-scoped tool-description caches too. */
667
+ refreshAccountContext() {
668
+ this.synchronizeAccountContext();
669
+ return this.accountGeneration;
670
+ }
645
671
  /** Whether a usable API token currently exists (read fresh from the keystore each call). */
646
672
  hasToken() {
647
- return !!this.store.getToken();
673
+ return !!this.synchronizeAccountContext();
648
674
  }
649
675
  /** The per-process session id — the join key telemetry shares with grouped saves. */
650
676
  getSessionId() {
651
677
  return this.sessionId;
652
678
  }
653
679
  getBoundSourceSession() {
680
+ this.synchronizeAccountContext();
654
681
  return this.requestSourceSession.getStore() ?? this.boundSourceSession;
655
682
  }
656
683
  async withSourceSession(sourceSession, operation) {
657
684
  return sourceSession ? this.requestSourceSession.run(sourceSession, operation) : operation();
658
685
  }
659
686
  async bindSourceSession(verified, persistForBridge = true) {
687
+ this.synchronizeAccountContext();
660
688
  const cached = this.sourceSessionsByCanonicalKey.get(verified.canonicalKey);
661
689
  if (cached) {
662
690
  if (persistForBridge)
@@ -739,16 +767,23 @@ class EchoMemApiClient {
739
767
  return undefined;
740
768
  }
741
769
  }
742
- /** Encryption config for the account, fetched once and cached. Failures are not cached. */
770
+ /** Encryption config for the current account, cached only for the lifetime of its token. */
743
771
  async getEncryptionConfig() {
772
+ const generation = this.refreshAccountContext();
744
773
  if (!this.encConfigPromise) {
745
- this.encConfigPromise = fetchEncryptionConfig(this.axios).catch((error) => {
746
- this.encConfigPromise = null; // allow retry next call
774
+ const request = fetchEncryptionConfig(this.axios).catch((error) => {
775
+ if (this.encConfigPromise === request)
776
+ this.encConfigPromise = null; // allow retry next call
747
777
  console.error(`[encryption] config fetch failed: ${describeError(error)} — refusing memory access`);
748
778
  throw new EncryptionStatusUnavailableError("EchoMem could not verify the account encryption state");
749
779
  });
780
+ this.encConfigPromise = request;
750
781
  }
751
- return this.encConfigPromise;
782
+ const request = this.encConfigPromise;
783
+ const config = await request;
784
+ if (this.refreshAccountContext() !== generation)
785
+ return this.getEncryptionConfig();
786
+ return config;
752
787
  }
753
788
  /**
754
789
  * Resolve the encryption state for a read/write. For encrypted accounts this REQUIRES a usable
@@ -756,13 +791,19 @@ class EchoMemApiClient {
756
791
  * handing the model ciphertext. For unencrypted accounts it returns `{ enabled: false }`.
757
792
  */
758
793
  async encState() {
794
+ const generation = this.refreshAccountContext();
759
795
  const cfg = await this.getEncryptionConfig();
796
+ if (this.refreshAccountContext() !== generation)
797
+ return this.encState();
760
798
  if (!cfg.enabled)
761
799
  return { enabled: false };
762
800
  const key = this.store.getKey();
763
801
  if (!key)
764
802
  throw new LockedError("EchoMem vault locked");
765
- if (!(await verifyKeyB64(key, cfg))) {
803
+ const isValid = await verifyKeyB64(key, cfg);
804
+ if (this.refreshAccountContext() !== generation)
805
+ return this.encState();
806
+ if (!isValid) {
766
807
  // A file-backed stale key can be removed safely; environment-provided
767
808
  // keys remain under the caller's control and are ignored until corrected.
768
809
  if (!process.env.ECHO_ENCRYPTION_KEY)
@@ -779,16 +820,23 @@ class EchoMemApiClient {
779
820
  }
780
821
  }
781
822
  async whoami() {
823
+ const generation = this.refreshAccountContext();
782
824
  if (!this.whoamiCache) {
783
- this.whoamiCache = this.axios
825
+ const request = this.axios
784
826
  .get("/api/openclaw/v1/whoami", { timeout: 6000 })
785
827
  .then((response) => response.data)
786
828
  .catch((error) => {
787
- this.whoamiCache = null; // don't pin a token-less failure; retry once a token exists
829
+ if (this.whoamiCache === request)
830
+ this.whoamiCache = null;
788
831
  throw error;
789
832
  });
833
+ this.whoamiCache = request;
790
834
  }
791
- return this.whoamiCache;
835
+ const request = this.whoamiCache;
836
+ const identity = await request;
837
+ if (this.refreshAccountContext() !== generation)
838
+ return this.whoami();
839
+ return identity;
792
840
  }
793
841
  async fetchMemoryById(id, enc) {
794
842
  try {
@@ -1096,6 +1144,8 @@ class EchoMemApiClient {
1096
1144
  kPerUser: parsed.kPerUser,
1097
1145
  similarityThreshold: parsed.similarityThreshold,
1098
1146
  timeFrameDays: parsed.timeFrameDays,
1147
+ workspaceId: parsed.workspaceId ?? parsed.groupId,
1148
+ scope: parsed.scope,
1099
1149
  requestId: this.sessionId,
1100
1150
  source: "mcp_friend_public_memory_search",
1101
1151
  });
@@ -1108,7 +1158,14 @@ class EchoMemApiClient {
1108
1158
  async getPublicMemory(args) {
1109
1159
  const parsed = publicMemorySchema.parse(args ?? {});
1110
1160
  try {
1111
- const response = await this.axios.get(`/api/extension/social/public-memories/${encodeURIComponent(parsed.memoryId)}?requestId=${encodeURIComponent(this.sessionId)}&source=mcp_friend_public_memory_fetch`);
1161
+ const workspaceId = parsed.workspaceId ?? parsed.groupId;
1162
+ const query = new URLSearchParams({
1163
+ requestId: this.sessionId,
1164
+ source: "mcp_friend_public_memory_fetch",
1165
+ });
1166
+ if (workspaceId)
1167
+ query.set("workspaceId", workspaceId);
1168
+ const response = await this.axios.get(`/api/extension/social/public-memories/${encodeURIComponent(parsed.memoryId)}?${query.toString()}`);
1112
1169
  return response.data;
1113
1170
  }
1114
1171
  catch (error) {
@@ -1131,9 +1188,13 @@ class EchoMemApiClient {
1131
1188
  }
1132
1189
  }
1133
1190
  async getGroupContext(args) {
1134
- groupContextSchema.parse(args ?? {});
1191
+ const parsed = groupContextSchema.parse(args ?? {});
1135
1192
  try {
1136
- const response = await this.axios.get("/api/extension/social/groups/current");
1193
+ const workspaceId = parsed.workspaceId ?? parsed.groupId;
1194
+ const path = workspaceId
1195
+ ? `/api/extension/social/groups/current?workspaceId=${encodeURIComponent(workspaceId)}`
1196
+ : "/api/extension/social/groups/current";
1197
+ const response = await this.axios.get(path);
1137
1198
  return response.data;
1138
1199
  }
1139
1200
  catch (error) {
@@ -1197,6 +1258,7 @@ class EchoMemApiClient {
1197
1258
  const enc = await this.encState();
1198
1259
  try {
1199
1260
  const response = await this.axios.post(`/api/extension/social/groups/current/memories/${encodeURIComponent(parsed.memoryId)}/publish`, {
1261
+ workspaceId: parsed.workspaceId ?? parsed.groupId,
1200
1262
  acknowledgedFlaggedMemoryIds: parsed.acknowledgedFlaggedMemoryIds,
1201
1263
  }, {
1202
1264
  headers: enc.enabled && enc.key ? { "X-Encryption-Key": enc.key } : undefined,
@@ -1212,6 +1274,7 @@ class EchoMemApiClient {
1212
1274
  const enc = await this.encState();
1213
1275
  try {
1214
1276
  const response = await this.axios.post("/api/extension/social/groups/current/memories/publish-batch", {
1277
+ workspaceId: parsed.workspaceId ?? parsed.groupId,
1215
1278
  memoryIds: parsed.memoryIds,
1216
1279
  contextId: parsed.contextId,
1217
1280
  selectionReason: parsed.selectionReason,
@@ -1240,6 +1303,7 @@ function capWait(pending) {
1240
1303
  class EchoMemMCPServer {
1241
1304
  server;
1242
1305
  client;
1306
+ accountGeneration = 0;
1243
1307
  mapCache = null;
1244
1308
  groupMapCache = null;
1245
1309
  events;
@@ -1312,8 +1376,19 @@ class EchoMemMCPServer {
1312
1376
  platform_source: hostPlatform,
1313
1377
  };
1314
1378
  }
1379
+ refreshAccountContext() {
1380
+ const generation = this.client.refreshAccountContext();
1381
+ if (generation === this.accountGeneration)
1382
+ return;
1383
+ this.accountGeneration = generation;
1384
+ this.mapCache = null;
1385
+ this.groupMapCache = null;
1386
+ this.mapInjected = false;
1387
+ this.groupMapInjected = false;
1388
+ }
1315
1389
  setupToolHandlers() {
1316
1390
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
1391
+ this.refreshAccountContext();
1317
1392
  const clientVersion = this.server.getClientVersion();
1318
1393
  this.mcpClientName = clientVersion?.name ?? this.mcpClientName;
1319
1394
  this.mcpClientVersion = clientVersion?.version ?? this.mcpClientVersion;
@@ -1345,6 +1420,7 @@ class EchoMemMCPServer {
1345
1420
  return { tools: listToolSpecs({ map, groupMap, updateNotice }) };
1346
1421
  });
1347
1422
  this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1423
+ this.refreshAccountContext();
1348
1424
  const resolvedCanonicalName = resolveCanonicalToolName(request.params.name);
1349
1425
  const recallRoute = routePersonalRecallInvocation(resolvedCanonicalName, request.params.arguments);
1350
1426
  const canonicalName = recallRoute.canonicalName;
@@ -1995,6 +2071,28 @@ Details: ${m.details || "N/A"}`)
1995
2071
  async handleOthers(args) {
1996
2072
  const parsed = othersSchema.parse(args ?? {});
1997
2073
  const payload = await this.client.searchOthersMemories(args);
2074
+ if (payload?.requiresWorkspaceSelection === true) {
2075
+ const availableWorkspaces = Array.isArray(payload?.availableWorkspaces)
2076
+ ? payload.availableWorkspaces.filter(isRecord)
2077
+ : [];
2078
+ const choices = availableWorkspaces
2079
+ .map((workspace) => `${readString(workspace, "name") ?? "Unnamed workspace"} (${readString(workspace, "id") ?? "unknown id"})`)
2080
+ .join(", ");
2081
+ return {
2082
+ content: [{
2083
+ type: "text",
2084
+ text: `You belong to ${availableWorkspaces.length} workspaces: ${choices}. Ask the user which workspace's teammates to search, then call search_others_memories again with workspaceId set to one of those ids. (To search a specific friend instead, pass that person as target.)`,
2085
+ }],
2086
+ };
2087
+ }
2088
+ if (payload?.groupScopeUnavailable === true) {
2089
+ return {
2090
+ content: [{
2091
+ type: "text",
2092
+ text: "You are not in a company workspace, so there are no teammates to search. To search friends instead, call search_others_memories again with scope \"friends\", or pass a specific person as target.",
2093
+ }],
2094
+ };
2095
+ }
1998
2096
  const memories = payload?.memories ?? [];
1999
2097
  const authenticatedViewer = isRecord(payload?.authenticatedViewer)
2000
2098
  ? payload.authenticatedViewer
@@ -2216,6 +2314,20 @@ Details: ${m.details || "N/A"}`;
2216
2314
  async handleGroupContext(args) {
2217
2315
  groupContextSchema.parse(args ?? {});
2218
2316
  const payload = await this.client.getGroupContext(args);
2317
+ if (payload?.requiresWorkspaceSelection === true) {
2318
+ const availableWorkspaces = Array.isArray(payload?.availableWorkspaces)
2319
+ ? payload.availableWorkspaces.filter(isRecord)
2320
+ : [];
2321
+ const choices = availableWorkspaces
2322
+ .map((workspace) => `${readString(workspace, "name") ?? "Unnamed workspace"} (${readString(workspace, "id") ?? "unknown id"})`)
2323
+ .join(", ");
2324
+ return {
2325
+ content: [{
2326
+ type: "text",
2327
+ text: `You belong to ${availableWorkspaces.length} workspaces: ${choices}. Ask the user which workspace to show context for, then call get_group_context again with workspaceId set to one of those ids.`,
2328
+ }],
2329
+ };
2330
+ }
2219
2331
  const group = isRecord(payload?.group) ? payload.group : null;
2220
2332
  const participants = Array.isArray(payload?.participants)
2221
2333
  ? payload.participants.filter(isRecord)
@@ -1090,6 +1090,41 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1090
1090
  function paidRecallPlan(plan) {
1091
1091
  return ["pro", "power", "team", "enterprise"].indexOf(String(plan || "").toLowerCase()) >= 0;
1092
1092
  }
1093
+ var planQuotaCatalog = null;
1094
+ var planQuotaCatalogLastFetchedAt = 0;
1095
+ function quotaLimitsForPlan(plan) {
1096
+ var normalized = String(plan || "free").toLowerCase();
1097
+ if (normalized === "team") normalized = "pro";
1098
+ if (normalized === "enterprise") normalized = "power";
1099
+ var limits = planQuotaCatalog && planQuotaCatalog[normalized];
1100
+ if (!limits || typeof limits !== "object") return null;
1101
+ if (
1102
+ typeof limits.historicalConversationLimit !== "number" ||
1103
+ typeof limits.memoryProcessingInputTokensWeeklyLimit !== "number" ||
1104
+ typeof limits.memorySearchWeeklyLimit !== "number"
1105
+ ) return null;
1106
+ return limits;
1107
+ }
1108
+ function quotaNumber(value) {
1109
+ return number(Math.max(0, Math.floor(value)));
1110
+ }
1111
+ function quotaTokens(value) {
1112
+ var normalized = Math.max(0, Math.floor(value));
1113
+ if (normalized > 0 && normalized % 1000000 === 0) return String(normalized / 1000000) + "M";
1114
+ return normalized > 0 && normalized % 1000 === 0 ? String(normalized / 1000) + "K" : quotaNumber(normalized);
1115
+ }
1116
+ async function refreshPlanQuotaCatalog() {
1117
+ if (Date.now() - planQuotaCatalogLastFetchedAt < 60000) return;
1118
+ try {
1119
+ var response = await fetch("https://echo-mem-chrome.vercel.app/api/public/plan-quotas", { cache: "no-store" });
1120
+ var payload = response.ok ? await response.json() : null;
1121
+ if (!payload || !payload.plans || typeof payload.plans !== "object") return;
1122
+ planQuotaCatalog = payload.plans;
1123
+ planQuotaCatalogLastFetchedAt = Date.now();
1124
+ } catch (_) {
1125
+ // Generic labels remain visible until the managed catalog is available.
1126
+ }
1127
+ }
1093
1128
  function setupPlanPrice(plan) {
1094
1129
  return plan === "power" ? "$100" : "$20";
1095
1130
  }
@@ -1151,7 +1186,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1151
1186
  var statusLabel = canceled ? "Canceled" : (state === "trialing" ? "Trial active" : "Active");
1152
1187
  var lifecycle = paidPlanLifecycleText(plan);
1153
1188
  var quota = billingStatus && billingStatus.historicalConversationQuota;
1154
- var limit = quota && typeof quota.limit === "number" ? quota.limit : (plan === "power" ? 2000 : 500);
1189
+ var limit = quota && typeof quota.limit === "number" ? quota.limit : null;
1155
1190
  var moneyFact = canceled
1156
1191
  ? "No future charge"
1157
1192
  : (state === "trialing" ? setupPlanPrice(plan) + "/month after trial" : setupPlanPrice(plan) + "/month");
@@ -1160,7 +1195,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1160
1195
  '<section class="billingCommitment' + (canceled ? ' is-canceled' : '') + '" aria-label="Current plan commitment">' +
1161
1196
  '<div class="billingCommitmentIdentity"><span>' + esc(statusLabel) + '</span><strong>' + esc(planLabel) + '</strong></div>' +
1162
1197
  '<div class="billingCommitmentFacts">' +
1163
- '<span><b>' + esc(number(limit)) + '</b> coding-session imports</span>' +
1198
+ '<span><b>' + esc(limit === null ? "Plan import allowance" : quotaNumber(limit)) + '</b>' + (limit === null ? "" : " coding-session imports") + '</span>' +
1164
1199
  '<span><b>' + esc(moneyFact) + '</b></span>' +
1165
1200
  '</div>' +
1166
1201
  '<p>' + esc(lifecycle || "Your paid plan is active.") + '</p>' +
@@ -1171,6 +1206,14 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1171
1206
  }
1172
1207
  function setupPlanDefinition(plan) {
1173
1208
  var trialAvailable = !billingStatus || billingStatus.trialAvailable !== false;
1209
+ var limits = quotaLimitsForPlan(plan);
1210
+ var features = limits
1211
+ ? [
1212
+ quotaNumber(limits.historicalConversationLimit) + " past chats",
1213
+ quotaTokens(limits.memoryProcessingInputTokensWeeklyLimit) + " new-chat tokens / week",
1214
+ quotaNumber(limits.memorySearchWeeklyLimit) + " memory recalls / week"
1215
+ ]
1216
+ : ["Past-chat imports", "Weekly new-chat processing", "Weekly memory recalls"];
1174
1217
  if (plan === "power") return {
1175
1218
  id: "power",
1176
1219
  name: "Power",
@@ -1180,11 +1223,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1180
1223
  cadence: trialAvailable ? "per month · 14-day trial" : "per month · billed immediately",
1181
1224
  sticker: "/hud-assets/echo-pricing-power-sticker.png",
1182
1225
  stickerAlt: "Power Echo arrives with a gold key and a crew of notebook helpers.",
1183
- features: [
1184
- "2,000 past chats",
1185
- "agent-heavy memory",
1186
- "2,000 memory recalls / week"
1187
- ]
1226
+ features: features
1188
1227
  };
1189
1228
  if (plan === "pro") return {
1190
1229
  id: "pro",
@@ -1196,11 +1235,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1196
1235
  sticker: "/hud-assets/echo-pricing-pro-sticker.png",
1197
1236
  stickerAlt: "Pro Echo organizes tabbed notebooks and a daily refresh control.",
1198
1237
  popular: true,
1199
- features: [
1200
- "500 past chats",
1201
- "daily memory updates",
1202
- "500 memory recalls / week"
1203
- ]
1238
+ features: features
1204
1239
  };
1205
1240
  return {
1206
1241
  id: "free",
@@ -1211,7 +1246,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1211
1246
  cadence: "free forever · no card needed",
1212
1247
  sticker: "/hud-assets/echo-pricing-free-sticker.png",
1213
1248
  stickerAlt: "Original Echo hugs one simple memory card.",
1214
- features: ["100 coding sessions", "a few new sessions / week", "100 memory recalls / week"]
1249
+ features: features
1215
1250
  };
1216
1251
  }
1217
1252
  function renderPlanHabitat() {
@@ -1333,7 +1368,11 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1333
1368
  if (setupPlanChoice === "free") {
1334
1369
  if (readySettings) readySettings.classList.remove("is-plan-open", "is-checkout-pending");
1335
1370
  if (planGateDecision) planGateDecision.classList.remove("is-checkout-pending");
1336
- slot.innerHTML = '<div class="setupPlanConfirmed"><span class="setupPlanConfirmedIcon" aria-hidden="true">' + setupIcon("check") + '</span><span class="setupPlanConfirmedCopy"><strong>Original Echo</strong><span>100 coding sessions and 100 memory recalls each week.</span></span><button type="button" class="textButton" id="changeSetupPlan">Change</button></div>';
1371
+ var freeLimits = quotaLimitsForPlan("free");
1372
+ var freePlanCopy = freeLimits
1373
+ ? quotaNumber(freeLimits.historicalConversationLimit) + " coding sessions and " + quotaNumber(freeLimits.memorySearchWeeklyLimit) + " memory recalls each week."
1374
+ : "Your managed import and weekly memory allowances are ready.";
1375
+ slot.innerHTML = '<div class="setupPlanConfirmed"><span class="setupPlanConfirmedIcon" aria-hidden="true">' + setupIcon("check") + '</span><span class="setupPlanConfirmedCopy"><strong>Original Echo</strong><span>' + esc(freePlanCopy) + '</span></span><button type="button" class="textButton" id="changeSetupPlan">Change</button></div>';
1337
1376
  renderBillingCommitment("");
1338
1377
  var changeBtn = document.getElementById("changeSetupPlan");
1339
1378
  if (changeBtn) changeBtn.onclick = function () {
@@ -1434,6 +1473,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1434
1473
  var nextBillingStatus = await getJson("/billing-status");
1435
1474
  if (billingEpoch !== accountStateEpoch || !connected) return;
1436
1475
  billingStatus = nextBillingStatus;
1476
+ await refreshPlanQuotaCatalog();
1437
1477
  billingLastCheckedAt = Date.now();
1438
1478
  billingCheckMessage = "";
1439
1479
  if (readyStage === "sessions" && setupPlanConfirmed()) {
package/dist/setup.js CHANGED
@@ -309,12 +309,51 @@ export function codexTomlBlock(entry) {
309
309
  const args = (Array.isArray(entry.args) ? entry.args : []).map((a) => JSON.stringify(String(a))).join(", ");
310
310
  return `[mcp_servers.echomem]\ncommand = ${command}\nargs = [${args}]\n`;
311
311
  }
312
- /**
313
- * Write/merge the EchoMem entry straight into Codex's config.toml — no `codex` CLI needed. Idempotent.
314
- * If an `[mcp_servers.echomem]` block already exists it is REPLACED (so re-running `setup` upgrades a
315
- * stale `npx -y` entry to the direct path); identical entries are left untouched.
316
- */
317
- export function writeCodexConfig(configPath, entry) {
312
+ function objectRecord(value) {
313
+ return value && typeof value === "object" && !Array.isArray(value)
314
+ ? value
315
+ : undefined;
316
+ }
317
+ function validDesktopManagedEntry(value) {
318
+ const entry = objectRecord(value);
319
+ const environment = objectRecord(entry?.env);
320
+ if (!entry || environment?.ECHO_DESKTOP_MANAGED !== "1")
321
+ return false;
322
+ const command = entry.command;
323
+ const args = Array.isArray(entry.args) ? entry.args : [];
324
+ return typeof command === "string"
325
+ && fs.existsSync(command)
326
+ && typeof args[0] === "string"
327
+ && fs.existsSync(args[0]);
328
+ }
329
+ function codexEntryFromBlock(lines, start, end) {
330
+ let command;
331
+ let args = [];
332
+ let desktopManaged = false;
333
+ for (const line of lines.slice(start + 1, end)) {
334
+ const commandMatch = line.match(/^\s*command\s*=\s*(.+?)\s*$/);
335
+ const argsMatch = line.match(/^\s*args\s*=\s*(.+?)\s*$/);
336
+ try {
337
+ if (commandMatch)
338
+ command = JSON.parse(commandMatch[1]);
339
+ if (argsMatch)
340
+ args = JSON.parse(argsMatch[1]);
341
+ }
342
+ catch {
343
+ return undefined;
344
+ }
345
+ if (/ECHO_DESKTOP_MANAGED\s*=\s*["']1["']/.test(line))
346
+ desktopManaged = true;
347
+ }
348
+ if (typeof command !== "string")
349
+ return undefined;
350
+ return {
351
+ command,
352
+ args: Array.isArray(args) ? args : [],
353
+ ...(desktopManaged ? { env: { ECHO_DESKTOP_MANAGED: "1" } } : {}),
354
+ };
355
+ }
356
+ export function writeCodexConfig(configPath, entry, options = {}) {
318
357
  let content = "";
319
358
  try {
320
359
  content = fs.readFileSync(configPath, "utf8");
@@ -331,6 +370,9 @@ export function writeCodexConfig(configPath, entry) {
331
370
  let end = start + 1;
332
371
  while (end < lines.length && !/^\s*\[/.test(lines[end]))
333
372
  end++;
373
+ if (!options.forceHeadless && validDesktopManagedEntry(codexEntryFromBlock(lines, start, end))) {
374
+ return "desktop-managed";
375
+ }
334
376
  if (lines.slice(start, end).join("\n").trimEnd() === block)
335
377
  return "exists"; // already correct
336
378
  const next = [...lines.slice(0, start), ...block.split("\n"), ...lines.slice(end)];
@@ -403,7 +445,7 @@ export function writeAgentsMemoryGuidance(filePath) {
403
445
  return "wrote";
404
446
  }
405
447
  /** Merge the EchoMem entry into a JSON client's `mcpServers` map without clobbering siblings. */
406
- export function writeJsonClientConfig(configPath, entry) {
448
+ export function writeJsonClientConfig(configPath, entry, options = {}) {
407
449
  let config = {};
408
450
  try {
409
451
  config = JSON.parse(fs.readFileSync(configPath, "utf8"));
@@ -412,17 +454,74 @@ export function writeJsonClientConfig(configPath, entry) {
412
454
  /* fresh config */
413
455
  }
414
456
  config.mcpServers = config.mcpServers || {};
457
+ if (!options.forceHeadless && validDesktopManagedEntry(config.mcpServers.echomem)) {
458
+ return "desktop-managed";
459
+ }
415
460
  config.mcpServers.echomem = entry;
416
461
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
417
462
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
463
+ return "wrote";
464
+ }
465
+ function readClaudeCodeConfigFile(configPath) {
466
+ try {
467
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
468
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
469
+ ? parsed
470
+ : {};
471
+ }
472
+ catch {
473
+ return {};
474
+ }
475
+ }
476
+ function echoMemEntryFromServers(value) {
477
+ return objectRecord(objectRecord(value)?.echomem);
478
+ }
479
+ function claudeEntriesMatch(actual, expected) {
480
+ if (!actual || actual.command !== expected.command)
481
+ return false;
482
+ const actualArgs = Array.isArray(actual.args) ? actual.args : [];
483
+ const expectedArgs = Array.isArray(expected.args) ? expected.args : [];
484
+ if (actualArgs.length !== expectedArgs.length || actualArgs.some((value, index) => value !== expectedArgs[index])) {
485
+ return false;
486
+ }
487
+ const expectedEnv = expected.env;
488
+ if (!expectedEnv || typeof expectedEnv !== "object" || Array.isArray(expectedEnv))
489
+ return true;
490
+ const actualEnv = actual.env;
491
+ if (!actualEnv || typeof actualEnv !== "object" || Array.isArray(actualEnv))
492
+ return false;
493
+ return Object.entries(expectedEnv).every(([key, value]) => actualEnv[key] === value);
494
+ }
495
+ function claudeCodeLocalEchoMemProjects(configPath) {
496
+ const projects = readClaudeCodeConfigFile(configPath).projects;
497
+ if (!projects || typeof projects !== "object" || Array.isArray(projects))
498
+ return [];
499
+ return Object.entries(projects)
500
+ .filter(([, value]) => {
501
+ if (!value || typeof value !== "object" || Array.isArray(value))
502
+ return false;
503
+ return Boolean(echoMemEntryFromServers(value.mcpServers));
504
+ })
505
+ .map(([projectPath]) => projectPath)
506
+ .sort();
418
507
  }
419
- export function writeClaudeCodeConfig(entry) {
420
- // EchoMem is a memory server that should load in EVERY Claude Code project, so it belongs at
421
- // `user` scope (~/.claude.json, all projects) rather than `local` scope (the current project only).
422
- const addArguments = ["mcp", "add-json", "-s", "user", "echomem", JSON.stringify(entry)];
423
- const removeFromScope = (scope) => {
508
+ export function writeClaudeCodeConfig(entry, options = {}) {
509
+ // EchoMem belongs at user scope so every Claude Code project resolves the same durable runtime.
510
+ // Older CLI versions wrote local/project entries, which take precedence over user scope and can
511
+ // keep launching a deleted npm cache or stale runtime. Migrate those only after user scope is safe.
512
+ const configPath = options.configPath ?? home(".claude.json");
513
+ const emptyResult = () => ({
514
+ state: "unavailable",
515
+ removedLocalProjects: [],
516
+ skippedLocalProjects: [],
517
+ failedLocalProjects: [],
518
+ restoredPreviousUserEntry: false,
519
+ preservedDesktopManaged: false,
520
+ });
521
+ const runClaude = (args, cwd) => {
424
522
  try {
425
- execFileSync("claude", ["mcp", "remove", "echomem", "-s", scope], {
523
+ execFileSync("claude", args, {
524
+ cwd,
426
525
  encoding: "utf8",
427
526
  stdio: ["ignore", "pipe", "pipe"],
428
527
  timeout: 10000,
@@ -433,39 +532,65 @@ export function writeClaudeCodeConfig(entry) {
433
532
  return false;
434
533
  }
435
534
  };
436
- // Older builds installed EchoMem at `local` scope. Left in place it would shadow the user-scoped
437
- // entry and keep launching the stale command, so drop it first. Best-effort: `local` is per-project,
438
- // so this only clears the directory setup runs from — a no-op (ignored) when nothing is there.
439
- removeFromScope("local");
440
- try {
441
- execFileSync("claude", addArguments, {
442
- encoding: "utf8",
443
- stdio: ["ignore", "pipe", "pipe"],
444
- timeout: 10000,
445
- });
446
- return "wrote";
447
- }
448
- catch (error) {
449
- const stderr = error.stderr;
450
- const detail = Buffer.isBuffer(stderr) ? stderr.toString("utf8") : String(stderr ?? "");
451
- if (!detail.includes("already exists"))
452
- return "unavailable";
453
- }
454
- // Claude Code's CLI will not replace a same-name server. Once the replacement entry is fully
455
- // constructed, remove only EchoMem and immediately re-add it; sibling MCP servers remain.
456
- try {
457
- if (!removeFromScope("user"))
458
- return "unavailable";
459
- execFileSync("claude", addArguments, {
460
- encoding: "utf8",
461
- stdio: ["ignore", "pipe", "pipe"],
462
- timeout: 10000,
463
- });
464
- return "wrote";
535
+ const addUser = (value) => runClaude([
536
+ "mcp", "add-json", "-s", "user", "echomem", JSON.stringify(value),
537
+ ]);
538
+ const removeUser = () => runClaude(["mcp", "remove", "echomem", "-s", "user"]);
539
+ const before = readClaudeCodeConfigFile(configPath);
540
+ const previousUserEntry = echoMemEntryFromServers(before.mcpServers);
541
+ const preservedDesktopManaged = !options.forceHeadless
542
+ && validDesktopManagedEntry(previousUserEntry);
543
+ const desiredUserEntry = preservedDesktopManaged ? previousUserEntry : entry;
544
+ let restoredPreviousUserEntry = false;
545
+ // Avoid interrupting active/new sessions when the correct global entry is already installed.
546
+ if (!claudeEntriesMatch(previousUserEntry, desiredUserEntry)) {
547
+ if (previousUserEntry && !removeUser())
548
+ return emptyResult();
549
+ if (!addUser(desiredUserEntry)) {
550
+ if (previousUserEntry)
551
+ restoredPreviousUserEntry = addUser(previousUserEntry);
552
+ return { ...emptyResult(), restoredPreviousUserEntry };
553
+ }
465
554
  }
466
- catch {
467
- return "unavailable";
555
+ const installedUserEntry = echoMemEntryFromServers(readClaudeCodeConfigFile(configPath).mcpServers);
556
+ if (!claudeEntriesMatch(installedUserEntry, desiredUserEntry)) {
557
+ return { ...emptyResult(), restoredPreviousUserEntry, preservedDesktopManaged };
558
+ }
559
+ const removedLocalProjects = [];
560
+ const skippedLocalProjects = [];
561
+ const failedLocalProjects = [];
562
+ for (const projectPath of claudeCodeLocalEchoMemProjects(configPath)) {
563
+ // A deleted directory cannot currently shadow user scope. Do not recreate it or hand-edit
564
+ // ~/.claude.json, which also contains Claude account/session state.
565
+ if (!fs.existsSync(projectPath)) {
566
+ skippedLocalProjects.push(projectPath);
567
+ continue;
568
+ }
569
+ let projectCwd = projectPath;
570
+ try {
571
+ projectCwd = fs.realpathSync(projectPath);
572
+ }
573
+ catch {
574
+ /* The existence check above already established the safe fallback path. */
575
+ }
576
+ if (runClaude(["mcp", "remove", "echomem", "-s", "local"], projectCwd)) {
577
+ removedLocalProjects.push(projectPath);
578
+ }
579
+ else {
580
+ failedLocalProjects.push(projectPath);
581
+ }
468
582
  }
583
+ const remainingActiveProjects = claudeCodeLocalEchoMemProjects(configPath)
584
+ .filter((projectPath) => fs.existsSync(projectPath));
585
+ const unresolved = [...new Set([...failedLocalProjects, ...remainingActiveProjects])].sort();
586
+ return {
587
+ state: unresolved.length > 0 ? "needs-repair" : "wrote",
588
+ removedLocalProjects,
589
+ skippedLocalProjects,
590
+ failedLocalProjects: unresolved,
591
+ restoredPreviousUserEntry,
592
+ preservedDesktopManaged,
593
+ };
469
594
  }
470
595
  function readJsonClientEntry(configPath) {
471
596
  try {
@@ -2668,6 +2793,9 @@ async function cmdSetup(flags) {
2668
2793
  const entry = buildServerEntry({ devEntryPath: typeof flags.dev === "string" ? flags.dev : undefined });
2669
2794
  const requested = typeof flags.client === "string" ? flags.client : undefined;
2670
2795
  const targets = selectSetupTargets(requested, Boolean(flags.all));
2796
+ // --dev is already an explicit request to replace the managed runtime with a checkout.
2797
+ const forceHeadless = flags["force-headless"] === true || typeof flags.dev === "string";
2798
+ const configurationFailures = [];
2671
2799
  if (targets.length === 0) {
2672
2800
  console.log("No client auto-detected. Add this MCP server entry manually:\n");
2673
2801
  console.log(JSON.stringify({ echomem: entry }, null, 2));
@@ -2676,27 +2804,57 @@ async function cmdSetup(flags) {
2676
2804
  else {
2677
2805
  for (const c of targets) {
2678
2806
  if (c.kind === "json") {
2679
- writeJsonClientConfig(c.configPath, entry);
2680
- console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath}`);
2807
+ const result = writeJsonClientConfig(c.configPath, entry, { forceHeadless });
2808
+ if (result === "desktop-managed") {
2809
+ console.log(`✅ Kept the valid Echo Desktop-managed EchoMem entry for ${c.label}: ${c.configPath}`);
2810
+ }
2811
+ else {
2812
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath}`);
2813
+ }
2681
2814
  }
2682
2815
  else if (c.kind === "command") {
2683
- const result = writeCodexConfig(c.configPath, entry);
2816
+ const result = writeCodexConfig(c.configPath, entry, { forceHeadless });
2684
2817
  if (result === "wrote")
2685
2818
  console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath} — start a new Codex session to load it.`);
2819
+ else if (result === "desktop-managed")
2820
+ console.log(`✅ Kept the valid Echo Desktop-managed EchoMem entry for ${c.label}: ${c.configPath}`);
2686
2821
  else
2687
2822
  console.log(`✅ ${c.label} already has the EchoMem MCP entry: ${c.configPath}`);
2688
2823
  }
2689
2824
  else {
2690
- const result = c.id === "claude-code" ? writeClaudeCodeConfig(entry) : "unavailable";
2691
- if (result === "wrote") {
2692
- console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
2825
+ const result = c.id === "claude-code"
2826
+ ? writeClaudeCodeConfig(entry, { forceHeadless })
2827
+ : "unavailable";
2828
+ if (result !== "unavailable" && result.state === "wrote") {
2829
+ if (result.preservedDesktopManaged) {
2830
+ console.log(`✅ Kept the valid Echo Desktop-managed EchoMem user entry for ${c.label}.`);
2831
+ }
2832
+ else {
2833
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
2834
+ }
2835
+ if (result.removedLocalProjects.length > 0) {
2836
+ console.log(`✅ Removed ${result.removedLocalProjects.length} stale Claude Code project-local EchoMem ${result.removedLocalProjects.length === 1 ? "entry" : "entries"}.`);
2837
+ }
2838
+ if (result.skippedLocalProjects.length > 0) {
2839
+ console.log(`ℹ️ Ignored ${result.skippedLocalProjects.length} EchoMem local ${result.skippedLocalProjects.length === 1 ? "entry" : "entries"} for deleted project directories; they cannot shadow the user entry.`);
2840
+ }
2693
2841
  }
2694
2842
  else {
2695
- console.log(`ℹ️ ${c.label}: ${c.note}\n entry: ${JSON.stringify(entry)}`);
2843
+ const failedProjects = result === "unavailable" ? [] : result.failedLocalProjects;
2844
+ configurationFailures.push(failedProjects.length > 0
2845
+ ? `${c.label} still has project-local EchoMem overrides in: ${failedProjects.join(", ")}`
2846
+ : `${c.label} user-scoped EchoMem entry could not be verified`);
2696
2847
  }
2697
2848
  }
2698
2849
  }
2699
2850
  }
2851
+ if (configurationFailures.length > 0) {
2852
+ throw new Error([
2853
+ "EchoMem MCP configuration is incomplete; onboarding was stopped before login/import.",
2854
+ ...configurationFailures.map((failure) => `- ${failure}`),
2855
+ `Retry with: ${MCP_UPDATE_COMMAND} --client claude-code`,
2856
+ ].join("\n"));
2857
+ }
2700
2858
  if (!flags["no-agents-md"]) {
2701
2859
  writeMemoryGuidanceForTargets(targets);
2702
2860
  }
@@ -3788,6 +3946,7 @@ Usage:
3788
3946
  echomem-mcp Run the MCP server (stdio; default — used by your editor)
3789
3947
  echomem-mcp setup [--client X] Detect editor, write its MCP config, then connect this device
3790
3948
  echomem-mcp setup --skip-login Write MCP config without opening login/browser
3949
+ echomem-mcp setup --force-headless Explicitly replace valid Echo Desktop-managed entries
3791
3950
  echomem-mcp setup --no-codex-skills Skip installing the bundled EchoMem Codex skills
3792
3951
  echomem-mcp update --all Install this bridge durably + repoint detected clients; no login/browser
3793
3952
  echomem-mcp update --client X Repoint one MCP client; no login/browser
@@ -165,6 +165,42 @@ const triggerMetadataSchema = {
165
165
  triggerMessage: z.string().optional(),
166
166
  triggerMessageRole: z.string().optional(),
167
167
  };
168
+ // Canonical workspace selector for tools that act inside a company workspace.
169
+ // `workspaceId` is the current name; `groupId` is the legacy alias kept working
170
+ // so existing prompts keep functioning. Handlers normalize with
171
+ // normalizeWorkspaceId (workspaceId ?? groupId). Required only when the user
172
+ // belongs to more than one workspace; a single-workspace user may omit it.
173
+ const WORKSPACE_SELECTOR_DESCRIPTION = "Workspace to act in. Required when the user belongs to more than one workspace; omit it when they have a single workspace. If omitted with multiple workspaces, the tool returns the available workspaces so you can ask the user which to use.";
174
+ const workspaceSelectorSchema = {
175
+ workspaceId: z.string().uuid().optional().describe(WORKSPACE_SELECTOR_DESCRIPTION),
176
+ groupId: z.string().uuid().optional().describe("Legacy alias for workspaceId."),
177
+ };
178
+ // The advertised JSON-Schema counterpart of workspaceSelectorSchema. Tool
179
+ // inputSchemas are hand-written, so the selector must be injected into every
180
+ // workspace-scoped tool's properties or clients never learn they can pass it.
181
+ const workspaceSelectorProperties = {
182
+ workspaceId: { type: "string", description: WORKSPACE_SELECTOR_DESCRIPTION },
183
+ groupId: { type: "string", description: "Legacy alias for workspaceId." },
184
+ };
185
+ const WORKSPACE_SCOPED_TOOL_NAMES = new Set([
186
+ canonicalToolNames.others,
187
+ canonicalToolNames.publicMemory,
188
+ canonicalToolNames.groupContext,
189
+ canonicalToolNames.createGroupInvite,
190
+ canonicalToolNames.prepareGroupPublication,
191
+ canonicalToolNames.updateGroupProfile,
192
+ canonicalToolNames.publishToGroup,
193
+ canonicalToolNames.publishBatchToGroup,
194
+ ]);
195
+ function injectWorkspaceSelector(specs) {
196
+ for (const spec of specs) {
197
+ if (!WORKSPACE_SCOPED_TOOL_NAMES.has(spec.name))
198
+ continue;
199
+ const existing = spec.inputSchema.properties ?? {};
200
+ spec.inputSchema.properties = { ...workspaceSelectorProperties, ...existing };
201
+ }
202
+ return specs;
203
+ }
168
204
  export const searchMemoriesSchema = z.object({
169
205
  ...triggerMetadataSchema,
170
206
  query: z.string().trim().min(1).optional(),
@@ -235,6 +271,8 @@ export const sendFriendRequestSchema = z.object({
235
271
  });
236
272
  export const othersSchema = z.object({
237
273
  ...triggerMetadataSchema,
274
+ ...workspaceSelectorSchema,
275
+ scope: z.enum(["group", "friends"]).optional(),
238
276
  query: z.string().trim().optional().default(""),
239
277
  limit: z.number().int().min(1).max(50).optional().default(10),
240
278
  target: z.string().optional(),
@@ -249,6 +287,7 @@ export const othersSchema = z.object({
249
287
  });
250
288
  export const publicMemorySchema = z.object({
251
289
  ...triggerMetadataSchema,
290
+ ...workspaceSelectorSchema,
252
291
  memoryId: z.string().min(1),
253
292
  });
254
293
  export const recordMemoryCitationsSchema = z.object({
@@ -258,6 +297,7 @@ export const recordMemoryCitationsSchema = z.object({
258
297
  });
259
298
  export const groupContextSchema = z.object({
260
299
  ...triggerMetadataSchema,
300
+ ...workspaceSelectorSchema,
261
301
  });
262
302
  export const getGroupSessionSharingSchema = z.object({
263
303
  ...triggerMetadataSchema,
@@ -282,6 +322,7 @@ export const createGroupSchema = z.object({
282
322
  });
283
323
  export const createGroupInviteSchema = z.object({
284
324
  ...triggerMetadataSchema,
325
+ ...workspaceSelectorSchema,
285
326
  expiresInDays: z.number().int().min(1).max(30).optional(),
286
327
  maxUses: z.number().int().min(1).max(100).optional(),
287
328
  });
@@ -294,6 +335,7 @@ export const joinGroupSchema = z.object({
294
335
  });
295
336
  export const prepareGroupPublicationSchema = z.object({
296
337
  ...triggerMetadataSchema,
338
+ ...workspaceSelectorSchema,
297
339
  scope: z.enum(["bootstrap", "since_last_scan", "context", "time_range"]).default("since_last_scan"),
298
340
  contextId: z.string().min(1).optional(),
299
341
  startAt: z.string().optional(),
@@ -310,6 +352,7 @@ export const flagPublicationAttentionSchema = z.object({
310
352
  });
311
353
  export const updateGroupProfileSchema = z.object({
312
354
  ...triggerMetadataSchema,
355
+ ...workspaceSelectorSchema,
313
356
  displayName: z.string().min(1).max(120).optional(),
314
357
  title: z.string().min(1).max(160),
315
358
  responsibilitySummary: z.string().min(1).max(1000),
@@ -325,11 +368,13 @@ export const completeGroupPublicationSchema = z.object({
325
368
  });
326
369
  export const publishToGroupSchema = z.object({
327
370
  ...triggerMetadataSchema,
371
+ ...workspaceSelectorSchema,
328
372
  memoryId: z.string().min(1),
329
373
  acknowledgedFlaggedMemoryIds: z.array(z.string().min(1)).max(1).optional(),
330
374
  });
331
375
  export const publishBatchToGroupSchema = z.object({
332
376
  ...triggerMetadataSchema,
377
+ ...workspaceSelectorSchema,
333
378
  memoryIds: z.array(z.string().min(1)).min(1).max(50),
334
379
  contextId: z.string().min(1).optional(),
335
380
  selectionReason: z.string().max(500).optional(),
@@ -573,10 +618,15 @@ export function listToolSpecs(opts = {}) {
573
618
  {
574
619
  name: canonicalToolNames.others,
575
620
  title: "Search teammates' and friends' memories",
576
- description: `PEER-MEMORY SEARCH for public memories owned by accepted friends or company-group members—not the user's own memories. Pass query for a topic; omit it only when intentionally browsing peer memories, and optionally use target to scope a person. Do not use this tool for the user's private memories or the EchoMem user directory. EchoMem identifies the caller from the EchoMem credential and has already excluded only that authenticated user's own memories. Present every returned owner; never filter again using a Claude account, host profile, git identity, or inference. For onboarding and division-of-work questions, call get_group_context first. Returned memories are recorded in memory_views for the owners. ${memoryCitationInstruction}${groupMapSection}`,
621
+ description: `PEER-MEMORY SEARCH for public memories owned by accepted friends or company-group members—not the user's own memories. Pass query for a topic. Set scope to pick the audience — 'group' (only workspace teammates) or 'friends' (only friends) or target for one person; scope defaults to teammates. Do not use this tool for the user's private memories or the EchoMem user directory. EchoMem identifies the caller from the EchoMem credential and has already excluded only that authenticated user's own memories. Present every returned owner; never filter again using a Claude account, host profile, git identity, or inference. For onboarding and division-of-work questions, call get_group_context first. Returned memories are recorded in memory_views for the owners. ${memoryCitationInstruction}${groupMapSection}`,
577
622
  inputSchema: {
578
623
  type: "object",
579
624
  properties: {
625
+ scope: {
626
+ type: "string",
627
+ enum: ["group", "friends"],
628
+ description: "Audience to search. 'group' = only the named workspace's teammates (you'll be asked which if the user is in several and names none). 'friends' = only accepted friends. For one specific person, omit scope and pass target instead. Omitting scope defaults to the group/teammates audience.",
629
+ },
580
630
  query: {
581
631
  type: "string",
582
632
  description: "Optional peer-memory topic. Omit only for an intentional broad browse; never send this field as conversation.",
@@ -584,7 +634,7 @@ export function listToolSpecs(opts = {}) {
584
634
  limit: { type: "integer", minimum: 1, maximum: 50, default: 10 },
585
635
  target: {
586
636
  type: "string",
587
- description: "Accessible friend or group-member user id or exact display name. Prefer this for @Name asks.",
637
+ description: "Accessible friend or group-member user id or exact display name. Prefer this for @Name asks or a single specific person.",
588
638
  },
589
639
  ownerUserId: {
590
640
  type: "string",
@@ -1021,5 +1071,5 @@ export function listToolSpecs(opts = {}) {
1021
1071
  },
1022
1072
  },
1023
1073
  ];
1024
- return tools.map(decorateLocalToolSpec);
1074
+ return injectWorkspaceSelector(tools).map(decorateLocalToolSpec);
1025
1075
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.41",
3
+ "version": "1.4.43",
4
4
  "description": "EchoMem MCP bridge: cloud-first memory tools and the Agent Doctor workspace forensics report (cost ledger + 3D repo city)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "test:ui": "npm run build && node test/setup-ui.test.mjs",
34
34
  "test:onboarding-resilience": "npm run build && node test/onboarding-resilience.test.mjs",
35
35
  "test:billing-ui": "npm run build && node test/setup-ui.test.mjs billing",
36
- "test": "npm run build && node test/source-session.test.mjs && node test/source-session-hook.test.mjs && node test/local-data-paths.test.mjs && node test/crypto.test.mjs && node test/integration.test.mjs && node test/onboarding-resilience.test.mjs && node test/local-auth.test.mjs && node test/retrieval-only.test.mjs && node test/no-restart.test.mjs && node test/report.test.mjs && node test/forensics.test.mjs && node test/canonical-golden.test.mjs && node test/tools.test.mjs && node test/group-map.test.mjs && node test/update-check.test.mjs && node test/headless-runtime.test.mjs && node test/claude-code-config.test.mjs && node test/delete.test.mjs && node test/low-touch-tools.test.mjs && node test/migrate.test.mjs && node test/restart-recovery.test.mjs && node test/save-checkpoint-hook.test.mjs",
36
+ "test": "npm run build && node test/source-session.test.mjs && node test/source-session-hook.test.mjs && node test/local-data-paths.test.mjs && node test/crypto.test.mjs && node test/integration.test.mjs && node test/onboarding-resilience.test.mjs && node test/local-auth.test.mjs && node test/retrieval-only.test.mjs && node test/no-restart.test.mjs && node test/report.test.mjs && node test/forensics.test.mjs && node test/canonical-golden.test.mjs && node test/tools.test.mjs && node test/workspace-selector.test.mjs && node test/group-map.test.mjs && node test/update-check.test.mjs && node test/headless-runtime.test.mjs && node test/claude-code-config.test.mjs && node test/delete.test.mjs && node test/low-touch-tools.test.mjs && node test/migrate.test.mjs && node test/restart-recovery.test.mjs && node test/save-checkpoint-hook.test.mjs",
37
37
  "prepack": "npm run build && node scripts/bundle-city.mjs"
38
38
  },
39
39
  "dependencies": {