@echomem/mcp 1.4.28 → 1.4.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import axios from "axios";
6
6
  import { ZodError } from "zod";
7
- import { canonicalToolNames, completeGroupPublicationSchema, createGroupInviteSchema, createGroupSchema, deleteMemorySchema, flagPublicationAttentionSchema, getGroupSessionSharingSchema, getByContextSchema, groupContextSchema, joinGroupSchema, keywordsSchema, listFriendsSchema, listToolSpecs, othersSchema, publishBatchToGroupSchema, publishToGroupSchema, prepareGroupPublicationSchema, publicMemorySchema, resolveCanonicalToolName, saveConversationSchema, searchMemoriesSchema, searchUsersSchema, sendFriendRequestSchema, timeRangeSchema, updateGroupProfileSchema, setGroupSessionSharingSchema, } from "./v1-contract.js";
7
+ import { canonicalToolNames, completeGroupPublicationSchema, createGroupInviteSchema, createGroupSchema, deleteMemorySchema, flagPublicationAttentionSchema, getGroupSessionSharingSchema, getByContextSchema, groupContextSchema, joinGroupSchema, keywordsSchema, listFriendsSchema, listToolSpecs, othersSchema, publishBatchToGroupSchema, publishToGroupSchema, prepareGroupPublicationSchema, publicMemorySchema, recordMemoryCitationsSchema, resolveCanonicalToolName, saveConversationSchema, searchMemoriesSchema, searchUsersSchema, sendFriendRequestSchema, timeRangeSchema, updateGroupProfileSchema, setGroupSessionSharingSchema, } from "./v1-contract.js";
8
8
  import { KeyStore } from "./keystore.js";
9
9
  import { EventLogger, hashText } from "./events.js";
10
10
  import { buildReportText } from "./report.js";
@@ -12,7 +12,7 @@ import { contextHealthMarkdown, recomposeCapsuleMarkdown } from "./hud/api.js";
12
12
  import { createHash, randomUUID } from "node:crypto";
13
13
  import { fetchEncryptionConfig, decryptMemoryFields, verifyKeyB64, } from "./encryption.js";
14
14
  import { runCli } from "./setup.js";
15
- import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS } from "./package-metadata.js";
15
+ import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS, MEMORY_CITATION_INSTRUCTION, } from "./package-metadata.js";
16
16
  import { clearBillingAlert, writeBillingAlert } from "./billing-alert.js";
17
17
  import { checkLatestUpdateStatus, formatUpdateNotice, formatUpdateStatusText, startBackgroundUpdateCheck, } from "./update-check.js";
18
18
  const ECHO_API_BASE_URL = process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app";
@@ -32,6 +32,9 @@ function memoryMarkdownLink(url, keys, description) {
32
32
  .replace(/\]/g, "\\]");
33
33
  return `[${label}](${url})`;
34
34
  }
35
+ function withMemoryCitationInstruction(text) {
36
+ return `${text}\n\n${MEMORY_CITATION_INSTRUCTION}`;
37
+ }
35
38
  /** Thrown when no API token is present yet — the model gets a "run login" nudge, not a hard error. */
36
39
  class NoTokenError extends Error {
37
40
  }
@@ -510,6 +513,16 @@ function inputAnalyticsForTool(canonicalName, args) {
510
513
  memory_id_hash: memoryId ? hashText(memoryId) : undefined,
511
514
  };
512
515
  }
