@echomem/mcp 1.4.26 → 1.4.28

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.
@@ -5,6 +5,21 @@ const ROLLOUT_FILE_RE = /^rollout-.*\.jsonl$/;
5
5
  const UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
6
6
  const FIRST_LINE_CHUNK_BYTES = 64 * 1024;
7
7
  const MAX_FIRST_LINE_BYTES = 2 * 1024 * 1024;
8
+ function includesCompleteSessionMetadataLine(buffers) {
9
+ const text = Buffer.concat(buffers).toString("utf8");
10
+ const lines = text.split(/\r?\n/);
11
+ if (!text.endsWith("\n"))
12
+ lines.pop();
13
+ return lines.some((line) => {
14
+ try {
15
+ const row = JSON.parse(line);
16
+ return isRecord(row) && row.type === "session_meta" && isRecord(row.payload);
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ });
22
+ }
8
23
  function initialJsonLines(file) {
9
24
  const fd = fs.openSync(file, "r");
10
25
  try {
@@ -19,6 +34,11 @@ function initialJsonLines(file) {
19
34
  const piece = Buffer.from(chunk.subarray(0, bytesRead));
20
35
  buffers.push(piece);
21
36
  total += bytesRead;
37
+ // Normal Codex rollouts put session metadata in the first block. Stop as soon as that
38
+ // complete row is available, while preserving the old 2MB fallback for unusual/legacy
39
+ // files whose metadata appears later.
40
+ if (includesCompleteSessionMetadataLine(buffers))
41
+ break;
22
42
  }
23
43
  if (!buffers.length)
24
44
  return [];
@@ -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), 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,13 +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(/\/$/, "");
21
- const ECHO_PERSONAL_MEMORY_WEB_URL = (process.env.ECHO_PERSONAL_MEMORY_WEB_URL || "https://echoknows.com/memories/timeline").replace(/\/$/, "");
20
+ const ECHO_MEMORY_WEB_URL = (process.env.ECHO_MEMORY_WEB_URL || "https://echoknows.com/memory").replace(/\/$/, "");
22
21
  function memoryWebUrl(memoryId) {
23
- return `${ECHO_MEMORY_WEB_URL}?memoryId=${encodeURIComponent(memoryId)}`;
22
+ return `${ECHO_MEMORY_WEB_URL}/${encodeURIComponent(memoryId)}`;
24
23
  }
25
24
  function personalMemoryWebUrl(memoryId) {
26
- return `${ECHO_PERSONAL_MEMORY_WEB_URL}?memoryId=${encodeURIComponent(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})`;
27
34
  }
28
35
  /** Thrown when no API token is present yet — the model gets a "run login" nudge, not a hard error. */
29
36
  class NoTokenError extends Error {
@@ -511,6 +518,11 @@ function inputAnalyticsForTool(canonicalName, args) {
511
518
  confirmed: a.confirmed === true,
512
519
  };
513
520
  }
521
+ case canonicalToolNames.setGroupSessionSharing:
522
+ return {
523
+ share_to_group: typeof a.share === "boolean" ? a.share : undefined,
524
+ confirmed: a.confirmed === true,
525
+ };
514
526
  case canonicalToolNames.publishBatchToGroup: {
515
527
  const memoryIds = Array.isArray(a.memoryIds) ? a.memoryIds.filter((id) => typeof id === "string") : [];
516
528
  return {
@@ -978,6 +990,17 @@ class EchoMemApiClient {
978
990
  throw new Error(`get_group_context failed against ${ECHO_API_BASE_URL}: ${describeError(error)}`);
979
991
  }
980
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
+ }
981
1004
  async createGroup(args) {
982
1005
  const parsed = createGroupSchema.parse(args ?? {});
983
1006
  const response = await this.axios.post("/api/extension/social/groups", parsed);
@@ -1227,6 +1250,10 @@ class EchoMemMCPServer {
1227
1250
  return await this.handlePublicMemory(request.params.arguments);
1228
1251
  case canonicalToolNames.groupContext:
1229
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);
1230
1257
  case canonicalToolNames.createGroup:
1231
1258
  return await this.handleCreateGroup(request.params.arguments);
1232
1259
  case canonicalToolNames.createGroupInvite:
@@ -1412,7 +1439,7 @@ class EchoMemMCPServer {
1412
1439
  return [
1413
1440
  `[${idx + 1}] ${key}${typeof score === "number" ? ` (score ${score.toFixed(3)})` : ""}`,
1414
1441
  memoryId ? `Memory ID: ${memoryId}` : "",
1415
- memoryId ? `Open private memory: ${personalMemoryWebUrl(memoryId)}` : "",
1442
+ memoryId ? `Open private memory: ${memoryMarkdownLink(personalMemoryWebUrl(memoryId), key, description)}` : "",
1416
1443
  meta,
1417
1444
  `Description: ${description}`,
1418
1445
  details ? `Details: ${details}` : "",
@@ -1430,7 +1457,7 @@ class EchoMemMCPServer {
1430
1457
  }
1431
1458
  const formattedResults = memories
1432
1459
  .map((m, idx) => `[Result ${idx + 1}] Memory ID: ${m.id || "unknown"} (Similarity: ${m.similarity_score?.toFixed(3) || "N/A"})
1433
- Open private memory: ${personalMemoryWebUrl(String(m.id || "unknown"))}
1460
+ Open private memory: ${memoryMarkdownLink(personalMemoryWebUrl(String(m.id || "unknown")), m.keys, m.description)}
1434
1461
  Time: ${m.time} | Location: ${m.location}
1435
1462
  Category: ${m.category} | Object: ${m.object} | Emotion: ${m.emotion}
1436
1463
  Description: ${m.description}
@@ -1453,7 +1480,7 @@ Details: ${m.details || "N/A"}`)
1453
1480
  rec.conversation_chars = text.length;
1454
1481
  rec.save_source = typeof a?.source === "string" ? a.source : sourceFallback;
1455
1482
  }
1456
- 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);
1457
1484
  if (!success)
1458
1485
  throw new Error(`EchoMem API Error: ${error}`);
1459
1486
  if (rec)
@@ -1472,6 +1499,24 @@ Details: ${m.details || "N/A"}`)
1472
1499
  // Surface WHAT was captured (not just the count) so the agent can verify the key facts survived
1473
1500
  // extraction, and so it holds the ids to deterministically re-fetch this batch later (warm-up).
1474
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")}` : "."}`;
1475
1520
  const list = saved
1476
1521
  .map((m, idx) => {
1477
1522
  const keys = readString(m, "keys") ?? "(no key)";
@@ -1487,6 +1532,7 @@ Details: ${m.details || "N/A"}`)
1487
1532
  .join("\n\n");
1488
1533
  const text = [
1489
1534
  `Successfully ingested conversation. Extracted ${memoriesExtracted} memory distinct events.`,
1535
+ receipt,
1490
1536
  typeof memoriesDiscarded === "number" && memoriesDiscarded > 0
1491
1537
  ? `${memoriesDiscarded} additional memories were not stored because your active-memory limit was reached.`
1492
1538
  : "",
@@ -1509,7 +1555,7 @@ Details: ${m.details || "N/A"}`)
1509
1555
  }
1510
1556
  const formattedResults = memories
1511
1557
  .map((m, idx) => `[${idx + 1}] Memory ID: ${m.id || "unknown"}
1512
- Open private memory: ${personalMemoryWebUrl(String(m.id || "unknown"))}
1558
+ Open private memory: ${memoryMarkdownLink(personalMemoryWebUrl(String(m.id || "unknown")), m.keys, m.description)}
1513
1559
  Time: ${m.time} | Location: ${m.location}
1514
1560
  Category: ${m.category} | Object: ${m.object} | Emotion: ${m.emotion}
1515
1561
  Description: ${m.description}
@@ -1536,7 +1582,7 @@ Details: ${m.details || "N/A"}`)
1536
1582
  }
1537
1583
  const formattedResults = memories
1538
1584
  .map((m, idx) => `[${idx + 1}] ${m.keys || "Saved memory"}${m.id ? ` · id ${m.id}` : ""}
1539
- ${m.id ? `Open private memory: ${personalMemoryWebUrl(String(m.id))}` : ""}
1585
+ ${m.id ? `Open private memory: ${memoryMarkdownLink(personalMemoryWebUrl(String(m.id)), m.keys, m.description)}` : ""}
1540
1586
  Time: ${m.time} | Category: ${m.category} | Object: ${m.object} | Emotion: ${m.emotion}
1541
1587
  Description: ${m.description}
1542
1588
  Details: ${m.details || "N/A"}`)
@@ -1616,7 +1662,7 @@ Details: ${m.details || "N/A"}`)
1616
1662
  }
1617
1663
  const formattedResults = memories
1618
1664
  .map((m, idx) => `[${idx + 1}] Memory ID: ${m.id || "unknown"}
1619
- Open private memory: ${personalMemoryWebUrl(String(m.id || "unknown"))}
1665
+ Open private memory: ${memoryMarkdownLink(personalMemoryWebUrl(String(m.id || "unknown")), m.keys, m.description)}
1620
1666
  Time: ${m.time} | Keys: ${m.keys || "N/A"}
1621
1667
  Location: ${m.location} | Category: ${m.category} | Object: ${m.object}
1622
1668
  Description: ${m.description}
@@ -1670,7 +1716,7 @@ Details: ${m.details || "N/A"}`)
1670
1716
  ? m.similarity_score
1671
1717
  : undefined;
1672
1718
  return `[${idx + 1}] Memory ID: ${m.id || "unknown"}
1673
- Open memory: ${memoryWebUrl(String(m.id || "unknown"))}
1719
+ Open memory: ${memoryMarkdownLink(memoryWebUrl(String(m.id || "unknown")), m.keys, m.description)}
1674
1720
  User ID: ${m.user_id || "unknown"}
1675
1721
  User Name: ${m.username || m.user_name || m.name || "Anonymous"}
1676
1722
  Time: ${m.time} | Location: ${m.location}
@@ -1796,7 +1842,7 @@ Details: ${m.details || "N/A"}`;
1796
1842
  }
