@echomem/mcp 1.4.30 → 1.4.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context-analysis/vendored-canonical.js +176 -31
- package/dist/forensics.js +36 -17
- package/dist/index.js +101 -36
- package/dist/package-metadata.js +3 -1
- package/dist/save-checkpoint-hook.js +3 -3
- package/dist/setup.js +7 -3
- package/dist/v1-contract.js +30 -8
- package/package.json +2 -1
- package/templates/codex-skills/echomem-save/SKILL.md +15 -5
- package/templates/echomem-recall.md +14 -5
|
@@ -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 =
|
|
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
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
|
513
|
-
|
|
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:
|
|
533
|
-
turnEnd:
|
|
534
|
-
wasteTokens:
|
|
535
|
-
dominantProblemId:
|
|
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,
|
|
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 =
|
|
1065
|
-
.filter((
|
|
1066
|
-
.sort((a, b) => (a.
|
|
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((
|
|
1069
|
-
"claude-code": records.filter((
|
|
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
|
|
1074
|
-
if (!
|
|
1073
|
+
for (const record of records) {
|
|
1074
|
+
if (!record.session)
|
|
1075
1075
|
continue;
|
|
1076
|
-
sourceOrdinals[
|
|
1077
|
-
const indexedTitle =
|
|
1078
|
-
sessionMeta.set(
|
|
1079
|
-
id:
|
|
1080
|
-
title: indexedTitle ||
|
|
1081
|
-
ordinal: sourceOrdinals[
|
|
1082
|
-
total: sourceTotals[
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
845
|
-
|
|
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
|
|
1034
|
-
|
|
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:
|
|
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
|
|
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
|
|
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?.
|
|
1581
|
-
?
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
:
|
|
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
|
-
|
|
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
|
}
|
|
@@ -1994,7 +2038,7 @@ Details: ${m.details || "N/A"}`;
|
|
|
1994
2038
|
participantText,
|
|
1995
2039
|
"",
|
|
1996
2040
|
"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
|
|
2041
|
+
"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
2042
|
currentParticipant
|
|
1999
2043
|
&& (!readString(currentParticipant, "title") || !readString(currentParticipant, "responsibilitySummary"))
|
|
2000
2044
|
? "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 +2049,29 @@ Details: ${m.details || "N/A"}`;
|
|
|
2005
2049
|
async handleGetGroupSessionSharing(args) {
|
|
2006
2050
|
getGroupSessionSharingSchema.parse(args ?? {});
|
|
2007
2051
|
const payload = await this.client.getGroupSessionSharing(args);
|
|
2052
|
+
const groupSharingScopeId = readString(payload ?? {}, "groupSharingScopeId");
|
|
2053
|
+
const scopeText = groupSharingScopeId
|
|
2054
|
+
? `Conversation sharing scope: ${groupSharingScopeId}\nReuse this exact groupSharingScopeId only in this conversation for later get/set/save calls. Never persist it as memory.`
|
|
2055
|
+
: "";
|
|
2008
2056
|
if (payload?.hasGroup !== true) {
|
|
2009
2057
|
return {
|
|
2010
2058
|
content: [{
|
|
2011
2059
|
type: "text",
|
|
2012
|
-
text: "No company group is configured for this user. Saves remain private; do not ask about
|
|
2060
|
+
text: "No company group is configured for this user. Saves remain private; do not ask about conversation sharing.",
|
|
2061
|
+
}],
|
|
2062
|
+
};
|
|
2063
|
+
}
|
|
2064
|
+
if (payload?.requiresGroupSelection === true) {
|
|
2065
|
+
const availableGroups = Array.isArray(payload?.availableGroups)
|
|
2066
|
+
? payload.availableGroups.filter(isRecord)
|
|
2067
|
+
: [];
|
|
2068
|
+
const choices = availableGroups
|
|
2069
|
+
.map((group) => `${readString(group, "name") ?? "Unnamed group"} (${readString(group, "id") ?? "unknown id"})`)
|
|
2070
|
+
.join(", ");
|
|
2071
|
+
return {
|
|
2072
|
+
content: [{
|
|
2073
|
+
type: "text",
|
|
2074
|
+
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
2075
|
}],
|
|
2014
2076
|
};
|
|
2015
2077
|
}
|
|
@@ -2019,16 +2081,19 @@ Details: ${m.details || "N/A"}`;
|
|
|
2019
2081
|
return {
|
|
2020
2082
|
content: [{
|
|
2021
2083
|
type: "text",
|
|
2022
|
-
text:
|
|
2084
|
+
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
2085
|
}],
|
|
2024
2086
|
};
|
|
2025
2087
|
}
|
|
2026
2088
|
return {
|
|
2027
2089
|
content: [{
|
|
2028
2090
|
type: "text",
|
|
2029
|
-
text:
|
|
2030
|
-
|
|
2031
|
-
|
|
2091
|
+
text: [
|
|
2092
|
+
scopeText,
|
|
2093
|
+
decision === "share"
|
|
2094
|
+
? `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.`
|
|
2095
|
+
: "This conversation is private. Future saves carrying this scope remain private unless the user explicitly changes this conversation's decision.",
|
|
2096
|
+
].filter(Boolean).join("\n\n"),
|
|
2032
2097
|
}],
|
|
2033
2098
|
};
|
|
2034
2099
|
}
|
|
@@ -2040,9 +2105,9 @@ Details: ${m.details || "N/A"}`;
|
|
|
2040
2105
|
const group = isRecord(payload?.group) ? payload.group : {};
|
|
2041
2106
|
const receipt = parsed.share
|
|
2042
2107
|
? sync?.synced === false
|
|
2043
|
-
? "
|
|
2044
|
-
: `
|
|
2045
|
-
: "
|
|
2108
|
+
? "Conversation sharing is enabled, but the initial group sync failed. Private memories were preserved; retry before claiming publication."
|
|
2109
|
+
: `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.`
|
|
2110
|
+
: "Conversation sharing is off. Future saves carrying this scope remain private.";
|
|
2046
2111
|
return {
|
|
2047
2112
|
content: [{
|
|
2048
2113
|
type: "text",
|
|
@@ -2098,8 +2163,8 @@ Details: ${m.details || "N/A"}`;
|
|
|
2098
2163
|
content: [{
|
|
2099
2164
|
type: "text",
|
|
2100
2165
|
text: payload?.alreadyMember
|
|
2101
|
-
? `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
|
|
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
|
|
2166
|
+
? `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.`
|
|
2167
|
+
: `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
2168
|
}],
|
|
2104
2169
|
};
|
|
2105
2170
|
}
|
package/dist/package-metadata.js
CHANGED
|
@@ -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
|
|
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
|
|
11
|
-
"
|
|
12
|
-
"Flagged memories are withheld from automatic
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
package/dist/v1-contract.js
CHANGED
|
@@ -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:
|
|
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: {
|
|
@@ -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.
|
|
502
|
+
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. 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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.4.32",
|
|
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,14 +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.
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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,8 +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
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
`
|
|
25
|
-
|
|
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.
|