516
+ case canonicalToolNames.recordCitations: {
517
+ const memoryIds = Array.isArray(a.memoryIds)
518
+ ? a.memoryIds.filter((id) => typeof id === "string")
519
+ : [];
520
+ const receiptId = readString(a, "receiptId");
521
+ return {
522
+ memory_count: memoryIds.length,
523
+ receipt_id_hash: receiptId ? hashText(receiptId) : undefined,
524
+ };
525
+ }
513
526
  case canonicalToolNames.flagPublicationAttention: {
514
527
  const memoryIds = Array.isArray(a.memoryIds) ? a.memoryIds.filter((id) => typeof id === "string") : [];
515
528
  return {
@@ -607,6 +620,21 @@ class EchoMemApiClient {
607
620
  return undefined;
608
621
  }
609
622
  }
623
+ /**
624
+ * Compact "who covers what" guide for the user's company group, used to decorate the group tool
625
+ * descriptions. Group publication snapshots are plaintext by design, so this works even when the
626
+ * local vault is locked — unlike `fetchMemoryMap`, it never needs a decryption key.
627
+ */
628
+ async fetchGroupMemoryMap() {
629
+ try {
630
+ const response = await this.axios.get("/api/extension/social/groups/current", { timeout: 6000 });
631
+ const map = typeof response.data?.map === "string" ? response.data.map.trim() : "";
632
+ return map || undefined;
633
+ }
634
+ catch {
635
+ return undefined;
636
+ }
637
+ }
610
638
  /** Encryption config for the account, fetched once and cached. Failures are not cached. */
611
639
  async getEncryptionConfig() {
612
640
  if (!this.encConfigPromise) {
@@ -980,6 +1008,16 @@ class EchoMemApiClient {
980
1008
  throw new Error(`get_public_memory failed against ${ECHO_API_BASE_URL}: ${describeError(error)}`);
981
1009
  }
982
1010
  }
1011
+ async recordMemoryCitations(args) {
1012
+ const parsed = recordMemoryCitationsSchema.parse(args ?? {});
1013
+ try {
1014
+ const response = await this.axios.post("/api/extension/social/memory-citations", { ...parsed, sessionKey: this.sessionId });
1015
+ return response.data;
1016
+ }
1017
+ catch (error) {
1018
+ throw new Error(`record_memory_citations failed against ${ECHO_API_BASE_URL}: ${describeError(error)}`);
1019
+ }
1020
+ }
983
1021
  async getGroupContext(args) {
984
1022
  groupContextSchema.parse(args ?? {});
985
1023
  try {
@@ -1073,15 +1111,28 @@ class EchoMemApiClient {
1073
1111
  }
1074
1112
  }
1075
1113
  const SERVER_VERSION = MCP_PACKAGE_VERSION;
1114
+ /** Tool-description decoration must never delay the MCP handshake; give up and decorate next listing. */
1115
+ const MAP_WAIT_MS = 2500;
1116
+ function capWait(pending) {
1117
+ if (!pending)
1118
+ return Promise.resolve(undefined);
1119
+ return Promise.race([
1120
+ pending,
1121
+ new Promise((resolve) => setTimeout(() => resolve(undefined), MAP_WAIT_MS)),
1122
+ ]);
1123
+ }
1076
1124
  class EchoMemMCPServer {
1077
1125
  server;
1078
1126
  client;
1079
1127
  mapCache = null;
1128
+ groupMapCache = null;
1080
1129
  events;
1081
1130
  mcpClientName;
1082
1131
  mcpClientVersion;
1083
1132
  /** Whether the most recent ListTools response carried the memory map (per-session recall signal). */
1084
1133
  mapInjected = false;
1134
+ /** Whether the most recent ListTools response carried the group memory map. */
1135
+ groupMapInjected = false;
1085
1136
  updateStatus;
1086
1137
  constructor(store) {
1087
1138
  this.server = new Server({
@@ -1130,15 +1181,23 @@ class EchoMemMCPServer {
1130
1181
  this.mapCache.then((m) => { if (!m)
1131
1182
  this.mapCache = null; }).catch(() => { this.mapCache = null; });
1132
1183
  }
1184
+ if (this.client.hasToken() && !this.groupMapCache) {
1185
+ this.groupMapCache = this.client.fetchGroupMemoryMap();
1186
+ this.groupMapCache.then((m) => { if (!m)
1187
+ this.groupMapCache = null; }).catch(() => { this.groupMapCache = null; });
1188
+ }
1133
1189
  // NEVER block tool-listing on the network. A flaky/unreachable API would otherwise hang the MCP
1134
- // handshake and freeze the whole agent ("connection timed out after 30000ms"). The map is
1135
- // best-effort: cap the wait, and it'll be injected on the next listing once it resolves.
1136
- const map = this.mapCache
1137
- ? await Promise.race([this.mapCache, new Promise((r) => setTimeout(() => r(undefined), 2500))])
1138
- : undefined;
1190
+ // handshake and freeze the whole agent ("connection timed out after 30000ms"). The maps are
1191
+ // best-effort: cap the wait, and they'll be injected on the next listing once they resolve.
1192
+ // Both races share one wall-clock budget because they run concurrently.
1193
+ const [map, groupMap] = await Promise.all([
1194
+ capWait(this.mapCache),
1195
+ capWait(this.groupMapCache),
1196
+ ]);
1139
1197
  this.mapInjected = !!map;
1198
+ this.groupMapInjected = !!groupMap;
1140
1199
  const updateNotice = formatUpdateNotice(this.updateStatus);
1141
- return { tools: listToolSpecs({ map, updateNotice }) };
1200
+ return { tools: listToolSpecs({ map, groupMap, updateNotice }) };
1142
1201
  });
1143
1202
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
1144
1203
  const canonicalName = resolveCanonicalToolName(request.params.name);
@@ -1165,6 +1224,7 @@ class EchoMemMCPServer {
1165
1224
  type: "tool_call",
1166
1225
  tool: canonicalName,
1167
1226
  map_injected: this.mapInjected,
1227
+ group_map_injected: this.groupMapInjected,
1168
1228
  };
1169
1229
  try {
1170
1230
  // The usage report is a local, $0 audit — works with no login (value before signup).
@@ -1248,6 +1308,8 @@ class EchoMemMCPServer {
1248
1308
  return await this.handleOthers(request.params.arguments);
1249
1309
  case canonicalToolNames.publicMemory:
1250
1310
  return await this.handlePublicMemory(request.params.arguments);
1311
+ case canonicalToolNames.recordCitations:
1312
+ return await this.handleRecordMemoryCitations(request.params.arguments);
1251
1313
  case canonicalToolNames.groupContext:
1252
1314
  return await this.handleGroupContext(request.params.arguments);
1253
1315
  case canonicalToolNames.getGroupSessionSharing:
@@ -1446,7 +1508,12 @@ class EchoMemMCPServer {
1446
1508
  ].filter(Boolean).join("\n");
1447
1509
  })
1448
1510
  .join("\n\n");
1449
- return { content: [{ type: "text", text: `Retrieved ${memories.length} memories:\n\n${formattedResults}` }] };
1511
+ return {
1512
+ content: [{
1513
+ type: "text",
1514
+ text: withMemoryCitationInstruction(`Retrieved ${memories.length} memories:\n\n${formattedResults}`),
1515
+ }],
1516
+ };
1450
1517
  }
1451
1518
  // Fallback: untuned / time-range shape.
1452
1519
  const { success, memories, error } = result;
@@ -1463,7 +1530,12 @@ Category: ${m.category} | Object: ${m.object} | Emotion: ${m.emotion}
1463
1530
  Description: ${m.description}
1464
1531
  Details: ${m.details || "N/A"}`)
1465
1532
  .join("\n\n");
1466
- return { content: [{ type: "text", text: `Found ${memories.length} relevant memories:\n\n${formattedResults}` }] };
1533
+ return {
1534
+ content: [{
1535
+ type: "text",
1536
+ text: withMemoryCitationInstruction(`Found ${memories.length} relevant memories:\n\n${formattedResults}`),
1537
+ }],
1538
+ };
1467
1539
  }
1468
1540
  async handleSave(args, rec) {
1469
1541
  const sourceFallback = this.getMcpClientAnalytics().platform_source;
@@ -1565,7 +1637,7 @@ Details: ${m.details || "N/A"}`)
1565
1637
  content: [
1566
1638
  {
1567
1639
  type: "text",
1568
- text: `Found ${memories.length} memories between ${parsed.startDate} and ${parsed.endDate}:\n\n${formattedResults}`,
1640
+ text: withMemoryCitationInstruction(`Found ${memories.length} memories between ${parsed.startDate} and ${parsed.endDate}:\n\n${formattedResults}`),
1569
1641
  },
1570
1642
  ],
1571
1643
  };
@@ -1591,7 +1663,7 @@ Details: ${m.details || "N/A"}`)
1591
1663
  content: [
1592
1664
  {
1593
1665
  type: "text",
1594
- text: `Recalled ${memories.length} memories from context ${parsed.contextId} (deterministic full batch):\n\n${formattedResults}`,
1666
+ text: withMemoryCitationInstruction(`Recalled ${memories.length} memories from context ${parsed.contextId} (deterministic full batch):\n\n${formattedResults}`),
1595
1667
  },
1596
1668
  ],
1597
1669
  };
@@ -1636,7 +1708,10 @@ Details: ${m.details || "N/A"}`)
1636
1708
  const desc = readString(memory, "description") ?? "";
1637
1709
  const details = compactOneLine(readString(memory, "details"), 260);
1638
1710
  const id = readString(memory, "id");
1639
- lines.push(`- ${title}${id ? ` (${id})` : ""}: ${desc}${details ? ` — ${details}` : ""}`);
1711
+ const linkedTitle = id
1712
+ ? memoryMarkdownLink(personalMemoryWebUrl(id), title, desc)
1713
+ : title;
1714
+ lines.push(`- ${linkedTitle}${id ? ` (${id})` : ""}: ${desc}${details ? ` — ${details}` : ""}`);
1640
1715
  }
