@echomem/mcp 1.4.52 → 1.4.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -133,6 +133,20 @@ Both hook entries are merged idempotently into `~/.codex/hooks.json` and
133
133
  `~/.claude/settings.json`; unrelated user hooks and settings are preserved. Hooks fail open when
134
134
  their local input is missing so they cannot prevent a conversation from starting or finishing.
135
135
 
136
+ ### Cloud Cowork through `remote-devices`
137
+
138
+ Cloud Cowork inference cannot use the local hook or transcript-verification path. The shared local
139
+ MCP process also must not retain one cloud conversation as global bridge state. Cloud Cowork agents
140
+ therefore pass their runtime-issued `session_...` identifier as `cloudCoworkSessionId` on each
141
+ EchoMem tool call. EchoMem binds that request to the canonical `cowork:session_...` context with
142
+ `cloud_cowork_tool_argument` evidence and `remote_mcp` origin, then links or saves against that
143
+ context without changing shared bridge state.
144
+
145
+ This identity is intentionally reported as **provisional**: the authenticated user owns the
146
+ context and ticket link, but EchoMem cannot independently corroborate the cloud identifier from a
147
+ local transcript. Verified local Cowork remains `cowork:local_...` with trusted local evidence.
148
+ Never convert one form into the other or silently present a provisional cloud link as verified.
149
+
136
150
  ### Manual / headless (SSH, containers, CI)
137
151
 
138
152
  No browser? Provide secrets directly — this is the documented headless path:
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS, MEMORY_CITATION_INSTRUCTI
16
16
  import { clearBillingAlert, writeBillingAlert } from "./billing-alert.js";
17
17
  import { checkLatestUpdateStatus, formatUpdateNotice, formatUpdateStatusText, startBackgroundUpdateCheck, } from "./update-check.js";
18
18
  import { autoUpdateHeadlessRuntime } from "./headless-runtime.js";
19
- import { resolveSourceSessionRequestContext, resolveSourceSessionFromBindingToken, } from "./source-session.js";
19
+ import { SOURCE_SESSION_CLOUD_TOOL_ARGUMENT_EVIDENCE, resolveCloudCoworkSessionFromToolArguments, resolveSourceSessionRequestContext, resolveSourceSessionFromBindingToken, } from "./source-session.js";
20
20
  const ECHO_API_BASE_URL = process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app";
21
21
  const ECHO_PRICING_URL = process.env.ECHO_PRICING_URL || "https://echoknows.com/account";
22
22
  const ECHO_MEMORY_WEB_URL = (process.env.ECHO_MEMORY_WEB_URL || "https://echoknows.com/memory").replace(/\/$/, "");
@@ -711,7 +711,7 @@ class EchoMemApiClient {
711
711
  const requestContext = this.requestContext.getStore();
712
712
  return requestContext === undefined ? this.boundSourceSession : requestContext.sourceSession;
713
713
  }
