@echomem/mcp 1.4.34 → 1.4.36
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 +12 -45
- package/dist/codex-sync.js +1 -1
- package/dist/context-analysis/claude-native-canonical.js +87 -29
- package/dist/forensics.js +12 -9
- package/dist/index.js +94 -17
- package/dist/package-metadata.js +10 -3
- package/dist/save-checkpoint-hook.js +1 -1
- package/dist/setup-page/client-extraction.js +35 -11
- package/dist/setup-page.js +1 -1
- package/dist/setup-preview.js +25 -2
- package/dist/setup.js +132 -79
- package/dist/v1-contract.js +106 -92
- package/package.json +11 -6
package/README.md
CHANGED
|
@@ -37,38 +37,26 @@ legacy EchoMem-synthesized recall answer.
|
|
|
37
37
|
|
|
38
38
|
## Quick start (recommended)
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
Install Echo Desktop, sign in once, then open **Connect MCP** and choose **Connect Echo**. The app
|
|
41
|
+
ships and installs the MCP runtime, configures the detected Codex and Claude hosts, mirrors the same
|
|
42
|
+
device credential used by the desktop app, and verifies the MCP handshake. No terminal login is
|
|
43
|
+
required.
|
|
44
|
+
|
|
45
|
+
The standalone HUD has been retired; Echo Desktop is the persistent setup and status surface. The
|
|
46
|
+
bridge still reads local agent logs for on-demand context-health tools, but it no longer publishes or
|
|
47
|
+
auto-launches an Electron overlay.
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
`npx` run resolves the bridge into a throwaway `_npx/<hash>` cache dir that npm later garbage-collects,
|
|
48
|
-
which would break the MCP server after the fact (`setup` now refuses to pin such a path and falls back
|
|
49
|
-
to the global install, but installing globally avoids the issue entirely). The context HUD's
|
|
50
|
-
launch-at-login also runs from the installed path. `init` is the flagship one-liner — it wraps `setup --all --with-hud`: it writes each
|
|
51
|
-
installed agent's MCP config (with **no secret** in it — credentials live in
|
|
52
|
-
`~/.echomem/credentials.json`, mode 0600), adds the EchoMem memory guidance to their global
|
|
53
|
-
`AGENTS.md` / `CLAUDE.md`, installs first-party `echomem-search`, `echomem-save`,
|
|
54
|
-
`echomem-forget`, and `echomem-login` skills for Codex, opens the browser to approve the device
|
|
55
|
-
(and unlock the vault for encrypted accounts), then launches the context HUD. EchoMem updates only
|
|
56
|
-
its own skill folders; other memory-provider skills are detected and reported but never modified.
|
|
57
|
-
Reload your editors and you're done.
|
|
58
|
-
|
|
59
|
-
Prefer to keep it minimal? `echomem-mcp setup` configures only the auto-detected editor and skips the
|
|
60
|
-
HUD; the granular commands below still work.
|
|
49
|
+
For headless systems and development, the granular CLI commands remain available:
|
|
61
50
|
|
|
62
51
|
| Command | What it does |
|
|
63
52
|
|---|---|
|
|
64
|
-
| `npm i -g @echomem/mcp@latest && echomem-mcp init` |
|
|
53
|
+
| `npm i -g @echomem/mcp@latest && echomem-mcp init` | Legacy/headless setup for installed agents + login |
|
|
65
54
|
| `npm i -g @echomem/mcp@latest && echomem-mcp setup` | Install the CLI globally and configure just the detected editor |
|
|
66
55
|
| `npx -y @echomem/mcp@latest setup` | One-off setup without keeping a global CLI command |
|
|
67
56
|
| `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config + log in |
|
|
68
57
|
| `echomem-mcp setup --skip-login [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config without opening the browser or changing credentials |
|
|
69
58
|
| `npx -y @echomem/mcp@latest update --all` | One-shot update: install the latest bridge durably and repoint detected client configs, with no browser login |
|
|
70
59
|
| `npx -y @echomem/mcp@latest update --client codex` | Update one client only |
|
|
71
|
-
| `echomem-mcp setup --with-hud [--client codex]` | Write client config + log in + launch the EchoMem context HUD |
|
|
72
60
|
| `echomem-mcp login` | Approve device in browser (or use `--token` / `--passphrase`) |
|
|
73
61
|
| `echomem-mcp unlock` | Privately unlock the vault on this trusted device |
|
|
74
62
|
| `echomem-mcp lock` | Remove the local vault key while keeping the device login |
|
|
@@ -82,29 +70,8 @@ checks npm for a newer published bridge using a cached, non-blocking check. Agen
|
|
|
82
70
|
`npx -y @echomem/mcp@latest update --all` if the user agrees. The bridge does not auto-update on
|
|
83
71
|
every MCP startup.
|
|
84
72
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
The package also ships `echomem-hud`, a local context-health sidecar for Codex and Claude. It reads
|
|
88
|
-
local agent logs, computes a tracked lower-bound "clean vs dirty" context score, and renders a small
|
|
89
|
-
HUD without adding tokens to the agent conversation.
|
|
90
|
-
|
|
91
|
-
```bash
|
|
92
|
-
echomem-hud app # Electron floating HUD
|
|
93
|
-
echomem-hud serve # Browser fallback at http://127.0.0.1:17377
|
|
94
|
-
echomem-hud summary --json # Machine-readable current score
|
|
95
|
-
echomem-hud status # Show detected Codex/Claude sources
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
Supported local sources:
|
|
99
|
-
- Codex: `~/.codex/sessions/**/rollout-*.jsonl`
|
|
100
|
-
- Claude Code: `~/.claude/echo-ctx/*.json` first, then local transcripts when present
|
|
101
|
-
- Claude desktop agent/Cowork: local agent-mode transcripts under Application Support
|
|
102
|
-
|
|
103
|
-
The v1 metric counts `range_redundant` reads only and reports pollution as `tracked dead-weight ≥`
|
|
104
|
-
because exact context composition and provider eviction are not observable.
|
|
105
|
-
|
|
106
|
-
MCP fallback: agents can call `echo_context_health` to get the same score as markdown in-chat. This
|
|
107
|
-
is on-demand; the passive HUD remains a separate local process.
|
|
73
|
+
Agents can still call `echo_context_health` for an on-demand local context-health report. It reads
|
|
74
|
+
the local Codex/Claude logs and does not require a separate process or desktop overlay.
|
|
108
75
|
|
|
109
76
|
## Agent Doctor — local workspace forensics (new in 1.4.9)
|
|
110
77
|
|
package/dist/codex-sync.js
CHANGED
|
@@ -132,7 +132,7 @@ function summarizeToolArgs(toolName, args) {
|
|
|
132
132
|
tag_count: Array.isArray(args.tags) ? args.tags.length : undefined,
|
|
133
133
|
};
|
|
134
134
|
}
|
|
135
|
-
if (toolName === "get_memories_by_time_range"
|
|
135
|
+
if (toolName === "get_memories_by_time_range") {
|
|
136
136
|
return {
|
|
137
137
|
has_start_date: !!asString(args.startDate),
|
|
138
138
|
has_end_date: !!asString(args.endDate),
|
|
@@ -7,6 +7,14 @@ import { walk } from "../report.js";
|
|
|
7
7
|
import { isStrongPositiveFeedback, } from "./workspace-report.js";
|
|
8
8
|
const OUTPUT_TOKEN_CAP = 12_000;
|
|
9
9
|
const IMAGE_TOKENS = 4_000;
|
|
10
|
+
// Request-by-request retained-context scoring grows faster than the raw JSONL. Keep onboarding
|
|
11
|
+
// predictable by analyzing a deterministic head/middle/tail sample from oversized sessions. The
|
|
12
|
+
// source transcript is untouched and remains available to the separate import pipeline.
|
|
13
|
+
const CLAUDE_MAX_TURNS_PER_SESSION = 1_000;
|
|
14
|
+
const CLAUDE_HEAD_TURNS = 200;
|
|
15
|
+
const CLAUDE_TAIL_TURNS = 500;
|
|
16
|
+
const CLAUDE_MIDDLE_WINDOWS = 3;
|
|
17
|
+
const CLAUDE_MIDDLE_WINDOW_TURNS = 100;
|
|
10
18
|
const PROBLEM_META = {
|
|
11
19
|
P01: { label: "Outdated Images & Screenshots", bucket: "dead", category: "Runtime Bug", confidence: "high" },
|
|
12
20
|
P02: { label: "Ignored User Instructions", bucket: "refind", category: "Model Behavior", confidence: "medium" },
|
|
@@ -350,26 +358,31 @@ function resultItem(args) {
|
|
|
350
358
|
}
|
|
351
359
|
function scoreClaudeNativeSession(parsed) {
|
|
352
360
|
classifyItems(parsed.items);
|
|
353
|
-
const
|
|
361
|
+
const terminalRequestByTurn = new Map();
|
|
354
362
|
for (const request of parsed.requests) {
|
|
355
363
|
const rawBuckets = { keep_oh: 0, keep_prod: 0, opt_dup: 0, opt_refind: 0, opt_dead: 0 };
|
|
356
364
|
const buckets = { duplicate: 0, refind: 0, dead: 0, unattributed: 0 };
|
|
357
365
|
const problemStats = new Map();
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
366
|
+
// Episode rendering only needs totals by episode/problem/bucket. Aggregating here avoids
|
|
367
|
+
// retaining one occurrence for every prior item on every request (quadratic heap growth).
|
|
368
|
+
const occurrenceMap = new Map();
|
|
369
|
+
const priorItems = [];
|
|
370
|
+
let usefulItemTokens = 0;
|
|
371
|
+
let wasteItemTokens = 0;
|
|
372
|
+
for (const item of parsed.items) {
|
|
373
|
+
if (item.requestSeq >= request.seq)
|
|
374
|
+
continue;
|
|
362
375
|
const classification = classForRequest(item, request);
|
|
363
|
-
|
|
364
|
-
|
|
376
|
+
if (!classification)
|
|
377
|
+
continue;
|
|
378
|
+
priorItems.push({ item, classification });
|
|
379
|
+
if (classification.kind === "useful")
|
|
380
|
+
usefulItemTokens += item.tokens;
|
|
381
|
+
else
|
|
382
|
+
wasteItemTokens += item.tokens;
|
|
383
|
+
}
|
|
365
384
|
const overhead = Math.min(request.inputTokens, parsed.overheadTokens);
|
|
366
385
|
const productBudget = Math.max(0, request.inputTokens - overhead);
|
|
367
|
-
const usefulItemTokens = priorItems
|
|
368
|
-
.filter(({ classification }) => classification.kind === "useful")
|
|
369
|
-
.reduce((sum, { item }) => sum + item.tokens, 0);
|
|
370
|
-
const wasteItemTokens = priorItems
|
|
371
|
-
.filter(({ classification }) => classification.kind === "waste")
|
|
372
|
-
.reduce((sum, { item }) => sum + item.tokens, 0);
|
|
373
386
|
const totalObserved = usefulItemTokens + wasteItemTokens;
|
|
374
387
|
const scale = totalObserved > productBudget && totalObserved > 0 ? productBudget / totalObserved : 1;
|
|
375
388
|
const keepProd = usefulItemTokens * scale;
|
|
@@ -413,7 +426,7 @@ function scoreClaudeNativeSession(parsed) {
|
|
|
413
426
|
});
|
|
414
427
|
}
|
|
415
428
|
problemStats.set(classification.problemId, stat);
|
|
416
|
-
|
|
429
|
+
recordNativeOccurrence(occurrenceMap, {
|
|
417
430
|
episode: request.episode,
|
|
418
431
|
problemId: classification.problemId,
|
|
419
432
|
bucket: classification.bucket,
|
|
@@ -437,7 +450,7 @@ function scoreClaudeNativeSession(parsed) {
|
|
|
437
450
|
problemStats,
|
|
438
451
|
buckets,
|
|
439
452
|
rawBuckets,
|
|
440
|
-
occurrences,
|
|
453
|
+
occurrences: occurrenceMap,
|
|
441
454
|
requestUsefulTokens: rawUseful,
|
|
442
455
|
requestWasteTokens: rawResidue,
|
|
443
456
|
});
|
|
@@ -466,7 +479,7 @@ function scoreClaudeNativeSession(parsed) {
|
|
|
466
479
|
turnWasteTokens: Math.round(rawResidue),
|
|
467
480
|
}];
|
|
468
481
|
}
|
|
469
|
-
|
|
482
|
+
terminalRequestByTurn.set(request.turn, {
|
|
470
483
|
request,
|
|
471
484
|
officialInputTokens: request.inputTokens,
|
|
472
485
|
usefulTokens: rawUseful,
|
|
@@ -477,10 +490,11 @@ function scoreClaudeNativeSession(parsed) {
|
|
|
477
490
|
buckets,
|
|
478
491
|
rawBuckets,
|
|
479
492
|
problems: problemStats,
|
|
480
|
-
occurrences,
|
|
493
|
+
occurrences: [...occurrenceMap.values()],
|
|
481
494
|
});
|
|
482
495
|
}
|
|
483
|
-
const selectedRows =
|
|
496
|
+
const selectedRows = [...terminalRequestByTurn.values()]
|
|
497
|
+
.sort((left, right) => left.request.turn - right.request.turn || left.request.seq - right.request.seq);
|
|
484
498
|
const selected = mergeRequestAccountingRows(selectedRows);
|
|
485
499
|
const zeroBuckets = { duplicate: 0, refind: 0, dead: 0, unattributed: 0 };
|
|
486
500
|
const zeroRawBuckets = { keep_oh: 0, keep_prod: 0, opt_dup: 0, opt_refind: 0, opt_dead: 0 };
|
|
@@ -537,7 +551,7 @@ function allocateResidualToProblemSignals(args) {
|
|
|
537
551
|
});
|
|
538
552
|
}
|
|
539
553
|
args.problemStats.set(problemId, stat);
|
|
540
|
-
args.occurrences
|
|
554
|
+
recordNativeOccurrence(args.occurrences, {
|
|
541
555
|
episode: args.request.episode,
|
|
542
556
|
problemId,
|
|
543
557
|
bucket,
|
|
@@ -548,15 +562,15 @@ function allocateResidualToProblemSignals(args) {
|
|
|
548
562
|
}
|
|
549
563
|
return allocated;
|
|
550
564
|
}
|
|
551
|
-
function
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
565
|
+
function recordNativeOccurrence(occurrences, occurrence) {
|
|
566
|
+
const key = `${occurrence.episode}:${occurrence.problemId}:${occurrence.bucket}`;
|
|
567
|
+
const existing = occurrences.get(key);
|
|
568
|
+
if (existing) {
|
|
569
|
+
existing.tokens += occurrence.tokens;
|
|
570
|
+
existing.turn = Math.max(existing.turn, occurrence.turn);
|
|
571
|
+
return;
|
|
558
572
|
}
|
|
559
|
-
|
|
573
|
+
occurrences.set(key, { ...occurrence });
|
|
560
574
|
}
|
|
561
575
|
function mergeRequestAccountingRows(rows) {
|
|
562
576
|
const buckets = { duplicate: 0, refind: 0, dead: 0, unattributed: 0 };
|
|
@@ -1023,7 +1037,52 @@ function internalBucket(bucket) {
|
|
|
1023
1037
|
return "opt_dead";
|
|
1024
1038
|
}
|
|
1025
1039
|
function readRows(file) {
|
|
1040
|
+
const totalTurns = countClaudeNativeTurns(file);
|
|
1041
|
+
const ranges = claudeTurnSampleRanges(totalTurns);
|
|
1026
1042
|
const rows = [];
|
|
1043
|
+
let turn = 0;
|
|
1044
|
+
forEachJsonRow(file, (row) => {
|
|
1045
|
+
if (stringValue(row.type) === "user" && !isClaudeToolResultUser(row))
|
|
1046
|
+
turn += 1;
|
|
1047
|
+
if (turn === 0 || turnInClaudeRanges(turn, ranges))
|
|
1048
|
+
rows.push(row);
|
|
1049
|
+
});
|
|
1050
|
+
return rows;
|
|
1051
|
+
}
|
|
1052
|
+
function countClaudeNativeTurns(file) {
|
|
1053
|
+
let turns = 0;
|
|
1054
|
+
forEachJsonRow(file, (row) => {
|
|
1055
|
+
if (stringValue(row.type) === "user" && !isClaudeToolResultUser(row))
|
|
1056
|
+
turns += 1;
|
|
1057
|
+
});
|
|
1058
|
+
return turns;
|
|
1059
|
+
}
|
|
1060
|
+
export function claudeTurnSampleRanges(totalTurns) {
|
|
1061
|
+
if (totalTurns <= CLAUDE_MAX_TURNS_PER_SESSION)
|
|
1062
|
+
return totalTurns > 0 ? [[1, totalTurns]] : [];
|
|
1063
|
+
const tailStart = totalTurns - CLAUDE_TAIL_TURNS + 1;
|
|
1064
|
+
const ranges = [
|
|
1065
|
+
[1, CLAUDE_HEAD_TURNS],
|
|
1066
|
+
[tailStart, totalTurns],
|
|
1067
|
+
];
|
|
1068
|
+
const middleStart = CLAUDE_HEAD_TURNS + 1;
|
|
1069
|
+
const middleEnd = tailStart - 1;
|
|
1070
|
+
const middleSpan = Math.max(0, middleEnd - middleStart + 1);
|
|
1071
|
+
for (let index = 0; index < CLAUDE_MIDDLE_WINDOWS; index += 1) {
|
|
1072
|
+
const segmentStart = middleStart + Math.floor((middleSpan * index) / CLAUDE_MIDDLE_WINDOWS);
|
|
1073
|
+
const segmentEnd = middleStart + Math.floor((middleSpan * (index + 1)) / CLAUDE_MIDDLE_WINDOWS) - 1;
|
|
1074
|
+
const segmentTurns = Math.max(0, segmentEnd - segmentStart + 1);
|
|
1075
|
+
const windowTurns = Math.min(CLAUDE_MIDDLE_WINDOW_TURNS, segmentTurns);
|
|
1076
|
+
const start = segmentStart + Math.floor((segmentTurns - windowTurns) / 2);
|
|
1077
|
+
if (windowTurns > 0)
|
|
1078
|
+
ranges.push([start, start + windowTurns - 1]);
|
|
1079
|
+
}
|
|
1080
|
+
return ranges.sort((left, right) => left[0] - right[0]);
|
|
1081
|
+
}
|
|
1082
|
+
function turnInClaudeRanges(turn, ranges) {
|
|
1083
|
+
return ranges.some(([start, end]) => turn >= start && turn <= end);
|
|
1084
|
+
}
|
|
1085
|
+
function forEachJsonRow(file, visit) {
|
|
1027
1086
|
const fd = fs.openSync(file, "r");
|
|
1028
1087
|
const decoder = new StringDecoder("utf8");
|
|
1029
1088
|
const buffer = Buffer.allocUnsafe(1 << 20);
|
|
@@ -1034,7 +1093,7 @@ function readRows(file) {
|
|
|
1034
1093
|
try {
|
|
1035
1094
|
const parsed = JSON.parse(line);
|
|
1036
1095
|
if (isRecord(parsed))
|
|
1037
|
-
|
|
1096
|
+
visit(parsed);
|
|
1038
1097
|
}
|
|
1039
1098
|
catch {
|
|
1040
1099
|
// Claude Code may leave an active final JSONL line partial.
|
|
@@ -1064,7 +1123,6 @@ function readRows(file) {
|
|
|
1064
1123
|
finally {
|
|
1065
1124
|
fs.closeSync(fd);
|
|
1066
1125
|
}
|
|
1067
|
-
return rows;
|
|
1068
1126
|
}
|
|
1069
1127
|
function isClaudeToolResultUser(record) {
|
|
1070
1128
|
const content = recordValue(record.message).content;
|
package/dist/forensics.js
CHANGED
|
@@ -1158,9 +1158,9 @@ function invalidSetupReport(code, message) {
|
|
|
1158
1158
|
/**
|
|
1159
1159
|
* Fail-closed boundary between the local scanner and setup UI.
|
|
1160
1160
|
*
|
|
1161
|
-
* The setup page must never infer that a fixture,
|
|
1162
|
-
* ledger is the user's report. This validator checks
|
|
1163
|
-
* the UI relies on before any absolute token number is rendered.
|
|
1161
|
+
* The setup page must never infer that a fixture, unreconciled scan, or internally inconsistent
|
|
1162
|
+
* token ledger is the user's report. This validator checks provenance and the cross-ledger
|
|
1163
|
+
* identities the UI relies on before any absolute token number is rendered.
|
|
1164
1164
|
*/
|
|
1165
1165
|
export function validateForensicReportForSetup(value) {
|
|
1166
1166
|
const report = recordValue(value);
|
|
@@ -1268,6 +1268,7 @@ export function validateForensicReportForSetup(value) {
|
|
|
1268
1268
|
return invalidSetupReport("REPORT_COST_INVALID", "Per-workspace costs do not reconcile with total spend.");
|
|
1269
1269
|
}
|
|
1270
1270
|
const diagnostics = canonical.diagnostics === undefined ? null : recordValue(canonical.diagnostics);
|
|
1271
|
+
let skippedCanonicalSessions = 0;
|
|
1271
1272
|
if (canonical.diagnostics !== undefined && !diagnostics) {
|
|
1272
1273
|
return invalidSetupReport("REPORT_MALFORMED", "Canonical scan diagnostics are malformed.");
|
|
1273
1274
|
}
|
|
@@ -1277,12 +1278,15 @@ export function validateForensicReportForSetup(value) {
|
|
|
1277
1278
|
if (skippedSessions === null || !Array.isArray(errors)) {
|
|
1278
1279
|
return invalidSetupReport("REPORT_MALFORMED", "Canonical scan diagnostics are malformed.");
|
|
1279
1280
|
}
|
|
1280
|
-
if (skippedSessions
|
|
1281
|
-
return invalidSetupReport("
|
|
1281
|
+
if (skippedSessions !== errors.length) {
|
|
1282
|
+
return invalidSetupReport("REPORT_MALFORMED", "Canonical scan diagnostics do not reconcile.");
|
|
1282
1283
|
}
|
|
1284
|
+
skippedCanonicalSessions = skippedSessions;
|
|
1283
1285
|
}
|
|
1284
|
-
if (
|
|
1285
|
-
|
|
1286
|
+
if (canonicalSessionCount > sessionCount ||
|
|
1287
|
+
canonicalSessionCount + skippedCanonicalSessions !== sessionCount ||
|
|
1288
|
+
(sessionCount > 0 && canonicalSessionCount === 0)) {
|
|
1289
|
+
return invalidSetupReport("REPORT_COHORT_MISMATCH", "Canonical analysis did not retain a usable local-session cohort.");
|
|
1286
1290
|
}
|
|
1287
1291
|
if (canonicalUseful + canonicalWaste !== canonicalInput) {
|
|
1288
1292
|
return invalidSetupReport("REPORT_CANONICAL_INVALID", "Canonical useful and waste totals do not reconcile.");
|
|
@@ -1517,8 +1521,7 @@ export async function buildForensicReport(opts) {
|
|
|
1517
1521
|
}
|
|
1518
1522
|
if (report.canonicalGoldenStandard &&
|
|
1519
1523
|
!("error" in report.canonicalGoldenStandard) &&
|
|
1520
|
-
|
|
1521
|
-
report.scale.sessionCount === report.canonicalGoldenStandard.summary.sessionsAnalyzed) {
|
|
1524
|
+
report.canonicalGoldenStandard.summary.sessionsAnalyzed > 0) {
|
|
1522
1525
|
const projection = projectCanonicalWasteToBilledInput(Number(report.scale.totalInputTokens || 0), report.canonicalGoldenStandard.summary);
|
|
1523
1526
|
if (projection)
|
|
1524
1527
|
report.billedWasteProjection = projection;
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,13 @@ import { checkLatestUpdateStatus, formatUpdateNotice, formatUpdateStatusText, st
|
|
|
18
18
|
const ECHO_API_BASE_URL = process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app";
|
|
19
19
|
const ECHO_PRICING_URL = process.env.ECHO_PRICING_URL || "https://echoknows.com/account";
|
|
20
20
|
const ECHO_MEMORY_WEB_URL = (process.env.ECHO_MEMORY_WEB_URL || "https://echoknows.com/memory").replace(/\/$/, "");
|
|
21
|
+
const DESKTOP_MANAGED = process.env.ECHO_DESKTOP_MANAGED === "1";
|
|
22
|
+
const CONNECT_DEVICE_INSTRUCTION = DESKTOP_MANAGED
|
|
23
|
+
? "Open Echo Desktop, sign in, and choose Connect MCP, then retry this action."
|
|
24
|
+
: "Run `echomem-mcp login` in a terminal to reconnect this device, then retry this action.";
|
|
25
|
+
const UNLOCK_VAULT_INSTRUCTION = DESKTOP_MANAGED
|
|
26
|
+
? "Open Echo Desktop and unlock the vault there. Keep the passphrase out of chat."
|
|
27
|
+
: "Open Terminal and run `echomem-mcp unlock` yourself. Do not have the agent run this interactive command and do not send your passphrase in chat.";
|
|
21
28
|
function memoryWebUrl(memoryId) {
|
|
22
29
|
return `${ECHO_MEMORY_WEB_URL}/${encodeURIComponent(memoryId)}`;
|
|
23
30
|
}
|
|
@@ -156,7 +163,7 @@ function formatReconnectRequiredResult(error) {
|
|
|
156
163
|
return null;
|
|
157
164
|
return [
|
|
158
165
|
"🔌 EchoMem's saved login is no longer accepted (Unauthorized, HTTP 401).",
|
|
159
|
-
|
|
166
|
+
`Action required from the user: ${CONNECT_DEVICE_INSTRUCTION}`,
|
|
160
167
|
"No editor restart is needed. Keep all credentials out of chat.",
|
|
161
168
|
].join("\n");
|
|
162
169
|
}
|
|
@@ -185,6 +192,56 @@ function readNumber(record, key) {
|
|
|
185
192
|
const value = record[key];
|
|
186
193
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
187
194
|
}
|
|
195
|
+
function normalizeQueryAlias(args) {
|
|
196
|
+
if (!isRecord(args) || !("conversation" in args))
|
|
197
|
+
return args;
|
|
198
|
+
const query = readString(args, "query") ?? readString(args, "conversation");
|
|
199
|
+
const { conversation: _ignored, ...rest } = args;
|
|
200
|
+
return query ? { ...rest, query } : rest;
|
|
201
|
+
}
|
|
202
|
+
const PERSONAL_RECALL_TOOL_NAMES = new Set([
|
|
203
|
+
canonicalToolNames.search,
|
|
204
|
+
canonicalToolNames.timeRange,
|
|
205
|
+
canonicalToolNames.keywords,
|
|
206
|
+
]);
|
|
207
|
+
function hasKeywordInput(args) {
|
|
208
|
+
const value = args.keywords;
|
|
209
|
+
if (Array.isArray(value)) {
|
|
210
|
+
return value.some((item) => typeof item === "string" && item.trim().length > 0);
|
|
211
|
+
}
|
|
212
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
213
|
+
}
|
|
214
|
+
function routePersonalRecallInvocation(canonicalName, args) {
|
|
215
|
+
if (!PERSONAL_RECALL_TOOL_NAMES.has(canonicalName)) {
|
|
216
|
+
return { canonicalName, args };
|
|
217
|
+
}
|
|
218
|
+
const normalizedArgs = normalizeQueryAlias(args);
|
|
219
|
+
if (!isRecord(normalizedArgs)) {
|
|
220
|
+
return { canonicalName, args: normalizedArgs };
|
|
221
|
+
}
|
|
222
|
+
const hasSemanticInput = Boolean(readString(normalizedArgs, "query")
|
|
223
|
+
|| readNumber(normalizedArgs, "timeFrameDays") !== undefined);
|
|
224
|
+
const hasKeywords = hasKeywordInput(normalizedArgs);
|
|
225
|
+
const hasDateRangeInput = Boolean(readString(normalizedArgs, "startDate") || readString(normalizedArgs, "endDate"));
|
|
226
|
+
const intentCount = Number(hasSemanticInput) + Number(hasKeywords) + Number(hasDateRangeInput);
|
|
227
|
+
if (intentCount > 1) {
|
|
228
|
+
return {
|
|
229
|
+
canonicalName,
|
|
230
|
+
args: normalizedArgs,
|
|
231
|
+
error: "Ambiguous memory search arguments. Use exactly one input shape: {query, optional timeFrameDays} for topic search, {keywords} for exact-key search, or {startDate, endDate} for a date range.",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (hasSemanticInput) {
|
|
235
|
+
return { canonicalName: canonicalToolNames.search, args: normalizedArgs };
|
|
236
|
+
}
|
|
237
|
+
if (hasKeywords) {
|
|
238
|
+
return { canonicalName: canonicalToolNames.keywords, args: normalizedArgs };
|
|
239
|
+
}
|
|
240
|
+
if (hasDateRangeInput) {
|
|
241
|
+
return { canonicalName: canonicalToolNames.timeRange, args: normalizedArgs };
|
|
242
|
+
}
|
|
243
|
+
return { canonicalName, args: normalizedArgs };
|
|
244
|
+
}
|
|
188
245
|
function errorCodeFrom(value) {
|
|
189
246
|
if (!isRecord(value))
|
|
190
247
|
return undefined;
|
|
@@ -776,12 +833,12 @@ class EchoMemApiClient {
|
|
|
776
833
|
};
|
|
777
834
|
}
|
|
778
835
|
async searchMemories(args, trace) {
|
|
779
|
-
const parsed = searchMemoriesSchema.parse(args ?? {});
|
|
836
|
+
const parsed = searchMemoriesSchema.parse(normalizeQueryAlias(args) ?? {});
|
|
780
837
|
const query = parsed.query?.trim();
|
|
781
838
|
const limit = parsed.limit ?? parsed.k ?? 10;
|
|
782
839
|
const threshold = parsed.threshold ?? 0.1;
|
|
783
840
|
if (!query && !parsed.timeFrameDays) {
|
|
784
|
-
throw new McpError(ErrorCode.InvalidParams, "
|
|
841
|
+
throw new McpError(ErrorCode.InvalidParams, "search_memories requires a non-empty query. For example: {\"query\":\"research article hero image\",\"timeFrameDays\":14}. For an explicit date range, call get_memories_by_time_range with both startDate and endDate.");
|
|
785
842
|
}
|
|
786
843
|
const enc = await this.encState(); // throws LockedError for an encrypted account without a key
|
|
787
844
|
if (!query) {
|
|
@@ -1157,9 +1214,11 @@ class EchoMemMCPServer {
|
|
|
1157
1214
|
});
|
|
1158
1215
|
this.client = new EchoMemApiClient(store);
|
|
1159
1216
|
this.events = new EventLogger({ session_id: this.client.getSessionId(), app_version: SERVER_VERSION });
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1217
|
+
if (!DESKTOP_MANAGED) {
|
|
1218
|
+
startBackgroundUpdateCheck((status) => {
|
|
1219
|
+
this.updateStatus = status;
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1163
1222
|
this.setupToolHandlers();
|
|
1164
1223
|
this.server.onerror = (error) => console.error("[MCP Error]", error);
|
|
1165
1224
|
process.on("SIGINT", async () => {
|
|
@@ -1207,11 +1266,16 @@ class EchoMemMCPServer {
|
|
|
1207
1266
|
]);
|
|
1208
1267
|
this.mapInjected = !!map;
|
|
1209
1268
|
this.groupMapInjected = !!groupMap;
|
|
1210
|
-
const updateNotice = formatUpdateNotice(this.updateStatus);
|
|
1269
|
+
const updateNotice = DESKTOP_MANAGED ? undefined : formatUpdateNotice(this.updateStatus);
|
|
1211
1270
|
return { tools: listToolSpecs({ map, groupMap, updateNotice }) };
|
|
1212
1271
|
});
|
|
1213
1272
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1214
|
-
const
|
|
1273
|
+
const resolvedCanonicalName = resolveCanonicalToolName(request.params.name);
|
|
1274
|
+
const recallRoute = routePersonalRecallInvocation(resolvedCanonicalName, request.params.arguments);
|
|
1275
|
+
const canonicalName = recallRoute.canonicalName;
|
|
1276
|
+
const toolArgs = canonicalName === canonicalToolNames.others
|
|
1277
|
+
? normalizeQueryAlias(recallRoute.args)
|
|
1278
|
+
: recallRoute.args;
|
|
1215
1279
|
const clientVersion = this.server.getClientVersion();
|
|
1216
1280
|
this.mcpClientName = clientVersion?.name ?? this.mcpClientName;
|
|
1217
1281
|
this.mcpClientVersion = clientVersion?.version ?? this.mcpClientVersion;
|
|
@@ -1226,8 +1290,8 @@ class EchoMemMCPServer {
|
|
|
1226
1290
|
conversation_id: this.client.getSessionId(),
|
|
1227
1291
|
tool_name: request.params.name,
|
|
1228
1292
|
canonical_tool_name: canonicalName,
|
|
1229
|
-
...triggerAnalyticsForTool(canonicalName,
|
|
1230
|
-
...inputAnalyticsForTool(canonicalName,
|
|
1293
|
+
...triggerAnalyticsForTool(canonicalName, toolArgs),
|
|
1294
|
+
...inputAnalyticsForTool(canonicalName, toolArgs),
|
|
1231
1295
|
};
|
|
1232
1296
|
const analyticsCallId = randomUUID();
|
|
1233
1297
|
// One event per call. Handlers enrich `rec` with tool-specific detail; we finalize + log in `finally`.
|
|
@@ -1238,11 +1302,22 @@ class EchoMemMCPServer {
|
|
|
1238
1302
|
group_map_injected: this.groupMapInjected,
|
|
1239
1303
|
};
|
|
1240
1304
|
try {
|
|
1305
|
+
if (recallRoute.error) {
|
|
1306
|
+
throw new McpError(ErrorCode.InvalidParams, recallRoute.error);
|
|
1307
|
+
}
|
|
1241
1308
|
// The usage report is a local, $0 audit — works with no login (value before signup).
|
|
1242
1309
|
if (canonicalName === canonicalToolNames.report) {
|
|
1243
1310
|
return { content: [{ type: "text", text: await buildReportText(false) }] };
|
|
1244
1311
|
}
|
|
1245
1312
|
if (canonicalName === canonicalToolNames.updateStatus) {
|
|
1313
|
+
if (DESKTOP_MANAGED) {
|
|
1314
|
+
return {
|
|
1315
|
+
content: [{
|
|
1316
|
+
type: "text",
|
|
1317
|
+
text: `Echo Desktop manages this MCP runtime (${MCP_PACKAGE_VERSION}). Install app updates from Echo Desktop, then start a new agent session.`,
|
|
1318
|
+
}],
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1246
1321
|
const force = isRecord(request.params.arguments) && request.params.arguments.force === true;
|
|
1247
1322
|
const status = await checkLatestUpdateStatus({ force });
|
|
1248
1323
|
this.updateStatus = status;
|
|
@@ -1298,17 +1373,17 @@ class EchoMemMCPServer {
|
|
|
1298
1373
|
}
|
|
1299
1374
|
switch (canonicalName) {
|
|
1300
1375
|
case canonicalToolNames.search:
|
|
1301
|
-
return await this.handleSearch(
|
|
1376
|
+
return await this.handleSearch(toolArgs, rec);
|
|
1302
1377
|
case canonicalToolNames.save:
|
|
1303
1378
|
return await this.handleSave(request.params.arguments, rec);
|
|
1304
1379
|
case canonicalToolNames.timeRange:
|
|
1305
|
-
return await this.handleTimeRange(
|
|
1380
|
+
return await this.handleTimeRange(toolArgs);
|
|
1306
1381
|
case canonicalToolNames.getByContext:
|
|
1307
1382
|
return await this.handleGetByContext(request.params.arguments);
|
|
1308
1383
|
case canonicalToolNames.checkpointByContext:
|
|
1309
1384
|
return await this.handleGetCheckpointByContext(request.params.arguments);
|
|
1310
1385
|
case canonicalToolNames.keywords:
|
|
1311
|
-
return await this.handleKeywords(
|
|
1386
|
+
return await this.handleKeywords(toolArgs);
|
|
1312
1387
|
case canonicalToolNames.friends:
|
|
1313
1388
|
return await this.handleFriends(request.params.arguments);
|
|
1314
1389
|
case canonicalToolNames.searchUsers:
|
|
@@ -1316,7 +1391,7 @@ class EchoMemMCPServer {
|
|
|
1316
1391
|
case canonicalToolNames.sendFriendRequest:
|
|
1317
1392
|
return await this.handleSendFriendRequest(request.params.arguments);
|
|
1318
1393
|
case canonicalToolNames.others:
|
|
1319
|
-
return await this.handleOthers(
|
|
1394
|
+
return await this.handleOthers(toolArgs);
|
|
1320
1395
|
case canonicalToolNames.publicMemory:
|
|
1321
1396
|
return await this.handlePublicMemory(request.params.arguments);
|
|
1322
1397
|
case canonicalToolNames.recordCitations:
|
|
@@ -1361,7 +1436,7 @@ class EchoMemMCPServer {
|
|
|
1361
1436
|
content: [
|
|
1362
1437
|
{
|
|
1363
1438
|
type: "text",
|
|
1364
|
-
text:
|
|
1439
|
+
text: `🔌 EchoMem isn't connected yet. ${CONNECT_DEVICE_INSTRUCTION} No editor restart is needed.`,
|
|
1365
1440
|
},
|
|
1366
1441
|
],
|
|
1367
1442
|
};
|
|
@@ -1375,8 +1450,10 @@ class EchoMemMCPServer {
|
|
|
1375
1450
|
text: [
|
|
1376
1451
|
"🔒 EchoMem vault is locked.",
|
|
1377
1452
|
"This encrypted account has no usable local decryption key. Once unlocked, this trusted device stays unlocked until you explicitly lock it or log out.",
|
|
1378
|
-
|
|
1379
|
-
|
|
1453
|
+
`Action required from the user: ${UNLOCK_VAULT_INSTRUCTION}`,
|
|
1454
|
+
...(DESKTOP_MANAGED ? [] : [
|
|
1455
|
+
"At `Vault passphrase (typing is hidden):`, type the passphrase and press Return. No characters will appear while you type; that is expected.",
|
|
1456
|
+
]),
|
|
1380
1457
|
"After the success message, retry this EchoMem action in the current session — no editor restart is needed.",
|
|
1381
1458
|
].join("\n"),
|
|
1382
1459
|
},
|
package/dist/package-metadata.js
CHANGED
|
@@ -21,15 +21,22 @@ export const MCP_PACKAGE_NAME = stringOrFallback(packageJson.name, FALLBACK_PACK
|
|
|
21
21
|
export const MCP_PACKAGE_VERSION = stringOrFallback(packageJson.version, FALLBACK_PACKAGE.version);
|
|
22
22
|
export const MCP_PACKAGE_DESCRIPTION = stringOrFallback(packageJson.description, FALLBACK_PACKAGE.description);
|
|
23
23
|
export const MCP_PACKAGE_LABEL = `${MCP_PACKAGE_NAME}@${MCP_PACKAGE_VERSION}`;
|
|
24
|
+
export const MCP_DESKTOP_MANAGED = process.env.ECHO_DESKTOP_MANAGED === "1";
|
|
24
25
|
export const MCP_UPDATE_COMMAND = `npx -y ${MCP_PACKAGE_NAME}@latest update`;
|
|
25
26
|
export const MCP_UPDATE_ALL_COMMAND = `${MCP_UPDATE_COMMAND} --all`;
|
|
27
|
+
export const MCP_VAULT_UNLOCK_INSTRUCTION = MCP_DESKTOP_MANAGED
|
|
28
|
+
? "open Echo Desktop and unlock the vault there"
|
|
29
|
+
: "run `echomem-mcp unlock` locally";
|
|
30
|
+
const MCP_UPDATE_INSTRUCTION = MCP_DESKTOP_MANAGED
|
|
31
|
+
? "Echo Desktop manages this MCP runtime; install an Echo Desktop update when one is offered"
|
|
32
|
+
: `update once with \`${MCP_UPDATE_ALL_COMMAND}\``;
|
|
26
33
|
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
34
|
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.';
|
|
28
35
|
export const MCP_SERVER_INSTRUCTIONS = [
|
|
29
36
|
`${MCP_PACKAGE_DESCRIPTION} (${MCP_PACKAGE_LABEL}).`,
|
|
30
|
-
`If this bridge is stale,
|
|
37
|
+
`If this bridge is stale, ${MCP_UPDATE_INSTRUCTION} and start a new MCP session; never auto-update at startup.`,
|
|
31
38
|
"Before re-deriving prior decisions or preferences, use search_memories.",
|
|
32
|
-
|
|
39
|
+
`Before the final response for a durable decision, implementation, fix, commit, passing verification, release, or milestone, call save_conversation. Skip secrets and trivial work. If the encrypted vault is locked, tell the user to ${MCP_VAULT_UNLOCK_INSTRUCTION}.`,
|
|
33
40
|
"After a successful save, show every memory created by that call in a compact EchoMem saved: list with canonical links; this is separate from \"EchoMem sources:\".",
|
|
34
41
|
"For company groups, call request_group_session_sharing near conversation start or after a qualifying save. It renders a native choice on clients with MCP elicitation and returns a text fallback otherwise. Omit groupSharingScopeId only on the first call, then reuse the returned scope only in this conversation. Each group needs an explicit choice; decline, cancel, or silence stays unset. Never infer consent. Flagged memories stay private.",
|
|
35
42
|
"EchoMem credential identity is authoritative over Claude profiles, host accounts, git identity, or inference. Use get_group_context before group-orientation answers and never re-filter owners returned by search_others_memories.",
|
|
@@ -38,5 +45,5 @@ export const MCP_SERVER_INSTRUCTIONS = [
|
|
|
38
45
|
"Group profiles, conversation sharing, publication, sensitive-memory flags, and deletion require explicit user confirmation. Never store or log an echo_grp_ invite code.",
|
|
39
46
|
].join(" ");
|
|
40
47
|
export function withMcpVersion(description) {
|
|
41
|
-
return `${description}\n\nEchoMem MCP bridge: ${MCP_PACKAGE_LABEL}. If this version is stale,
|
|
48
|
+
return `${description}\n\nEchoMem MCP bridge: ${MCP_PACKAGE_LABEL}. If this version is stale, ${MCP_UPDATE_INSTRUCTION}, then start a new MCP session. Do not run updates repeatedly or on every startup.`;
|
|
42
49
|
}
|
|
@@ -10,7 +10,7 @@ const CHECKPOINT_REASON = [
|
|
|
10
10
|
"Private persistence happens first. For a user with a company group, call `request_group_session_sharing` near conversation start or after a qualifying private save.",
|
|
11
11
|
"On the first request_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/request/set/save calls, and never persist it as memory. If multiple groups are returned, reuse the same scope and call once per groupId. Supported clients render a native Share with team / Keep private choice; if the tool returns a text fallback, relay its exact question and call set_group_session_sharing only after an explicit Yes/No. Decline, cancel, or silence leaves that group's state unset. A Share decision syncs eligible memories from later saves carrying the same scope to that group; saves automatically sync to every approved group. Keep private keeps them private for that group.",
|
|
12
12
|
"Flagged memories are withheld from automatic conversation sync and remain private.",
|
|
13
|
-
"If EchoMem reports that the encrypted vault is locked, tell the user to
|
|
13
|
+
"If EchoMem reports that the encrypted vault is locked, tell the user to open Echo Desktop and unlock the vault there; on a headless system, use `echomem-mcp unlock`. Never silently skip a qualifying checkpoint.",
|
|
14
14
|
].join(" ");
|
|
15
15
|
function currentTurnSlice(transcript) {
|
|
16
16
|
const lines = transcript.split(/\r?\n/);
|