1797
1843
  const text = [
1798
1844
  `Memory ID: ${memory.id || parsed.memoryId}`,
1799
- `Open memory: ${memoryWebUrl(String(memory.id || parsed.memoryId))}`,
1845
+ `Open memory: ${memoryMarkdownLink(memoryWebUrl(String(memory.id || parsed.memoryId)), memory.keys, memory.description)}`,
1800
1846
  `Owner User ID: ${memory.owner_user_id || memory.user_id || "Unknown"}`,
1801
1847
  memory.time ? `Time: ${memory.time}` : "",
1802
1848
  memory.location ? `Location: ${memory.location}` : "",
@@ -1851,6 +1897,7 @@ Details: ${m.details || "N/A"}`;
1851
1897
  participantText,
1852
1898
  "",
1853
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.",
1854
1901
  currentParticipant
1855
1902
  && (!readString(currentParticipant, "title") || !readString(currentParticipant, "responsibilitySummary"))
1856
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."
@@ -1858,6 +1905,54 @@ Details: ${m.details || "N/A"}`;
1858
1905
  ].filter(Boolean).join("\n");
1859
1906
  return { content: [{ type: "text", text }] };
1860
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
+ }
1861
1956
  async handlePublishToGroup(args) {
1862
1957
  const parsed = publishToGroupSchema.parse(args ?? {});
1863
1958
  const payload = await this.client.publishMemoryToGroup(args);
@@ -1906,8 +2001,8 @@ Details: ${m.details || "N/A"}`;
1906
2001
  content: [{
1907
2002
  type: "text",
1908
2003
  text: payload?.alreadyMember
1909
- ? `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.`
1910
- : `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.`,
1911
2006
  }],
1912
2007
  };
1913
2008
  }
@@ -1924,7 +2019,7 @@ Details: ${m.details || "N/A"}`;
1924
2019
  : null;
1925
2020
  return [
1926
2021
  `[${index + 1}] Memory ID: ${readString(candidate, "memoryId") ?? "unknown"}`,
1927
- `Open private memory: ${personalMemoryWebUrl(readString(candidate, "memoryId") ?? "unknown")}`,
2022
+ `Open private memory: ${memoryMarkdownLink(personalMemoryWebUrl(readString(candidate, "memoryId") ?? "unknown"), readString(candidate, "keys"), readString(candidate, "description"))}`,
1928
2023
  `Created: ${readString(candidate, "createdAt") ?? "unknown"}`,
1929
2024
  `Category: ${readString(candidate, "category") ?? "unknown"}`,
1930
2025
  `Description: ${readString(candidate, "description") ?? ""}`,
package/dist/migrate.js CHANGED
@@ -186,14 +186,17 @@ export function normalizeCwd(cwd) {
186
186
  const m = cwd.match(/worktrees\/[^/]+\/(.+)$/);
187
187
  return m ? m[1] : cwd;
188
188
  }
189
- function userCreatedCodexFiles(codexRoot) {
189
+ function userCreatedCodexSessionFiles(codexRoot) {
190
190
  return discoverCodexSessionFiles({
191
191
  ...(codexRoot
192
192
  ? { roots: [{ kind: "active", path: codexRoot, priority: 0 }] }
193
193
  : {}),
194
194
  includeArchived: false,
195
195
  userInitiatedOnly: true,
196
- }).files.map((file) => file.path);
196
+ }).files;
197
+ }
198
+ function userCreatedCodexFiles(codexRoot) {
199
+ return userCreatedCodexSessionFiles(codexRoot).map((file) => file.path);
197
200
  }
198
201
  function userCreatedClaudeFiles(claudeRoot) {
199
202
  return walk(claudeRoot, (candidate) => candidate.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows").filter(isUserCreatedClaudeSessionFile);
@@ -312,20 +315,30 @@ function fastSessionInfo(file, source) {
312
315
  }
313
316
  function fastSessionEntries(opts = {}) {
314
317
  const out = [];
315
- for (const filePath of userCreatedCodexFiles(opts.codexRoot)) {
316
- const stat = statSafe(filePath);
317
- const info = fastSessionInfo(filePath, "codex");
318
- // Include if we found text OR a real session id (big sessions can have their first text turn beyond
319
- // the 1MB probe window — gating only on text dropped them entirely; exact discovery refines later).
320
- // We require a real key so the fast/exact conversationKey match (no sha16 fallback mismatch).
321
- if (info.hasTextTurn || info.hasRealKey)
322
- out.push({ filePath, source: "codex", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
318
+ for (const file of userCreatedCodexSessionFiles(opts.codexRoot)) {
319
+ // Codex discovery has already classified the rollout as user-created and resolved its stable
320
+ // session key. Do not reread another 1MB of transcript just to rediscover the same ID.
321
+ const stableId = file.sessionKey.startsWith("session:")
322
+ ? file.sessionKey.slice("session:".length)
323
+ : null;
324
+ const fallback = stableId ? null : fastSessionInfo(file.path, "codex");
325
+ out.push({
326
+ filePath: file.path,
327
+ source: "codex",
328
+ conversationKey: stableId ? `codex:${stableId}` : fallback?.conversationKey || `codex:${sha16(file.path)}`,
329
+ size: file.size,
330
+ mtimeMs: Math.round(file.mtimeMs),
331
+ });
323
332
  }
324
333
  const claudeRoot = opts.claudeRoot ?? resolveClaudeProjectsDir();
325
334
  if (claudeRoot) {
326
335
  for (const filePath of userCreatedClaudeFiles(claudeRoot)) {
327
336
  const stat = statSafe(filePath);
328
- const info = fastSessionInfo(filePath, "claude-code");
337
+ const filenameId = path.basename(filePath, path.extname(filePath));
338
+ const filenameHasStableId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(filenameId);
339
+ const info = filenameHasStableId
340
+ ? { conversationKey: `claude-code:${filenameId.toLowerCase()}`, hasTextTurn: true, hasRealKey: true }
341
+ : fastSessionInfo(filePath, "claude-code");
329
342
  // Same rule as codex: include on text OR a real session id so large sessions aren't undercounted,
330
343
  // while keeping the key stable (claude-code sessionId appears on every line, so hasRealKey is reliable).
331
344
  if (info.hasTextTurn || info.hasRealKey)