@echomem/mcp 1.4.25 → 1.4.27

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.
@@ -378,6 +378,38 @@ function slimScored(row) {
378
378
  repo: row.repo,
379
379
  };
380
380
  }
381
+ /**
382
+ * The vendored scorer's waste ledger is the canonical classified quantity. Useful context is its
383
+ * complement inside the provider's official input window. Deriving the complement here prevents a
384
+ * long floating-point accumulation from making useful + waste exceed the official window.
385
+ */
386
+ export function reconcileCanonicalTokenPartition(officialInputTokens, wasteTokens) {
387
+ const input = Math.max(0, Math.round(Number(officialInputTokens) || 0));
388
+ const waste = Math.max(0, Math.min(input, Math.round(Number(wasteTokens) || 0)));
389
+ return {
390
+ officialInputTokens: input,
391
+ usefulTokens: input - waste,
392
+ wasteTokens: waste,
393
+ };
394
+ }
395
+ function reconcileBucketParts(values, target) {
396
+ const safe = values.map((value) => Math.max(0, Number(value) || 0));
397
+ const total = safe.reduce((sum, value) => sum + value, 0);
398
+ const exactTarget = Math.max(0, Math.round(target));
399
+ if (Math.abs(total - exactTarget) < 1e-9 && safe.every(Number.isInteger))
400
+ return safe;
401
+ if (total <= 0)
402
+ return safe.map((_, index) => index === 0 ? exactTarget : 0);
403
+ const scaled = safe.map((value) => (value / total) * exactTarget);
404
+ const result = scaled.map(Math.floor);
405
+ let remainder = exactTarget - result.reduce((sum, value) => sum + value, 0);
406
+ const byFraction = scaled
407
+ .map((value, index) => ({ index, fraction: value - Math.floor(value) }))
408
+ .sort((a, b) => b.fraction - a.fraction || a.index - b.index);
409
+ for (let index = 0; index < remainder; index++)
410
+ result[byFraction[index % byFraction.length].index] += 1;
411
+ return result;
412
+ }
381
413
  function aggregate(scored, metadata) {
382
414
  const totals = scored.reduce((sum, row) => {
383
415
  const value = row.dashboard.totals;
@@ -402,6 +434,17 @@ function aggregate(scored, metadata) {
402
434
  rawBuckets.opt_dead += turn.opt_dead || 0;
403
435
  }
404
436
  }
437
+ const partition = reconcileCanonicalTokenPartition(totals.input, totals.waste);
438
+ const [keepOh, keepProd] = reconcileBucketParts([rawBuckets.keep_oh, rawBuckets.keep_prod], partition.usefulTokens);
439
+ const [optDup, optRefind, optDead] = reconcileBucketParts([rawBuckets.opt_dup, rawBuckets.opt_refind, rawBuckets.opt_dead], partition.wasteTokens);
440
+ const reconciledRawBuckets = {
441
+ keep_oh: keepOh,
442
+ keep_prod: keepProd,
443
+ opt_dup: optDup,
444
+ opt_refind: optRefind,
445
+ opt_dead: optDead,
446
+ };
447
+ const [duplicate, refind, dead, unattributed] = reconcileBucketParts([totals.duplicate, totals.refind, totals.dead, totals.unattributed], partition.wasteTokens);
405
448
  const problemRows = new Map();
406
449
  for (const id of PROBLEM_IDS)
407
450
  problemRows.set(id, { tokens: 0, count: 0, pressure: 0, sessions: 0, examples: [] });
@@ -517,19 +560,19 @@ function aggregate(scored, metadata) {
517
560
  sessionsAnalyzed: scored.length,
518
561
  reposAnalyzed: repoRows.size,
519
562
  turnsAnalyzed: totals.turns,
520
- officialInputTokens: totals.input,
521
- usefulTokens: totals.useful,
522
- wasteTokens: totals.waste,
523
- usefulPct: percentage(totals.useful, totals.input),
524
- wastePct: percentage(totals.waste, totals.input),
525
- attributedWasteTokens: Math.round(Math.max(0, totals.waste - totals.unattributed)),
526
- unattributedWasteTokens: totals.unattributed,
527
- rawUsefulTokens: rawBuckets.keep_oh + rawBuckets.keep_prod,
528
- rawOutcomeResidueTokens: rawBuckets.opt_dup + rawBuckets.opt_refind + rawBuckets.opt_dead,
563
+ officialInputTokens: partition.officialInputTokens,
564
+ usefulTokens: partition.usefulTokens,
565
+ wasteTokens: partition.wasteTokens,
566
+ usefulPct: percentage(partition.usefulTokens, partition.officialInputTokens),
567
+ wastePct: percentage(partition.wasteTokens, partition.officialInputTokens),
568
+ attributedWasteTokens: partition.wasteTokens - unattributed,
569
+ unattributedWasteTokens: unattributed,
570
+ rawUsefulTokens: keepOh + keepProd,
571
+ rawOutcomeResidueTokens: optDup + optRefind + optDead,
529
572
  excludedUnlabeledTokens: totals.excluded,
530
573
  },
531
- buckets: { duplicate: totals.duplicate, refind: totals.refind, dead: totals.dead, unattributed: totals.unattributed },
532
- rawBuckets,
574
+ buckets: { duplicate, refind, dead, unattributed },
575
+ rawBuckets: reconciledRawBuckets,
533
576
  problems,
534
577
  sessions,
535
578
  repos: [...repoRows.entries()].map(([repo, values]) => ({ repo, ...values })).sort((a, b) => b.officialInputTokens - a.officialInputTokens),
package/dist/forensics.js CHANGED
@@ -1373,8 +1373,25 @@ export async function buildForensicReport(opts) {
1373
1373
  : [];
1374
1374
  const total = codexFiles.length + claudeFiles.length;
1375
1375
  let done = 0;
1376
- const progress = (stage, detail, progressDone = done, progressTotal = total) => {
1377
- opts?.onProgress?.(progressDone, progressTotal, stage, detail);
1376
+ // Every stage reports the same file counters as scanned/total, so the visible "N of M sessions
1377
+ // scanned" only ever climbs. A stage's own counters (the canonical pass counts a different set,
1378
+ // on a different scale) drive nothing but its slice of the overall bar — feeding them straight
1379
+ // into scanned/total is what made the bar and the caption fall back partway through the scan.
1380
+ const STAGE_BANDS = {
1381
+ "reading-transcripts": [0, 0.7],
1382
+ "building-summary": [0.7, 0.75],
1383
+ "classifying-repeated-context": [0.75, 0.95],
1384
+ "finalizing-report": [0.95, 1],
1385
+ };
1386
+ const overallFor = (stage, stageDone, stageTotal) => {
1387
+ const band = STAGE_BANDS[stage];
1388
+ if (!band)
1389
+ return 0;
1390
+ const ratio = stageTotal > 0 ? Math.min(1, Math.max(0, stageDone / stageTotal)) : 0;
1391
+ return band[0] + (band[1] - band[0]) * ratio;
1392
+ };
1393
+ const progress = (stage, detail, stageDone = done, stageTotal = total) => {
1394
+ opts?.onProgress?.(done, total, stage, detail, overallFor(stage, stageDone, stageTotal));
1378
1395
  };
1379
1396
  progress("reading-transcripts", "finding local Codex and Claude transcript files");
1380
1397
  const tick = (stage = "reading-transcripts") => {
@@ -1417,7 +1434,10 @@ export async function buildForensicReport(opts) {
1417
1434
  timelineFiles.sort((a, b) => (a.fe.firstTs ?? Number.POSITIVE_INFINITY) - (b.fe.firstTs ?? Number.POSITIVE_INFINITY) ||
1418
1435
  a.path.localeCompare(b.path));
1419
1436
  replayTimelineFiles(eng, timelineFiles);
1420
- progress("building-summary", "aggregating rereads, model usage, cost, and local context signals");
1437
+ // Entering a stage means it has done none of its own work yet, so it opens its band rather than
1438
+ // inheriting the finished file counters — otherwise a stage announces itself at its band ceiling
1439
+ // and its real sub-progress then drags the bar back down.
1440
+ progress("building-summary", "aggregating rereads, model usage, cost, and local context signals", 0, 1);
1421
1441
  const report = eng.build();
1422
1442
  const firstTimelineSession = timelineFiles.find(({ fe }) => fe.firstTs != null);
1423
1443
  report.firstSession = firstTimelineSession ? {
@@ -1461,7 +1481,7 @@ export async function buildForensicReport(opts) {
1461
1481
  canonicalSources.push("codex");
1462
1482
  if (sources.includes("claude"))
1463
1483
  canonicalSources.push("claude-code");
1464
- progress("classifying-repeated-context", "reconstructing context windows and attributing P01/P03/P08/P10/P13 waste");
1484
+ progress("classifying-repeated-context", "reconstructing context windows and attributing P01/P03/P08/P10/P13 waste", 0, 1);
1465
1485
  report.canonicalGoldenStandard = await buildCanonicalGoldenReport({
1466
1486
  sources: canonicalSources,
1467
1487
  codexSessionPaths: canonicalCodexFiles,
package/dist/hud/cli.js CHANGED
@@ -5,11 +5,12 @@ import { fileURLToPath } from "node:url";
5
5
  import { adapterList } from "./adapters.js";
6
6
  import { autostartPlistPath, autostartSupported, disableAutostart, enableAutostart, isAutostartEnabled } from "./autostart.js";
7
7
  import { statSignature } from "./fs.js";
8
- import { installHooks } from "./hooks.js";
8
+ import { installHooks, installSaveCheckpointHooks } from "./hooks.js";
9
9
  import { HudMonitor } from "./monitor.js";
10
10
  import { renderStateText } from "./render.js";
11
11
  import { renderReportText, runReport } from "./report.js";
12
12
  import { createHudServer } from "./server.js";
13
+ import { runSaveCheckpointHook } from "../save-checkpoint-hook.js";
13
14
  const argv = process.argv.slice(2);
14
15
  const command = argv[0] || "summary";
15
16
  const flags = parseFlags(argv.slice(1));
@@ -24,6 +25,10 @@ try {
24
25
  await cmdApp(flags);
25
26
  else if (command === "install-hooks")
26
27
  await cmdInstallHooks(flags);
28
+ else if (command === "install-save-hooks")
29
+ await cmdInstallSaveHooks(flags);
30
+ else if (command === "save-checkpoint")
31
+ await cmdSaveCheckpoint();
27
32
  else if (command === "autostart")
28
33
  cmdAutostart(argv[1] || "status", flags);
29
34
  else if (command === "status")
@@ -114,6 +119,17 @@ async function cmdInstallHooks(flags) {
114
119
  console.log(`Installed EchoMem HUD hook support:\n${paths.map((p) => `- ${p}`).join("\n")}`);
115
120
  console.log("Codex users: run /hooks in a new Codex session to review and trust changed hooks.");
116
121
  }
122
+ async function cmdInstallSaveHooks(flags) {
123
+ const paths = installSaveCheckpointHooks(parseMode(flags.client));
124
+ console.log(`Installed EchoMem private-save checkpoint hooks:\n${paths.map((p) => `- ${p}`).join("\n")}`);
125
+ console.log("Codex users: run /hooks in a new Codex session to review and trust changed hooks.");
126
+ }
127
+ async function cmdSaveCheckpoint() {
128
+ let input = "";
129
+ for await (const chunk of process.stdin)
130
+ input += String(chunk);
131
+ process.stdout.write(runSaveCheckpointHook(input));
132
+ }
117
133
  async function cmdReport(flags) {
118
134
  const result = runReport(parseMode(flags.client), { limit: readNumber(flags.limit, 40) });
119
135
  if (flags.json)
@@ -166,6 +182,7 @@ Usage:
166
182
  echomem-hud serve [--client codex|claude-code|claude-desktop|both|auto] [--port 17377]
167
183
  echomem-hud app [--client codex|claude-code|claude-desktop|both|auto]
168
184
  echomem-hud install-hooks [--client codex|claude-code|both]
185
+ echomem-hud install-save-hooks [--client codex|claude-code|both]
169
186
  echomem-hud autostart on|off|status (show the HUD after restart — macOS)
170
187
  echomem-hud status
171
188
  echomem-hud report [--client codex|claude-code|claude-desktop|auto] [--limit 40] [--json]
package/dist/hud/hooks.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  export function installHooks(mode) {
5
6
  const written = [];
6
7
  if (mode === "codex" || mode === "both" || mode === "auto") {
@@ -11,6 +12,16 @@ export function installHooks(mode) {
11
12
  }
12
13
  return written;
13
14
  }
15
+ export function installSaveCheckpointHooks(mode) {
16
+ const written = [];
17
+ if (mode === "codex" || mode === "both" || mode === "auto") {
18
+ written.push(installCodexSaveCheckpointHook());
19
+ }
20
+ if (mode === "claude-code" || mode === "both" || mode === "auto") {
21
+ written.push(installClaudeCodeSaveCheckpointHook());
22
+ }
23
+ return written;
24
+ }
14
25
  function installCodexHooks() {
15
26
  const dir = path.join(os.homedir(), ".codex");
16
27
  const file = path.join(dir, "hooks.json");
@@ -24,6 +35,44 @@ function installCodexHooks() {
24
35
  fs.writeFileSync(file, JSON.stringify(content, null, 2));
25
36
  return file;
26
37
  }
38
+ function saveCheckpointCommand() {
39
+ const lifecycleCli = fileURLToPath(new URL("./cli.js", import.meta.url));
40
+ return `${JSON.stringify(process.execPath)} ${JSON.stringify(lifecycleCli)} save-checkpoint`;
41
+ }
42
+ function installCodexSaveCheckpointHook() {
43
+ const dir = path.join(os.homedir(), ".codex");
44
+ const file = path.join(dir, "hooks.json");
45
+ fs.mkdirSync(dir, { recursive: true });
46
+ const content = readHooksFile(file);
47
+ content.hooks = content.hooks || {};
48
+ content.hooks.Stop = mergeSaveCheckpointGroup(content.hooks.Stop, {
49
+ hooks: [{
50
+ type: "command",
51
+ command: saveCheckpointCommand(),
52
+ timeout: 10,
53
+ statusMessage: "Checking whether completed work should be remembered",
54
+ }],
55
+ });
56
+ fs.writeFileSync(file, JSON.stringify(content, null, 2));
57
+ return file;
58
+ }
59
+ function installClaudeCodeSaveCheckpointHook() {
60
+ const dir = path.join(os.homedir(), ".claude");
61
+ const file = path.join(dir, "settings.json");
62
+ fs.mkdirSync(dir, { recursive: true });
63
+ const content = readHooksFile(file);
64
+ content.hooks = content.hooks || {};
65
+ content.hooks.Stop = mergeSaveCheckpointGroup(content.hooks.Stop, {
66
+ hooks: [{
67
+ type: "command",
68
+ command: saveCheckpointCommand(),
69
+ timeout: 10,
70
+ statusMessage: "Checking whether completed work should be remembered",
71
+ }],
72
+ });
73
+ fs.writeFileSync(file, JSON.stringify(content, null, 2));
74
+ return file;
75
+ }
27
76
  function installClaudeCodeSnippet() {
28
77
  const dir = path.join(os.homedir(), ".claude", "echo-ctx");
29
78
  fs.mkdirSync(dir, { recursive: true });
@@ -43,8 +92,18 @@ function mergeHookGroup(existing, group) {
43
92
  groups.push(group);
44
93
  return groups;
45
94
  }
95
+ function mergeSaveCheckpointGroup(existing, group) {
96
+ const groups = Array.isArray(existing) ? existing.filter((item) => !isEchoSaveCheckpointGroup(item)) : [];
97
+ groups.push(group);
98
+ return groups;
99
+ }
46
100
  function isEchoHudGroup(value) {
47
101
  if (typeof value !== "object" || value === null)
48
102
  return false;
49
103
  return JSON.stringify(value).includes("summary --client codex --json");
50
104
  }
105
+ function isEchoSaveCheckpointGroup(value) {
106
+ if (typeof value !== "object" || value === null)
107
+ return false;
108
+ return JSON.stringify(value).includes("save-checkpoint");
109
+ }
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, getByContextSchema, groupContextSchema, joinGroupSchema, keywordsSchema, listFriendsSchema, listToolSpecs, othersSchema, publishBatchToGroupSchema, publishToGroupSchema, prepareGroupPublicationSchema, publicMemorySchema, resolveCanonicalToolName, saveConversationSchema, searchMemoriesSchema, searchUsersSchema, sendFriendRequestSchema, timeRangeSchema, updateGroupProfileSchema, } from "./v1-contract.js";
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";
8
8
  import { KeyStore } from "./keystore.js";
9
9
  import { EventLogger, hashText } from "./events.js";
10
10
  import { buildReportText } from "./report.js";
@@ -17,9 +17,20 @@ 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";
19
19
  const ECHO_PRICING_URL = process.env.ECHO_PRICING_URL || "https://echoknows.com/account";
20
- const ECHO_MEMORY_WEB_URL = (process.env.ECHO_MEMORY_WEB_URL || "https://echoknows.com/company/memories").replace(/\/$/, "");
20
+ const ECHO_MEMORY_WEB_URL = (process.env.ECHO_MEMORY_WEB_URL || "https://echoknows.com/memory").replace(/\/$/, "");
21
21
  function memoryWebUrl(memoryId) {
22
- return `${ECHO_MEMORY_WEB_URL}?memoryId=${encodeURIComponent(memoryId)}`;
22
+ return `${ECHO_MEMORY_WEB_URL}/${encodeURIComponent(memoryId)}`;
23
+ }
24
+ function personalMemoryWebUrl(memoryId) {
25
+ return memoryWebUrl(memoryId);
26
+ }
27
+ function memoryMarkdownLink(url, keys, description) {
28
+ const rawLabel = String(keys || description || "Open memory").replace(/\s+/g, " ").trim();
29
+ const label = (rawLabel.length > 100 ? `${rawLabel.slice(0, 97).trim()}…` : rawLabel)
30
+ .replace(/\\/g, "\\\\")
31
+ .replace(/\[/g, "\\[")
32
+ .replace(/\]/g, "\\]");
33
+ return `[${label}](${url})`;
23
34
  }
24
35
  /** Thrown when no API token is present yet — the model gets a "run login" nudge, not a hard error. */
25
36
  class NoTokenError extends Error {
@@ -507,6 +518,11 @@ function inputAnalyticsForTool(canonicalName, args) {
507
518
  confirmed: a.confirmed === true,
508
519
  };
509
520
  }
521
+ case canonicalToolNames.setGroupSessionSharing:
522
+ return {
523
+ share_to_group: typeof a.share === "boolean" ? a.share : undefined,
524
+ confirmed: a.confirmed === true,
525
+ };
510
526
  case canonicalToolNames.publishBatchToGroup: {
511
527
  const memoryIds = Array.isArray(a.memoryIds) ? a.memoryIds.filter((id) => typeof id === "string") : [];
512
528
  return {
@@ -974,6 +990,17 @@ class EchoMemApiClient {
974
990
  throw new Error(`get_group_context failed against ${ECHO_API_BASE_URL}: ${describeError(error)}`);
975
991
  }
976
992
  }
993
+ async getGroupSessionSharing(args) {
994
+ getGroupSessionSharingSchema.parse(args ?? {});
995
+ const response = await this.axios.get(`/api/extension/social/groups/current/session-sharing?sessionKey=${encodeURIComponent(this.sessionId)}`);
996
+ return response.data;
997
+ }
998
+ async setGroupSessionSharing(args) {
999
+ const parsed = setGroupSessionSharingSchema.parse(args ?? {});
1000
+ const enc = await this.encState();
1001
+ const response = await this.axios.patch("/api/extension/social/groups/current/session-sharing", { ...parsed, sessionKey: this.sessionId }, { headers: enc.enabled && enc.key ? { "X-Encryption-Key": enc.key } : undefined });
1002
+ return response.data;
1003
+ }
977
1004
  async createGroup(args) {
978
1005
  const parsed = createGroupSchema.parse(args ?? {});
979
1006
  const response = await this.axios.post("/api/extension/social/groups", parsed);
@@ -1223,6 +1250,10 @@ class EchoMemMCPServer {
1223
1250
  return await this.handlePublicMemory(request.params.arguments);
1224
1251
  case canonicalToolNames.groupContext:
1225
1252
  return await this.handleGroupContext(request.params.arguments);
1253
+ case canonicalToolNames.getGroupSessionSharing:
1254
+ return await this.handleGetGroupSessionSharing(request.params.arguments);
1255
+ case canonicalToolNames.setGroupSessionSharing:
1256
+ return await this.handleSetGroupSessionSharing(request.params.arguments);
1226
1257
  case canonicalToolNames.createGroup:
1227
1258
  return await this.handleCreateGroup(request.params.arguments);
1228
1259
  case canonicalToolNames.createGroupInvite:
@@ -1404,8 +1435,11 @@ class EchoMemMCPServer {
1404
1435
  : m.details == null
1405
1436
  ? ""
1406
1437
  : String(m.details).trim();
1438
+ const memoryId = readString(m, "id");
1407
1439
  return [
1408
1440
  `[${idx + 1}] ${key}${typeof score === "number" ? ` (score ${score.toFixed(3)})` : ""}`,
1441
+ memoryId ? `Memory ID: ${memoryId}` : "",
1442
+ memoryId ? `Open private memory: ${memoryMarkdownLink(personalMemoryWebUrl(memoryId), key, description)}` : "",
1409
1443
  meta,
1410
1444
  `Description: ${description}`,
1411
1445
  details ? `Details: ${details}` : "",
@@ -1423,6 +1457,7 @@ class EchoMemMCPServer {
1423
1457
  }
1424
1458
  const formattedResults = memories
1425
1459
  .map((m, idx) => `[Result ${idx + 1}] Memory ID: ${m.id || "unknown"} (Similarity: ${m.similarity_score?.toFixed(3) || "N/A"})
1460
+ Open private memory: ${memoryMarkdownLink(personalMemoryWebUrl(String(m.id || "unknown")), m.keys, m.description)}
1426
1461
  Time: ${m.time} | Location: ${m.location}
1427
1462
  Category: ${m.category} | Object: ${m.object} | Emotion: ${m.emotion}
1428
1463
  Description: ${m.description}
@@ -1445,7 +1480,7 @@ Details: ${m.details || "N/A"}`)
1445
1480
  rec.conversation_chars = text.length;
1446
1481
  rec.save_source = typeof a?.source === "string" ? a.source : sourceFallback;
1447
1482
  }
1448
- const { success, memoriesExtracted, memoriesDiscarded, extractedMemories, contextId, capsuleId, passthrough: isPassthrough, error } = await this.client.saveConversation(enrichedArgs);
1483
+ const { success, memoriesExtracted, memoriesDiscarded, extractedMemories, contextId, capsuleId, passthrough: isPassthrough, groupSessionSharing, groupSync, error, } = await this.client.saveConversation(enrichedArgs);
1449
1484
  if (!success)
1450
1485
  throw new Error(`EchoMem API Error: ${error}`);
1451
1486
  if (rec)
@@ -1464,6 +1499,24 @@ Details: ${m.details || "N/A"}`)
1464
1499
  // Surface WHAT was captured (not just the count) so the agent can verify the key facts survived
1465
1500
  // extraction, and so it holds the ids to deterministically re-fetch this batch later (warm-up).
1466
1501
  const saved = Array.isArray(extractedMemories) ? extractedMemories.filter(isRecord) : [];
1502
+ const sharing = isRecord(groupSessionSharing) ? groupSessionSharing : null;
1503
+ const sync = isRecord(groupSync) ? groupSync : null;
1504
+ const sharingGroup = isRecord(sharing?.group) ? sharing.group : {};
1505
+ const protectedIds = Array.isArray(sync?.protectedMemoryIds) ? sync.protectedMemoryIds : [];
1506
+ const receipt = sharing?.hasGroup !== true
1507
+ ? "Saved to your private memory."
1508
+ : sharing?.decision === null || sharing?.decision === undefined
1509
+ ? `Saved to your private memory. Ask once: “Share memories saved from this session with ${readString(sharingGroup, "name") ?? "your current group"}?” Then call set_group_session_sharing with the confirmed answer.`
1510
+ : sharing.decision === "private"
1511
+ ? "Saved to your private memory."
1512
+ : sync?.synced === true
1513
+ ? [
1514
+ "Saved to your private memory and synced to the group.",
1515
+ protectedIds.length
1516
+ ? `${protectedIds.length} protected memory item(s) stayed private.`
1517
+ : "",
1518
+ ].filter(Boolean).join(" ")
1519
+ : `Saved to your private memory, but group sync failed${readString(sync ?? {}, "error") ? `: ${readString(sync ?? {}, "error")}` : "."}`;
1467
1520
  const list = saved
1468
1521
  .map((m, idx) => {
1469
1522
  const keys = readString(m, "keys") ?? "(no key)";
@@ -1479,6 +1532,7 @@ Details: ${m.details || "N/A"}`)
1479
1532
  .join("\n\n");
1480
1533
  const text = [
1481
1534
  `Successfully ingested conversation. Extracted ${memoriesExtracted} memory distinct events.`,
1535
+ receipt,
1482
1536
  typeof memoriesDiscarded === "number" && memoriesDiscarded > 0
1483
1537
  ? `${memoriesDiscarded} additional memories were not stored because your active-memory limit was reached.`
1484
1538
  : "",
@@ -1500,7 +1554,9 @@ Details: ${m.details || "N/A"}`)
1500
1554
  };
1501
1555
  }
1502
1556
  const formattedResults = memories
1503
- .map((m, idx) => `[${idx + 1}] Time: ${m.time} | Location: ${m.location}
1557
+ .map((m, idx) => `[${idx + 1}] Memory ID: ${m.id || "unknown"}
1558
+ Open private memory: ${memoryMarkdownLink(personalMemoryWebUrl(String(m.id || "unknown")), m.keys, m.description)}
1559
+ Time: ${m.time} | Location: ${m.location}
1504
1560
  Category: ${m.category} | Object: ${m.object} | Emotion: ${m.emotion}
1505
1561
  Description: ${m.description}
1506
1562
  Details: ${m.details || "N/A"}`)
@@ -1526,6 +1582,7 @@ Details: ${m.details || "N/A"}`)
1526
1582
  }
1527
1583
  const formattedResults = memories
1528
1584
  .map((m, idx) => `[${idx + 1}] ${m.keys || "Saved memory"}${m.id ? ` · id ${m.id}` : ""}
1585
+ ${m.id ? `Open private memory: ${memoryMarkdownLink(personalMemoryWebUrl(String(m.id)), m.keys, m.description)}` : ""}
1529
1586
  Time: ${m.time} | Category: ${m.category} | Object: ${m.object} | Emotion: ${m.emotion}
1530
1587
  Description: ${m.description}
1531
1588
  Details: ${m.details || "N/A"}`)
@@ -1604,7 +1661,9 @@ Details: ${m.details || "N/A"}`)
1604
1661
  };
1605
1662
  }
1606
1663
  const formattedResults = memories
1607
- .map((m, idx) => `[${idx + 1}] Time: ${m.time} | Keys: ${m.keys || "N/A"}
1664
+ .map((m, idx) => `[${idx + 1}] Memory ID: ${m.id || "unknown"}
1665
+ Open private memory: ${memoryMarkdownLink(personalMemoryWebUrl(String(m.id || "unknown")), m.keys, m.description)}
1666
+ Time: ${m.time} | Keys: ${m.keys || "N/A"}
1608
1667
  Location: ${m.location} | Category: ${m.category} | Object: ${m.object}
1609
1668
  Description: ${m.description}
1610
1669
  Details: ${m.details || "N/A"}`)
@@ -1657,7 +1716,7 @@ Details: ${m.details || "N/A"}`)
1657
1716
  ? m.similarity_score
1658
1717
  : undefined;
1659
1718
  return `[${idx + 1}] Memory ID: ${m.id || "unknown"}
1660
- Open memory: ${memoryWebUrl(String(m.id || "unknown"))}
1719
+ Open memory: ${memoryMarkdownLink(memoryWebUrl(String(m.id || "unknown")), m.keys, m.description)}
1661
1720
  User ID: ${m.user_id || "unknown"}
1662
1721
  User Name: ${m.username || m.user_name || m.name || "Anonymous"}
1663
1722
  Time: ${m.time} | Location: ${m.location}
@@ -1783,7 +1842,7 @@ Details: ${m.details || "N/A"}`;
1783
1842
  }
1784
1843
  const text = [
1785
1844
  `Memory ID: ${memory.id || parsed.memoryId}`,
1786
- `Open memory: ${memoryWebUrl(String(memory.id || parsed.memoryId))}`,
1845
+ `Open memory: ${memoryMarkdownLink(memoryWebUrl(String(memory.id || parsed.memoryId)), memory.keys, memory.description)}`,
1787
1846
  `Owner User ID: ${memory.owner_user_id || memory.user_id || "Unknown"}`,
1788
1847
  memory.time ? `Time: ${memory.time}` : "",
1789
1848
  memory.location ? `Location: ${memory.location}` : "",
@@ -1838,6 +1897,7 @@ Details: ${m.details || "N/A"}`;
1838
1897
  participantText,
1839
1898
  "",
1840
1899
  "Declared titles and responsibilities are directory facts. Use search_others_memories for current work evidence, and label suggested contribution areas as inference that should be confirmed with the team.",
1900
+ "Use get_group_session_sharing to read this exact session's decision; never infer sharing from the member's role or memories.",
1841
1901
  currentParticipant
1842
1902
  && (!readString(currentParticipant, "title") || !readString(currentParticipant, "responsibilitySummary"))
1843
1903
  ? "Your group profile is incomplete. Use prepare_group_publication to review your memory evidence, propose the missing fields, and save them only after confirmation with update_group_profile."
@@ -1845,6 +1905,54 @@ Details: ${m.details || "N/A"}`;
1845
1905
  ].filter(Boolean).join("\n");
1846
1906
  return { content: [{ type: "text", text }] };
1847
1907
  }
1908
+ async handleGetGroupSessionSharing(args) {
1909
+ getGroupSessionSharingSchema.parse(args ?? {});
1910
+ const payload = await this.client.getGroupSessionSharing(args);
1911
+ if (payload?.hasGroup !== true) {
1912
+ return {
1913
+ content: [{
1914
+ type: "text",
1915
+ text: "No company group is configured for this user. Saves remain private; do not ask about session sharing.",
1916
+ }],
1917
+ };
1918
+ }
1919
+ const group = isRecord(payload?.group) ? payload.group : {};
1920
+ const decision = typeof payload?.decision === "string" ? payload.decision : null;
1921
+ if (!decision) {
1922
+ return {
1923
+ content: [{
1924
+ type: "text",
1925
+ text: `No sharing decision exists for this session. Ask once: “Share memories saved from this session with ${readString(group, "name") ?? "your group"}?” Then call set_group_session_sharing with the explicit Yes/No answer.`,
1926
+ }],
1927
+ };
1928
+ }
1929
+ return {
1930
+ content: [{
1931
+ type: "text",
1932
+ text: decision === "share"
1933
+ ? `This session is approved for ${readString(group, "name") ?? "the current group"}. Each save persists privately first, then eligible memories sync automatically. Flagged memories stay private.`
1934
+ : "This session is private. Future saves remain private unless the user explicitly changes this session's decision.",
1935
+ }],
1936
+ };
1937
+ }
1938
+ async handleSetGroupSessionSharing(args) {
1939
+ const parsed = setGroupSessionSharingSchema.parse(args ?? {});
1940
+ const payload = await this.client.setGroupSessionSharing(parsed);
1941
+ const sync = isRecord(payload?.sync) ? payload.sync : null;
1942
+ const protectedIds = Array.isArray(sync?.protectedMemoryIds) ? sync.protectedMemoryIds : [];
1943
+ const group = isRecord(payload?.group) ? payload.group : {};
1944
+ const receipt = parsed.share
1945
+ ? sync?.synced === false
1946
+ ? "Session sharing is enabled, but the initial group sync failed. Private memories were preserved; retry before claiming publication."
1947
+ : `Session sharing is enabled for ${readString(group, "name") ?? "the current group"}. Existing eligible session memories were synced and later saves will sync automatically.`
1948
+ : "Session sharing is off. Future saves in this session remain private.";
1949
+ return {
1950
+ content: [{
1951
+ type: "text",
1952
+ text: `${receipt}${protectedIds.length ? ` ${protectedIds.length} flagged ${protectedIds.length === 1 ? "memory was" : "memories were"} protected and kept private.` : ""}`,
1953
+ }],
1954
+ };
1955
+ }
1848
1956
  async handlePublishToGroup(args) {
1849
1957
  const parsed = publishToGroupSchema.parse(args ?? {});
1850
1958
  const payload = await this.client.publishMemoryToGroup(args);
@@ -1893,8 +2001,8 @@ Details: ${m.details || "N/A"}`;
1893
2001
  content: [{
1894
2002
  type: "text",
1895
2003
  text: payload?.alreadyMember
1896
- ? `You are already a member of ${payload?.group?.name ?? "this group"}. No memories were published. Use prepare_group_publication to review memories and propose any missing title or responsibility fields.`
1897
- : `Joined ${payload?.group?.name ?? "the company group"}. No memories were published. Next use prepare_group_publication to review candidates, infer a proposed title and responsibility summary, and ask the user to confirm that profile together with the publication preview.`,
2004
+ ? `You are already a member of ${payload?.group?.name ?? "this group"}. No memories were published. Use prepare_group_publication to review memories and propose any missing title or responsibility fields, then call get_group_session_sharing and ask once if this session has no decision.`
2005
+ : `Joined ${payload?.group?.name ?? "the company group"}. No memories were published. Next use prepare_group_publication to review candidates, infer a proposed title and responsibility summary, and ask the user to confirm that profile together with the publication preview. Also call get_group_session_sharing; if unset, ask once whether memories saved from this session should be shared.`,
1898
2006
  }],
1899
2007
  };
1900
2008
  }
@@ -1911,7 +2019,7 @@ Details: ${m.details || "N/A"}`;
1911
2019
  : null;
1912
2020
  return [
1913
2021
  `[${index + 1}] Memory ID: ${readString(candidate, "memoryId") ?? "unknown"}`,
1914
- `Open memory: ${memoryWebUrl(readString(candidate, "memoryId") ?? "unknown")}`,
2022
+ `Open private memory: ${memoryMarkdownLink(personalMemoryWebUrl(readString(candidate, "memoryId") ?? "unknown"), readString(candidate, "keys"), readString(candidate, "description"))}`,
1915
2023
  `Created: ${readString(candidate, "createdAt") ?? "unknown"}`,
1916
2024
  `Category: ${readString(candidate, "category") ?? "unknown"}`,
1917
2025
  `Description: ${readString(candidate, "description") ?? ""}`,
@@ -1938,9 +2046,9 @@ Details: ${m.details || "N/A"}`;
1938
2046
  formatted,
1939
2047
  "",
1940
2048
  flaggedIds.length
1941
- ? `Extra attention required: ${flaggedIds.length} candidate(s) are flagged (${flaggedIds.join(", ")}). Show them in a separate warning and require a separate explicit acknowledgement before including their exact IDs in acknowledgedFlaggedMemoryIds.`
2049
+ ? `Extra attention required: ${flaggedIds.length} candidate(s) are flagged (${flaggedIds.join(", ")}). Nothing has been published. Show them in a separate warning and offer to exclude them, review them separately, or first search for and mark similar sensitive owned memories for publication attention. Never auto-flag based on inference. Require separate explicit acknowledgement before including their exact IDs in acknowledgedFlaggedMemoryIds.`
1942
2050
  : "",
1943
- "Select exact memory IDs matching the user's instruction. From memory evidence, draft a concise title and responsibility summary. Show the profile proposal and memory preview together, then ask for explicit confirmation. After confirmation, call update_group_profile and complete_group_publication.",
2051
+ "Select exact memory IDs matching the user's instruction. From memory evidence, draft a concise title and responsibility summary. 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 you will call it out and ask for detailed confirmation whenever a later publication includes it. Never auto-flag. Show the profile proposal and memory preview together, then ask for explicit confirmation. After confirmation, call update_group_profile and complete_group_publication.",
1944
2052
  ].join("\n"),
1945
2053
  }],
1946
2054
  };
@@ -1958,6 +2066,7 @@ Details: ${m.details || "N/A"}`;
1958
2066
  `Flagged ${payload?.flaggedCount ?? parsed.memoryIds.length} memories for publication attention.`,
1959
2067
  `Label: ${parsed.label}`,
1960
2068
  `Memory IDs: ${parsed.memoryIds.join(", ")}`,
2069
+ `Private review links:\n${parsed.memoryIds.map((memoryId) => `- ${memoryId}: ${personalMemoryWebUrl(memoryId)}`).join("\n")}`,
1961
2070
  "No memory was published, decrypted, or made globally public.",
1962
2071
  alreadyPublished > 0
1963
2072
  ? `Attention: ${alreadyPublished} flagged memories already have group snapshots. This flag does not retract those existing snapshots.`
@@ -28,11 +28,14 @@ export const MCP_SERVER_INSTRUCTIONS = [
28
28
  `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.`,
29
29
  "Use echomem_update_status to check whether npm has a newer bridge; it is cached and non-blocking during normal tool listing.",
30
30
  "Do not auto-update on every MCP startup; this bridge is intentionally stable between explicit updates.",
31
+ "Before the final response for work that produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone, call save_conversation. This is a private checkpoint and does not require a separate user request. Skip secrets, incidental chatter, and trivial read-only work. If an encrypted vault is locked, tell the user to run echomem-mcp unlock rather than silently skipping the checkpoint.",
32
+ "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 store only the explicit Yes/No answer with set_group_session_sharing. A Yes decision syncs eligible memories after each private save; a No decision keeps later saves private. Flagged memories stay private.",
31
33
  "For company-group sharing, use get_group_context for orientation; create_memory_group/create_group_invite/join_memory_group for membership; and prepare_group_publication as a no-publication preview.",
32
34
  "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.",
33
- "Each group-search result is one memory: preserve its Memory ID and direct echoknows.com link when citing it.",
34
- "For sensitive-topic flags, search and preview exact owned memories before confirmed flag_memories_for_publication_attention. During publication, separate flagged candidates and require exact-ID acknowledgement.",
35
- "Never save an inferred group profile or complete a group publication without explicit confirmation, and never store or log an echo_grp_ invite code.",
35
+ "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
+ "Each search result is one memory: preserve its Memory ID and canonical echoknows.com link when citing it.",
37
+ "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
+ "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.",
36
39
  ].join(" ");
37
40
  export function withMcpVersion(description) {
38
41
  return `${description}\n\nEchoMem MCP bridge: ${MCP_PACKAGE_LABEL}. If this version is stale, update once with \`${MCP_UPDATE_ALL_COMMAND}\` (or add \`--client cursor|windsurf|claude-desktop|claude-code|codex\` for a single client), then start a new MCP session. Do not run updates repeatedly or on every startup.`;