714
- getRequestVerifiedSourceSession() {
714
+ getRequestSourceSession() {
715
715
  const requestContext = this.requestContext.getStore();
716
716
  if (!requestContext || requestContext.lineageStatus === "unbound")
717
717
  return null;
@@ -723,26 +723,29 @@ class EchoMemApiClient {
723
723
  async withRequestContext(requestContext, operation) {
724
724
  return this.requestContext.run(requestContext, operation);
725
725
  }
726
- async bindSourceSession(verified, persistForBridge = true) {
726
+ async bindSourceSession(identity, persistForBridge = true) {
727
727
  this.synchronizeAccountContext();
728
- const cached = this.sourceSessionsByCanonicalKey.get(verified.canonicalKey);
728
+ if (identity.evidence === SOURCE_SESSION_CLOUD_TOOL_ARGUMENT_EVIDENCE && persistForBridge) {
729
+ throw new Error("A provisional Cloud Cowork identity cannot become shared bridge state");
730
+ }
731
+ const cached = this.sourceSessionsByCanonicalKey.get(identity.canonicalKey);
729
732
  if (cached) {
730
733
  if (persistForBridge)
731
734
  this.boundSourceSession = cached;
732
735
  return { ...cached, created: false };
733
736
  }
734
737
  const response = await this.axios.post("/api/extension/source-sessions/bind", {
735
- provider: verified.provider,
736
- providerSessionId: verified.providerSessionId,
737
- evidence: verified.evidence,
738
+ provider: identity.provider,
739
+ providerSessionId: identity.providerSessionId,
740
+ evidence: identity.evidence,
738
741
  });
739
742
  const contextId = readString(response.data, "contextId");
740
743
  const canonicalKey = readString(response.data, "canonicalKey");
741
- if (response.data?.success !== true || !contextId || canonicalKey !== verified.canonicalKey) {
744
+ if (response.data?.success !== true || !contextId || canonicalKey !== identity.canonicalKey) {
742
745
  throw new Error("EchoMem returned an invalid source-session binding receipt");
743
746
  }
744
- const bound = { ...verified, contextId };
745
- this.sourceSessionsByCanonicalKey.set(verified.canonicalKey, bound);
747
+ const bound = { ...identity, contextId };
748
+ this.sourceSessionsByCanonicalKey.set(identity.canonicalKey, bound);
746
749
  if (this.sourceSessionsByCanonicalKey.size > 64) {
747
750
  const oldestKey = this.sourceSessionsByCanonicalKey.keys().next().value;
748
751
  if (oldestKey)
@@ -759,9 +762,10 @@ class EchoMemApiClient {
759
762
  const parsed = linkWorkspaceTicketSessionSchema.parse(args ?? {});
760
763
  // A bridge-level compatibility binding can outlive a conversation in
761
764
  // long-running hosts. Ticket links are therefore allowed to take the fast
762
- // path only from identity verified for this exact request; otherwise the
763
- // Desktop transcript watcher completes the link from local evidence.
764
- const sourceSession = this.getRequestVerifiedSourceSession();
765
+ // path only from identity scoped to this exact request. That identity is
766
+ // either verified host metadata or an explicitly marked provisional Cloud
767
+ // Cowork claim; otherwise Desktop completes the link from local evidence.
768
+ const sourceSession = this.getRequestSourceSession();
765
769
  if (!sourceSession)
766
770
  return null;
767
771
  const response = await this.axios.post(`/api/extension/workspace-tickets/${encodeURIComponent(parsed.ticketId)}/sessions`, {
@@ -1040,10 +1044,14 @@ class EchoMemApiClient {
1040
1044
  // For an encrypted account, hand the server the key transiently in the X-Encryption-Key header
1041
1045
  // so it encrypts at rest (mirrors the extension's write path, spec §3.1a). Locked → LockedError.
1042
1046
  const enc = await this.encState();
1047
+ const boundSourceSession = this.getBoundSourceSession();
1048
+ const originChannel = boundSourceSession?.evidence === SOURCE_SESSION_CLOUD_TOOL_ARGUMENT_EVIDENCE
1049
+ ? "remote_mcp"
1050
+ : "local_mcp";
1043
1051
  const config = {
1044
1052
  headers: {
1045
1053
  "X-EchoMem-Request-Id": randomUUID(),
1046
- "X-EchoMem-Origin-Channel": "local_mcp",
1054
+ "X-EchoMem-Origin-Channel": originChannel,
1047
1055
  ...(enc.enabled && enc.key ? { "X-Encryption-Key": enc.key } : {}),
1048
1056
  },
1049
1057
  };
@@ -1053,9 +1061,10 @@ class EchoMemApiClient {
1053
1061
  sourceUrl: parsed.url,
1054
1062
  source: parsed.source || "mcp_server",
1055
1063
  title: parsed.title,
1056
- // A verified source session owns the memory context. The independent opaque scope still
1064
+ // A source-session identity owns the memory context. Provisional Cloud Cowork identity
1065
+ // is explicitly marked by its evidence and origin channel. The independent opaque scope still
1057
1066
  // governs group-sharing consent and never becomes the source-session identity.
1058
- conversationKey: this.getBoundSourceSession()?.canonicalKey ?? groupSharingScopeId,
1067
+ conversationKey: boundSourceSession?.canonicalKey ?? groupSharingScopeId,
1059
1068
  groupSharingScopeId,
1060
1069
  passthrough: parsed.passthrough || false,
1061
1070
  triggerMessage: parsed.triggerMessage ||
@@ -1502,6 +1511,23 @@ class EchoMemMCPServer {
1502
1511
  catch {
1503
1512
  // Malformed host metadata is not model input. Ignore it and retain the explicit fallback.
1504
1513
  }
1514
+ let provisionalCloudSession = null;
1515
+ try {
1516
+ provisionalCloudSession = resolveCloudCoworkSessionFromToolArguments(toolArgs);
1517
+ }
1518
+ catch (error) {
1519
+ throw new McpError(ErrorCode.InvalidParams, error instanceof Error ? error.message : "Invalid Cloud Cowork session identity");
1520
+ }
1521
+ if (provisionalCloudSession && sourceResolution.sourceSession) {
1522
+ throw new McpError(ErrorCode.InvalidParams, "This request already has a verified local source session. Omit cloudCoworkSessionId rather than mixing identities.");
1523
+ }
1524
+ if (provisionalCloudSession) {
1525
+ sourceResolution = {
1526
+ sourceSession: provisionalCloudSession,
1527
+ isSubagent: false,
1528
+ lineageStatus: "not_applicable",
1529
+ };
1530
+ }
1505
1531
  // A child request starts fail-closed. Resolved children bind to the originating root; unresolved
1506
1532
  // children carry an explicit null so AsyncLocalStorage cannot fall through to a global binding.
1507
1533
  let requestSourceSession = sourceResolution.isSubagent
@@ -2052,17 +2078,25 @@ Details: ${m.details || "N/A"}`)
2052
2078
  const ticket = isRecord(result.ticket) ? result.ticket : {};
2053
2079
  const workspaceId = readString(ticket, "workspaceId") ?? parsed.workspaceId;
2054
2080
  const sourceSession = this.client.getBoundSourceSession();
2081
+ const provisional = sourceSession?.evidence === SOURCE_SESSION_CLOUD_TOOL_ARGUMENT_EVIDENCE;
2055
2082
  return {
2056
2083
  content: [{
2057
2084
  type: "text",
2058
2085
  text: [
2059
2086
  result.changed === true
2060
- ? "Linked this verified source session to the Echo workspace ticket."
2061
- : "This verified source session was already linked to the Echo workspace ticket.",
2087
+ ? provisional
2088
+ ? "Linked this provisional Cloud Cowork session to the Echo workspace ticket."
2089
+ : "Linked this verified source session to the Echo workspace ticket."
2090
+ : provisional
2091
+ ? "This provisional Cloud Cowork session was already linked to the Echo workspace ticket."
2092
+ : "This verified source session was already linked to the Echo workspace ticket.",
2062
2093
  `Ticket: ${parsed.ticketId}`,
2063
2094
  workspaceId ? `Workspace: ${workspaceId}` : "",
2064
2095
  sourceSession ? `Source session: ${sourceSession.canonicalKey}` : "",
2065
2096
  sourceSession ? `Context: ${sourceSession.contextId}` : "",
2097
+ provisional
2098
+ ? "Identity trust: provisional (reported by Cloud Cowork in the tool call; not verified by a local transcript). Repeat cloudCoworkSessionId on later EchoMem calls from this conversation."
2099
+ : "",
2066
2100
  "Retries are safe and do not create duplicate links or history events.",
2067
2101
  ].filter(Boolean).join("\n"),
2068
2102
  }],
@@ -4,6 +4,7 @@ import { resolveClaudeCoworkSessionsDir, resolveClaudeProjectsDir, resolveCodexS
4
4
  export const SOURCE_SESSION_BINDING_EVIDENCE = "local_jsonl_tool_call";
5
5
  export const SOURCE_SESSION_HOOK_EVIDENCE = "local_session_start_hook";
6
6
  export const SOURCE_SESSION_MCP_METADATA_EVIDENCE = "local_mcp_session_metadata";
7
+ export const SOURCE_SESSION_CLOUD_TOOL_ARGUMENT_EVIDENCE = "cloud_cowork_tool_argument";
7
8
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
9
  const UUID_IN_TEXT_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
9
10
  const MAX_TAIL_BYTES = 512 * 1024;
@@ -20,13 +21,18 @@ function normalizedProviderSessionId(provider, value) {
20
21
  if (provider === "codex" && !UUID_RE.test(normalized)) {
21
22
  throw new Error("Codex source-session ID must be a UUID");
22
23
  }
23
- if (provider === "cowork" && !/^local_[A-Za-z0-9_-]{1,194}$/.test(normalized)) {
24
- throw new Error("Cowork source-session ID must use the local_ host session identifier");
24
+ if (provider === "cowork"
25
+ && !/^local_[A-Za-z0-9_-]{1,194}$/.test(normalized)
26
+ && !/^session_[A-Za-z0-9_-]{1,192}$/.test(normalized)) {
27
+ throw new Error("Cowork source-session ID must use a local_ host ID or session_ Cloud Cowork ID");
25
28
  }
26
29
  return UUID_RE.test(normalized) ? normalized.toLowerCase() : normalized;
27
30
  }
28
31
  export function verifiedSourceSession(provider, providerSessionId, evidence) {
29
32
  const normalized = normalizedProviderSessionId(provider, providerSessionId);
33
+ if (provider === "cowork" && !normalized.startsWith("local_")) {
34
+ throw new Error("Verified Cowork source-session ID must use the local_ host identifier");
35
+ }
30
36
  return {
31
37
  provider,
32
38
  providerSessionId: normalized,
@@ -34,6 +40,26 @@ export function verifiedSourceSession(provider, providerSessionId, evidence) {
34
40
  evidence,
35
41
  };
36
42
  }
43
+ export function provisionalCloudCoworkSession(providerSessionId) {
44
+ const normalized = normalizedProviderSessionId("cowork", providerSessionId);
45
+ if (!normalized.startsWith("session_")) {
46
+ throw new Error("Cloud Cowork source-session ID must use the session_ identifier from the cloud runtime");
47
+ }
48
+ return {
49
+ provider: "cowork",
50
+ providerSessionId: normalized,
51
+ canonicalKey: `cowork:${normalized}`,
52
+ evidence: SOURCE_SESSION_CLOUD_TOOL_ARGUMENT_EVIDENCE,
53
+ };
54
+ }
55
+ export function resolveCloudCoworkSessionFromToolArguments(args) {
56
+ if (!isRecord(args) || args.cloudCoworkSessionId === undefined)
57
+ return null;
58
+ if (typeof args.cloudCoworkSessionId !== "string") {
59
+ throw new Error("cloudCoworkSessionId must be the current Cloud Cowork session_ identifier");
60
+ }
61
+ return provisionalCloudCoworkSession(args.cloudCoworkSessionId);
62
+ }
37
63
  function isRecord(value) {
38
64
  return typeof value === "object" && value !== null && !Array.isArray(value);
39
65
  }
@@ -393,7 +419,10 @@ export function resolveSourceSessionRequestContext(metadata, options = {}) {
393
419
  return { sourceSession: null, isSubagent: false, lineageStatus: "unbound" };
394
420
  }
395
421
  export function resolveSourceSessionFromMcpContext(metadata, options = {}) {
396
- return resolveSourceSessionRequestContext(metadata, options).sourceSession;
422
+ const sourceSession = resolveSourceSessionRequestContext(metadata, options).sourceSession;
423
+ return sourceSession?.evidence === SOURCE_SESSION_CLOUD_TOOL_ARGUMENT_EVIDENCE
424
+ ? null
425
+ : sourceSession;
397
426
  }
398
427
  export function resolveSourceSessionFromBindingToken(bindingToken, options = {}) {
399
428
  const normalizedToken = bindingToken.trim().toLowerCase();
@@ -164,7 +164,32 @@ function decorateLocalToolSpec(tool) {
164
164
  const triggerMetadataSchema = {
165
165
  triggerMessage: z.string().optional(),
166
166
  triggerMessageRole: z.string().optional(),
167
+ cloudCoworkSessionId: z.string().regex(/^session_[A-Za-z0-9_-]{1,192}$/).optional(),
167
168
  };
169
+ const CLOUD_COWORK_SESSION_DESCRIPTION = "Cloud Cowork only: the current session_ identifier supplied by the Cowork runtime. Repeat it on each EchoMem call so the shared remote-devices bridge can attach that one request to the same provisional conversation context. Never guess or reuse an ID from another conversation. Local Cowork, Claude Code, and Codex must omit this field.";
170
+ const CLOUD_COWORK_SESSION_PROPERTY = {
171
+ type: "string",
172
+ pattern: "^session_[A-Za-z0-9_-]{1,192}$",
173
+ description: CLOUD_COWORK_SESSION_DESCRIPTION,
174
+ };
175
+ const SOURCE_CONTEXT_FREE_TOOL_NAMES = new Set([
176
+ canonicalToolNames.bindSourceSession,
177
+ canonicalToolNames.updateStatus,
178
+ canonicalToolNames.contextHealth,
179
+ canonicalToolNames.recompose,
180
+ ]);
181
+ function injectCloudCoworkSession(specs) {
182
+ for (const spec of specs) {
183
+ if (SOURCE_CONTEXT_FREE_TOOL_NAMES.has(spec.name))
184
+ continue;
185
+ const existing = spec.inputSchema.properties ?? {};
186
+ spec.inputSchema.properties = {
187
+ ...existing,
188
+ cloudCoworkSessionId: CLOUD_COWORK_SESSION_PROPERTY,
189
+ };
190
+ }
191
+ return specs;
192
+ }
168
193
  // Canonical workspace selector for tools that act inside a company workspace.
169
194
  // `workspaceId` is the current name; `groupId` is the legacy alias kept working
170
195
  // so existing prompts keep functioning. Handlers normalize with
@@ -478,7 +503,7 @@ export function listToolSpecs(opts = {}) {
478
503
  {
479
504
  name: canonicalToolNames.linkWorkspaceTicketSession,
480
505
  title: "Link this agent conversation to an Echo workspace ticket",
481
- description: withMcpVersion("Call when the user asks to work on, continue, attach, or bind this conversation to an Echo workspace ticket and supplies its UUID. Pass the ticketId exactly; pass workspaceId when a structured Echo ticket marker provides it. The operation is idempotent. If the current source session is already verified, EchoMem links it immediately. Otherwise Echo Desktop can recover this exact tool invocation from the local transcript and finish the verified link; never guess or ask the user for a source-session/context ID."),
506
+ description: withMcpVersion("Call when the user asks to work on, continue, attach, or bind this conversation to an Echo workspace ticket and supplies its UUID. Pass the ticketId exactly; pass workspaceId when a structured Echo ticket marker provides it. The operation is idempotent. A verified local source session links immediately. Cloud Cowork has no local transcript, so pass its current session_ runtime identifier as cloudCoworkSessionId; EchoMem records that path explicitly as provisional and links it immediately. Otherwise Echo Desktop can recover this exact invocation from a local transcript. Never guess or ask the user for a source-session/context ID."),
482
507
  inputSchema: {
483
508
  type: "object",
484
509
  properties: {
@@ -1097,5 +1122,5 @@ export function listToolSpecs(opts = {}) {
1097
1122
  },
1098
1123
  },
1099
1124
  ];
1100
- return injectWorkspaceSelector(tools).map(decorateLocalToolSpec);
1125
+ return injectWorkspaceSelector(injectCloudCoworkSession(tools)).map(decorateLocalToolSpec);
1101
1126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.52",
3
+ "version": "1.4.53",
4
4
  "description": "EchoMem MCP bridge for cross-agent memory, local history import, and recall",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",