@echomem/mcp 1.4.49 → 1.4.51

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 { bindSourceSessionSchema, 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";
7
+ import { bindSourceSessionSchema, canonicalToolNames, completeGroupPublicationSchema, createGroupInviteSchema, createGroupSchema, deleteMemorySchema, flagPublicationAttentionSchema, getGroupSessionSharingSchema, getByContextSchema, groupContextSchema, joinGroupSchema, keywordsSchema, listFriendsSchema, listToolSpecs, linkWorkspaceTicketSessionSchema, 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 { contextHealthMarkdown, recomposeCapsuleMarkdown } from "./hud/api.js";
@@ -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 { resolveSourceSessionFromMcpContext, resolveSourceSessionFromBindingToken, } from "./source-session.js";
19
+ import { 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(/\/$/, "");
@@ -582,6 +582,14 @@ function inputAnalyticsForTool(canonicalName, args) {
582
582
  receipt_id_hash: receiptId ? hashText(receiptId) : undefined,
583
583
  };
584
584
  }
585
+ case canonicalToolNames.linkWorkspaceTicketSession: {
586
+ const ticketId = readString(a, "ticketId");
587
+ const workspaceId = readString(a, "workspaceId");
588
+ return {
589
+ ticket_id_hash: ticketId ? hashText(ticketId) : undefined,
590
+ workspace_id_hash: workspaceId ? hashText(workspaceId) : undefined,
591
+ };
592
+ }
585
593
  case canonicalToolNames.flagPublicationAttention: {
586
594
  const memoryIds = Array.isArray(a.memoryIds) ? a.memoryIds.filter((id) => typeof id === "string") : [];
587
595
  return {
@@ -609,13 +617,22 @@ function inputAnalyticsForTool(canonicalName, args) {
609
617
  function toolEventName(canonicalName, status) {
610
618
  return `[MCP] ${canonicalName} ${status}`;
611
619
  }
620
+ const SUBAGENT_DURABLE_WRITE_MESSAGE = [
621
+ "Codex subagents cannot save EchoMem memories or manage conversation sharing.",
622
+ "Return durable findings to the root agent; the root agent must consolidate them and call save_conversation once.",
623
+ ].join(" ");
624
+ const SUBAGENT_ROOT_ONLY_TOOLS = new Set([
625
+ canonicalToolNames.save,
626
+ canonicalToolNames.requestGroupSessionSharing,
627
+ canonicalToolNames.setGroupSessionSharing,
628
+ ]);
612
629
  class EchoMemApiClient {
613
630
  store;
614
631
  axios;
615
632
  activeToken;
616
633
  accountGeneration = 0;
617
634
  boundSourceSession = null;
618
- requestSourceSession = new AsyncLocalStorage();
635
+ requestContext = new AsyncLocalStorage();
619
636
  sourceSessionsByCanonicalKey = new Map();
620
637
  whoamiCache = null;
621
638
  /** One id per bridge process — groups all saves from this coding session under a single EchoMem context. */
@@ -677,10 +694,20 @@ class EchoMemApiClient {
677
694
  }
678
695
  getBoundSourceSession() {
679
696
  this.synchronizeAccountContext();
680
- return this.requestSourceSession.getStore() ?? this.boundSourceSession;
697
+ const requestContext = this.requestContext.getStore();
698
+ return requestContext === undefined ? this.boundSourceSession : requestContext.sourceSession;
699
+ }
700
+ getRequestVerifiedSourceSession() {
701
+ const requestContext = this.requestContext.getStore();
702
+ if (!requestContext || requestContext.lineageStatus === "unbound")
703
+ return null;
704
+ return requestContext.sourceSession;
681
705
  }
682
- async withSourceSession(sourceSession, operation) {
683
- return sourceSession ? this.requestSourceSession.run(sourceSession, operation) : operation();
706
+ isSubagentRequest() {
707
+ return this.requestContext.getStore()?.isSubagent === true;
708
+ }
709
+ async withRequestContext(requestContext, operation) {
710
+ return this.requestContext.run(requestContext, operation);
684
711
  }
685
712
  async bindSourceSession(verified, persistForBridge = true) {
686
713
  this.synchronizeAccountContext();
@@ -714,6 +741,21 @@ class EchoMemApiClient {
714
741
  created: response.data?.created === true,
715
742
  };
716
743
  }
744
+ async linkWorkspaceTicketSession(args) {
745
+ const parsed = linkWorkspaceTicketSessionSchema.parse(args ?? {});
746
+ // A bridge-level compatibility binding can outlive a conversation in
747
+ // long-running hosts. Ticket links are therefore allowed to take the fast
748
+ // path only from identity verified for this exact request; otherwise the
749
+ // Desktop transcript watcher completes the link from local evidence.
750
+ const sourceSession = this.getRequestVerifiedSourceSession();
751
+ if (!sourceSession)
752
+ return null;
753
+ const response = await this.axios.post(`/api/extension/workspace-tickets/${encodeURIComponent(parsed.ticketId)}/sessions`, {
754
+ contextId: sourceSession.contextId,
755
+ workspaceId: parsed.workspaceId,
756
+ });
757
+ return response.data;
758
+ }
717
759
  async trackMcpAnalyticsEvent(eventType, eventProperties, insertId) {
718
760
  if (!this.hasToken())
719
761
  return;
@@ -966,6 +1008,9 @@ class EchoMemApiClient {
966
1008
  return data;
967
1009
  }
968
1010
  async saveConversation(args) {
1011
+ if (this.isSubagentRequest()) {
1012
+ throw new Error(SUBAGENT_DURABLE_WRITE_MESSAGE);
1013
+ }
969
1014
  const parsed = saveConversationSchema.parse(args ?? {});
970
1015
  const groupSharingScopeId = parsed.groupSharingScopeId ?? randomUUID();
971
1016
  let rawData = parsed.conversation?.trim() || "";
@@ -984,6 +1029,7 @@ class EchoMemApiClient {
984
1029
  const config = {
985
1030
  headers: {
986
1031
  "X-EchoMem-Request-Id": randomUUID(),
1032
+ "X-EchoMem-Origin-Channel": "local_mcp",
987
1033
  ...(enc.enabled && enc.key ? { "X-Encryption-Key": enc.key } : {}),
988
1034
  },
989
1035
  };
@@ -1429,21 +1475,32 @@ class EchoMemMCPServer {
1429
1475
  const clientVersion = this.server.getClientVersion();
1430
1476
  this.mcpClientName = clientVersion?.name ?? this.mcpClientName;
1431
1477
  this.mcpClientVersion = clientVersion?.version ?? this.mcpClientVersion;
1432
- let requestSourceSession = this.client.getBoundSourceSession();
1433
- let inferred = null;
1478
+ let sourceResolution = {
1479
+ sourceSession: null,
1480
+ isSubagent: false,
1481
+ lineageStatus: "unbound",
1482
+ };
1434
1483
  try {
1435
- inferred = resolveSourceSessionFromMcpContext(extra._meta, { hostPlatform: this.getMcpClientAnalytics().host_platform });
1484
+ sourceResolution = resolveSourceSessionRequestContext(extra._meta, { hostPlatform: this.getMcpClientAnalytics().host_platform });
1436
1485
  }
1437
1486
  catch {
1438
1487
  // Malformed host metadata is not model input. Ignore it and retain the explicit fallback.
1439
1488
  }
1489
+ // A child request starts fail-closed. Resolved children bind to the originating root; unresolved
1490
+ // children carry an explicit null so AsyncLocalStorage cannot fall through to a global binding.
1491
+ let requestSourceSession = sourceResolution.isSubagent
1492
+ ? null
1493
+ : this.client.getBoundSourceSession();
1440
1494
  // Verified request metadata always wins over a bridge-level compatibility fallback. The
1441
1495
  // startup hook performs the eager write; this path attaches the exact context to the current
1442
1496
  // request and retries the backend write if startup raced login.
1443
- if (inferred && this.client.hasToken()) {
1444
- requestSourceSession = await this.client.bindSourceSession(inferred, false);
1497
+ if (sourceResolution.sourceSession && this.client.hasToken()) {
1498
+ requestSourceSession = await this.client.bindSourceSession(sourceResolution.sourceSession, false);
1445
1499
  }
1446
- return this.client.withSourceSession(requestSourceSession, async () => {
1500
+ return this.client.withRequestContext({
1501
+ ...sourceResolution,
1502
+ sourceSession: requestSourceSession,
1503
+ }, async () => {
1447
1504
  const t0 = Date.now();
1448
1505
  const analyticsBase = {
1449
1506
  surface: "mcp",
@@ -1454,6 +1511,18 @@ class EchoMemMCPServer {
1454
1511
  codex_session_id: this.client.getBoundSourceSession()?.canonicalKey ?? this.client.getSessionId(),
1455
1512
  conversation_id: this.client.getBoundSourceSession()?.canonicalKey ?? this.client.getSessionId(),
1456
1513
  context_id: this.client.getBoundSourceSession()?.contextId,
1514
+ agent_is_subagent: sourceResolution.isSubagent,
1515
+ agent_lineage_status: sourceResolution.lineageStatus,
1516
+ agent_depth: sourceResolution.agentDepth,
1517
+ agent_thread_id_hash: sourceResolution.agentThreadId
1518
+ ? hashText(sourceResolution.agentThreadId)
1519
+ : undefined,
1520
+ agent_parent_thread_id_hash: sourceResolution.parentThreadId
1521
+ ? hashText(sourceResolution.parentThreadId)
1522
+ : undefined,
1523
+ agent_root_thread_id_hash: sourceResolution.rootThreadId
1524
+ ? hashText(sourceResolution.rootThreadId)
1525
+ : undefined,
1457
1526
  tool_name: request.params.name,
1458
1527
  canonical_tool_name: canonicalName,
1459
1528
  ...triggerAnalyticsForTool(canonicalName, toolArgs),
@@ -1471,6 +1540,13 @@ class EchoMemMCPServer {
1471
1540
  if (recallRoute.error) {
1472
1541
  throw new McpError(ErrorCode.InvalidParams, recallRoute.error);
1473
1542
  }
1543
+ if (sourceResolution.isSubagent && SUBAGENT_ROOT_ONLY_TOOLS.has(canonicalName)) {
1544
+ rec.error_kind = "invalid_args";
1545
+ return {
1546
+ content: [{ type: "text", text: SUBAGENT_DURABLE_WRITE_MESSAGE }],
1547
+ isError: true,
1548
+ };
1549
+ }
1474
1550
  if (canonicalName === canonicalToolNames.updateStatus) {
1475
1551
  if (DESKTOP_MANAGED) {
1476
1552
  return {
@@ -1502,6 +1578,14 @@ class EchoMemMCPServer {
1502
1578
  ? client
1503
1579
  : "auto";
1504
1580
  const capsuleText = await recomposeCapsuleMarkdown(mode);
1581
+ if (this.client.isSubagentRequest()) {
1582
+ return {
1583
+ content: [{
1584
+ type: "text",
1585
+ text: `${capsuleText}\n\n---\nNot persisted: ${SUBAGENT_DURABLE_WRITE_MESSAGE}`,
1586
+ }],
1587
+ };
1588
+ }
1505
1589
  // If logged in, persist the capsule via passthrough so it's retrievable by contextId.
1506
1590
  if (this.client.hasToken()) {
1507
1591
  try {
@@ -1536,6 +1620,8 @@ class EchoMemMCPServer {
1536
1620
  switch (canonicalName) {
1537
1621
  case canonicalToolNames.bindSourceSession:
1538
1622
  return await this.handleBindSourceSession(request.params.arguments);
1623
+ case canonicalToolNames.linkWorkspaceTicketSession:
1624
+ return await this.handleLinkWorkspaceTicketSession(request.params.arguments);
1539
1625
  case canonicalToolNames.search:
1540
1626
  return await this.handleSearch(toolArgs, rec);
1541
1627
  case canonicalToolNames.save:
@@ -1925,6 +2011,37 @@ Details: ${m.details || "N/A"}`)
1925
2011
  }],
1926
2012
  };
1927
2013
  }
2014
+ async handleLinkWorkspaceTicketSession(args) {
2015
+ const parsed = linkWorkspaceTicketSessionSchema.parse(args ?? {});
2016
+ const result = await this.client.linkWorkspaceTicketSession(parsed);
2017
+ if (!result) {
2018
+ return {
2019
+ content: [{
2020
+ type: "text",
2021
+ text: [
2022
+ `Ticket ${parsed.ticketId} link is pending verified source-session discovery.`,
2023
+ "Echo Desktop can finish this link from the structured ticket marker or this exact local tool invocation.",
2024
+ "Do not guess a context ID or call bind_source_session on the user's behalf unless its documented compatibility fallback is actually needed.",
2025
+ ].join("\n"),
2026
+ }],
2027
+ };
2028
+ }
2029
+ const ticket = isRecord(result.ticket) ? result.ticket : {};
2030
+ const workspaceId = readString(ticket, "workspaceId") ?? parsed.workspaceId;
2031
+ return {
2032
+ content: [{
2033
+ type: "text",
2034
+ text: [
2035
+ result.changed === true
2036
+ ? "Linked this verified source session to the Echo workspace ticket."
2037
+ : "This verified source session was already linked to the Echo workspace ticket.",
2038
+ `Ticket: ${parsed.ticketId}`,
2039
+ workspaceId ? `Workspace: ${workspaceId}` : "",
2040
+ "Retries are safe and do not create duplicate links or history events.",
2041
+ ].filter(Boolean).join("\n"),
2042
+ }],
2043
+ };
2044
+ }
1928
2045
  async handleTimeRange(args) {
1929
2046
  const parsed = timeRangeSchema.parse(args ?? {});
1930
2047
  const { success, memories, error } = await this.client.getMemoriesByTimeRange(args);
@@ -97,7 +97,7 @@ export function resolveClaudeDesktopSupportRoots(opts = {}) {
97
97
  if (typeof configured === "string" && configured.trim()) {
98
98
  candidates.push(expandCurrentUserHome(configured, homeDir));
99
99
  }
100
- if (platform === "darwin") {
100
+ else if (platform === "darwin") {
101
101
  candidates.push(path.join(homeDir, "Library", "Application Support", "Claude"));
102
102
  }
103
103
  else if (platform === "win32") {
@@ -0,0 +1,215 @@
1
+ import http from "node:http";
2
+ import { randomUUID } from "node:crypto";
3
+ function connectEchoPage(nonce) {
4
+ const safeNonce = JSON.stringify(nonce);
5
+ return `<!doctype html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="utf-8" />
9
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
10
+ <title>Connect Echo</title>
11
+ <style>
12
+ :root { color-scheme:light; --ink:#172019; --muted:#667168; --line:#dce4da; --leaf:#315d38; --leaf-soft:#edf5eb; --paper:#f6f7f2; --warn:#9b392b; --warn-soft:#faebe7; --amber:#8a611b; --amber-soft:#fff4d8; }
13
+ * { box-sizing:border-box; }
14
+ body { margin:0; background:radial-gradient(circle at 14% 0%,#eef4e8 0,transparent 33%),var(--paper); color:var(--ink); font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; }
15
+ main { width:min(980px,calc(100% - 32px)); margin:38px auto 80px; }
16
+ h1,h2,h3,p { margin:0; } h1 { font-size:clamp(34px,5vw,58px); letter-spacing:-.05em; line-height:.98; }
17
+ button { border:0; border-radius:11px; padding:10px 14px; font:inherit; font-weight:800; cursor:pointer; }
18
+ button:disabled { cursor:wait; opacity:.52; }
19
+ .primary { color:white; background:var(--leaf); } .secondary { color:var(--ink); background:#eef1eb; } .danger { color:white; background:var(--warn); }
20
+ .top { display:flex; justify-content:space-between; align-items:end; gap:22px; margin-bottom:20px; }
21
+ .eyebrow { color:var(--leaf); font-size:12px; font-weight:850; letter-spacing:.14em; text-transform:uppercase; margin-bottom:8px; }
22
+ .local { border:1px solid var(--line); border-radius:999px; padding:7px 11px; color:var(--muted); background:rgba(255,255,255,.85); white-space:nowrap; }
23
+ .panel { background:rgba(255,255,255,.94); border:1px solid var(--line); border-radius:20px; padding:20px; box-shadow:0 16px 50px rgba(34,54,35,.065); margin-top:14px; }
24
+ .account { display:grid; grid-template-columns:1fr auto; align-items:center; gap:18px; }
25
+ .accountTitle { display:flex; align-items:center; gap:9px; margin-bottom:5px; }
26
+ .accountTitle strong { font-size:20px; } .sub,.note,.host p { color:var(--muted); }
27
+ .facts { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:8px; }
28
+ .pill,.badge { border-radius:999px; padding:5px 9px; background:var(--leaf-soft); color:#2d6337; font-size:12px; font-weight:850; }
29
+ .pill.bad,.badge.bad { color:var(--warn); background:var(--warn-soft); } .pill.warn,.badge.warn { color:var(--amber); background:var(--amber-soft); }
30
+ .environment { display:none; margin-top:12px; border-radius:12px; background:var(--amber-soft); color:#6d4c15; padding:10px 12px; }
31
+ .sectionHead { display:flex; justify-content:space-between; align-items:center; gap:16px; margin-bottom:14px; }
32
+ .sectionHead h2 { font-size:22px; letter-spacing:-.02em; }
33
+ .hosts { display:grid; grid-template-columns:repeat(auto-fit,minmax(255px,1fr)); gap:12px; }
34
+ .host { border:1px solid var(--line); border-radius:16px; padding:16px; min-height:190px; display:flex; flex-direction:column; }
35
+ .hostHead { display:flex; justify-content:space-between; align-items:center; gap:10px; }
36
+ .host h3 { font-size:17px; } .host p { margin-top:8px; font-size:13px; overflow-wrap:anywhere; }
37
+ .host code { display:block; margin-top:7px; color:#788179; font:11px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace; overflow-wrap:anywhere; }
38
+ .hostActions { display:flex; flex-wrap:wrap; gap:8px; margin-top:auto; padding-top:15px; }
39
+ .empty { padding:24px; border:1px dashed #cbd5c9; border-radius:14px; color:var(--muted); }
40
+ .recovery { display:grid; grid-template-columns:1fr auto; align-items:center; gap:18px; }
41
+ .recovery h2 { font-size:20px; } .recovery p { color:var(--muted); margin-top:5px; max-width:650px; }
42
+ .advanced { margin-top:14px; border-top:1px solid var(--line); padding-top:13px; }
43
+ .advanced summary { cursor:pointer; color:var(--muted); font-weight:750; }
44
+ .advancedBody { display:flex; justify-content:space-between; align-items:center; gap:18px; margin-top:12px; }
45
+ .advancedBody p { color:var(--muted); max-width:680px; }
46
+ #message { min-height:25px; margin:14px 2px 0; color:var(--muted); }
47
+ @media(max-width:700px) { .top { align-items:start; flex-direction:column; } .account,.recovery { grid-template-columns:1fr; } .facts { justify-content:flex-start; } .advancedBody { align-items:start; flex-direction:column; } }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <main>
52
+ <header class="top"><div><p class="eyebrow">Windows MCP control center</p><h1>Connect Echo</h1></div><span class="local">Localhost only</span></header>
53
+ <section class="panel account" aria-live="polite">
54
+ <div><div class="accountTitle"><strong id="accountHeadline">Checking this profile…</strong><span class="pill warn" id="accountBadge">Checking</span></div><p class="sub" id="accountDetail">Validating the account, vault, and managed runtime.</p><p class="environment" id="environment"></p></div>
55
+ <div class="facts"><span class="pill" id="runtimeFact">Runtime —</span><span class="pill" id="vaultFact">Vault —</span><span class="pill" id="activityFact">No tool activity yet</span></div>
56
+ </section>
57
+ <section class="panel">
58
+ <div class="sectionHead"><div><h2>Your AI tools</h2><p class="note">Connect, inspect, or repair each detected Windows host independently.</p></div><button class="secondary" id="refresh">Run Doctor</button></div>
59
+ <div class="hosts" id="hosts"><p class="empty">Detecting supported MCP hosts…</p></div>
60
+ </section>
61
+ <section class="panel recovery">
62
+ <div><h2>Clean reconnect</h2><p>Install a validated latest runtime, clear inactive EchoMem-managed versions, and repair every detected host. Credentials and cloud memories stay intact.</p></div>
63
+ <button class="primary" id="reconnect">Reconnect all</button>
64
+ </section>
65
+ <details class="panel advanced">
66
+ <summary>Advanced controls</summary>
67
+ <div class="advancedBody"><p>Uninstall removes EchoMem host registrations, lifecycle hooks, guidance, Codex skills, and the managed runtime. It preserves the account credential, vault key, and cloud memories.</p><button class="danger" id="uninstall">Uninstall MCP…</button></div>
68
+ </details>
69
+ <p id="message" role="status"></p>
70
+ </main>
71
+ <script>
72
+ (() => {
73
+ const nonce = ${safeNonce};
74
+ const message = document.getElementById("message");
75
+ const buttons = () => Array.from(document.querySelectorAll("button"));
76
+ const esc = (value) => String(value == null ? "" : value).replace(/[&<>"']/g, (ch) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[ch]));
77
+ const busy = (state) => buttons().forEach((button) => { button.disabled = state; });
78
+ async function request(path, method, payload) {
79
+ const response = await fetch(path + "?nonce=" + encodeURIComponent(nonce), {
80
+ method: method || "GET", credentials:"omit", cache:"no-store",
81
+ headers:{"Content-Type":"application/json"}, body:method === "POST" ? JSON.stringify(Object.assign({nonce},payload || {})) : undefined
82
+ });
83
+ const raw = await response.text(); let data = {};
84
+ try { data = raw ? JSON.parse(raw) : {}; } catch (_) {}
85
+ if (!response.ok) throw new Error(data.message || raw || ("HTTP " + response.status));
86
+ return data;
87
+ }
88
+ function tone(state) {
89
+ return state === "connected" || state === "unlocked" || state === "unencrypted" || state === "ready" ? "" : state === "unreachable" || state === "unknown" || state === "attention" || state === "not_checked" ? "warn" : "bad";
90
+ }
91
+ function label(value) { return String(value || "unknown").replace(/_/g," "); }
92
+ function render(report) {
93
+ const account = report.account || {state:report.credentialsPresent ? "not_checked" : "not_connected",detail:""};
94
+ const vault = report.vault || {state:report.vaultKeyPresent ? "not_checked" : "unknown",detail:""};
95
+ const accountBadge = document.getElementById("accountBadge");
96
+ document.getElementById("accountHeadline").textContent = account.state === "connected" ? "Echo account connected" : account.state === "not_connected" ? "Echo account not connected" : account.state === "invalid" ? "Echo account needs reconnecting" : "Echo account check needs attention";
97
+ accountBadge.textContent = label(account.state);
98
+ accountBadge.className = "pill " + tone(account.state);
99
+ document.getElementById("accountDetail").textContent = account.detail || "";
100
+ const runtime = report.runtime && report.runtime.version ? report.runtime.version : "not installed";
101
+ const runtimeFact = document.getElementById("runtimeFact"); runtimeFact.textContent = "Runtime " + runtime; runtimeFact.className = "pill " + (report.runtime ? "" : "bad");
102
+ const vaultFact = document.getElementById("vaultFact"); vaultFact.textContent = "Vault " + label(vault.state); vaultFact.className = "pill " + tone(vault.state);
103
+ const activity = document.getElementById("activityFact");
104
+ activity.textContent = report.lastSearch ? (report.lastSearch.ok ? "Last search succeeded" : "Last search failed") : "No recent search";
105
+ activity.className = "pill " + (report.lastSearch && report.lastSearch.ok === false ? "bad" : report.lastSearch ? "" : "warn");
106
+ const environment = document.getElementById("environment");
107
+ environment.textContent = report.environment && report.environment.detail || "";
108
+ environment.style.display = report.environment && (report.environment.wsl || !report.environment.windowsNative) ? "block" : "none";
109
+
110
+ const clients = Array.isArray(report.clients) ? report.clients : [];
111
+ document.getElementById("hosts").innerHTML = clients.length ? clients.map((client) => {
112
+ const action = client.recommendedAction || (!client.configured ? "connect" : client.state === "ok" ? "none" : "repair");
113
+ const primary = action === "connect" ? '<button class="primary" data-host-action="connect" data-client-id="' + esc(client.id) + '">Connect</button>' : action === "repair" ? '<button class="primary" data-host-action="repair" data-client-id="' + esc(client.id) + '">Repair</button>' : '';
114
+ const disconnect = client.configured ? '<button class="secondary" data-host-action="disconnect" data-client-id="' + esc(client.id) + '">Disconnect</button>' : '';
115
+ const version = client.version || (client.runtime === "latest" ? "latest at launch" : "version unknown");
116
+ return '<article class="host"><div class="hostHead"><h3>' + esc(client.label) + '</h3><span class="badge ' + tone(client.health) + '">' + esc(label(client.health)) + '</span></div><p>' + esc(client.configured ? "EchoMem MCP is configured for this host." : "Detected on this profile, but EchoMem is not configured.") + '</p><p>' + esc(version) + '</p><code>' + esc(client.detail || "") + '</code><div class="hostActions">' + primary + disconnect + '</div></article>';
117
+ }).join("") : '<p class="empty">No supported MCP host was detected in this profile. Native Windows and WSL have separate installations.</p>';
118
+ }
119
+ async function doctor(statusText) { busy(true); message.textContent = statusText || "Running MCP Doctor…"; try { const data = await request("/doctor"); render(data); message.textContent = "Doctor finished. No configuration was changed."; return data; } catch (error) { message.textContent = error.message; } finally { busy(false); } }
120
+ document.getElementById("refresh").onclick = () => void doctor();
121
+ document.getElementById("hosts").onclick = async (event) => {
122
+ const button = event.target && event.target.closest ? event.target.closest("[data-host-action]") : null;
123
+ if (!button) return;
124
+ const clientId = button.getAttribute("data-client-id"); const action = button.getAttribute("data-host-action");
125
+ if (action === "disconnect" && !confirm("Disconnect EchoMem from this host? Your account and memories will be preserved.")) return;
126
+ busy(true); message.textContent = action === "disconnect" ? "Disconnecting this host…" : action === "repair" ? "Repairing this host…" : "Connecting this host…";
127
+ try { await request(action === "disconnect" ? "/disconnect-host" : "/connect-host","POST",{clientId,repair:action === "repair"}); await doctor(); message.textContent = action === "disconnect" ? "Host disconnected. Restart it to unload EchoMem." : "Host configured. Start a new session in it to load EchoMem."; } catch (error) { message.textContent = error.message; } finally { busy(false); }
128
+ };
129
+ document.getElementById("reconnect").onclick = async () => { if (!confirm("Reconnect every detected host with a clean managed runtime? Active sessions keep their current runtime until restarted.")) return; busy(true); message.textContent = "Installing a clean runtime and repairing detected hosts…"; try { await request("/reconnect","POST"); await doctor(); message.textContent = "Clean reconnect complete. Restart each host to load the new runtime."; } catch (error) { message.textContent = error.message; } finally { busy(false); } };
130
+ document.getElementById("uninstall").onclick = async () => { if (!confirm("Uninstall EchoMem MCP from this profile? Login, vault credentials, and cloud memories will be preserved.")) return; busy(true); message.textContent = "Removing EchoMem MCP components…"; try { await request("/uninstall","POST"); await doctor(); message.textContent = "EchoMem MCP removed. Credentials and cloud memories were preserved."; } catch (error) { message.textContent = error.message; } finally { busy(false); } };
131
+ void doctor("Checking this Windows profile…");
132
+ })();
133
+ </script>
134
+ </body></html>`;
135
+ }
136
+ function readJsonBody(req) {
137
+ return new Promise((resolve, reject) => {
138
+ const chunks = [];
139
+ let size = 0;
140
+ req.on("data", (chunk) => {
141
+ size += chunk.length;
142
+ if (size > 4096) {
143
+ reject(new Error("Request too large"));
144
+ req.destroy();
145
+ return;
146
+ }
147
+ chunks.push(chunk);
148
+ });
149
+ req.on("end", () => {
150
+ try {
151
+ const raw = Buffer.concat(chunks).toString("utf8");
152
+ const parsed = raw ? JSON.parse(raw) : {};
153
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
154
+ throw new Error("Invalid JSON body");
155
+ resolve(parsed);
156
+ }
157
+ catch (error) {
158
+ reject(error);
159
+ }
160
+ });
161
+ req.on("error", reject);
162
+ });
163
+ }
164
+ export async function startMcpControlServer(actions) {
165
+ const nonce = randomUUID();
166
+ let operation = null;
167
+ const server = http.createServer((req, res) => {
168
+ const url = new URL(req.url || "/", "http://127.0.0.1");
169
+ const json = (status, body) => res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" }).end(JSON.stringify(body));
170
+ const run = async () => {
171
+ if (url.searchParams.get("nonce") !== nonce)
172
+ return void json(403, { message: "Invalid local control nonce." });
173
+ if (url.pathname === "/" && req.method === "GET")
174
+ return void res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }).end(connectEchoPage(nonce));
175
+ if (url.pathname === "/doctor" && req.method === "GET")
176
+ return void json(200, await actions.doctor());
177
+ if (req.method !== "POST")
178
+ return void json(404, { message: "Not found." });
179
+ const body = await readJsonBody(req);
180
+ if (operation)
181
+ return void json(409, { message: "Another Connect Echo operation is still running." });
182
+ const clientId = typeof body.clientId === "string" ? body.clientId : "";
183
+ const action = url.pathname === "/connect-host"
184
+ ? () => actions.connectHost(clientId, body.repair === true)
185
+ : url.pathname === "/disconnect-host"
186
+ ? () => actions.disconnectHost(clientId)
187
+ : url.pathname === "/reconnect"
188
+ ? actions.reconnect
189
+ : url.pathname === "/uninstall"
190
+ ? actions.uninstall
191
+ : null;
192
+ if (!action)
193
+ return void json(404, { message: "Not found." });
194
+ operation = Promise.resolve().then(action);
195
+ try {
196
+ return void json(200, await operation);
197
+ }
198
+ finally {
199
+ operation = null;
200
+ }
201
+ };
202
+ run().catch((error) => {
203
+ if (!res.writableEnded)
204
+ json(500, { message: error instanceof Error ? error.message : String(error) });
205
+ });
206
+ });
207
+ await new Promise((resolve, reject) => {
208
+ server.once("error", reject);
209
+ server.listen(0, "127.0.0.1", () => resolve());
210
+ });
211
+ const address = server.address();
212
+ if (!address || typeof address === "string")
213
+ throw new Error("Could not bind the local Connect Echo page.");
214
+ return { url: `http://127.0.0.1:${address.port}/?nonce=${encodeURIComponent(nonce)}`, close: () => server.close() };
215
+ }
package/dist/migrate.js CHANGED
@@ -320,16 +320,49 @@ function hasClaudeText(content) {
320
320
  return false;
321
321
  return content.some((block) => isRecord(block) && block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0);
322
322
  }
323
+ /**
324
+ * True when this turn opens the conversation rather than continuing one.
325
+ *
326
+ * `parentUuid === null` is the common case, but a session started by dropping in a file opens with
327
+ * an `attachment` record and parents its first real turn to that — requiring a null parent silently
328
+ * discarded those whole transcripts. Walk the chain instead: a turn opens the conversation when
329
+ * nothing conversational precedes it in this file. A parent the file does not contain means the
330
+ * thread continues from somewhere else, which is not a user-created session.
331
+ */
332
+ function startsConversation(obj, byUuid) {
333
+ let parentUuid = obj.parentUuid;
334
+ for (let depth = 0; depth < 64; depth += 1) {
335
+ if (parentUuid === null || parentUuid === undefined)
336
+ return true;
337
+ if (typeof parentUuid !== "string")
338
+ return false;
339
+ const parent = byUuid.get(parentUuid);
340
+ if (!parent)
341
+ return false;
342
+ if (parent.type === "user" || parent.type === "assistant")
343
+ return false;
344
+ parentUuid = parent.parentUuid;
345
+ }
346
+ return false; // pathological chain; treat as not session-initiating rather than loop
347
+ }
323
348
  function isUserCreatedClaudeSessionFile(file) {
324
- return initialJsonObjects(file).some((obj) => {
325
- if (!isRecord(obj) || obj.type !== "user")
349
+ const objects = initialJsonObjects(file).filter(isRecord);
350
+ const byUuid = new Map();
351
+ for (const obj of objects) {
352
+ if (typeof obj.uuid === "string" && obj.uuid)
353
+ byUuid.set(obj.uuid, obj);
354
+ }
355
+ return objects.some((obj) => {
356
+ if (obj.type !== "user")
326
357
  return false;
327
- if (obj.userType !== "external" || obj.isSidechain !== false || obj.parentUuid !== null)
358
+ if (obj.userType !== "external" || obj.isSidechain !== false)
328
359
  return false;
329
360
  if (obj.isMeta === true || obj.isCompactSummary === true)
330
361
  return false;
331
362
  if (typeof obj.agentId === "string" && obj.agentId.trim())
332
363
  return false;
364
+ if (!startsConversation(obj, byUuid))
365
+ return false;
333
366
  const message = isRecord(obj.message) ? obj.message : {};
334
367
  return hasClaudeText(message.content);
335
368
  });