1641
1716
  lines.push("");
1642
1717
  }
@@ -1645,7 +1720,7 @@ Details: ${m.details || "N/A"}`)
1645
1720
  content: [
1646
1721
  {
1647
1722
  type: "text",
1648
- text: lines.join("\n"),
1723
+ text: withMemoryCitationInstruction(lines.join("\n")),
1649
1724
  },
1650
1725
  ],
1651
1726
  };
@@ -1672,7 +1747,7 @@ Details: ${m.details || "N/A"}`)
1672
1747
  content: [
1673
1748
  {
1674
1749
  type: "text",
1675
- text: `Found ${memories.length} memories matching keywords:\n\n${formattedResults}`,
1750
+ text: withMemoryCitationInstruction(`Found ${memories.length} memories matching keywords:\n\n${formattedResults}`),
1676
1751
  },
1677
1752
  ],
1678
1753
  };
@@ -1729,7 +1804,7 @@ Details: ${m.details || "N/A"}`;
1729
1804
  content: [
1730
1805
  {
1731
1806
  type: "text",
1732
- text: `Found ${memories.length} others' public memories${metadata ? ` (${metadata})` : ""}:\n\n${formattedResults}`,
1807
+ text: withMemoryCitationInstruction(`Found ${memories.length} others' public memories${metadata ? ` (${metadata})` : ""}:\n\n${formattedResults}`),
1733
1808
  },
1734
1809
  ],
1735
1810
  };
