@echomem/mcp 1.4.43 → 1.4.44
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 +25 -28
- package/dist/config-files.js +63 -0
- package/dist/context-analysis/claude-native-canonical.js +2 -2
- package/dist/context-analysis/vendored-canonical.js +2 -2
- package/dist/context-analysis/workspace-report.js +3 -3
- package/dist/hud/hooks.js +43 -31
- package/dist/index.js +15 -79
- package/dist/local-jsonl.js +87 -0
- package/dist/migrate.js +6 -4
- package/dist/onboarding-stats.js +16 -0
- package/dist/save-checkpoint-hook.js +1 -1
- package/dist/setup-page/client-core.js +18 -372
- package/dist/setup-page/client-extraction.js +33 -188
- package/dist/setup-page/client-lifecycle.js +31 -101
- package/dist/setup-page/client.js +0 -2
- package/dist/setup-page/styles-extraction.js +1 -31
- package/dist/setup-page/styles-foundation.js +0 -89
- package/dist/setup-page/styles-mvp.js +10 -155
- package/dist/setup-page/styles-website-alignment.js +0 -204
- package/dist/setup-page/styles.js +0 -4
- package/dist/setup-page.js +4 -4
- package/dist/setup-preview.js +4 -212
- package/dist/setup.js +314 -667
- package/dist/v1-contract.js +0 -8
- package/package.json +9 -7
- package/dist/city/README.md +0 -9
- package/dist/city/echo-ai-city-only.html +0 -2232
- package/dist/city/echo-extraction-plate.html +0 -330
- package/dist/city/echo-face-cutout.png +0 -0
- package/dist/city/personality_stickers/bossy.png +0 -0
- package/dist/city/personality_stickers/ghosty.png +0 -0
- package/dist/city/personality_stickers/loopy.png +0 -0
- package/dist/city/personality_stickers/lusty.png +0 -0
- package/dist/city/personality_stickers/maxxy.png +0 -0
- package/dist/city/personality_stickers/tabby.png +0 -0
- package/dist/city/vendor/OrbitControls.js +0 -1417
- package/dist/city/vendor/RoundedBoxGeometry.js +0 -155
- package/dist/city/vendor/echo_general-file-21.riv +0 -0
- package/dist/city/vendor/rive.js +0 -8139
- package/dist/city/vendor/rive.wasm +0 -0
- package/dist/city/vendor/three.module.min.js +0 -6
- package/dist/forensics.js +0 -1531
- package/dist/report.js +0 -721
- package/dist/setup-page/client-report-audit.js +0 -819
- package/dist/setup-page/client-report-city.js +0 -356
- package/dist/setup-page/client-report.js +0 -6
- package/dist/setup-page/styles-city-report.js +0 -880
- package/dist/setup-page/styles-context-audit.js +0 -470
package/README.md
CHANGED
|
@@ -35,22 +35,36 @@ Unencrypted accounts are unaffected. `search_memories` returns retrieved memorie
|
|
|
35
35
|
your MCP client does the final answer generation; pass `includeAnswer: true` only if you need the
|
|
36
36
|
legacy EchoMem-synthesized recall answer.
|
|
37
37
|
|
|
38
|
-
## Quick start
|
|
38
|
+
## Quick start
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
required.
|
|
40
|
+
The standalone package installs and manages its own durable per-user MCP runtime. It configures
|
|
41
|
+
detected Codex and Claude hosts, connects this device, imports selected local coding history, and
|
|
42
|
+
keeps compatible runtime updates ready for the next agent session.
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
Prerequisite: **Node.js 18 or newer**. Run the command in the same environment as the agent you
|
|
45
|
+
want to configure. Native Windows and WSL have separate home directories and client configs, so a
|
|
46
|
+
WSL install does not configure native Windows apps.
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
macOS, Linux, or WSL:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx -y @echomem/mcp@latest init
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Windows PowerShell:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
npx.cmd -y @echomem/mcp@latest init
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The one-off `npx` command is the recommended install path; it stages a durable per-user runtime
|
|
61
|
+
and does not require a global npm install. On Windows, use a new PowerShell window after installing
|
|
62
|
+
Node if `npx.cmd` is not found.
|
|
50
63
|
|
|
51
64
|
| Command | What it does |
|
|
52
65
|
|---|---|
|
|
53
|
-
| `
|
|
66
|
+
| `npx -y @echomem/mcp@latest init` | One-command setup for installed agents + login (use `npx.cmd` in Windows PowerShell) |
|
|
67
|
+
| `npm i -g @echomem/mcp@latest`, then `echomem-mcp init` | Optional global install for a persistent CLI command |
|
|
54
68
|
| `npm i -g @echomem/mcp@latest && echomem-mcp setup` | Install the CLI globally and configure just the detected editor |
|
|
55
69
|
| `npx -y @echomem/mcp@latest setup` | One-off setup without keeping a global CLI command |
|
|
56
70
|
| `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config + log in |
|
|
@@ -95,23 +109,6 @@ Both hook entries are merged idempotently into `~/.codex/hooks.json` and
|
|
|
95
109
|
`~/.claude/settings.json`; unrelated user hooks and settings are preserved. Hooks fail open when
|
|
96
110
|
their local input is missing so they cannot prevent a conversation from starting or finishing.
|
|
97
111
|
|
|
98
|
-
## Agent Doctor — local workspace forensics (new in 1.4.9)
|
|
99
|
-
|
|
100
|
-
`echomem-mcp init` / `setup` now ends with an **Agent Doctor report**: a local scan of your
|
|
101
|
-
Codex and Claude Code session logs that shows what your AI coding actually cost and where the
|
|
102
|
-
context went dirty — rendered as an interactive 3D "repo city" plus a full forensic breakdown.
|
|
103
|
-
|
|
104
|
-
- **Unified cost ledger.** Every provider request is priced individually from official API list
|
|
105
|
-
rates (model, cache write/read, long-context premiums, intro-pricing windows), so the headline
|
|
106
|
-
TOTAL SPENT, per-repo costs, per-model costs, and cost components all come from one path and
|
|
107
|
-
reconcile exactly. It is an **API-equivalent estimate of volume, not a bill you paid**.
|
|
108
|
-
- **Fail-closed validation.** The report refuses to render unless every ledger reconciles
|
|
109
|
-
(tokens, costs, canonical waste analysis). Stale or preview data can never appear as your data.
|
|
110
|
-
- **Wasteful-context analysis.** Canonical context windows label input tokens useful vs wasteful
|
|
111
|
-
(re-reads of unchanged files, re-finding, dead work) and project the waste over your billed input.
|
|
112
|
-
- **Fully local, zero upload.** The scan reads local JSONL logs only: no transcripts are uploaded
|
|
113
|
-
and no LLM calls are made to build the report.
|
|
114
|
-
|
|
115
112
|
### Manual / headless (SSH, containers, CI)
|
|
116
113
|
|
|
117
114
|
No browser? Provide secrets directly — this is the documented headless path:
|
|
@@ -140,7 +137,7 @@ npm test # crypto compat + encrypted-local integration tests
|
|
|
140
137
|
|
|
141
138
|
### Add to your IDE (Cursor or Windsurf)
|
|
142
139
|
|
|
143
|
-
> Prefer `
|
|
140
|
+
> Prefer `npx -y @echomem/mcp@latest init` above (`npx.cmd` in Windows PowerShell) — it writes these files for you and keeps secrets out of
|
|
144
141
|
> the client config. The manual steps below are the fallback.
|
|
145
142
|
|
|
146
143
|
#### For Cursor
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function isErrno(error, code) {
|
|
5
|
+
return error instanceof Error
|
|
6
|
+
&& "code" in error
|
|
7
|
+
&& error.code === code;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Read a user-owned JSON configuration without treating corruption as an empty file.
|
|
11
|
+
* Setup must fail closed here: replacing malformed JSON would destroy unrelated client settings.
|
|
12
|
+
*/
|
|
13
|
+
export function readJsonObjectFile(file, label = "configuration") {
|
|
14
|
+
let raw;
|
|
15
|
+
try {
|
|
16
|
+
raw = fs.readFileSync(file, "utf8");
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (isErrno(error, "ENOENT"))
|
|
20
|
+
return {};
|
|
21
|
+
throw new Error(`Could not read ${label} at ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
22
|
+
}
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(raw);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
throw new Error(`Could not safely update ${label} at ${file} because the existing file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
29
|
+
}
|
|
30
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
31
|
+
throw new Error(`Could not safely update ${label} at ${file} because the existing JSON root is not an object.`);
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
/** Write beside the destination and rename only after the complete replacement is durable locally. */
|
|
36
|
+
export function atomicWriteTextFile(file, content) {
|
|
37
|
+
const directory = path.dirname(file);
|
|
38
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
39
|
+
const temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
|
|
40
|
+
let mode;
|
|
41
|
+
try {
|
|
42
|
+
mode = fs.statSync(file).mode;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* A fresh file uses the process default mode. */
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
fs.writeFileSync(temporary, content, mode === undefined ? undefined : { mode });
|
|
49
|
+
fs.renameSync(temporary, file);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
try {
|
|
53
|
+
fs.rmSync(temporary, { force: true });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* Preserve the original error. */
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function atomicWriteJsonObject(file, value) {
|
|
62
|
+
atomicWriteTextFile(file, JSON.stringify(value, null, 2));
|
|
63
|
+
}
|
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { StringDecoder } from "node:string_decoder";
|
|
5
5
|
import { resolveClaudeProjectsDir } from "../local-data-paths.js";
|
|
6
|
-
import {
|
|
6
|
+
import { walkLocalFiles } from "../local-jsonl.js";
|
|
7
7
|
import { isStrongPositiveFeedback, } from "./workspace-report.js";
|
|
8
8
|
const OUTPUT_TOKEN_CAP = 12_000;
|
|
9
9
|
const IMAGE_TOKENS = 4_000;
|
|
@@ -33,7 +33,7 @@ const PROBLEM_META = {
|
|
|
33
33
|
const PROBLEM_IDS = Object.keys(PROBLEM_META);
|
|
34
34
|
export function buildClaudeNativeCanonicalReport(opts) {
|
|
35
35
|
const claudeRoot = opts?.sessionPaths ? null : resolveClaudeProjectsDir();
|
|
36
|
-
const discovered = (opts?.sessionPaths ?? (claudeRoot ?
|
|
36
|
+
const discovered = (opts?.sessionPaths ?? (claudeRoot ? walkLocalFiles(claudeRoot, (candidate) => candidate.endsWith(".jsonl") && !candidate.includes(`${path.sep}subagents${path.sep}`) && !candidate.includes(`${path.sep}workflows${path.sep}`), () => false) : [])).sort();
|
|
37
37
|
const files = typeof opts?.limitFiles === "number" ? discovered.slice(-Math.max(0, opts.limitFiles)) : discovered;
|
|
38
38
|
const sessions = [];
|
|
39
39
|
const errors = [];
|
|
@@ -6,7 +6,7 @@ import readline from "node:readline";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { discoverCodexSessionFiles } from "../codex-session-files.js";
|
|
8
8
|
import { resolveClaudeProjectsDir } from "../local-data-paths.js";
|
|
9
|
-
import {
|
|
9
|
+
import { walkLocalFiles } from "../local-jsonl.js";
|
|
10
10
|
import { buildClaudeNativeCanonicalReport } from "./claude-native-canonical.js";
|
|
11
11
|
const PROBLEM_IDS = [
|
|
12
12
|
"P01", "P02", "P03", "P04", "P05", "P06", "P07", "P08", "P09", "P10", "P11", "P12", "P13",
|
|
@@ -89,7 +89,7 @@ export async function buildVendoredCanonicalReport(opts) {
|
|
|
89
89
|
.filter(isUserInitiatedSession)
|
|
90
90
|
: [];
|
|
91
91
|
const discoveredClaude = sources.includes("claude-code")
|
|
92
|
-
? (opts?.claudeSessionPaths ?? (claudeRoot ?
|
|
92
|
+
? (opts?.claudeSessionPaths ?? (claudeRoot ? walkLocalFiles(claudeRoot, (candidate) => candidate.endsWith(".jsonl") && !candidate.includes(`${path.sep}subagents${path.sep}`) && !candidate.includes(`${path.sep}workflows${path.sep}`), () => false) : [])).sort()
|
|
93
93
|
: [];
|
|
94
94
|
const codexFiles = limitFiles(discoveredCodex, opts?.limitFiles);
|
|
95
95
|
const claudeFiles = limitFiles(discoveredClaude, opts?.limitFiles);
|
|
@@ -3,7 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { discoverCodexSessionFiles } from "../codex-session-files.js";
|
|
5
5
|
import { resolveClaudeProjectsDir } from "../local-data-paths.js";
|
|
6
|
-
import {
|
|
6
|
+
import { eachJsonLine, walkLocalFiles } from "../local-jsonl.js";
|
|
7
7
|
const PROBLEMS = {
|
|
8
8
|
P01: { id: "P01", label: "Outdated Images & Screenshots", bucket: "dead", category: "Runtime Bug", confidence: "high" },
|
|
9
9
|
P02: { id: "P02", label: "Ignored User Instructions", bucket: "refind", category: "Model Behavior", confidence: "medium" },
|
|
@@ -52,7 +52,7 @@ export function buildWorkspaceContextReport(opts) {
|
|
|
52
52
|
? discoveredCodexFiles.filter(isUserInitiatedCodexSession)
|
|
53
53
|
: discoveredCodexFiles;
|
|
54
54
|
const claudeFiles = sources.includes("claude-code")
|
|
55
|
-
? (opts?.claudeSessionPaths ?? (claudeRoot ?
|
|
55
|
+
? (opts?.claudeSessionPaths ?? (claudeRoot ? walkLocalFiles(claudeRoot, (p) => p.endsWith(".jsonl") && !p.includes(`${path.sep}subagents${path.sep}`) && !p.includes(`${path.sep}workflows${path.sep}`), () => false) : [])).sort()
|
|
56
56
|
: [];
|
|
57
57
|
const selectedCodex = typeof opts?.limitFiles === "number" ? codexFiles.slice(-Math.max(0, opts.limitFiles)) : codexFiles;
|
|
58
58
|
const selectedClaude = typeof opts?.limitFiles === "number" ? claudeFiles.slice(-Math.max(0, opts.limitFiles)) : claudeFiles;
|
|
@@ -472,7 +472,7 @@ function parseCodexSession(file) {
|
|
|
472
472
|
const turns = [];
|
|
473
473
|
const ledgerItems = [];
|
|
474
474
|
const callMeta = new Map();
|
|
475
|
-
|
|
475
|
+
eachJsonLine(file, (raw) => {
|
|
476
476
|
const top = isRecord(raw) ? raw : {};
|
|
477
477
|
const payload = isRecord(top.payload) ? top.payload : top;
|
|
478
478
|
const type = typeof payload.type === "string" ? payload.type : typeof top.type === "string" ? top.type : "";
|
package/dist/hud/hooks.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { atomicWriteJsonObject, readJsonObjectFile } from "../config-files.js";
|
|
5
6
|
export function installHooks(mode) {
|
|
6
7
|
const written = [];
|
|
7
8
|
if (mode === "codex" || mode === "both" || mode === "auto") {
|
|
@@ -33,16 +34,16 @@ export function installSourceSessionHooks(mode) {
|
|
|
33
34
|
return written;
|
|
34
35
|
}
|
|
35
36
|
function installCodexHooks() {
|
|
36
|
-
const dir =
|
|
37
|
+
const dir = profileDirectory("CODEX_HOME", ".codex");
|
|
37
38
|
const file = path.join(dir, "hooks.json");
|
|
38
39
|
fs.mkdirSync(dir, { recursive: true });
|
|
39
40
|
const hookCommand = `${JSON.stringify(process.execPath)} ${JSON.stringify(process.argv[1])} summary --client codex --json`;
|
|
40
41
|
const content = readHooksFile(file);
|
|
41
42
|
content.hooks = content.hooks || {};
|
|
42
|
-
content.hooks.PostToolUse = mergeHookGroup(content.hooks.PostToolUse, { matcher: "*", hooks: [
|
|
43
|
-
content.hooks.PostCompact = mergeHookGroup(content.hooks.PostCompact, { hooks: [
|
|
44
|
-
content.hooks.Stop = mergeHookGroup(content.hooks.Stop, { hooks: [
|
|
45
|
-
|
|
43
|
+
content.hooks.PostToolUse = mergeHookGroup(content.hooks.PostToolUse, { matcher: "*", hooks: [codexCommandHook(hookCommand, 5)] });
|
|
44
|
+
content.hooks.PostCompact = mergeHookGroup(content.hooks.PostCompact, { hooks: [codexCommandHook(hookCommand, 5)] });
|
|
45
|
+
content.hooks.Stop = mergeHookGroup(content.hooks.Stop, { hooks: [codexCommandHook(hookCommand, 5)] });
|
|
46
|
+
atomicWriteJsonObject(file, content);
|
|
46
47
|
return file;
|
|
47
48
|
}
|
|
48
49
|
function saveCheckpointCommand() {
|
|
@@ -54,24 +55,19 @@ function sourceSessionCommand() {
|
|
|
54
55
|
return `${JSON.stringify(process.execPath)} ${JSON.stringify(lifecycleCli)} bind-source-session`;
|
|
55
56
|
}
|
|
56
57
|
function installCodexSourceSessionHook() {
|
|
57
|
-
const dir =
|
|
58
|
+
const dir = profileDirectory("CODEX_HOME", ".codex");
|
|
58
59
|
const file = path.join(dir, "hooks.json");
|
|
59
60
|
fs.mkdirSync(dir, { recursive: true });
|
|
60
61
|
const content = readHooksFile(file);
|
|
61
62
|
content.hooks = content.hooks || {};
|
|
62
63
|
content.hooks.SessionStart = mergeSourceSessionGroup(content.hooks.SessionStart, {
|
|
63
|
-
hooks: [
|
|
64
|
-
type: "command",
|
|
65
|
-
command: sourceSessionCommand(),
|
|
66
|
-
timeout: 5,
|
|
67
|
-
statusMessage: "Binding EchoMem to this conversation",
|
|
68
|
-
}],
|
|
64
|
+
hooks: [codexCommandHook(sourceSessionCommand(), 5, "Binding EchoMem to this conversation")],
|
|
69
65
|
});
|
|
70
|
-
|
|
66
|
+
atomicWriteJsonObject(file, content);
|
|
71
67
|
return file;
|
|
72
68
|
}
|
|
73
69
|
function installClaudeCodeSourceSessionHook() {
|
|
74
|
-
const dir =
|
|
70
|
+
const dir = profileDirectory("CLAUDE_CONFIG_DIR", ".claude");
|
|
75
71
|
const file = path.join(dir, "settings.json");
|
|
76
72
|
fs.mkdirSync(dir, { recursive: true });
|
|
77
73
|
const content = readHooksFile(file);
|
|
@@ -84,28 +80,23 @@ function installClaudeCodeSourceSessionHook() {
|
|
|
84
80
|
statusMessage: "Binding EchoMem to this conversation",
|
|
85
81
|
}],
|
|
86
82
|
});
|
|
87
|
-
|
|
83
|
+
atomicWriteJsonObject(file, content);
|
|
88
84
|
return file;
|
|
89
85
|
}
|
|
90
86
|
function installCodexSaveCheckpointHook() {
|
|
91
|
-
const dir =
|
|
87
|
+
const dir = profileDirectory("CODEX_HOME", ".codex");
|
|
92
88
|
const file = path.join(dir, "hooks.json");
|
|
93
89
|
fs.mkdirSync(dir, { recursive: true });
|
|
94
90
|
const content = readHooksFile(file);
|
|
95
91
|
content.hooks = content.hooks || {};
|
|
96
92
|
content.hooks.Stop = mergeSaveCheckpointGroup(content.hooks.Stop, {
|
|
97
|
-
hooks: [
|
|
98
|
-
type: "command",
|
|
99
|
-
command: saveCheckpointCommand(),
|
|
100
|
-
timeout: 10,
|
|
101
|
-
statusMessage: "Checking whether completed work should be remembered",
|
|
102
|
-
}],
|
|
93
|
+
hooks: [codexCommandHook(saveCheckpointCommand(), 10, "Checking whether completed work should be remembered")],
|
|
103
94
|
});
|
|
104
|
-
|
|
95
|
+
atomicWriteJsonObject(file, content);
|
|
105
96
|
return file;
|
|
106
97
|
}
|
|
107
98
|
function installClaudeCodeSaveCheckpointHook() {
|
|
108
|
-
const dir =
|
|
99
|
+
const dir = profileDirectory("CLAUDE_CONFIG_DIR", ".claude");
|
|
109
100
|
const file = path.join(dir, "settings.json");
|
|
110
101
|
fs.mkdirSync(dir, { recursive: true });
|
|
111
102
|
const content = readHooksFile(file);
|
|
@@ -118,22 +109,43 @@ function installClaudeCodeSaveCheckpointHook() {
|
|
|
118
109
|
statusMessage: "Checking whether completed work should be remembered",
|
|
119
110
|
}],
|
|
120
111
|
});
|
|
121
|
-
|
|
112
|
+
atomicWriteJsonObject(file, content);
|
|
122
113
|
return file;
|
|
123
114
|
}
|
|
124
115
|
function installClaudeCodeSnippet() {
|
|
125
|
-
const dir = path.join(
|
|
116
|
+
const dir = path.join(profileDirectory("CLAUDE_CONFIG_DIR", ".claude"), "echo-ctx");
|
|
126
117
|
fs.mkdirSync(dir, { recursive: true });
|
|
127
118
|
return dir;
|
|
128
119
|
}
|
|
129
120
|
function readHooksFile(file) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
121
|
+
return readJsonObjectFile(file, "agent hooks");
|
|
122
|
+
}
|
|
123
|
+
function profileDirectory(envKey, fallbackName) {
|
|
124
|
+
const home = os.homedir();
|
|
125
|
+
const configured = process.env[envKey]?.trim();
|
|
126
|
+
if (!configured)
|
|
127
|
+
return path.join(home, fallbackName);
|
|
128
|
+
if (configured === "~")
|
|
129
|
+
return home;
|
|
130
|
+
if (configured.startsWith("~/") || configured.startsWith("~\\")) {
|
|
131
|
+
return path.join(home, configured.slice(2));
|
|
133
132
|
}
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
if (!path.isAbsolute(configured)) {
|
|
134
|
+
throw new Error(`${envKey} must be an absolute path or start with ~/`);
|
|
136
135
|
}
|
|
136
|
+
return path.normalize(configured);
|
|
137
|
+
}
|
|
138
|
+
function codexCommandHook(command, timeout, statusMessage) {
|
|
139
|
+
return {
|
|
140
|
+
type: "command",
|
|
141
|
+
command,
|
|
142
|
+
// Codex supports an explicit Windows override. Keeping it identical is intentional: the
|
|
143
|
+
// absolute node.exe and CLI paths are already quoted, and Codex can select this field without
|
|
144
|
+
// interpreting a POSIX-only fallback.
|
|
145
|
+
commandWindows: command,
|
|
146
|
+
timeout,
|
|
147
|
+
...(statusMessage ? { statusMessage } : {}),
|
|
148
|
+
};
|
|
137
149
|
}
|
|
138
150
|
function mergeHookGroup(existing, group) {
|
|
139
151
|
const groups = Array.isArray(existing) ? existing.filter((item) => !isEchoHudGroup(item)) : [];
|
package/dist/index.js
CHANGED
|
@@ -7,12 +7,11 @@ import { ZodError } from "zod";
|
|
|
7
7
|
import { bindSourceSessionSchema, canonicalToolNames, completeGroupPublicationSchema, createGroupInviteSchema, createGroupSchema, deleteMemorySchema, flagPublicationAttentionSchema, getGroupSessionSharingSchema, getByContextSchema, groupContextSchema, joinGroupSchema, keywordsSchema, listFriendsSchema, listToolSpecs, othersSchema, publishBatchToGroupSchema, publishToGroupSchema, prepareGroupPublicationSchema, publicMemorySchema, recordMemoryCitationsSchema, requestGroupSessionSharingSchema, resolveCanonicalToolName, saveConversationSchema, searchMemoriesSchema, searchUsersSchema, sendFriendRequestSchema, timeRangeSchema, updateGroupProfileSchema, setGroupSessionSharingSchema, } from "./v1-contract.js";
|
|
8
8
|
import { KeyStore } from "./keystore.js";
|
|
9
9
|
import { EventLogger, hashText } from "./events.js";
|
|
10
|
-
import { buildReportText } from "./report.js";
|
|
11
10
|
import { contextHealthMarkdown, recomposeCapsuleMarkdown } from "./hud/api.js";
|
|
12
11
|
import { createHash, randomUUID } from "node:crypto";
|
|
13
12
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
14
13
|
import { fetchEncryptionConfig, decryptMemoryFields, verifyKeyB64, } from "./encryption.js";
|
|
15
|
-
import { runCli } from "./setup.js";
|
|
14
|
+
import { refreshInstalledMemoryGuidance, runCli } from "./setup.js";
|
|
16
15
|
import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS, MEMORY_CITATION_INSTRUCTION, SAVED_MEMORY_RECEIPT_INSTRUCTION, } from "./package-metadata.js";
|
|
17
16
|
import { clearBillingAlert, writeBillingAlert } from "./billing-alert.js";
|
|
18
17
|
import { checkLatestUpdateStatus, formatUpdateNotice, formatUpdateStatusText, startBackgroundUpdateCheck, } from "./update-check.js";
|
|
@@ -613,8 +612,6 @@ function toolEventName(canonicalName, status) {
|
|
|
613
612
|
class EchoMemApiClient {
|
|
614
613
|
store;
|
|
615
614
|
axios;
|
|
616
|
-
activeToken;
|
|
617
|
-
accountGeneration = 0;
|
|
618
615
|
boundSourceSession = null;
|
|
619
616
|
requestSourceSession = new AsyncLocalStorage();
|
|
620
617
|
sourceSessionsByCanonicalKey = new Map();
|
|
@@ -625,7 +622,6 @@ class EchoMemApiClient {
|
|
|
625
622
|
deleteConfirmations = new Map();
|
|
626
623
|
constructor(store) {
|
|
627
624
|
this.store = store;
|
|
628
|
-
this.activeToken = store.getToken();
|
|
629
625
|
this.axios = axios.create({
|
|
630
626
|
baseURL: ECHO_API_BASE_URL.replace(/\/$/, ""),
|
|
631
627
|
headers: {
|
|
@@ -635,7 +631,7 @@ class EchoMemApiClient {
|
|
|
635
631
|
// Read the token fresh on EVERY request (not baked in at construction) so a `login` that runs
|
|
636
632
|
// after the editor already launched the bridge is picked up on the next call — no restart.
|
|
637
633
|
this.axios.interceptors.request.use((config) => {
|
|
638
|
-
const token = this.
|
|
634
|
+
const token = this.store.getToken();
|
|
639
635
|
if (token)
|
|
640
636
|
config.headers.set("Authorization", `Bearer ${token}`);
|
|
641
637
|
const sourceSession = this.getBoundSourceSession();
|
|
@@ -645,46 +641,21 @@ class EchoMemApiClient {
|
|
|
645
641
|
return config;
|
|
646
642
|
});
|
|
647
643
|
}
|
|
648
|
-
/**
|
|
649
|
-
* Account-scoped state must move atomically with the credential file. The token is intentionally
|
|
650
|
-
* read fresh, but encryption, identity, source-session, and confirmation caches belong to the
|
|
651
|
-
* token that populated them and cannot survive an in-process account switch.
|
|
652
|
-
*/
|
|
653
|
-
synchronizeAccountContext() {
|
|
654
|
-
const token = this.store.getToken();
|
|
655
|
-
if (token === this.activeToken)
|
|
656
|
-
return token;
|
|
657
|
-
this.activeToken = token;
|
|
658
|
-
this.accountGeneration += 1;
|
|
659
|
-
this.encConfigPromise = null;
|
|
660
|
-
this.whoamiCache = null;
|
|
661
|
-
this.boundSourceSession = null;
|
|
662
|
-
this.sourceSessionsByCanonicalKey.clear();
|
|
663
|
-
this.deleteConfirmations.clear();
|
|
664
|
-
return token;
|
|
665
|
-
}
|
|
666
|
-
/** Lets the MCP server invalidate its account-scoped tool-description caches too. */
|
|
667
|
-
refreshAccountContext() {
|
|
668
|
-
this.synchronizeAccountContext();
|
|
669
|
-
return this.accountGeneration;
|
|
670
|
-
}
|
|
671
644
|
/** Whether a usable API token currently exists (read fresh from the keystore each call). */
|
|
672
645
|
hasToken() {
|
|
673
|
-
return !!this.
|
|
646
|
+
return !!this.store.getToken();
|
|
674
647
|
}
|
|
675
648
|
/** The per-process session id — the join key telemetry shares with grouped saves. */
|
|
676
649
|
getSessionId() {
|
|
677
650
|
return this.sessionId;
|
|
678
651
|
}
|
|
679
652
|
getBoundSourceSession() {
|
|
680
|
-
this.synchronizeAccountContext();
|
|
681
653
|
return this.requestSourceSession.getStore() ?? this.boundSourceSession;
|
|
682
654
|
}
|
|
683
655
|
async withSourceSession(sourceSession, operation) {
|
|
684
656
|
return sourceSession ? this.requestSourceSession.run(sourceSession, operation) : operation();
|
|
685
657
|
}
|
|
686
658
|
async bindSourceSession(verified, persistForBridge = true) {
|
|
687
|
-
this.synchronizeAccountContext();
|
|
688
659
|
const cached = this.sourceSessionsByCanonicalKey.get(verified.canonicalKey);
|
|
689
660
|
if (cached) {
|
|
690
661
|
if (persistForBridge)
|
|
@@ -767,23 +738,16 @@ class EchoMemApiClient {
|
|
|
767
738
|
return undefined;
|
|
768
739
|
}
|
|
769
740
|
}
|
|
770
|
-
/** Encryption config for the
|
|
741
|
+
/** Encryption config for the account, fetched once and cached. Failures are not cached. */
|
|
771
742
|
async getEncryptionConfig() {
|
|
772
|
-
const generation = this.refreshAccountContext();
|
|
773
743
|
if (!this.encConfigPromise) {
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
this.encConfigPromise = null; // allow retry next call
|
|
744
|
+
this.encConfigPromise = fetchEncryptionConfig(this.axios).catch((error) => {
|
|
745
|
+
this.encConfigPromise = null; // allow retry next call
|
|
777
746
|
console.error(`[encryption] config fetch failed: ${describeError(error)} — refusing memory access`);
|
|
778
747
|
throw new EncryptionStatusUnavailableError("EchoMem could not verify the account encryption state");
|
|
779
748
|
});
|
|
780
|
-
this.encConfigPromise = request;
|
|
781
749
|
}
|
|
782
|
-
|
|
783
|
-
const config = await request;
|
|
784
|
-
if (this.refreshAccountContext() !== generation)
|
|
785
|
-
return this.getEncryptionConfig();
|
|
786
|
-
return config;
|
|
750
|
+
return this.encConfigPromise;
|
|
787
751
|
}
|
|
788
752
|
/**
|
|
789
753
|
* Resolve the encryption state for a read/write. For encrypted accounts this REQUIRES a usable
|
|
@@ -791,19 +755,13 @@ class EchoMemApiClient {
|
|
|
791
755
|
* handing the model ciphertext. For unencrypted accounts it returns `{ enabled: false }`.
|
|
792
756
|
*/
|
|
793
757
|
async encState() {
|
|
794
|
-
const generation = this.refreshAccountContext();
|
|
795
758
|
const cfg = await this.getEncryptionConfig();
|
|
796
|
-
if (this.refreshAccountContext() !== generation)
|
|
797
|
-
return this.encState();
|
|
798
759
|
if (!cfg.enabled)
|
|
799
760
|
return { enabled: false };
|
|
800
761
|
const key = this.store.getKey();
|
|
801
762
|
if (!key)
|
|
802
763
|
throw new LockedError("EchoMem vault locked");
|
|
803
|
-
|
|
804
|
-
if (this.refreshAccountContext() !== generation)
|
|
805
|
-
return this.encState();
|
|
806
|
-
if (!isValid) {
|
|
764
|
+
if (!(await verifyKeyB64(key, cfg))) {
|
|
807
765
|
// A file-backed stale key can be removed safely; environment-provided
|
|
808
766
|
// keys remain under the caller's control and are ignored until corrected.
|
|
809
767
|
if (!process.env.ECHO_ENCRYPTION_KEY)
|
|
@@ -820,23 +778,16 @@ class EchoMemApiClient {
|
|
|
820
778
|
}
|
|
821
779
|
}
|
|
822
780
|
async whoami() {
|
|
823
|
-
const generation = this.refreshAccountContext();
|
|
824
781
|
if (!this.whoamiCache) {
|
|
825
|
-
|
|
782
|
+
this.whoamiCache = this.axios
|
|
826
783
|
.get("/api/openclaw/v1/whoami", { timeout: 6000 })
|
|
827
784
|
.then((response) => response.data)
|
|
828
785
|
.catch((error) => {
|
|
829
|
-
|
|
830
|
-
this.whoamiCache = null;
|
|
786
|
+
this.whoamiCache = null; // don't pin a token-less failure; retry once a token exists
|
|
831
787
|
throw error;
|
|
832
788
|
});
|
|
833
|
-
this.whoamiCache = request;
|
|
834
789
|
}
|
|
835
|
-
|
|
836
|
-
const identity = await request;
|
|
837
|
-
if (this.refreshAccountContext() !== generation)
|
|
838
|
-
return this.whoami();
|
|
839
|
-
return identity;
|
|
790
|
+
return this.whoamiCache;
|
|
840
791
|
}
|
|
841
792
|
async fetchMemoryById(id, enc) {
|
|
842
793
|
try {
|
|
@@ -1303,7 +1254,6 @@ function capWait(pending) {
|
|
|
1303
1254
|
class EchoMemMCPServer {
|
|
1304
1255
|
server;
|
|
1305
1256
|
client;
|
|
1306
|
-
accountGeneration = 0;
|
|
1307
1257
|
mapCache = null;
|
|
1308
1258
|
groupMapCache = null;
|
|
1309
1259
|
events;
|
|
@@ -1376,19 +1326,8 @@ class EchoMemMCPServer {
|
|
|
1376
1326
|
platform_source: hostPlatform,
|
|
1377
1327
|
};
|
|
1378
1328
|
}
|
|
1379
|
-
refreshAccountContext() {
|
|
1380
|
-
const generation = this.client.refreshAccountContext();
|
|
1381
|
-
if (generation === this.accountGeneration)
|
|
1382
|
-
return;
|
|
1383
|
-
this.accountGeneration = generation;
|
|
1384
|
-
this.mapCache = null;
|
|
1385
|
-
this.groupMapCache = null;
|
|
1386
|
-
this.mapInjected = false;
|
|
1387
|
-
this.groupMapInjected = false;
|
|
1388
|
-
}
|
|
1389
1329
|
setupToolHandlers() {
|
|
1390
1330
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
1391
|
-
this.refreshAccountContext();
|
|
1392
1331
|
const clientVersion = this.server.getClientVersion();
|
|
1393
1332
|
this.mcpClientName = clientVersion?.name ?? this.mcpClientName;
|
|
1394
1333
|
this.mcpClientVersion = clientVersion?.version ?? this.mcpClientVersion;
|
|
@@ -1420,7 +1359,6 @@ class EchoMemMCPServer {
|
|
|
1420
1359
|
return { tools: listToolSpecs({ map, groupMap, updateNotice }) };
|
|
1421
1360
|
});
|
|
1422
1361
|
this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
1423
|
-
this.refreshAccountContext();
|
|
1424
1362
|
const resolvedCanonicalName = resolveCanonicalToolName(request.params.name);
|
|
1425
1363
|
const recallRoute = routePersonalRecallInvocation(resolvedCanonicalName, request.params.arguments);
|
|
1426
1364
|
const canonicalName = recallRoute.canonicalName;
|
|
@@ -1472,10 +1410,6 @@ class EchoMemMCPServer {
|
|
|
1472
1410
|
if (recallRoute.error) {
|
|
1473
1411
|
throw new McpError(ErrorCode.InvalidParams, recallRoute.error);
|
|
1474
1412
|
}
|
|
1475
|
-
// The usage report is a local, $0 audit — works with no login (value before signup).
|
|
1476
|
-
if (canonicalName === canonicalToolNames.report) {
|
|
1477
|
-
return { content: [{ type: "text", text: await buildReportText(false) }] };
|
|
1478
|
-
}
|
|
1479
1413
|
if (canonicalName === canonicalToolNames.updateStatus) {
|
|
1480
1414
|
if (DESKTOP_MANAGED) {
|
|
1481
1415
|
return {
|
|
@@ -1671,8 +1605,7 @@ class EchoMemMCPServer {
|
|
|
1671
1605
|
rec.ok = rec.error_kind === "none";
|
|
1672
1606
|
rec.latency_ms = Date.now() - t0;
|
|
1673
1607
|
this.events.record(rec);
|
|
1674
|
-
if (canonicalName !== canonicalToolNames.
|
|
1675
|
-
canonicalName !== canonicalToolNames.updateStatus &&
|
|
1608
|
+
if (canonicalName !== canonicalToolNames.updateStatus &&
|
|
1676
1609
|
canonicalName !== canonicalToolNames.contextHealth &&
|
|
1677
1610
|
canonicalName !== canonicalToolNames.recompose &&
|
|
1678
1611
|
canonicalName !== canonicalToolNames.save &&
|
|
@@ -2764,6 +2697,9 @@ async function main() {
|
|
|
2764
2697
|
// inherited descriptor — can't leave the terminal hanging after setup/migrate completes.
|
|
2765
2698
|
process.exit(process.exitCode ?? 0);
|
|
2766
2699
|
}
|
|
2700
|
+
// Existing standalone users may receive a new runtime without re-running setup. Refresh only
|
|
2701
|
+
// EchoMem's marker-owned block so stale Desktop-era instructions disappear automatically.
|
|
2702
|
+
refreshInstalledMemoryGuidance();
|
|
2767
2703
|
const store = new KeyStore();
|
|
2768
2704
|
// Serve mode is meant to be SPAWNED by the MCP client (editor), which drives us over a piped stdin.
|
|
2769
2705
|
// A human who runs the bare server in a terminal instead gets a process that blocks forever on stdio
|