@echomem/mcp 1.4.31 → 1.4.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ This MCP Server bridges local tools and your EchoMem Cloud API entirely via auth
10
10
  - `save_conversation`: Connects to `POST /api/extension/memories/ingest`
11
11
  - `get_memories_by_time_range`: Connects to `POST /api/extension/memories/time-range`
12
12
  - `search_memories_by_keywords`: Connects to `POST /api/extension/memories/keywords`
13
- - `search_others_memories`: Connects to MemoryFeed public search without sending the authenticated Echo user id
13
+ - `search_others_memories`: Connects to MemoryFeed public search without trusting a model-supplied identity; the hosted API resolves the caller from the EchoMem credential and the tool returns that authenticated viewer explicitly
14
14
  - `delete_memory`: Previews one personal memory and returns a confirmation token; only deletes after a second confirmed call
15
15
 
16
16
  No direct access to the `IndexedDB` or local files is required.
@@ -27,18 +27,40 @@ const PROBLEM_META = {
27
27
  P13: { label: "Agent's Reasoning Notes", bucket: "dead", category: "Context Hygiene", confidence: "fallback" },
28
28
  };
29
29
  const SCORER_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../assets/canonical-scorer");
30
+ const CANONICAL_FULL_FILE_MAX_BYTES = 8 * 1024 * 1024;
31
+ const CANONICAL_MAX_TURNS_PER_SESSION = 10_000;
32
+ const CANONICAL_HEAD_TURNS = 1_000;
33
+ const CANONICAL_TAIL_TURNS = 6_000;
34
+ const CANONICAL_MIDDLE_WINDOWS = 3;
35
+ const CANONICAL_MIDDLE_WINDOW_TURNS = 1_000;
30
36
  // Per-session scored-result cache. Each Codex session is scored by spawning 3 vendored scorer
31
37
  // processes (~300ms/session) — the dominant cost of the whole forensic scan. A session's scored
32
38
  // output is a pure function of its immutable JSONL, so cache it by (mtime, size): re-runs only
33
39
  // re-score new/changed sessions. Mirrors the forensic engine's codex parse cache. Bump the version
34
40
  // whenever the vendored scorer scripts change so stale results are discarded.
35
- const CANONICAL_CACHE_VERSION = 3;
41
+ const CANONICAL_CACHE_VERSION = 4;
36
42
  function canonicalCachePath() {
37
43
  return path.join(os.homedir(), ".echomem", "canonical-cache.json");
38
44
  }