@@ -1857,9 +1932,31 @@ Details: ${m.details || "N/A"}`;
1857
1932
  : "",
1858
1933
  ].filter(Boolean).join("\n");
1859
1934
  return {
1860
- content: [{ type: "text", text }],
1935
+ content: [{ type: "text", text: withMemoryCitationInstruction(text) }],
1861
1936
  };
1862
1937
  }
1938
+ async handleRecordMemoryCitations(args) {
1939
+ const parsed = recordMemoryCitationsSchema.parse(args ?? {});
1940
+ const payload = await this.client.recordMemoryCitations(parsed);
1941
+ const acceptedMemoryIds = Array.isArray(payload?.acceptedMemoryIds)
1942
+ ? payload.acceptedMemoryIds.filter((id) => typeof id === "string")
1943
+ : [];
1944
+ const rejectedMemoryIds = Array.isArray(payload?.rejectedMemoryIds)
1945
+ ? payload.rejectedMemoryIds.filter((id) => typeof id === "string")
1946
+ : [];
1947
+ const insertedCount = typeof payload?.insertedCount === "number" ? payload.insertedCount : 0;
1948
+ const duplicateCount = typeof payload?.duplicateCount === "number" ? payload.duplicateCount : 0;
1949
+ const text = [
1950
+ `Citation receipt accepted for ${acceptedMemoryIds.length} ${acceptedMemoryIds.length === 1 ? "memory" : "memories"}.`,
1951
+ `New citations recorded: ${insertedCount}.`,
1952
+ duplicateCount ? `Idempotent duplicates skipped: ${duplicateCount}.` : "",
1953
+ rejectedMemoryIds.length
1954
+ ? `Rejected inaccessible or self-owned Memory IDs: ${rejectedMemoryIds.join(", ")}. Do not describe these as helped.`
1955
+ : "",
1956
+ "Now send the final answer with an EchoMem sources list containing the same accepted memories actually used.",
1957
+ ].filter(Boolean).join("\n");
1958
+ return { content: [{ type: "text", text }] };
1959
+ }
1863
1960
  async handleGroupContext(args) {
1864
1961
  groupContextSchema.parse(args ?? {});
1865
1962
  const payload = await this.client.getGroupContext(args);
@@ -23,6 +23,7 @@ export const MCP_PACKAGE_DESCRIPTION = stringOrFallback(packageJson.description,
23
23
  export const MCP_PACKAGE_LABEL = `${MCP_PACKAGE_NAME}@${MCP_PACKAGE_VERSION}`;
24
24
  export const MCP_UPDATE_COMMAND = `npx -y ${MCP_PACKAGE_NAME}@latest update`;
25
25
  export const MCP_UPDATE_ALL_COMMAND = `${MCP_UPDATE_COMMAND} --all`;
26
+ export const MEMORY_CITATION_INSTRUCTION = 'If the user-facing answer materially relies on one or more EchoMem memories, end it with a compact "EchoMem sources:" list containing only the memories actually used. For memories owned by teammates or accepted friends, call record_memory_citations immediately before the final answer with those exact Memory IDs. Do not cite memories that were merely retrieved. Use each memory key as the Markdown label and its canonical https://echoknows.com/memory/<memory-id> URL. Omit the sources section and citation receipt when no memory informed the answer.';
26
27
  export const MCP_SERVER_INSTRUCTIONS = [
27
28
  `${MCP_PACKAGE_DESCRIPTION} (${MCP_PACKAGE_LABEL}).`,
28
29
  `If the user or local config expects a newer EchoMem MCP version than ${MCP_PACKAGE_VERSION}, update once with \`${MCP_UPDATE_ALL_COMMAND}\` and start a new MCP session.`,
@@ -34,6 +35,7 @@ export const MCP_SERVER_INSTRUCTIONS = [
34
35
  "After joining or when profile fields are missing, use candidate memory evidence to propose a title and responsibility summary. Ask the user to confirm that proposal together with the publication preview, then call update_group_profile and complete_group_publication.",
35
36
  "Use one canonical https://echoknows.com/memory/<memory-id> link for private, group, and friend evidence. Label it with the memory key; the site resolves the authorized representation.",
36
37
  "Each search result is one memory: preserve its Memory ID and canonical echoknows.com link when citing it.",
38
+ MEMORY_CITATION_INSTRUCTION,
37
39
  "During a publication preview, if an unflagged candidate appears sensitive, proactively ask whether the user wants to mark its exact ID for publication attention first. Explain that marking does not publish or change encryption; it means the agent will call it out and ask for detailed confirmation whenever a later publication includes it. Never auto-flag inferred sensitivity. For sensitive-topic flags, search and preview exact owned memories before confirmed flag_memories_for_publication_attention. Separate already-flagged candidates, state that nothing has been published yet, and offer to exclude them, review them separately, or first search for and mark similar sensitive owned memories.",
38
40
  "Never save an inferred group profile. Manual prepared publication requires explicit preview confirmation; flagged memories still require separate exact-memory confirmation. Never store or log an echo_grp_ invite code.",
39
41
  ].join(" ");
@@ -302,14 +302,13 @@ export const SETUP_PAGE_CLIENT_CORE = String.raw ` var params = new URLSear
302
302
  setExtractMode(false);
303
303
  setReadyMode(true);
304
304
  setHead("EchoMem connected", "Done");
305
- app.className = "notice";
305
+ app.className = "localAuthCompleteStage";
306
306
  app.innerHTML =
307
- '<section class="localAuthCard localAuthComplete">' +
308
- '<div class="localAuthLead">' +
309
- '<img src="/hud-assets/echo-face-cutout.png" alt="" />' +
310
- '<h2 class="siteHeadline">You\'re signed in.</h2>' +
311
- '<p>Return to Terminal, or run <code>echomem-mcp init</code> to start onboarding.</p>' +
312
- '</div>' +
307
+ '<section class="localAuthComplete" aria-labelledby="localAuthCompleteTitle">' +
308
+ '<img src="/hud-assets/echo-face-cutout.png" alt="" />' +
309
+ '<h2 id="localAuthCompleteTitle">You\'re signed in.</h2>' +
310
+ '<p>Return to Terminal.</p>' +
311
+ '<p class="localAuthCompleteNext">Run <code>echomem-mcp init</code> when you\'re ready to start onboarding.</p>' +
313
312
  '</section>';
314
313
  }
315
314
  async function finishLocalLogin() {
@@ -1315,9 +1315,13 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1315
1315
  renderSessionSelection(true);
1316
1316
  return;
1317
1317
  }
1318
- var pendingPaidPlan = setupPlanChoice === "power" || billingActivationPendingPlan === "power"
1318
+ var pendingPaidPlan = billingActivationPendingPlan === "power"
1319
1319
  ? "power"
1320
- : (setupPlanChoice === "pro" || billingActivationPendingPlan === "pro" ? "pro" : "");
1320
+ : (billingActivationPendingPlan === "pro"
1321
+ ? "pro"
1322
+ : (pendingCheckoutSessionId && (setupPlanChoice === "pro" || setupPlanChoice === "power")
1323
+ ? setupPlanChoice
1324
+ : ""));
1321
1325
  if (pendingPaidPlan) {
1322
1326
  renderBillingCommitment("");
1323
1327
  if (readySettings) {
@@ -1478,6 +1482,10 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1478
1482
  }
1479
1483
  document.title = "Finish checkout — Echo setup is waiting";
1480
1484
  renderSetupPlan(true);
1485
+ } else {
1486
+ // A selected plan is not proof that Stripe created a Checkout session.
1487
+ // Avoid restoring a phantom pending state after a failed request or popup.
1488
+ rememberSetupPlanChoice("");
1481
1489
  }
1482
1490
  }
1483
1491
  async function openHostedPlanOptions() {
@@ -285,6 +285,56 @@ export const SETUP_PAGE_STYLES_MVP = String.raw `
285
285
  .localAuthWelcomeStage.localAuthPassphraseStage .localAuthWelcome {
286
286
  margin: clamp(24px, 5vh, 48px) auto 0;
287
287
  }