39
45
  function loadCanonicalCache() {
40
46
  try {
41
- const parsed = JSON.parse(fs.readFileSync(canonicalCachePath(), "utf8"));
47
+ const cachePath = canonicalCachePath();
48
+ // JSON.parse materializes the complete object before we can inspect `v`. Version 3 caches can
49
+ // contain full per-turn timelines and exceed the worker heap by themselves, so reject stale
50
+ // cache files from their small header before reading the full payload.
51
+ const header = Buffer.alloc(64);
52
+ const fd = fs.openSync(cachePath, "r");
53
+ let bytesRead = 0;
54
+ try {
55
+ bytesRead = fs.readSync(fd, header, 0, header.length, 0);
56
+ }
57
+ finally {
58
+ fs.closeSync(fd);
59
+ }
60
+ const cachedVersion = /"v"\s*:\s*(\d+)/.exec(header.toString("utf8", 0, bytesRead))?.[1];
61
+ if (cachedVersion && Number(cachedVersion) !== CANONICAL_CACHE_VERSION)
62
+ return {};
63
+ const parsed = JSON.parse(fs.readFileSync(cachePath, "utf8"));
42
64
  if (parsed && parsed.v === CANONICAL_CACHE_VERSION && parsed.files && typeof parsed.files === "object")
43
65
  return parsed.files;
44
66
  }
@@ -96,7 +118,7 @@ export async function buildVendoredCanonicalReport(opts) {
96
118
  try {
97
119
  const hit = st ? cache[file] : null;
98
120
  const fresh = hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size;
99
- const slim = fresh ? hit.scored : slimScored(await scoreSession(file, "codex"));
121
+ const slim = fresh ? hit.scored : await scoreSession(file, "codex");
100
122
  if (fresh)
101
123
  cachedCount++;
102
124
  else
@@ -159,7 +181,10 @@ export async function buildVendoredCanonicalReport(opts) {
159
181
  async function scoreSession(sessionPath, source) {
160
182
  const temp = fs.mkdtempSync(path.join(os.tmpdir(), "echomem-canonical-"));
161
183
  try {
162
- const scorerInput = sessionPath;
184
+ // Large single-session histories make the vendored scorer's per-turn reconstruction grow beyond
185
+ // a practical onboarding budget. Use a deterministic internal sample for the immediate report;
186
+ // the original transcript remains untouched for the later import pipeline.
187
+ const scorerInput = await boundedCanonicalScorerInput(sessionPath, temp);
163
188
  const sessionId = sessionIdFor(sessionPath);
164
189
  const problemsPath = path.join(temp, "problems.json");
165
190
  const efficiencyPath = path.join(temp, "efficiency.json");
@@ -199,17 +224,114 @@ async function scoreSession(sessionPath, source) {
199
224
  const dashboard = readJson(dashboardPath);
200
225
  const efficiency = readJson(efficiencyPath);
201
226
  await enrichCodexDashboardTurns(dashboard, sessionPath);
202
- return {
227
+ return slimScored({
203
228
  dashboard,
204
229
  efficiency,
205
230
  repo: repoLabel(dashboard.session.sourceSession?.cwd || null),
206
231
  source,
207
- };
232
+ episodeSummaries: [],
233
+ });
208
234
  }
209
235
  finally {
210
236
  fs.rmSync(temp, { recursive: true, force: true });
211
237
  }
212
238
  }
239
+ async function boundedCanonicalScorerInput(sessionPath, temp) {
240
+ let size = 0;
241
+ try {
242
+ size = fs.statSync(sessionPath).size;
243
+ }
244
+ catch {
245
+ return sessionPath;
246
+ }
247
+ if (size <= CANONICAL_FULL_FILE_MAX_BYTES)
248
+ return sessionPath;
249
+ const totalTurns = await countCodexTurns(sessionPath);
250
+ if (totalTurns <= CANONICAL_MAX_TURNS_PER_SESSION)
251
+ return sessionPath;
252
+ const ranges = canonicalTurnSampleRanges(totalTurns);
253
+ const sampledPath = path.join(temp, "bounded-session.jsonl");
254
+ const output = fs.openSync(sampledPath, "w");
255
+ let currentTurn = 0;
256
+ let currentUserMessage = "";
257
+ try {
258
+ const lines = readline.createInterface({ input: fs.createReadStream(sessionPath), crlfDelay: Infinity });
259
+ for await (const line of lines) {
260
+ if (!line.trim())
261
+ continue;
262
+ const parsed = parseCodexLine(line);
263
+ const userMessage = parsed ? codexUserMessageText(parsed.row, parsed.payload, parsed.type) : "";
264
+ if (userMessage && userMessage !== currentUserMessage) {
265
+ currentTurn += 1;
266
+ currentUserMessage = userMessage;
267
+ }
268
+ const isSessionMeta = parsed && (stringValue(parsed.row.type) === "session_meta" || parsed.type === "session_meta");
269
+ if (isSessionMeta || (currentTurn > 0 && turnInRanges(currentTurn, ranges))) {
270
+ fs.writeSync(output, `${line}\n`);
271
+ }
272
+ }
273
+ }
274
+ finally {
275
+ fs.closeSync(output);
276
+ }
277
+ return sampledPath;
278
+ }
279
+ async function countCodexTurns(sessionPath) {
280
+ let turns = 0;
281
+ let currentUserMessage = "";
282
+ const lines = readline.createInterface({ input: fs.createReadStream(sessionPath), crlfDelay: Infinity });
283
+ for await (const line of lines) {
284
+ if (!line.trim())
285
+ continue;
286
+ const parsed = parseCodexLine(line);
287
+ if (!parsed)
288
+ continue;
289
+ const userMessage = codexUserMessageText(parsed.row, parsed.payload, parsed.type);
290
+ if (!userMessage || userMessage === currentUserMessage)
291
+ continue;
292
+ currentUserMessage = userMessage;
293
+ turns += 1;
294
+ }
295
+ return turns;
296
+ }
297
+ function parseCodexLine(line) {
298
+ let parsed;
299
+ try {
300
+ parsed = JSON.parse(line);
301
+ }
302
+ catch {
303
+ return null;
304
+ }
305
+ if (!isRecord(parsed))
306
+ return null;
307
+ const payload = recordValue(parsed.payload);
308
+ return {
309
+ row: parsed,
310
+ payload,
311
+ type: stringValue(payload.type) || stringValue(parsed.type),
312
+ };
313
+ }
314
+ function canonicalTurnSampleRanges(totalTurns) {
315
+ if (totalTurns <= CANONICAL_MAX_TURNS_PER_SESSION)
316
+ return [[1, totalTurns]];
317
+ const tailStart = totalTurns - CANONICAL_TAIL_TURNS + 1;
318
+ const ranges = [
319
+ [1, CANONICAL_HEAD_TURNS],
320
+ [tailStart, totalTurns],
321
+ ];
322
+ const middleStart = CANONICAL_HEAD_TURNS + 1;
323
+ const middleEnd = tailStart - 1;
324
+ const middleSpan = Math.max(0, middleEnd - middleStart + 1);
325
+ for (let index = 1; index <= CANONICAL_MIDDLE_WINDOWS; index += 1) {
326
+ const center = middleStart + Math.floor((middleSpan * index) / (CANONICAL_MIDDLE_WINDOWS + 1));
327
+ const start = Math.max(middleStart, Math.min(middleEnd - CANONICAL_MIDDLE_WINDOW_TURNS + 1, center - Math.floor(CANONICAL_MIDDLE_WINDOW_TURNS / 2)));
328
+ ranges.push([start, Math.min(middleEnd, start + CANONICAL_MIDDLE_WINDOW_TURNS - 1)]);
329
+ }
330
+ return ranges.sort((left, right) => left[0] - right[0]);
331
+ }
332
+ function turnInRanges(turn, ranges) {
333
+ return ranges.some(([start, end]) => turn >= start && turn <= end);
334
+ }
213
335
  // Run fn over items with at most `limit` in flight at once, returning results in input order.
214
336
  async function mapPool(items, limit, fn) {
215
337
  const results = new Array(items.length);
@@ -325,13 +447,41 @@ function compactBlockPreview(text, limit = 1200) {
325
447
  function slimScored(row) {
326
448
  const d = row.dashboard;
327
449
  const t = d.totals;
450
+ const evidenceTurns = new Set((d.problemContributions || [])
451
+ .map((problem) => problem.worstTurnByAllocatedWaste?.turn)
452
+ .filter((turn) => typeof turn === "number"));
453
+ const rawBucketTotals = (row.efficiency.series || []).reduce((totals, series) => {
454
+ totals.keep_oh += series.keep_oh || 0;
455
+ totals.keep_prod += series.keep_prod || 0;
456
+ totals.opt_dup += series.opt_dup || 0;
457
+ totals.opt_refind += series.opt_refind || 0;
458
+ totals.opt_dead += series.opt_dead || 0;
459
+ return totals;
460
+ }, { keep_oh: 0, keep_prod: 0, opt_dup: 0, opt_refind: 0, opt_dead: 0 });
461
+ const episodeRows = new Map();
462
+ for (const turn of d.timeline || []) {
463
+ const episode = episodeRows.get(turn.episode) || {
464
+ turnStart: turn.turn,
465
+ turnEnd: turn.turn,
466
+ wasteTokens: 0,
467
+ byProblem: new Map(),
468
+ };
469
+ episode.turnEnd = turn.turn;
470
+ episode.wasteTokens += turn.wasteTokens || 0;
471
+ for (const item of turn.allocatedItems || []) {
472
+ if (!item.problemId)
473
+ continue;
474
+ episode.byProblem.set(item.problemId, (episode.byProblem.get(item.problemId) || 0) + (item.tokens || 0));
475
+ }
476
+ episodeRows.set(turn.episode, episode);
477
+ }
328
478
  return {
329
479
  dashboard: {
330
480
  session: {
331
481
  id: d.session.id,
332
482
  sourceSession: d.session.sourceSession ? {
333
483
  cwd: d.session.sourceSession.cwd ?? null,
334
- turns: d.session.sourceSession.turns,
484
+ turns: d.session.sourceSession.turns ?? d.timeline.length,
335
485
  startedAt: d.session.sourceSession.startedAt ?? null,
336
486
  } : undefined,
337
487
  },
@@ -360,7 +510,9 @@ function slimScored(row) {
360
510
  }
361
511
  : null,
362
512
  })),
363
- timeline: (d.timeline || []).map((tt) => ({
513
+ // Workspace aggregation needs message text only for each problem's single worst evidence turn.
514
+ // Keeping every turn made one very long session retain hundreds of MB through the entire scan.
515
+ timeline: (d.timeline || []).filter((tt) => evidenceTurns.has(tt.turn)).map((tt) => ({
364
516
  turn: tt.turn, episode: tt.episode,
365
517
  inputTokens: tt.inputTokens || 0, usefulTokens: tt.usefulTokens || 0, wasteTokens: tt.wasteTokens || 0,
366
518
  rawUsefulTokens: tt.rawUsefulTokens || 0, rawOutcomeWasteTokens: tt.rawOutcomeWasteTokens || 0,
@@ -371,10 +523,16 @@ function slimScored(row) {
371
523
  })),
372
524
  },
373
525
  efficiency: {
374
- series: (row.efficiency.series || []).map((s) => ({
375
- keep_oh: s.keep_oh || 0, keep_prod: s.keep_prod || 0, opt_dup: s.opt_dup || 0, opt_refind: s.opt_refind || 0, opt_dead: s.opt_dead || 0,
376
- })),
526
+ // aggregate() only sums these fields. Store one per-session total instead of one row per turn.
527
+ series: [rawBucketTotals],
377
528
  },
529
+ episodeSummaries: [...episodeRows.entries()].map(([episode, values]) => ({
530
+ episode,
531
+ turnStart: values.turnStart,
532
+ turnEnd: values.turnEnd,
533
+ wasteTokens: values.wasteTokens,
534
+ dominantProblemId: [...values.byProblem.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || null,
535
+ })),
378
536
  repo: row.repo,
379
537
  };
380
538
  }
@@ -509,30 +667,17 @@ function aggregate(scored, metadata) {
509
667
  }
510
668
  const episodes = [];
511
669
  for (const row of scored) {
512
- const byEpisode = new Map();
513
- for (const turn of row.dashboard.timeline) {
514
- const list = byEpisode.get(turn.episode) || [];
515
- list.push(turn);
516
- byEpisode.set(turn.episode, list);
517
- }
518
- for (const [episode, turns] of byEpisode) {
519
- const byProblem = new Map();
520
- for (const turn of turns)
521
- for (const item of turn.allocatedItems || []) {
522
- if (item.problemId)
523
- byProblem.set(item.problemId, (byProblem.get(item.problemId) || 0) + (item.tokens || 0));
524
- }
525
- const dominant = [...byProblem.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
526
- if (!dominant)
670
+ for (const episode of row.episodeSummaries) {
671
+ if (!episode.dominantProblemId)
527
672
  continue;
528
673
  episodes.push({
529
- id: `E${episode}`,
674
+ id: `E${episode.episode}`,
530
675
  session: row.dashboard.session.id,
531
676
  repo: row.repo,
532
- turnStart: turns[0]?.turn || 0,
533
- turnEnd: turns[turns.length - 1]?.turn || 0,
534
- wasteTokens: turns.reduce((sum, turn) => sum + turn.wasteTokens, 0),
535
- dominantProblemId: dominant,
677
+ turnStart: episode.turnStart,
678
+ turnEnd: episode.turnEnd,
679
+ wasteTokens: episode.wasteTokens,
680
+ dominantProblemId: episode.dominantProblemId,
536
681
  });
537
682
  }
538
683
  }
package/dist/forensics.js CHANGED
@@ -1056,30 +1056,30 @@ function loadClaudeSessionTitles() {
1056
1056
  }
1057
1057
  return titles;
1058
1058
  }
1059
- function enrichCanonicalSessionExamples(canonical, timelineFiles) {
1059
+ function enrichCanonicalSessionExamples(canonical, sessionRecords) {
1060
1060
  if (!canonical || "error" in canonical)
1061
1061
  return;
1062
1062
  const codexTitles = loadCodexSessionTitles();
1063
1063
  const claudeTitles = loadClaudeSessionTitles();
1064
- const records = timelineFiles
1065
- .filter(({ fe }) => Boolean(fe.session))
1066
- .sort((a, b) => (a.fe.firstTs ?? Number.POSITIVE_INFINITY) - (b.fe.firstTs ?? Number.POSITIVE_INFINITY));
1064
+ const records = sessionRecords
1065
+ .filter((record) => Boolean(record.session))
1066
+ .sort((a, b) => (a.firstTs ?? Number.POSITIVE_INFINITY) - (b.firstTs ?? Number.POSITIVE_INFINITY));
1067
1067
  const sourceTotals = {
1068
- codex: records.filter(({ fe }) => fe.source === "codex").length,
1069
- "claude-code": records.filter(({ fe }) => fe.source === "claude-code").length,
1068
+ codex: records.filter((record) => record.source === "codex").length,
1069
+ "claude-code": records.filter((record) => record.source === "claude-code").length,
1070
1070
  };
1071
1071
  const sourceOrdinals = { codex: 0, "claude-code": 0 };
1072
1072
  const sessionMeta = new Map();
1073
- for (const { fe } of records) {
1074
- if (!fe.session)
1073
+ for (const record of records) {
1074
+ if (!record.session)
1075
1075
  continue;
1076
- sourceOrdinals[fe.source] += 1;
1077
- const indexedTitle = fe.source === "codex" ? codexTitles.get(fe.session) : claudeTitles.get(fe.session);
1078
- sessionMeta.set(fe.session, {
1079
- id: fe.session,
1080
- title: indexedTitle || fe.fallbackTitle || `${fe.cwd ? path.basename(fe.cwd) : "Local"} session`,
1081
- ordinal: sourceOrdinals[fe.source],
1082
- total: sourceTotals[fe.source],
1076
+ sourceOrdinals[record.source] += 1;
1077
+ const indexedTitle = record.source === "codex" ? codexTitles.get(record.session) : claudeTitles.get(record.session);
1078
+ sessionMeta.set(record.session, {
1079
+ id: record.session,
1080
+ title: indexedTitle || record.fallbackTitle || `${record.cwd ? path.basename(record.cwd) : "Local"} session`,
1081
+ ordinal: sourceOrdinals[record.source],
1082
+ total: sourceTotals[record.source],
1083
1083
  });
1084
1084
  }
1085
1085
  const reports = [canonical];
@@ -1353,7 +1353,7 @@ export function validateForensicReportForSetup(value) {
1353
1353
  /** Scan both sources and build the merged forensic report. Local-only, $0, never throws on bad files. */
1354
1354
  export async function buildForensicReport(opts) {
1355
1355
  const sources = opts?.sources ?? ["codex", "claude"];
1356
- const eng = new Forensics();
1356
+ let eng = new Forensics();
1357
1357
  const codexDiscovery = sources.includes("codex")
1358
1358
  ? discoverCodexSessionFiles({ includeArchived: opts?.includeArchivedCodex !== false })
1359
1359
  : null;
@@ -1433,12 +1433,22 @@ export async function buildForensicReport(opts) {
1433
1433
  }
1434
1434
  timelineFiles.sort((a, b) => (a.fe.firstTs ?? Number.POSITIVE_INFINITY) - (b.fe.firstTs ?? Number.POSITIVE_INFINITY) ||
1435
1435
  a.path.localeCompare(b.path));
1436
+ // The title/enrichment pass only needs five scalar fields per session. Capture those before
1437
+ // releasing the much larger usage/context/event arrays ahead of canonical classification.
1438
+ const canonicalSessionRecords = timelineFiles.map(({ fe }) => ({
1439
+ source: fe.source,
1440
+ session: fe.session,
1441
+ cwd: fe.cwd,
1442
+ firstTs: fe.firstTs,
1443
+ fallbackTitle: fe.fallbackTitle,
1444
+ }));
1436
1445
  replayTimelineFiles(eng, timelineFiles);
1437
1446
  // Entering a stage means it has done none of its own work yet, so it opens its band rather than
1438
1447
  // inheriting the finished file counters — otherwise a stage announces itself at its band ceiling
1439
1448
  // and its real sub-progress then drags the bar back down.
1440
1449
  progress("building-summary", "aggregating rereads, model usage, cost, and local context signals", 0, 1);
1441
1450
  const report = eng.build();
1451
+ eng = null;
1442
1452
  const firstTimelineSession = timelineFiles.find(({ fe }) => fe.firstTs != null);
1443
1453
  report.firstSession = firstTimelineSession ? {
1444
1454
  date: firstTimelineSession.fe.firstTs != null ? new Date(firstTimelineSession.fe.firstTs).toISOString() : null,
@@ -1467,6 +1477,15 @@ export async function buildForensicReport(opts) {
1467
1477
  },
1468
1478
  };
1469
1479
  }
1480
+ // The forensic cache and timeline can be hundreds of MB for a small number of very long sessions.
1481
+ // They are no longer needed after replay/build, and retaining them while loading the canonical
1482
+ // cache was the peak-memory overlap that terminated the setup worker on large histories.
1483
+ parsedCodexFiles.length = 0;
1484
+ timelineFiles.length = 0;
1485
+ for (const key of Object.keys(cache))
1486
+ delete cache[key];
1487
+ for (const key of Object.keys(nextCache))
1488
+ delete nextCache[key];
1470
1489
  if (opts?.includeLegacyGoldenStandard !== false) {
1471
1490
  try {
1472
1491
  report.goldenStandard = buildWorkspaceContextReport();
@@ -1491,7 +1510,7 @@ export async function buildForensicReport(opts) {
1491
1510
  strictSessionErrors: false,
1492
1511
  onProgress: (canonicalDone, canonicalTotal) => progress("classifying-repeated-context", "reconstructing context windows and attributing P01/P03/P08/P10/P13 waste", canonicalDone, canonicalTotal),
1493
1512
  });
1494
- enrichCanonicalSessionExamples(report.canonicalGoldenStandard, timelineFiles);
1513
+ enrichCanonicalSessionExamples(report.canonicalGoldenStandard, canonicalSessionRecords);
1495
1514
  }
1496
1515
  catch (error) {
1497
1516
  report.canonicalGoldenStandard = { error: error instanceof Error ? error.message : String(error) };
package/dist/index.js CHANGED
@@ -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, MEMORY_CITATION_INSTRUCTION, } from "./package-metadata.js";
15
+ import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS, MEMORY_CITATION_INSTRUCTION, SAVED_MEMORY_RECEIPT_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";
@@ -816,6 +816,7 @@ class EchoMemApiClient {
816
816
  }
817
817
  async saveConversation(args) {
818
818
  const parsed = saveConversationSchema.parse(args ?? {});
819
+ const groupSharingScopeId = parsed.groupSharingScopeId ?? randomUUID();
819
820
  let rawData = parsed.conversation?.trim() || "";
820
821
  if (!rawData && parsed.messages?.length) {
821
822
  rawData = formatMessagesForIngest(parsed.messages);
@@ -841,15 +842,16 @@ class EchoMemApiClient {
841
842
  sourceUrl: parsed.url,
842
843
  source: parsed.source || "mcp_server",
843
844
  title: parsed.title,
844
- // Stable per-session id so multiple saves in this coding session group under one context.
845
- conversationKey: this.sessionId,
845
+ // The MCP transport can outlive a host conversation. Group saves only by the opaque
846
+ // scope carried in that conversation, never by the bridge process id.
847
+ conversationKey: groupSharingScopeId,
846
848
  passthrough: parsed.passthrough || false,
847
849
  triggerMessage: parsed.triggerMessage ||
848
850
  lastUserMessageFromMessages(parsed.messages) ||
849
851
  lastUserMessageFromConversationText(parsed.conversation),
850
852
  triggerMessageRole: parsed.triggerMessageRole || "user",
851
853
  }, config);
852
- return response.data;
854
+ return { ...response.data, groupSharingScopeId };
853
855
  }
854
856
  catch (error) {
855
857
  if (axios.isAxiosError(error) && error.response?.status === 429) {
@@ -1029,14 +1031,18 @@ class EchoMemApiClient {
1029
1031
  }
1030
1032
  }
1031
1033
  async getGroupSessionSharing(args) {
1032
- getGroupSessionSharingSchema.parse(args ?? {});
1033
- const response = await this.axios.get(`/api/extension/social/groups/current/session-sharing?sessionKey=${encodeURIComponent(this.sessionId)}`);
1034
- return response.data;
1034
+ const parsed = getGroupSessionSharingSchema.parse(args ?? {});
1035
+ const groupSharingScopeId = parsed.groupSharingScopeId ?? randomUUID();
1036
+ const query = new URLSearchParams({ sessionKey: groupSharingScopeId });
1037
+ if (parsed.groupId)
1038
+ query.set("groupId", parsed.groupId);
1039
+ const response = await this.axios.get(`/api/extension/social/groups/current/session-sharing?${query.toString()}`);
1040
+ return { ...response.data, groupSharingScopeId };
1035
1041
  }
1036
1042
  async setGroupSessionSharing(args) {
1037
1043
  const parsed = setGroupSessionSharingSchema.parse(args ?? {});
1038
1044
  const enc = await this.encState();
1039
- 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 });
1045
+ const response = await this.axios.patch("/api/extension/social/groups/current/session-sharing", { ...parsed, sessionKey: parsed.groupSharingScopeId }, { headers: enc.enabled && enc.key ? { "X-Encryption-Key": enc.key } : undefined });
1040
1046
  return response.data;
1041
1047
  }
1042
1048
  async createGroup(args) {
@@ -1552,7 +1558,7 @@ Details: ${m.details || "N/A"}`)
1552
1558
  rec.conversation_chars = text.length;
1553
1559
  rec.save_source = typeof a?.source === "string" ? a.source : sourceFallback;
1554
1560
  }
1555
- const { success, memoriesExtracted, memoriesDiscarded, extractedMemories, contextId, capsuleId, passthrough: isPassthrough, groupSessionSharing, groupSync, error, } = await this.client.saveConversation(enrichedArgs);
1561
+ const { success, memoriesExtracted, memoriesDiscarded, extractedMemories, contextId, capsuleId, passthrough: isPassthrough, groupSessionSharing, groupSync, groupSyncs, groupSharingScopeId, error, } = await this.client.saveConversation(enrichedArgs);
1556
1562
  if (!success)
1557
1563
  throw new Error(`EchoMem API Error: ${error}`);
1558
1564
  if (rec)
@@ -1562,7 +1568,13 @@ Details: ${m.details || "N/A"}`)
1562
1568
  const text = [
1563
1569
  `Session capsule saved (passthrough, no extraction).`,
1564
1570
  typeof capsuleId === "string" && capsuleId ? `Capsule ID: ${capsuleId}` : "",
1571
+ typeof capsuleId === "string" && capsuleId
1572
+ ? `Open saved capsule: ${memoryMarkdownLink(personalMemoryWebUrl(capsuleId), "Session capsule")}`
1573
+ : "",
1565
1574
  typeof contextId === "string" && contextId ? `Context: ${contextId}` : "",
1575
+ typeof groupSharingScopeId === "string" && groupSharingScopeId
1576
+ ? `Conversation sharing scope: ${groupSharingScopeId}`
1577
+ : "",
1566
1578
  "",
1567
1579
  `To reload this capsule in a fresh session: get_memories_by_context({ contextId: "${contextId}" })`,
1568
1580
  ].filter(Boolean).join("\n");
@@ -1572,23 +1584,49 @@ Details: ${m.details || "N/A"}`)
1572
1584
  // extraction, and so it holds the ids to deterministically re-fetch this batch later (warm-up).
1573
1585
  const saved = Array.isArray(extractedMemories) ? extractedMemories.filter(isRecord) : [];
1574
1586
  const sharing = isRecord(groupSessionSharing) ? groupSessionSharing : null;
1575
- const sync = isRecord(groupSync) ? groupSync : null;
1587
+ const syncList = Array.isArray(groupSyncs)
1588
+ ? groupSyncs.filter(isRecord)
1589
+ : isRecord(groupSync)
1590
+ ? [groupSync]
1591
+ : [];
1592
+ const sync = syncList.length === 1 ? syncList[0] : null;
1576
1593
  const sharingGroup = isRecord(sharing?.group) ? sharing.group : {};
1577
- const protectedIds = Array.isArray(sync?.protectedMemoryIds) ? sync.protectedMemoryIds : [];
1594
+ const availableGroups = Array.isArray(sharing?.availableGroups)
1595
+ ? sharing.availableGroups.filter(isRecord)
1596
+ : [];
1597
+ const syncedGroupNames = syncList
1598
+ .filter((item) => item.synced === true)
1599
+ .map((item) => readString(item, "groupName"))
1600
+ .filter((name) => Boolean(name));
1601
+ const failedSyncs = syncList.filter((item) => item.attempted === true && item.synced !== true);
1602
+ const protectedIds = Array.from(new Set(syncList.flatMap((item) => Array.isArray(item.protectedMemoryIds) ? item.protectedMemoryIds : [])));
1578
1603
  const receipt = sharing?.hasGroup !== true
1579
1604
  ? "Saved to your private memory."
1580
- : sharing?.decision === null || sharing?.decision === undefined
1581
- ? `Saved to your private memory. Ask: “Share memories saved from this session with ${readString(sharingGroup, "name") ?? "your current group"}?” Silence leaves the state unset, so ask again at a later qualifying checkpoint until the user explicitly answers Yes or No; do not repeat the prompt in the same response.`
1582
- : sharing.decision === "private"
1583
- ? "Saved to your private memory."
1584
- : sync?.synced === true
1585
- ? [
1586
- "Saved to your private memory and synced to the group.",
1587
- protectedIds.length
1588
- ? `${protectedIds.length} protected memory item(s) stayed private.`
1589
- : "",
1590
- ].filter(Boolean).join(" ")
1591
- : `Saved to your private memory, but group sync failed${readString(sync ?? {}, "error") ? `: ${readString(sync ?? {}, "error")}` : "."}`;
1605
+ : sharing?.requiresGroupSelection === true
1606
+ ? [
1607
+ syncedGroupNames.length
1608
+ ? `Saved privately first and synced to approved groups: ${syncedGroupNames.join(", ")}.`
1609
+ : "Saved to your private memory.",
1610
+ `This user has multiple groups: ${availableGroups.map((group) => `${readString(group, "name") ?? "Unnamed group"} (${readString(group, "id") ?? "unknown id"})`).join(", ")}. Call get_group_session_sharing with this conversation scope and one groupId at a time; obtain a separate explicit Yes/No decision for each group.`,
1611
+ failedSyncs.length
1612
+ ? `${failedSyncs.length} approved group sync(s) failed; private memories were preserved.`
1613
+ : "",
1614
+ protectedIds.length
1615
+ ? `${protectedIds.length} protected memory item(s) stayed private.`
1616
+ : "",
1617
+ ].filter(Boolean).join(" ")
1618
+ : sharing?.decision === null || sharing?.decision === undefined
1619
+ ? `Saved to your private memory. Ask: “Share memories saved from this conversation with ${readString(sharingGroup, "name") ?? "your current group"}?” Silence leaves the state unset, so ask again at a later qualifying checkpoint until the user explicitly answers Yes or No; do not repeat the prompt in the same response.`
1620
+ : sharing.decision === "private"
1621
+ ? "Saved to your private memory."
1622
+ : syncedGroupNames.length
1623
+ ? [
1624
+ `Saved to your private memory and synced to ${syncedGroupNames.join(", ")}.`,
1625
+ protectedIds.length
1626
+ ? `${protectedIds.length} protected memory item(s) stayed private.`
1627
+ : "",
1628
+ ].filter(Boolean).join(" ")
1629
+ : `Saved to your private memory, but group sync failed${readString(failedSyncs[0] ?? sync ?? {}, "error") ? `: ${readString(failedSyncs[0] ?? sync ?? {}, "error")}` : "."}`;
1592
1630
  const list = saved
1593
1631
  .map((m, idx) => {
1594
1632
  const keys = readString(m, "keys") ?? "(no key)";
@@ -1596,7 +1634,9 @@ Details: ${m.details || "N/A"}`)
1596
1634
  const details = readString(m, "details") ?? "";
1597
1635
  const id = readString(m, "id");
1598
1636
  return [
1599
- `[${idx + 1}] ${keys}${id ? ` · id ${id}` : ""}`,
1637
+ id
1638
+ ? `[${idx + 1}] ${memoryMarkdownLink(personalMemoryWebUrl(id), keys, description)} · id ${id}`
1639
+ : `[${idx + 1}] ${keys}`,
1600
1640
  description ? `Description: ${description}` : "",
1601
1641
  details ? `Details: ${details}` : "",
1602
1642
  ].filter(Boolean).join("\n");
@@ -1605,13 +1645,17 @@ Details: ${m.details || "N/A"}`)
1605
1645
  const text = [
1606
1646
  `Successfully ingested conversation. Extracted ${memoriesExtracted} memory distinct events.`,
1607
1647
  receipt,
1648
+ typeof groupSharingScopeId === "string" && groupSharingScopeId
1649
+ ? `Conversation sharing scope: ${groupSharingScopeId}\nReuse this exact groupSharingScopeId only for get_group_session_sharing, set_group_session_sharing, and save_conversation calls in this conversation. Never reuse it in another conversation or save it as memory.`
1650
+ : "",
1608
1651
  typeof memoriesDiscarded === "number" && memoriesDiscarded > 0
1609
1652
  ? `${memoriesDiscarded} additional memories were not stored because your active-memory limit was reached.`
1610
1653
  : "",
1611
- list,
1654
+ list ? `EchoMem saved:\n\n${list}` : "",
1612
1655
  saved.length
1613
1656
  ? `Verify these captured the key facts. Re-fetch this exact batch later by searching the ids above${typeof contextId === "string" && contextId ? ` (context ${contextId})` : ""}.`
1614
1657
  : "",
1658
+ saved.length ? SAVED_MEMORY_RECEIPT_INSTRUCTION : "",
1615
1659
  ].filter(Boolean).join("\n\n");
1616
1660
  return { content: [{ type: "text", text }] };
1617
1661
  }
@@ -1756,6 +1800,22 @@ Details: ${m.details || "N/A"}`)
1756
1800
  const parsed = othersSchema.parse(args ?? {});
1757
1801
  const payload = await this.client.searchOthersMemories(args);
1758
1802
  const memories = payload?.memories ?? [];
1803
+ const authenticatedViewer = isRecord(payload?.authenticatedViewer)
1804
+ ? payload.authenticatedViewer
1805
+ : null;
1806
+ const authenticatedUserId = authenticatedViewer
1807
+ ? readString(authenticatedViewer, "userId")
1808
+ : null;
1809
+ const authenticatedDisplayName = authenticatedViewer
1810
+ ? readString(authenticatedViewer, "displayName")
1811
+ : null;
1812
+ const authenticatedIdentity = authenticatedUserId
1813
+ ? [
1814
+ `Authenticated EchoMem user: ${authenticatedDisplayName ?? "Unknown display name"} (User ID: ${authenticatedUserId}).`,
1815
+ "This identity comes from the EchoMem credential and is authoritative for this tool call.",
1816
+ "EchoMem has already excluded only this authenticated user's own memories. Return every memory below; do not filter again using a Claude account, host profile, git identity, or inferred identity.",
1817
+ ].join("\n")
1818
+ : "EchoMem has already applied credential-based self filtering. Do not filter the returned memories again using a host account, profile, git identity, or inferred identity.";
1759
1819
  if (!memories.length) {
1760
1820
  const scope = parsed.ownerUserId
1761
1821
  ? ` for peer ${parsed.ownerUserId}`
@@ -1770,7 +1830,7 @@ Details: ${m.details || "N/A"}`)
1770
1830
  : "";
1771
1831
  const queryLabel = parsed.query?.trim() ? ` matching the query: ${parsed.query}` : "";
1772
1832
  return {
1773
- content: [{ type: "text", text: `No others' public memories found${scope}${queryLabel}` }],
1833
+ content: [{ type: "text", text: `${authenticatedIdentity}\n\nNo others' public memories found${scope}${queryLabel}` }],
1774
1834
  };
1775
1835
  }
1776
1836
  const metadata = [
@@ -1804,7 +1864,7 @@ Details: ${m.details || "N/A"}`;
1804
1864
  content: [
1805
1865
  {
1806
1866
  type: "text",
1807
- text: withMemoryCitationInstruction(`Found ${memories.length} others' public memories${metadata ? ` (${metadata})` : ""}:\n\n${formattedResults}`),
1867
+ text: withMemoryCitationInstruction(`${authenticatedIdentity}\n\nFound ${memories.length} others' public memories${metadata ? ` (${metadata})` : ""}:\n\n${formattedResults}`),
1808
1868
  },
1809
1869
  ],
1810
1870
  };
@@ -1989,12 +2049,15 @@ Details: ${m.details || "N/A"}`;
1989
2049
  const text = [
1990
2050
  `Company group: ${readString(group, "name") ?? "Unnamed group"}`,
1991
2051
  readString(group, "description") ? `Description: ${readString(group, "description")}` : "",
2052
+ currentParticipant
2053
+ ? `Authenticated EchoMem member: ${readString(currentParticipant, "displayName") ?? "Unknown display name"} (User ID: ${readString(currentParticipant, "userId") ?? "unknown"}). This identity comes from the EchoMem credential and overrides any conflicting host-account, Claude-profile, git, or inferred identity.`
2054
+ : "EchoMem credential identity is authoritative for this tool; do not substitute a host-account, Claude-profile, git, or inferred identity.",
1992
2055
  `Coverage: ${coveredCount} of ${participantCount} participants have published memories (${totalPublished} total).`,
1993
2056
  "",
1994
2057
  participantText,
1995
2058
  "",
1996
2059
  "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.",
1997
- "Use get_group_session_sharing to read this exact session's decision; never infer sharing from the member's role or memories.",
2060
+ "Use get_group_session_sharing to mint or read this conversation's sharing scope; never infer sharing from the member's role, memories, or MCP transport session.",
1998
2061
  currentParticipant
1999
2062
  && (!readString(currentParticipant, "title") || !readString(currentParticipant, "responsibilitySummary"))
2000
2063
  ? "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."
@@ -2005,11 +2068,29 @@ Details: ${m.details || "N/A"}`;
2005
2068
  async handleGetGroupSessionSharing(args) {
2006
2069
  getGroupSessionSharingSchema.parse(args ?? {});
2007
2070
  const payload = await this.client.getGroupSessionSharing(args);
2071
+ const groupSharingScopeId = readString(payload ?? {}, "groupSharingScopeId");
2072
+ const scopeText = groupSharingScopeId
2073
+ ? `Conversation sharing scope: ${groupSharingScopeId}\nReuse this exact groupSharingScopeId only in this conversation for later get/set/save calls. Never persist it as memory.`
2074
+ : "";
2008
2075
  if (payload?.hasGroup !== true) {
2009
2076
  return {
2010
2077
  content: [{
2011
2078
  type: "text",
2012
- text: "No company group is configured for this user. Saves remain private; do not ask about session sharing.",
2079
+ text: "No company group is configured for this user. Saves remain private; do not ask about conversation sharing.",
2080
+ }],
2081
+ };
2082
+ }
2083
+ if (payload?.requiresGroupSelection === true) {
2084
+ const availableGroups = Array.isArray(payload?.availableGroups)
2085
+ ? payload.availableGroups.filter(isRecord)
2086
+ : [];
2087
+ const choices = availableGroups
2088
+ .map((group) => `${readString(group, "name") ?? "Unnamed group"} (${readString(group, "id") ?? "unknown id"})`)
2089
+ .join(", ");
2090
+ return {
2091
+ content: [{
2092
+ type: "text",
2093
+ text: `${scopeText}\n\nThis user belongs to multiple groups: ${choices}. Reuse this same conversation scope and call get_group_session_sharing once per groupId. Ask for a separate explicit Yes/No sharing decision for each selected group.`,
2013
2094
  }],
2014
2095
  };
2015
2096
  }
@@ -2019,16 +2100,19 @@ Details: ${m.details || "N/A"}`;
2019
2100
  return {
2020
2101
  content: [{
2021
2102
  type: "text",
2022
- text: `No sharing decision exists for this session. Ask: “Share memories saved from this session with ${readString(group, "name") ?? "your group"}?” Silence is not a No: leave the state unset and ask again at a later qualifying checkpoint until the user explicitly answers Yes or No, without repeating the prompt in the same response. Then call set_group_session_sharing with that explicit answer; an explicit No stops later prompts for this exact session.`,
2103
+ text: `${scopeText}\n\nNo sharing decision exists for this conversation. Ask: “Share memories saved from this conversation with ${readString(group, "name") ?? "your group"}?” Silence is not a No: leave the state unset and ask again at a later qualifying checkpoint until the user explicitly answers Yes or No, without repeating the prompt in the same response. Then call set_group_session_sharing with that explicit answer and the scope above; an explicit No stops later prompts for this conversation.`,
2023
2104
  }],
2024
2105
  };
2025
2106
  }
2026
2107
  return {
2027
2108
  content: [{
2028
2109
  type: "text",
2029
- text: decision === "share"
2030
- ? `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.`
2031
- : "This session is private. Future saves remain private unless the user explicitly changes this session's decision.",
2110
+ text: [
2111
+ scopeText,
2112
+ decision === "share"
2113
+ ? `This conversation is approved for ${readString(group, "name") ?? "the current group"}. Each save carrying this scope persists privately first, then eligible memories sync automatically. Flagged memories stay private.`
2114
+ : "This conversation is private. Future saves carrying this scope remain private unless the user explicitly changes this conversation's decision.",
2115
+ ].filter(Boolean).join("\n\n"),
2032
2116
  }],
2033
2117
  };
2034
2118
  }
@@ -2040,9 +2124,9 @@ Details: ${m.details || "N/A"}`;
2040
2124
  const group = isRecord(payload?.group) ? payload.group : {};
2041
2125
  const receipt = parsed.share
2042
2126
  ? sync?.synced === false
2043
- ? "Session sharing is enabled, but the initial group sync failed. Private memories were preserved; retry before claiming publication."
2044
- : `Session sharing is enabled for ${readString(group, "name") ?? "the current group"}. Existing eligible session memories were synced and later saves will sync automatically.`
2045
- : "Session sharing is off. Future saves in this session remain private.";
2127
+ ? "Conversation sharing is enabled, but the initial group sync failed. Private memories were preserved; retry before claiming publication."
2128
+ : `Conversation sharing is enabled for ${readString(group, "name") ?? "the current group"}. Existing eligible memories in this scope were synced and later saves carrying the same scope will sync automatically.`
2129
+ : "Conversation sharing is off. Future saves carrying this scope remain private.";
2046
2130
  return {
2047
2131
  content: [{
2048
2132
  type: "text",
@@ -2099,7 +2183,7 @@ Details: ${m.details || "N/A"}`;
2099
2183
  type: "text",
2100
2184
  text: payload?.alreadyMember
2101
2185
  ? `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, if unset, ask again at later qualifying checkpoints until the user explicitly answers Yes or No.`
2102
- : `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 whether memories saved from this session should be shared and ask again at a later qualifying checkpoint after silence until an explicit Yes or No.`,
2186
+ : `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 without a scope to mint one for this conversation; if unset, ask whether memories saved from this conversation should be shared and ask again at a later qualifying checkpoint after silence until an explicit Yes or No.`,
2103
2187
  }],
2104
2188
  };
2105
2189
  }
@@ -24,18 +24,20 @@ 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
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.';
27
+ export const SAVED_MEMORY_RECEIPT_INSTRUCTION = 'After save_conversation succeeds and returns one or more saved memory IDs, make the save visible in the final user-facing answer with a compact "EchoMem saved:" list containing every memory created by that call. Use each memory key as the Markdown label and its canonical https://echoknows.com/memory/<memory-id> URL. This save receipt is separate from "EchoMem sources:" and does not imply that the newly saved memories informed the answer.';
27
28
  export const MCP_SERVER_INSTRUCTIONS = [
28
29
  `${MCP_PACKAGE_DESCRIPTION} (${MCP_PACKAGE_LABEL}).`,
29
30
  `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.`,
30
31
  "Use echomem_update_status to check whether npm has a newer bridge; it is cached and non-blocking during normal tool listing.",
31
32
  "Do not auto-update on every MCP startup; this bridge is intentionally stable between explicit updates.",
32
33
  "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.",
33
- "For a user with a company group, call get_group_session_sharing near session start or after a qualifying private save. If this exact session has no decision, ask whether memories saved from the session should be shared with the current group. Silence is not a No: leave the state unset and ask again at a later qualifying checkpoint until the user explicitly answers Yes or No, without repeating the prompt in the same response. Store only the explicit answer with set_group_session_sharing. A Yes decision syncs eligible memories after each private save; a No decision keeps later saves private and stops later prompts for this exact session. Flagged memories stay private.",
34
+ "For a user with a company group, call get_group_session_sharing near conversation start or after a qualifying private save. On the first call in a conversation, omit groupSharingScopeId so EchoMem mints a fresh opaque scope. Reuse that exact scope only in this conversation for later get/set/save calls, and never persist it as memory. If multiple groups are returned, reuse the same scope and check one groupId at a time. Each group requires its own explicit Yes/No decision. Silence leaves that group's state unset; never infer an answer or repeat the prompt in the same response. Store only explicit answers with set_group_session_sharing, the same scope, and the selected groupId. Saves sync eligible memories to every approved group; a No keeps them private for that group. Flagged memories stay private.",
34
35
  "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.",
35
36
  "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.",
36
37
  "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.",
37
38
  "Each search result is one memory: preserve its Memory ID and canonical echoknows.com link when citing it.",
38
39
  MEMORY_CITATION_INSTRUCTION,
40
+ SAVED_MEMORY_RECEIPT_INSTRUCTION,
39
41
  "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.",
40
42
  "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.",
41
43
  ].join(" ");
@@ -7,9 +7,9 @@ const CHECKPOINT_REASON = [
7
7
  "Run EchoMem's private completion checkpoint before finishing this turn.",
8
8
  "Review the work that just completed. If it produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone, call `save_conversation` now with a concise structured summary of the outcome, reasoning, relevant files, and constraints.",
9
9
  "Do not save secrets or incidental chatter. If the work was not durable, do not call the tool and finish normally.",
10
- "Private persistence happens first. For a user with a company group, call `get_group_session_sharing` near session start or after a qualifying private save.",
11
- "If this exact session has no decision, ask whether memories saved from the session should be shared with the current group. Silence leaves the state unset, so ask again at a later qualifying checkpoint until the user explicitly answers Yes or No; do not repeat the prompt in the same response. Call `set_group_session_sharing` only with that explicit answer. A Yes decision automatically syncs eligible memories after later private saves; a No decision keeps later saves private and stops later prompts for this exact session.",
12
- "Flagged memories are withheld from automatic session sync and remain private.",
10
+ "Private persistence happens first. For a user with a company group, call `get_group_session_sharing` near conversation start or after a qualifying private save.",
11
+ "On the first get_group_session_sharing call in a conversation, omit groupSharingScopeId so EchoMem mints a fresh opaque scope. Reuse that exact scope only in this conversation for later get/set/save calls, and never persist it as memory. If multiple groups are returned, reuse the same scope and check one groupId at a time. Each group needs its own explicit Yes/No decision. Silence leaves that group's state unset; do not infer an answer or repeat the prompt in the same response. A Yes decision syncs eligible memories from later saves carrying the same scope to that group; saves automatically sync to every approved group. A No decision keeps them private for that group.",
12
+ "Flagged memories are withheld from automatic conversation sync and remain private.",
13
13
  "If EchoMem reports that the encrypted vault is locked, tell the user to run `echomem-mcp unlock`; never silently skip a qualifying checkpoint.",
14
14
  ].join(" ");
15
15
  function currentTurnSlice(transcript) {
package/dist/setup.js CHANGED
@@ -391,13 +391,14 @@ function echomemGuidanceBlock() {
391
391
  "- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
392
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.',
393
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.",
394
- "- For a user with a company group, call `get_group_session_sharing` near session start or after a qualifying private save. If this exact session has no decision, ask whether memories saved from the session should be shared with the current group. Silence is not a No: leave the state unset and ask again at a later qualifying checkpoint until the user explicitly answers Yes or No, without repeating the prompt in the same response. Call `set_group_session_sharing` only with that explicit answer. A Yes decision syncs eligible memories after each private save; a No decision keeps later saves private and stops later prompts for this exact session.",
394
+ '- After `save_conversation` succeeds and returns one or more saved memory IDs, make the save visible in the final user-facing answer with a compact `EchoMem saved:` list containing every memory created by that call. Link each memory key to its canonical `https://echoknows.com/memory/<memory-id>` URL. This save receipt is separate from `EchoMem sources:` and does not imply the newly saved memories informed the answer.',
395
+ "- For a user with a company group, call `get_group_session_sharing` near conversation start or after a qualifying private save. On the first call in a conversation, omit `groupSharingScopeId` so EchoMem mints a fresh opaque scope. Reuse that exact scope only in this conversation for later get/set/save calls, and never persist it as memory. If multiple groups are returned, reuse the same scope and check one `groupId` at a time. Each group requires its own explicit Yes/No decision. Silence leaves that group's state unset; never infer an answer or repeat the prompt in the same response. Store only explicit answers with `set_group_session_sharing`, the same scope, and the selected groupId. Saves sync eligible memories to every approved group; a No keeps them private for that group.",
395
396
  "- 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.",
396
397
  "",
397
398
  "### Company group memory",
398
399
  "- Use `get_group_context` when the user asks who is in their company group, what teammates are responsible for, or what work is already covered. Treat declared participant fields as facts and published-memory conclusions as evidence or inference.",
399
400
  "- A group publication is separate from a globally public memory: publishing to a group creates a group-scoped snapshot and must not change the encrypted original or its global `is_public` setting.",
400
- "- Group session sharing is scoped to the exact current MCP session and current group. Membership is rechecked for each sync. Flagged memories are withheld from automatic session sync and remain private.",
401
+ "- Group sharing is scoped to an opaque id carried only in the current conversation, not to the MCP transport session. Membership is rechecked for each sync. Flagged memories are withheld from automatic conversation sync and remain private.",
401
402
  "- If a user asks to create a group, call `create_memory_group`; if they ask for a code to share, call `create_group_invite` and return the secret invite code only to that user. Never save the invite code to memory or include it in logs, analytics, summaries, or unrelated output.",
402
403
  "- If a user supplies an `echo_grp_...` code and explicitly asks to join, call `join_memory_group`. Joining never authorizes publishing by itself and must not move a user out of another group. After joining, continue into the profile-and-publication preview instead of leaving title or responsibility blank.",
403
404
  "- For requests such as “prepare my recent work memories,” “upload work from this ticket,” or “publish work since my last sync,” call `prepare_group_publication` first. This is a no-publication preview. For encrypted accounts, tell the user to run `echomem-mcp unlock` locally if the tool reports that the key is required.",
@@ -1325,7 +1326,10 @@ export function buildForensicReportOffThread(onProgress, options = {}) {
1325
1326
  parentPort?.postMessage({ ok: false, message: error instanceof Error ? error.message : String(error) });
1326
1327
  }
1327
1328
  `;
1328
- const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
1329
+ const requestedHeapMb = options.maxOldGenerationSizeMb;
1330
+ const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`), Number.isFinite(requestedHeapMb)
1331
+ ? { resourceLimits: { maxOldGenerationSizeMb: Math.max(16, Math.floor(requestedHeapMb)) } }
1332
+ : undefined);
1329
1333
  return new Promise((resolve, reject) => {
1330
1334
  let settled = false;
1331
1335
  const requestedTimeoutMs = options.timeoutMs ?? 15 * 60_000;
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { MEMORY_CITATION_INSTRUCTION, withMcpVersion } from "./package-metadata.js";
2
+ import { MEMORY_CITATION_INSTRUCTION, SAVED_MEMORY_RECEIPT_INSTRUCTION, withMcpVersion, } from "./package-metadata.js";
3
3
  export const canonicalToolNames = {
4
4
  search: "search_memories",
5
5
  save: "save_conversation",
@@ -59,6 +59,7 @@ export const saveConversationSchema = z.object({
59
59
  source: z.string().optional(),
60
60
  tags: z.array(z.string()).optional(),
61
61
  passthrough: z.boolean().optional(),
62
+ groupSharingScopeId: z.string().uuid().optional(),
62
63
  messages: z
63
64
  .array(z.object({
64
65
  role: z.string(),
@@ -125,9 +126,13 @@ export const groupContextSchema = z.object({
125
126
  });
126
127
  export const getGroupSessionSharingSchema = z.object({
127
128
  ...triggerMetadataSchema,
129
+ groupSharingScopeId: z.string().uuid().optional(),
130
+ groupId: z.string().uuid().optional(),
128
131
  });
129
132
  export const setGroupSessionSharingSchema = z.object({
130
133
  ...triggerMetadataSchema,
134
+ groupSharingScopeId: z.string().uuid(),
135
+ groupId: z.string().uuid().optional(),
131
136
  share: z.boolean(),
132
137
  confirmed: z.literal(true),
133
138
  });
@@ -260,7 +265,7 @@ export function listToolSpecs(opts = {}) {
260
265
  },
261
266
  {
262
267
  name: canonicalToolNames.save,
263
- description: "Save durable knowledge from this conversation into the user's private EchoMem (durable memories are extracted automatically). Call before the final response when work produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone; this private checkpoint does not require a separate user request. Omit secrets, incidental chatter, and trivial read-only work. If the encrypted vault is locked, tell the user to run `echomem-mcp unlock` and never silently skip a qualifying checkpoint. Private persistence happens first. If this MCP session has confirmed group sharing, eligible memories are then synced to the current group automatically; flagged memories stay private. If the session has no decision yet, ask whether to share. Silence leaves it unset, so ask again at a later qualifying checkpoint until the user explicitly answers Yes or No; do not repeat the prompt in the same response. New extraction input uses the plan's weekly processing allowance; if the limit is reached, nothing is saved. passthrough=true stores the text verbatim as a session capsule.",
268
+ description: `Save durable knowledge from this conversation into the user's private EchoMem (durable memories are extracted automatically). Call before the final response when work produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone; this private checkpoint does not require a separate user request. Omit secrets, incidental chatter, and trivial read-only work. If the encrypted vault is locked, tell the user to run \`echomem-mcp unlock\` and never silently skip a qualifying checkpoint. Private persistence happens first. For group sharing, reuse the exact groupSharingScopeId returned by get_group_session_sharing or an earlier save in this conversation. Never reuse it in another conversation or save it as memory. Each group has an independent decision under the same conversation scope; eligible memories sync automatically to every approved group, while flagged memories stay private. If a selected group has no decision yet, ask whether to share with that named group. Silence leaves it unset; never infer the answer. New extraction input uses the plan's weekly processing allowance; if the limit is reached, nothing is saved. passthrough=true stores the text verbatim as a session capsule. ${SAVED_MEMORY_RECEIPT_INSTRUCTION}`,
264
269
  inputSchema: {
265
270
  type: "object",
266
271
  properties: {
@@ -273,6 +278,11 @@ export function listToolSpecs(opts = {}) {
273
278
  type: "boolean",
274
279
  description: "When true, store the conversation text verbatim as a session capsule — no LLM extraction, no embeddings. Use for warm-up capsules.",
275
280
  },
281
+ groupSharingScopeId: {
282
+ type: "string",
283
+ format: "uuid",
284
+ description: "Opaque scope returned by get_group_session_sharing or an earlier save in this conversation. Reuse only within this conversation; never persist it as memory.",
285
+ },
276
286
  messages: {
277
287
  type: "array",
278
288
  items: {
@@ -397,7 +407,7 @@ export function listToolSpecs(opts = {}) {
397
407
  },
398
408
  {
399
409
  name: canonicalToolNames.others,
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}`,
410
+ description: `Search public memories from accepted friends or people who share your company group. EchoMem identifies the caller from the EchoMem credential and has already excluded only that authenticated user's own memories. Present every returned owner; never filter again using a Claude account, host profile, git identity, or inferred identity. 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}`,
401
411
  inputSchema: {
402
412
  type: "object",
403
413
  properties: {
@@ -489,7 +499,7 @@ export function listToolSpecs(opts = {}) {
489
499
  },
490
500
  {
491
501
  name: canonicalToolNames.groupContext,
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}`,
502
+ description: `Get your current company group, its participant directory, declared titles and responsibilities, published-memory coverage, and the current member authenticated by the EchoMem credential. That authenticated EchoMem identity is authoritative over any conflicting Claude account, host profile, git identity, or inference. 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. Conversation sharing is separate; call get_group_session_sharing instead of inferring it.${groupMapSection}`,
493
503
  inputSchema: {
494
504
  type: "object",
495
505
  properties: {
@@ -503,10 +513,20 @@ export function listToolSpecs(opts = {}) {
503
513
  },
504
514
  {
505
515
  name: canonicalToolNames.getGroupSessionSharing,
506
- description: "Read the confirmed sharing decision for this exact MCP session. Call near session start or after a qualifying private save. If the user has a group and no decision exists, ask: “Share memories saved from this session with <group>?” Silence leaves the state unset; ask again at a later qualifying checkpoint until the user explicitly answers Yes or No, but never repeat the prompt in the same response or infer the answer. An explicit No stops later prompts for this exact session.",
516
+ description: "Read the confirmed sharing decision for one group in this exact conversation. On the first call, omit groupSharingScopeId; EchoMem returns a new opaque scope. Reuse that scope only in this conversation. If the user belongs to multiple groups and groupId is omitted, EchoMem returns the available groups; call again with the same scope and one groupId at a time. Each group requires an independent explicit Yes/No decision. Silence leaves that group's state unset; never infer the answer.",
507
517
  inputSchema: {
508
518
  type: "object",
509
519
  properties: {
520
+ groupSharingScopeId: {
521
+ type: "string",
522
+ format: "uuid",
523
+ description: "Reuse the opaque scope previously returned in this conversation. Omit only on the first call so EchoMem can mint a fresh scope.",
524
+ },
525
+ groupId: {
526
+ type: "string",
527
+ format: "uuid",
528
+ description: "Group to inspect. Required when the user belongs to multiple groups; EchoMem validates membership server-side.",
529
+ },
510
530
  triggerMessage: { type: "string" },
511
531
  triggerMessageRole: { type: "string", default: "user" },
512
532
  },
@@ -514,14 +534,16 @@ export function listToolSpecs(opts = {}) {
514
534
  },
515
535
  {
516
536
  name: canonicalToolNames.setGroupSessionSharing,
517
- description: "Store the user's explicit Yes/No sharing decision for this exact MCP session. share=true immediately syncs eligible memories already saved in the session and automatically syncs later private saves. share=false keeps later saves private. Flagged memories remain private and are reported as protected.",
537
+ description: "Store the user's explicit Yes/No sharing decision for one group in this exact conversation scope. Pass the exact groupSharingScopeId returned in this conversation and the selected groupId when the user belongs to multiple groups. EchoMem validates membership server-side. Decisions are independent per group: share=true backfills that group and later saves sync to every approved group; share=false keeps this conversation private for that group. Flagged memories remain private.",
518
538
  inputSchema: {
519
539
  type: "object",
520
540
  properties: {
541
+ groupSharingScopeId: { type: "string", format: "uuid" },
542
+ groupId: { type: "string", format: "uuid" },
521
543
  share: { type: "boolean" },
522
544
  confirmed: { type: "boolean", const: true },
523
545
  },
524
- required: ["share", "confirmed"],
546
+ required: ["groupSharingScopeId", "share", "confirmed"],
525
547
  },
526
548
  },
527
549
  {
@@ -552,7 +574,7 @@ export function listToolSpecs(opts = {}) {
552
574
  },
553
575
  {
554
576
  name: canonicalToolNames.joinGroup,
555
- description: "Join a company memory group using an invite code after the user explicitly asks to join. Joining never publishes memories. Next call prepare_group_publication, infer a proposed title and responsibility summary from the user's own candidate memories, and ask the user to confirm the profile together with the publication preview. Also call get_group_session_sharing and, if unset, ask whether memories saved from this session should be shared. Silence stays unset and should be prompted again at a later qualifying checkpoint until an explicit Yes or No.",
577
+ description: "Join a company memory group using an invite code after the user explicitly asks to join. Joining never publishes memories. Next call prepare_group_publication, infer a proposed title and responsibility summary from the user's own candidate memories, and ask the user to confirm the profile together with the publication preview. Also call get_group_session_sharing without a scope to mint one for this conversation and, if unset, ask whether memories saved from this conversation should be shared. Silence stays unset and should be prompted again at a later qualifying checkpoint until an explicit Yes or No.",
556
578
  inputSchema: {
557
579
  type: "object",
558
580
  properties: {
@@ -622,7 +644,7 @@ export function listToolSpecs(opts = {}) {
622
644
  },
623
645
  {
624
646
  name: canonicalToolNames.completeGroupPublication,
625
- description: "Publish only exact memory ids from a prepared scan after the user approves the preview with confirmed=true. This manual workflow is separate from session sharing. Explicitly confirmed flagged memories require their exact IDs in acknowledgedFlaggedMemoryIds. If an unflagged candidate appears sensitive, offer to first search for and mark similar sensitive owned memories for publication attention. Never auto-flag inferred sensitivity. Completing an empty selection safely advances the scan cursor.",
647
+ description: "Publish only exact memory ids from a prepared scan after the user approves the preview with confirmed=true. This manual workflow is separate from conversation sharing. Explicitly confirmed flagged memories require their exact IDs in acknowledgedFlaggedMemoryIds. If an unflagged candidate appears sensitive, offer to first search for and mark similar sensitive owned memories for publication attention. Never auto-flag inferred sensitivity. Completing an empty selection safely advances the scan cursor.",
626
648
  inputSchema: {
627
649
  type: "object",
628
650
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.31",
3
+ "version": "1.4.33",
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",
@@ -22,6 +22,7 @@
22
22
  "dev": "node dist/index.js",
23
23
  "smoke": "node smoke.mjs",
24
24
  "preview:extraction": "npm run build && node scripts/preview-extraction.mjs",
25
+ "stress:long-history": "npm run build && node scripts/stress-long-history.mjs",
25
26
  "test:artifact": "npm run build && node test/package-artifact.test.mjs",
26
27
  "test:registry": "node test/registry-artifact.test.mjs",
27
28
  "test:registry-ui": "npm run build && node test/registry-ui.test.mjs",
@@ -11,17 +11,24 @@ Use the `save_conversation` tool from the `echomem` MCP server.
11
11
  2. Prefer structured `messages`; add a concise title, source, and useful tags.
12
12
  3. Use `passthrough: true` only when the user explicitly needs a verbatim session capsule.
13
13
  4. Never include secrets, credentials, or unnecessary sensitive content.
14
- 5. Confirm what EchoMem saved. If saving fails, report the failure without switching providers silently.
14
+ 5. Confirm what EchoMem saved. When the tool returns one or more saved memory IDs, end the final
15
+ user-facing answer with a compact `EchoMem saved:` list containing every memory created by that
16
+ call. Use each memory key as the Markdown label and its canonical
17
+ `https://echoknows.com/memory/<memory-id>` URL. Keep this separate from `EchoMem sources:`; a save
18
+ receipt does not imply the newly saved memories informed the answer. If saving fails, report the
19
+ failure without switching providers silently.
15
20
  6. Before a final response, use this workflow proactively when the task produced a durable decision,
16
21
  confirmed preference, implementation, resolved bug, commit, passing verification, release, or
17
22
  project milestone. A private checkpoint does not require a separate user request. Skip trivial
18
23
  read-only work and incidental chatter.
19
24
  7. If the encrypted vault is locked, tell the user to run `echomem-mcp unlock`; never silently skip a
20
25
  qualifying checkpoint.
21
- 8. For a user with a company group, call `get_group_session_sharing` near session start or after a
22
- qualifying private save. If unset, ask whether memories saved from this session should be shared.
23
- Silence is not a No: leave the state unset and ask again at a later qualifying checkpoint until the
24
- user explicitly answers Yes or No, without repeating the prompt in the same response. Then call
25
- `set_group_session_sharing` with that explicit answer. A Yes decision syncs eligible memories after
26
- private persistence; a No decision stops later prompts for this exact session; flagged memories
27
- remain private.
26
+ 8. For a user with a company group, call `get_group_session_sharing` near conversation start or after a
27
+ qualifying private save. On the first call in a conversation, omit `groupSharingScopeId` so EchoMem
28
+ mints a fresh opaque scope. Reuse that exact scope only in this conversation for later get/set/save
29
+ calls, and never persist it as memory. If multiple groups are returned, reuse the same scope and
30
+ check one `groupId` at a time. Each group requires its own explicit Yes/No decision. Silence leaves
31
+ that group's state unset; never infer an answer or repeat the prompt in the same response. Store only
32
+ explicit answers with `set_group_session_sharing`, the same scope, and the selected groupId. Saves
33
+ sync eligible memories to every approved group; a No keeps them private for that group; flagged
34
+ memories remain private.
@@ -18,10 +18,17 @@ This project has EchoMem connected — the user's long-term memory across all th
18
18
  `save_conversation`. This private checkpoint does not require a separate user request. Save a
19
19
  concise structured outcome rather than the full transcript. Never save secrets, credentials,
20
20
  incidental chatter, or trivial read-only work. This does not authorize company-group publication.
21
- - For a user with a company group, call `get_group_session_sharing` near session start or after a
22
- qualifying private save. If this exact session has no decision, ask whether memories saved from it
23
- should be shared. Silence is not a No: leave the state unset and ask again at a later qualifying
24
- checkpoint until the user explicitly answers Yes or No, without repeating the prompt in the same
25
- response. Store only the explicit answer with `set_group_session_sharing`. A Yes decision syncs
26
- eligible memories after private persistence; a No decision stops later prompts for this exact
27
- session; flagged memories stay private.
21
+ - After `save_conversation` succeeds and returns one or more saved memory IDs, make the save visible
22
+ in the final user-facing answer with a compact `EchoMem saved:` list containing every memory
23
+ created by that call. Link each memory key to its canonical
24
+ `https://echoknows.com/memory/<memory-id>` URL. This save receipt is separate from
25
+ `EchoMem sources:` and does not imply that the newly saved memories informed the answer.
26
+ - For a user with a company group, call `get_group_session_sharing` near conversation start or after a
27
+ qualifying private save. On the first call in a conversation, omit `groupSharingScopeId` so EchoMem
28
+ mints a fresh opaque scope. Reuse that exact scope only in this conversation for later get/set/save
29
+ calls, and never persist it as memory. If multiple groups are returned, reuse the same scope and
30
+ check one `groupId` at a time. Each group requires its own explicit Yes/No decision. Silence leaves
31
+ that group's state unset; never infer an answer or repeat the prompt in the same response. Store only
32
+ explicit answers with `set_group_session_sharing`, the same scope, and the selected groupId. Saves
33
+ sync eligible memories to every approved group; a No keeps them private for that group; flagged
34
+ memories stay private.