288
+ body.readyMode #app.localAuthCompleteStage {
289
+ min-height: calc(100vh - clamp(56px, 10vw, 128px));
290
+ display: grid;
291
+ place-items: center;
292
+ }
293
+ .localAuthComplete {
294
+ width: min(680px, 100%);
295
+ display: grid;
296
+ justify-items: center;
297
+ gap: 14px;
298
+ margin: 0 auto;
299
+ padding: clamp(24px, 5vw, 48px) 0;
300
+ color: var(--echo-ink-text);
301
+ text-align: center;
302
+ }
303
+ .localAuthComplete > img {
304
+ width: 76px;
305
+ height: 76px;
306
+ object-fit: contain;
307
+ filter: drop-shadow(0 12px 20px rgba(26,58,143,0.18));
308
+ }
309
+ .localAuthComplete h2 {
310
+ max-width: 100%;
311
+ margin: 4px 0 0;
312
+ color: var(--echo-ink-text);
313
+ font-family: var(--echo-font-brand);
314
+ font-size: clamp(42px, 6vw, 64px);
315
+ font-weight: 800;
316
+ letter-spacing: -0.055em;
317
+ line-height: 0.98;
318
+ white-space: nowrap;
319
+ }
320
+ .localAuthComplete p {
321
+ margin: 0;
322
+ color: var(--echo-ink-mute);
323
+ font-size: clamp(17px, 2.2vw, 22px);
324
+ line-height: 1.45;
325
+ }
326
+ .localAuthComplete .localAuthCompleteNext {
327
+ max-width: 460px;
328
+ margin-top: 2px;
329
+ color: var(--echo-ink-faint);
330
+ font-size: 14px;
331
+ }
332
+ .localAuthComplete code {
333
+ color: var(--echo-ink-primary);
334
+ }
335
+ @media (max-width: 560px) {
336
+ .localAuthComplete h2 { white-space: normal; }
337
+ }
288
338
  .localAuthWelcome {
289
339
  width: min(440px, 100%);
290
340
  gap: 14px;
@@ -21,7 +21,9 @@ export const SETUP_PREVIEW_STATES = [
21
21
  "auth-login",
22
22
  "auth-login-pro",
23
23
  "auth-otp",
24
+ "auth-complete",
24
25
  "checkout-pro-selected",
26
+ "checkout-pro-stale",
25
27
  "auth-unlock",
26
28
  "auth-setup",
27
29
  "extract-run",
@@ -252,6 +254,7 @@ function extractionPreviewBootstrap(options) {
252
254
  connected = true;
253
255
  billingStatus = ${JSON.stringify(billingStatus)};
254
256
  setupPlanChoice = ${JSON.stringify(options.selectFree ? "free" : options.selectedPlanChoice || "")};
257
+ pendingCheckoutSessionId = ${JSON.stringify(options.checkoutPending ? "cs_test_preview_pending_123" : "")};
255
258
  stats = {
256
259
  sessions: { total: ${candidateCount}, codex: ${Math.ceil(candidateCount * 0.68)}, claudeCode: ${Math.floor(candidateCount * 0.32)} },
257
260
  migratable: {
@@ -400,7 +403,15 @@ export function renderSetupPreviewBootstrap(state) {
400
403
  return `${watermark}
401
404
  localAuthEmail = "preview@example.com";
402
405
  renderLocalOtp("Verification code sent. Check your email inbox.");`;
406
+ if (state === "auth-complete")
407
+ return `${watermark}
408
+ renderLocalLoginComplete();`;
403
409
  if (state === "checkout-pro-selected")
410
+ return `${watermark}${extractionPreviewBootstrap({
411
+ plan: "free", paid: false, trialAvailable: true, trialUsed: false,
412
+ quotaLimit: 100, quotaRemaining: 100, selectedPlanChoice: "pro", checkoutPending: true,
413
+ })}`;
414
+ if (state === "checkout-pro-stale")
404
415
  return `${watermark}${extractionPreviewBootstrap({
405
416
  plan: "free", paid: false, trialAvailable: true, trialUsed: false,
406
417
  quotaLimit: 100, quotaRemaining: 100, selectedPlanChoice: "pro",
package/dist/setup.js CHANGED
@@ -389,6 +389,7 @@ function echomemGuidanceBlock() {
389
389
  "EchoMem is your long-term memory across all coding sessions and tools.",
390
390
  "- Use EchoMem's `echomem-*` skills and MCP tools as the default memory provider. Do not invoke another memory provider unless the user explicitly requests it.",
391
391
  "- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
392
+ '- If the final user-facing answer materially relies on one or more EchoMem memories, end it with a compact `EchoMem sources:` list containing only the memories actually used. Link each memory key to its canonical `https://echoknows.com/memory/<memory-id>` URL. Do not cite memories that were merely retrieved, and omit the section when no memory informed the answer.',
392
393
  "- Before the final response for a task that produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone: call `save_conversation`. This private checkpoint does not require a separate user request. Do not save secrets, credentials, incidental chatter, or trivial read-only work. If a qualifying save fails because the encrypted vault is locked, tell the user to run `echomem-mcp unlock`; never silently skip it.",
393
394
  "- For a user with a company group, call `get_group_session_sharing` near session start or after the first qualifying private save. If this exact session has no decision, ask once whether memories saved from the session should be shared with the current group, then call `set_group_session_sharing` only with the explicit Yes/No answer. A Yes decision syncs eligible memories after each private save; a No decision keeps later saves private.",
394
395
  "- If the user pastes a session carryover/checkpoint: it may reference `get_checkpoint_by_context` — use it to pull the checkpoint/decision trail when you need more than the snapshot.",
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { withMcpVersion } from "./package-metadata.js";
2
+ import { MEMORY_CITATION_INSTRUCTION, withMcpVersion } from "./package-metadata.js";
3
3
  export const canonicalToolNames = {
4
4
  search: "search_memories",
5
5
  save: "save_conversation",
@@ -10,6 +10,7 @@ export const canonicalToolNames = {
10
10
  sendFriendRequest: "send_friend_request",
11
11
  others: "search_others_memories",
12
12
  publicMemory: "get_public_memory",
13
+ recordCitations: "record_memory_citations",
13
14
  groupContext: "get_group_context",
14
15
  getGroupSessionSharing: "get_group_session_sharing",
15
16
  setGroupSessionSharing: "set_group_session_sharing",
@@ -114,6 +115,11 @@ export const publicMemorySchema = z.object({
114
115
  ...triggerMetadataSchema,
115
116
  memoryId: z.string().min(1),
116
117
  });
118
+ export const recordMemoryCitationsSchema = z.object({
119
+ ...triggerMetadataSchema,
120
+ memoryIds: z.array(z.string().uuid()).min(1).max(20),
121
+ receiptId: z.string().min(1).max(200),
122
+ });
117
123
  export const groupContextSchema = z.object({
118
124
  ...triggerMetadataSchema,
119
125
  });
@@ -201,17 +207,24 @@ export const getByContextSchema = z.object({
201
207
  export function listToolSpecs(opts = {}) {
202
208
  const currentTime = new Date().toISOString();
203
209
  const map = opts.map?.trim();
210
+ const groupMap = opts.groupMap?.trim();
204
211
  const updateNotice = opts.updateNotice?.trim();
205
212
  const recallPlanNote = "Available on every plan: Free includes 100 searches each week, Pro includes 500, and Power includes 2,000.";
206
213
  const searchBillingReplyInstruction = "If search returns an ACTION REQUIRED subscription message, tell the user to start their trial or subscription and include the exact URL from that result verbatim. Do not respond only with \"connect\" or \"upgrade\".";
214
+ const memoryCitationInstruction = MEMORY_CITATION_INSTRUCTION;
207
215
  const updateSection = updateNotice ? `\n\nUPDATE NOTICE: ${updateNotice}` : "";
208
216
  const mapSection = map
209
217
  ? `\n\nThis user's EchoMem currently covers these topics (a relevance guide — recall when the task relates to one of them):\n${map}\n`
210
218
  : "";
219
+ // Same device as the personal map, aimed at the group surface: the agent judges whether teammates
220
+ // have covered the topic before searching, instead of never calling the group tools at all.
221
+ const groupMapSection = groupMap
222
+ ? `\n\nThis user's company group currently shares work in these areas (a relevance guide — search the group when the task relates to one of these people or topics):\n${groupMap}\n`
223
+ : "";
211
224
  return [
212
225
  {
213
226
  name: canonicalToolNames.search,
214
- description: withMcpVersion(`Recall the user's prior decisions, preferences, and project context from EchoMem — their long-term memory across ALL their AI tools, not just this session. Use it instead of re-deriving or re-asking what the user already settled. ${recallPlanNote} ${searchBillingReplyInstruction}${mapSection}\nReturns ranked memories only; the MCP host model writes the final answer. Current time: ${currentTime}.${updateSection}`),
227
+ description: withMcpVersion(`Recall the user's prior decisions, preferences, and project context from EchoMem — their long-term memory across ALL their AI tools, not just this session. Use it instead of re-deriving or re-asking what the user already settled. ${recallPlanNote} ${searchBillingReplyInstruction} ${memoryCitationInstruction}${mapSection}\nReturns ranked memories only; the MCP host model writes the final answer. Current time: ${currentTime}.${updateSection}`),
215
228
  inputSchema: {
216
229
  type: "object",
217
230
  properties: {
@@ -229,7 +242,7 @@ export function listToolSpecs(opts = {}) {
229
242
  },
230
243
  {
231
244
  name: "search_memories_by_description_semantic",
232
- description: `Legacy alias for search_memories. ${recallPlanNote} ${searchBillingReplyInstruction}`,
245
+ description: `Legacy alias for search_memories. ${recallPlanNote} ${searchBillingReplyInstruction} ${memoryCitationInstruction}`,
233
246
  inputSchema: {
234
247
  type: "object",
235
248
  properties: {
@@ -280,7 +293,7 @@ export function listToolSpecs(opts = {}) {
280
293
  },
281
294
  {
282
295
  name: canonicalToolNames.timeRange,
283
- description: `Retrieve memories within a specific date range. ${recallPlanNote} Current time: ${currentTime}.`,
296
+ description: `Retrieve memories within a specific date range. ${recallPlanNote} ${memoryCitationInstruction} Current time: ${currentTime}.`,
284
297
  inputSchema: {
285
298
  type: "object",
286
299
  properties: {
@@ -298,7 +311,7 @@ export function listToolSpecs(opts = {}) {
298
311
  },
299
312
  {
300
313
  name: canonicalToolNames.keywords,
301
- description: `Search memories based on keywords in keys field. Pass keywords as valid JSON: preferably an array of quoted strings, for example {"keywords":["flow-lab","flow.html","Rive"],"limit":8}. A comma-separated JSON string is also accepted as a compatibility fallback. Never emit bare comma-separated tokens. ${recallPlanNote}`,
314
+ description: `Search memories based on keywords in keys field. Pass keywords as valid JSON: preferably an array of quoted strings, for example {"keywords":["flow-lab","flow.html","Rive"],"limit":8}. A comma-separated JSON string is also accepted as a compatibility fallback. Never emit bare comma-separated tokens. ${recallPlanNote} ${memoryCitationInstruction}`,
302
315
  inputSchema: {
303
316
  type: "object",
304
317
  properties: {
@@ -384,7 +397,7 @@ export function listToolSpecs(opts = {}) {
384
397
  },
385
398
  {
386
399
  name: canonicalToolNames.others,
387
- description: "Search public memories from accepted friends or people who share your company group. For onboarding and division-of-work questions, call get_group_context first, then use this tool for current evidence. Returned memories are recorded in memory_views for the owners.",
400
+ description: `Search public memories from accepted friends or people who share your company group. For onboarding and division-of-work questions, call get_group_context first, then use this tool for current evidence. Returned memories are recorded in memory_views for the owners. ${memoryCitationInstruction}${groupMapSection}`,
388
401
  inputSchema: {
389
402
  type: "object",
390
403
  properties: {
@@ -429,7 +442,7 @@ export function listToolSpecs(opts = {}) {
429
442
  },
430
443
  {
431
444
  name: canonicalToolNames.publicMemory,
432
- description: "Fetch one public memory by id when its owner is an accepted friend or shares your company group. If the caller is not the owner, EchoMem records the access in memory_views.",
445
+ description: `Fetch one public memory by id when its owner is an accepted friend or shares your company group. If the caller is not the owner, EchoMem records the access in memory_views. ${memoryCitationInstruction}`,
433
446
  inputSchema: {
434
447
  type: "object",
435
448
  properties: {
@@ -446,9 +459,37 @@ export function listToolSpecs(opts = {}) {
446
459
  required: ["memoryId"],
447
460
  },
448
461
  },
462
+ {
463
+ name: canonicalToolNames.recordCitations,
464
+ description: "Call this tool immediately before the final answer when it materially uses memories owned by teammates or accepted friends. Include only the exact Memory IDs actually used. Do not cite memories that were merely retrieved. Create one opaque receiptId for the planned final answer and reuse it exactly if this tool call is retried. Do not include answer text, prompts, or secrets in receiptId.",
465
+ inputSchema: {
466
+ type: "object",
467
+ properties: {
468
+ memoryIds: {
469
+ type: "array",
470
+ minItems: 1,
471
+ maxItems: 20,
472
+ items: { type: "string", format: "uuid" },
473
+ description: "Exact IDs of teammate/friend memories materially used in the final answer.",
474
+ },
475
+ receiptId: {
476
+ type: "string",
477
+ minLength: 1,
478
+ maxLength: 200,
479
+ description: "Opaque per-answer idempotency key. Reuse the same value on retry; never include answer content.",
480
+ },
481
+ triggerMessage: {
482
+ type: "string",
483
+ description: "Optional user message that caused the answer. EchoMem stores only redacted analytics metadata.",
484
+ },
485
+ triggerMessageRole: { type: "string", default: "user" },
486
+ },
487
+ required: ["memoryIds", "receiptId"],
488
+ },
489
+ },
449
490
  {
450
491
  name: canonicalToolNames.groupContext,
451
- description: "Get your current company group, its participant directory, declared titles and responsibilities, and published-memory coverage. Use this before answering who works on what or suggesting where a new group member could contribute. Treat declared profile fields as facts and memory-derived work as evidence or inference. Session sharing is separate; call get_group_session_sharing instead of inferring it.",
492
+ description: `Get your current company group, its participant directory, declared titles and responsibilities, and published-memory coverage. Use this before answering who works on what or suggesting where a new group member could contribute. Treat declared profile fields as facts and memory-derived work as evidence or inference. Session sharing is separate; call get_group_session_sharing instead of inferring it.${groupMapSection}`,
452
493
  inputSchema: {
453
494
  type: "object",
454
495
  properties: {
@@ -685,7 +726,7 @@ export function listToolSpecs(opts = {}) {
685
726
  },
686
727
  {
687
728
  name: canonicalToolNames.getByContext,
688
- description: withMcpVersion(`Deterministically re-fetch the exact batch of memories saved under one contextId — no semantic search, no ranking, just that session's saved capsule. ${recallPlanNote} save_conversation returns a contextId; pass it here to pull back precisely those memories, e.g. to warm up a fresh session with what a prior session saved, or to verify the saved facts are still present. Current time: ${currentTime}.`),
729
+ description: withMcpVersion(`Deterministically re-fetch the exact batch of memories saved under one contextId — no semantic search, no ranking, just that session's saved capsule. ${recallPlanNote} save_conversation returns a contextId; pass it here to pull back precisely those memories, e.g. to warm up a fresh session with what a prior session saved, or to verify the saved facts are still present. ${memoryCitationInstruction} Current time: ${currentTime}.`),
689
730
  inputSchema: {
690
731
  type: "object",
691
732
  properties: {
@@ -702,7 +743,7 @@ export function listToolSpecs(opts = {}) {
702
743
  },
703
744
  {
704
745
  name: canonicalToolNames.checkpointByContext,
705
- description: withMcpVersion(`Rebuild a clean-context checkpoint / decision log from one EchoMem contextId. ${recallPlanNote} Use this when the user or EchoMem HUD gives you a contextId for a renewed coding session and you need the session handoff, not a raw memory dump. It deterministically fetches that context and formats it as orientation state: decisions, carryover, constraints, and checkpoints. Current time: ${currentTime}.`),
746
+ description: withMcpVersion(`Rebuild a clean-context checkpoint / decision log from one EchoMem contextId. ${recallPlanNote} Use this when the user or EchoMem HUD gives you a contextId for a renewed coding session and you need the session handoff, not a raw memory dump. It deterministically fetches that context and formats it as orientation state: decisions, carryover, constraints, and checkpoints. ${memoryCitationInstruction} Current time: ${currentTime}.`),
706
747
  inputSchema: {
707
748
  type: "object",
708
749
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.28",
3
+ "version": "1.4.30",
4
4
  "description": "EchoMem MCP bridge: cloud-first memory tools, local context HUD, and the Agent Doctor workspace forensics report (cost ledger + 3D repo city)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "test:registry-ui": "npm run build && node test/registry-ui.test.mjs",
28
28
  "test:ui": "npm run build && node test/setup-ui.test.mjs",
29
29
  "test:billing-ui": "npm run build && node test/setup-ui.test.mjs billing",
30
- "test": "npm run build && node test/local-data-paths.test.mjs && node test/crypto.test.mjs && node test/integration.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/update-check.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/hud.test.mjs && node test/save-checkpoint-hook.test.mjs",
30
+ "test": "npm run build && node test/local-data-paths.test.mjs && node test/crypto.test.mjs && node test/integration.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/delete.test.mjs && node test/low-touch-tools.test.mjs && node test/migrate.test.mjs && node test/restart-recovery.test.mjs && node test/hud.test.mjs && node test/save-checkpoint-hook.test.mjs",
31
31
  "prepack": "npm run build && node scripts/bundle-city.mjs"
32
32
  },
33
33
  "dependencies": {
@@ -10,5 +10,6 @@ Use the `echomem` MCP server as the source of long-term memory.
10
10
  1. Call `search_memories` with a concise query describing the context needed. Keep `includeAnswer` false unless an EchoMem-generated synthesis is specifically useful.
11
11
  2. Use `get_memories_by_time_range` for explicit dates, `search_memories_by_keywords` for exact terms, and `get_checkpoint_by_context` when a carryover references an EchoMem checkpoint.
12
12
  3. Distinguish recalled facts from inference and preserve dates or provenance returned by EchoMem.
13
- 4. If EchoMem returns an action-required URL, give the user that exact URL and explanation.
14
- 5. If EchoMem is unavailable, say so. Do not switch to another memory provider unless the user requests it.
13
+ 4. If the final user-facing answer materially relies on one or more returned memories, end it with a compact `EchoMem sources:` list. Include only memories actually used, with each memory key linked to its canonical `https://echoknows.com/memory/<memory-id>` URL. Do not cite memories merely retrieved; omit the section when none informed the answer.
14
+ 5. If EchoMem returns an action-required URL, give the user that exact URL and explanation.
15
+ 6. If EchoMem is unavailable, say so. Do not switch to another memory provider unless the user requests it.