@echomem/mcp 1.4.40 → 1.4.41
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 +21 -0
- package/dist/hud/cli.js +25 -4
- package/dist/hud/hooks.js +58 -0
- package/dist/index.js +328 -244
- package/dist/package-metadata.js +2 -1
- package/dist/setup.js +11 -8
- package/dist/source-session-hook.js +103 -0
- package/dist/source-session.js +261 -0
- package/dist/v1-contract.js +22 -0
- package/package.json +2 -2
- package/templates/echomem-recall.md +3 -0
- package/dist/forensics-10-problems.js +0 -633
package/dist/package-metadata.js
CHANGED
|
@@ -35,10 +35,11 @@ export const SAVED_MEMORY_RECEIPT_INSTRUCTION = 'After save_conversation succeed
|
|
|
35
35
|
export const MCP_SERVER_INSTRUCTIONS = [
|
|
36
36
|
`${MCP_PACKAGE_DESCRIPTION} (${MCP_PACKAGE_LABEL}).`,
|
|
37
37
|
`If stale, ${MCP_UPDATE_INSTRUCTION}; restart the MCP session afterward. Updates never delay startup.`,
|
|
38
|
+
"A local SessionStart hook binds supported conversations to their real provider sessions; use bind_source_session only as fallback.",
|
|
38
39
|
"Before re-deriving prior decisions or preferences, use search_memories.",
|
|
39
40
|
`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}.`,
|
|
40
41
|
"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:\".",
|
|
41
|
-
"For company groups,
|
|
42
|
+
"For company groups, request sharing near conversation start or after a save; relay its exact question and set only after explicit Yes or No. Reuse the returned scope only in this conversation. Each group needs its own choice; silence means unset. Flagged memories stay private.",
|
|
42
43
|
"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.",
|
|
43
44
|
"When a final answer materially uses teammate or friend memories, call record_memory_citations with only the exact used Memory IDs and a unique per-answer receiptId.",
|
|
44
45
|
"End memory-informed answers with a compact EchoMem sources: list. Use each memory key as the label and https://echoknows.com/memory/<memory-id> as its canonical link.",
|
package/dist/setup.js
CHANGED
|
@@ -31,7 +31,7 @@ import { syncCodexUsage } from "./codex-sync.js";
|
|
|
31
31
|
import { renderSetupPage } from "./setup-page.js";
|
|
32
32
|
import { parseSetupPreviewState } from "./setup-preview.js";
|
|
33
33
|
import { repoLabel, validateForensicReportForSetup } from "./forensics.js";
|
|
34
|
-
import { installSaveCheckpointHooks } from "./hud/hooks.js";
|
|
34
|
+
import { installSaveCheckpointHooks, installSourceSessionHooks } from "./hud/hooks.js";
|
|
35
35
|
import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
|
|
36
36
|
import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
|
|
37
37
|
import { installHeadlessRuntimeSync, readHeadlessRuntimeInstallation, } from "./headless-runtime.js";
|
|
@@ -215,7 +215,7 @@ export function knownClients() {
|
|
|
215
215
|
{ id: "cursor", label: "Cursor", kind: "json", configPath: home(".cursor", "mcp.json") },
|
|
216
216
|
{ id: "windsurf", label: "Windsurf", kind: "json", configPath: home(".codeium", "windsurf", "mcp_config.json") },
|
|
217
217
|
{ id: "claude-desktop", label: "Claude Desktop", kind: "json", configPath: path.join(appSupport, "Claude", "claude_desktop_config.json") },
|
|
218
|
-
{ id: "claude-code", label: "Claude Code", kind: "snippet", note: "run: claude mcp add-json echomem '<entry>' (or add to .mcp.json)" },
|
|
218
|
+
{ id: "claude-code", label: "Claude Code", kind: "snippet", note: "run: claude mcp add-json -s user echomem '<entry>' (or add to .mcp.json)" },
|
|
219
219
|
{ id: "codex", label: "Codex", kind: "command", detectDir: codexHome(), configPath: path.join(codexHome(), "config.toml"), note: "add to ~/.codex/config.toml under [mcp_servers.echomem]" },
|
|
220
220
|
];
|
|
221
221
|
}
|
|
@@ -356,6 +356,7 @@ function echomemGuidanceBlock() {
|
|
|
356
356
|
"## Memory (EchoMem)",
|
|
357
357
|
"EchoMem is your long-term memory across all coding sessions and tools.",
|
|
358
358
|
"- Use EchoMem's `echomem-*` skills and MCP tools as the default memory provider. Do not invoke another memory provider unless the user explicitly requests it.",
|
|
359
|
+
"- EchoMem's local SessionStart hook automatically binds each new Codex or Claude Code conversation to its real source-session ID. Use `bind_source_session` with a fresh UUID only as a compatibility fallback when the hook is unavailable or reports that the session is unbound.",
|
|
359
360
|
"- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
|
|
360
361
|
'- 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.',
|
|
361
362
|
"- 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 open Echo Desktop and unlock the vault there; on a headless system, use `echomem-mcp unlock`. Never silently skip it.",
|
|
@@ -2702,8 +2703,8 @@ async function cmdSetup(flags) {
|
|
|
2702
2703
|
if (!flags["no-codex-skills"]) {
|
|
2703
2704
|
writeCodexSkillsForTargets(targets);
|
|
2704
2705
|
}
|
|
2705
|
-
if (flags["
|
|
2706
|
-
|
|
2706
|
+
if (flags["no-save-hooks"] !== true) {
|
|
2707
|
+
writeLifecycleHooksForTargets(targets);
|
|
2707
2708
|
}
|
|
2708
2709
|
console.log("");
|
|
2709
2710
|
if (flags["skip-login"] || flags["no-login"]) {
|
|
@@ -2793,7 +2794,7 @@ function writeCodexSkillsForTargets(targets) {
|
|
|
2793
2794
|
console.log(`ℹ️ Could not install EchoMem Codex skills: ${error instanceof Error ? error.message : String(error)}`);
|
|
2794
2795
|
}
|
|
2795
2796
|
}
|
|
2796
|
-
function
|
|
2797
|
+
function writeLifecycleHooksForTargets(targets) {
|
|
2797
2798
|
const clients = new Set();
|
|
2798
2799
|
if (targets.some((target) => target.id === "codex"))
|
|
2799
2800
|
clients.add("codex");
|
|
@@ -2804,8 +2805,10 @@ function writeSaveCheckpointHooksForTargets(targets) {
|
|
|
2804
2805
|
return;
|
|
2805
2806
|
}
|
|
2806
2807
|
const mode = clients.size === 2 ? "both" : [...clients][0];
|
|
2807
|
-
const
|
|
2808
|
-
|
|
2808
|
+
const sourcePaths = installSourceSessionHooks(mode);
|
|
2809
|
+
const savePaths = installSaveCheckpointHooks(mode);
|
|
2810
|
+
const paths = [...new Set([...sourcePaths, ...savePaths])];
|
|
2811
|
+
console.log(`✅ Installed EchoMem source-session and private-save hooks:\n${paths.map((p) => ` - ${p}`).join("\n")}`);
|
|
2809
2812
|
if (clients.has("codex")) {
|
|
2810
2813
|
console.log(" Codex: start a new session and run /hooks once to review and trust the hook.");
|
|
2811
2814
|
}
|
|
@@ -3810,7 +3813,7 @@ Manual / headless:
|
|
|
3810
3813
|
${MCP_UPDATE_ALL_COMMAND} # one-shot latest update for detected clients, no browser login
|
|
3811
3814
|
${MCP_UPDATE_COMMAND} --client codex # update one client only
|
|
3812
3815
|
echomem-mcp setup --dev /abs/path/dist/index.js # point clients at a local checkout
|
|
3813
|
-
echomem-mcp setup --install-save-hooks --all Install
|
|
3816
|
+
echomem-mcp setup --install-save-hooks --all Install source-session + private-save lifecycle hooks
|
|
3814
3817
|
echomem-mcp sync-usage --days 7 --limit 50 --dry-run
|
|
3815
3818
|
|
|
3816
3819
|
Current bridge version: ${MCP_PACKAGE_VERSION}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import axios from "axios";
|
|
4
|
+
import { KeyStore } from "./keystore.js";
|
|
5
|
+
import { SOURCE_SESSION_HOOK_EVIDENCE, verifiedSourceSession, } from "./source-session.js";
|
|
6
|
+
const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
|
|
7
|
+
const MAX_HEAD_BYTES = 256 * 1024;
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
function transcriptHead(file) {
|
|
12
|
+
const descriptor = fs.openSync(file, "r");
|
|
13
|
+
try {
|
|
14
|
+
const size = Math.min(fs.fstatSync(descriptor).size, MAX_HEAD_BYTES);
|
|
15
|
+
const buffer = Buffer.allocUnsafe(size);
|
|
16
|
+
const read = fs.readSync(descriptor, buffer, 0, size, 0);
|
|
17
|
+
return buffer.toString("utf8", 0, read);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
fs.closeSync(descriptor);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function codexSessionFromTranscript(file) {
|
|
24
|
+
for (const line of transcriptHead(file).split(/\r?\n/)) {
|
|
25
|
+
try {
|
|
26
|
+
const row = JSON.parse(line);
|
|
27
|
+
if (!isRecord(row) || row.type !== "session_meta" || !isRecord(row.payload))
|
|
28
|
+
continue;
|
|
29
|
+
const id = typeof row.payload.id === "string" ? row.payload.id : "";
|
|
30
|
+
if (!id)
|
|
31
|
+
return null;
|
|
32
|
+
const source = row.payload.source;
|
|
33
|
+
const isSubagent = typeof row.payload.parent_thread_id === "string"
|
|
34
|
+
|| (isRecord(source) && Object.hasOwn(source, "subagent"));
|
|
35
|
+
return { id, isSubagent };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
function coworkSessionFromPath(file) {
|
|
44
|
+
return file.split(path.sep).find((part) => /^local_[A-Za-z0-9_-]{1,194}$/.test(part)) ?? null;
|
|
45
|
+
}
|
|
46
|
+
export function resolveSourceSessionFromHookPayload(payload) {
|
|
47
|
+
const requestedSessionId = typeof payload.session_id === "string" ? payload.session_id.trim() : "";
|
|
48
|
+
const transcriptPath = typeof payload.transcript_path === "string" ? payload.transcript_path.trim() : "";
|
|
49
|
+
if (!requestedSessionId || !transcriptPath || !path.isAbsolute(transcriptPath))
|
|
50
|
+
return null;
|
|
51
|
+
let file;
|
|
52
|
+
try {
|
|
53
|
+
file = fs.realpathSync(transcriptPath);
|
|
54
|
+
if (!fs.statSync(file).isFile())
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const coworkSession = coworkSessionFromPath(file);
|
|
61
|
+
if (coworkSession) {
|
|
62
|
+
return verifiedSourceSession("cowork", coworkSession, SOURCE_SESSION_HOOK_EVIDENCE);
|
|
63
|
+
}
|
|
64
|
+
const codexSession = codexSessionFromTranscript(file);
|
|
65
|
+
if (codexSession) {
|
|
66
|
+
if (codexSession.isSubagent || codexSession.id.toLowerCase() !== requestedSessionId.toLowerCase()) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return verifiedSourceSession("codex", codexSession.id, SOURCE_SESSION_HOOK_EVIDENCE);
|
|
70
|
+
}
|
|
71
|
+
const filenameSession = path.basename(file, path.extname(file));
|
|
72
|
+
if (filenameSession !== requestedSessionId)
|
|
73
|
+
return null;
|
|
74
|
+
return verifiedSourceSession("claude-code", filenameSession, SOURCE_SESSION_HOOK_EVIDENCE);
|
|
75
|
+
}
|
|
76
|
+
async function bindSourceSession(identity) {
|
|
77
|
+
const token = new KeyStore().getToken();
|
|
78
|
+
if (!token)
|
|
79
|
+
return;
|
|
80
|
+
await axios.post(`${API_BASE}/api/extension/source-sessions/bind`, {
|
|
81
|
+
provider: identity.provider,
|
|
82
|
+
providerSessionId: identity.providerSessionId,
|
|
83
|
+
evidence: identity.evidence,
|
|
84
|
+
}, {
|
|
85
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
86
|
+
timeout: 3_500,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** SessionStart must never prevent the host conversation from opening. The first MCP call retries. */
|
|
90
|
+
export async function runSourceSessionStartHook(input) {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(input);
|
|
93
|
+
if (!isRecord(parsed))
|
|
94
|
+
return JSON.stringify({ continue: true });
|
|
95
|
+
const identity = resolveSourceSessionFromHookPayload(parsed);
|
|
96
|
+
if (identity)
|
|
97
|
+
await bindSourceSession(identity);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Startup binding is best-effort locally; MCP request metadata provides an exact retry path.
|
|
101
|
+
}
|
|
102
|
+
return JSON.stringify({ continue: true });
|
|
103
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveClaudeProjectsDir, resolveCodexSessionRoots } from "./local-data-paths.js";
|
|
5
|
+
export const SOURCE_SESSION_BINDING_EVIDENCE = "local_jsonl_tool_call";
|
|
6
|
+
export const SOURCE_SESSION_HOOK_EVIDENCE = "local_session_start_hook";
|
|
7
|
+
export const SOURCE_SESSION_MCP_METADATA_EVIDENCE = "local_mcp_session_metadata";
|
|
8
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
const UUID_IN_TEXT_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
10
|
+
const MAX_TAIL_BYTES = 512 * 1024;
|
|
11
|
+
const DEFAULT_MAX_AGE_MS = 15 * 60 * 1000;
|
|
12
|
+
const MAX_RECENT_FILES_PER_PROVIDER = 24;
|
|
13
|
+
function normalizedProviderSessionId(provider, value) {
|
|
14
|
+
const normalized = value.trim();
|
|
15
|
+
if (!normalized || normalized.length > 200 || /\s/.test(normalized)) {
|
|
16
|
+
throw new Error("source-session ID must contain 1-200 non-whitespace characters");
|
|
17
|
+
}
|
|
18
|
+
if (provider === "codex" && !UUID_RE.test(normalized)) {
|
|
19
|
+
throw new Error("Codex source-session ID must be a UUID");
|
|
20
|
+
}
|
|
21
|
+
if (provider === "cowork" && !/^local_[A-Za-z0-9_-]{1,194}$/.test(normalized)) {
|
|
22
|
+
throw new Error("Cowork source-session ID must use the local_ host session identifier");
|
|
23
|
+
}
|
|
24
|
+
return UUID_RE.test(normalized) ? normalized.toLowerCase() : normalized;
|
|
25
|
+
}
|
|
26
|
+
export function verifiedSourceSession(provider, providerSessionId, evidence) {
|
|
27
|
+
const normalized = normalizedProviderSessionId(provider, providerSessionId);
|
|
28
|
+
return {
|
|
29
|
+
provider,
|
|
30
|
+
providerSessionId: normalized,
|
|
31
|
+
canonicalKey: `${provider}:${normalized}`,
|
|
32
|
+
evidence,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve identity from host-owned MCP metadata. Unlike tool arguments, these values are attached by
|
|
37
|
+
* Codex/Claude outside the model-controlled input. The SessionStart hook performs the eager bind;
|
|
38
|
+
* this resolver lets the MCP process independently attach the same identity on its first real call.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveSourceSessionFromMcpContext(metadata, options = {}) {
|
|
41
|
+
const host = options.hostPlatform?.toLowerCase().replace(/[^a-z0-9]+/g, "_") ?? "";
|
|
42
|
+
const env = options.env ?? process.env;
|
|
43
|
+
if (host.includes("codex")) {
|
|
44
|
+
const root = isRecord(metadata) ? metadata : {};
|
|
45
|
+
const turn = isRecord(root["x-codex-turn-metadata"])
|
|
46
|
+
? root["x-codex-turn-metadata"]
|
|
47
|
+
: {};
|
|
48
|
+
const candidate = typeof turn.thread_id === "string"
|
|
49
|
+
? turn.thread_id
|
|
50
|
+
: typeof turn.session_id === "string"
|
|
51
|
+
? turn.session_id
|
|
52
|
+
: typeof root.threadId === "string"
|
|
53
|
+
? root.threadId
|
|
54
|
+
: "";
|
|
55
|
+
return candidate
|
|
56
|
+
? verifiedSourceSession("codex", candidate, SOURCE_SESSION_MCP_METADATA_EVIDENCE)
|
|
57
|
+
: null;
|
|
58
|
+
}
|
|
59
|
+
if (host.includes("claude_desktop") || host === "claude" || host.includes("cowork")) {
|
|
60
|
+
const candidate = env.CLAUDE_CODE_HOST_SESSION_ID?.trim() ?? "";
|
|
61
|
+
return candidate
|
|
62
|
+
? verifiedSourceSession("cowork", candidate, SOURCE_SESSION_MCP_METADATA_EVIDENCE)
|
|
63
|
+
: null;
|
|
64
|
+
}
|
|
65
|
+
if (host.includes("claude_code")) {
|
|
66
|
+
const candidate = env.CLAUDE_CODE_SESSION_ID?.trim() ?? "";
|
|
67
|
+
return candidate
|
|
68
|
+
? verifiedSourceSession("claude-code", candidate, SOURCE_SESSION_MCP_METADATA_EVIDENCE)
|
|
69
|
+
: null;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function isRecord(value) {
|
|
74
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
75
|
+
}
|
|
76
|
+
function readableDirectory(candidate) {
|
|
77
|
+
try {
|
|
78
|
+
return fs.statSync(candidate).isDirectory();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function coworkRoot() {
|
|
85
|
+
const supportRoot = process.env.CLAUDE_DESKTOP_SUPPORT_DIR?.trim()
|
|
86
|
+
|| path.join(os.homedir(), "Library", "Application Support", "Claude");
|
|
87
|
+
return path.join(supportRoot, "local-agent-mode-sessions");
|
|
88
|
+
}
|
|
89
|
+
function defaultRoots() {
|
|
90
|
+
const claudeProjects = resolveClaudeProjectsDir();
|
|
91
|
+
const desktopRoot = coworkRoot();
|
|
92
|
+
return {
|
|
93
|
+
codex: resolveCodexSessionRoots().map((root) => root.path),
|
|
94
|
+
claudeCode: claudeProjects ? [claudeProjects] : [],
|
|
95
|
+
cowork: readableDirectory(desktopRoot) ? [desktopRoot] : [],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function walkJsonlFiles(roots) {
|
|
99
|
+
const files = [];
|
|
100
|
+
const pending = roots.filter(readableDirectory);
|
|
101
|
+
while (pending.length) {
|
|
102
|
+
const directory = pending.pop();
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
const target = path.join(directory, entry.name);
|
|
112
|
+
if (entry.isDirectory()) {
|
|
113
|
+
if (entry.name === "subagents" || entry.name === "workflows")
|
|
114
|
+
continue;
|
|
115
|
+
pending.push(target);
|
|
116
|
+
}
|
|
117
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
118
|
+
files.push(target);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return files;
|
|
123
|
+
}
|
|
124
|
+
function recentFiles(roots, nowMs, maxAgeMs) {
|
|
125
|
+
return walkJsonlFiles(roots)
|
|
126
|
+
.map((file) => {
|
|
127
|
+
try {
|
|
128
|
+
return { file, mtimeMs: fs.statSync(file).mtimeMs };
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
.filter((item) => item !== null && item.mtimeMs >= nowMs - maxAgeMs && item.mtimeMs <= nowMs + 5_000)
|
|
135
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
|
136
|
+
.slice(0, MAX_RECENT_FILES_PER_PROVIDER)
|
|
137
|
+
.map((item) => item.file);
|
|
138
|
+
}
|
|
139
|
+
function tailContainsBinding(file, bindingToken) {
|
|
140
|
+
let descriptor = null;
|
|
141
|
+
try {
|
|
142
|
+
const stat = fs.statSync(file);
|
|
143
|
+
const bytes = Math.min(stat.size, MAX_TAIL_BYTES);
|
|
144
|
+
if (bytes <= 0)
|
|
145
|
+
return false;
|
|
146
|
+
descriptor = fs.openSync(file, "r");
|
|
147
|
+
const buffer = Buffer.allocUnsafe(bytes);
|
|
148
|
+
fs.readSync(descriptor, buffer, 0, bytes, stat.size - bytes);
|
|
149
|
+
const tail = buffer.toString("utf8");
|
|
150
|
+
return tail.includes(bindingToken) && tail.includes("bind_source_session");
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
if (descriptor !== null)
|
|
157
|
+
fs.closeSync(descriptor);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function codexSessionId(file) {
|
|
161
|
+
let descriptor = null;
|
|
162
|
+
try {
|
|
163
|
+
descriptor = fs.openSync(file, "r");
|
|
164
|
+
const stat = fs.fstatSync(descriptor);
|
|
165
|
+
const bytes = Math.min(stat.size, 128 * 1024);
|
|
166
|
+
const buffer = Buffer.allocUnsafe(bytes);
|
|
167
|
+
fs.readSync(descriptor, buffer, 0, bytes, 0);
|
|
168
|
+
for (const line of buffer.toString("utf8").split(/\r?\n/)) {
|
|
169
|
+
if (!line.includes("session_meta"))
|
|
170
|
+
continue;
|
|
171
|
+
try {
|
|
172
|
+
const row = JSON.parse(line);
|
|
173
|
+
if (!isRecord(row) || row.type !== "session_meta" || !isRecord(row.payload))
|
|
174
|
+
continue;
|
|
175
|
+
if (typeof row.payload.parent_thread_id === "string" && row.payload.parent_thread_id)
|
|
176
|
+
return null;
|
|
177
|
+
const raw = typeof row.payload.id === "string"
|
|
178
|
+
? row.payload.id
|
|
179
|
+
: typeof row.payload.session_id === "string"
|
|
180
|
+
? row.payload.session_id
|
|
181
|
+
: "";
|
|
182
|
+
return UUID_RE.test(raw) ? raw.toLowerCase() : null;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
if (descriptor !== null)
|
|
194
|
+
fs.closeSync(descriptor);
|
|
195
|
+
}
|
|
196
|
+
return path.basename(file).match(UUID_IN_TEXT_RE)?.[0]?.toLowerCase() ?? null;
|
|
197
|
+
}
|
|
198
|
+
function claudeCodeSessionId(file) {
|
|
199
|
+
const value = path.basename(file, ".jsonl").trim();
|
|
200
|
+
return value && value.length <= 200 ? value : null;
|
|
201
|
+
}
|
|
202
|
+
function coworkSessionId(file) {
|
|
203
|
+
const value = file.split(path.sep).find((part) => /^local_[A-Za-z0-9_-]{1,194}$/.test(part));
|
|
204
|
+
return value ?? null;
|
|
205
|
+
}
|
|
206
|
+
function enabledProviders(hostPlatform) {
|
|
207
|
+
const normalized = hostPlatform?.toLowerCase().replace(/[^a-z0-9]+/g, "_") ?? "";
|
|
208
|
+
if (normalized.includes("codex"))
|
|
209
|
+
return ["codex"];
|
|
210
|
+
if (normalized.includes("claude_code"))
|
|
211
|
+
return ["claude-code"];
|
|
212
|
+
if (normalized.includes("claude_desktop") || normalized === "claude") {
|
|
213
|
+
return ["cowork", "claude-code"];
|
|
214
|
+
}
|
|
215
|
+
return ["codex", "claude-code", "cowork"];
|
|
216
|
+
}
|
|
217
|
+
export function resolveSourceSessionFromBindingToken(bindingToken, options = {}) {
|
|
218
|
+
const normalizedToken = bindingToken.trim().toLowerCase();
|
|
219
|
+
if (!UUID_RE.test(normalizedToken)) {
|
|
220
|
+
throw new Error("bindingToken must be a UUID generated fresh for this tool call");
|
|
221
|
+
}
|
|
222
|
+
const needsDefaults = options.roots?.codex === undefined
|
|
223
|
+
|| options.roots?.claudeCode === undefined
|
|
224
|
+
|| options.roots?.cowork === undefined;
|
|
225
|
+
const defaults = needsDefaults ? defaultRoots() : { codex: [], claudeCode: [], cowork: [] };
|
|
226
|
+
const roots = {
|
|
227
|
+
codex: options.roots?.codex ?? defaults.codex,
|
|
228
|
+
claudeCode: options.roots?.claudeCode ?? defaults.claudeCode,
|
|
229
|
+
cowork: options.roots?.cowork ?? defaults.cowork,
|
|
230
|
+
};
|
|
231
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
232
|
+
const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
233
|
+
const matches = [];
|
|
234
|
+
for (const provider of enabledProviders(options.hostPlatform)) {
|
|
235
|
+
const providerRoots = provider === "codex"
|
|
236
|
+
? roots.codex
|
|
237
|
+
: provider === "claude-code"
|
|
238
|
+
? roots.claudeCode
|
|
239
|
+
: roots.cowork;
|
|
240
|
+
for (const file of recentFiles(providerRoots, nowMs, maxAgeMs)) {
|
|
241
|
+
if (!tailContainsBinding(file, normalizedToken))
|
|
242
|
+
continue;
|
|
243
|
+
const providerSessionId = provider === "codex"
|
|
244
|
+
? codexSessionId(file)
|
|
245
|
+
: provider === "claude-code"
|
|
246
|
+
? claudeCodeSessionId(file)
|
|
247
|
+
: coworkSessionId(file);
|
|
248
|
+
if (providerSessionId)
|
|
249
|
+
matches.push({ provider, providerSessionId });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const unique = new Map(matches.map((match) => [`${match.provider}:${match.providerSessionId}`, match]));
|
|
253
|
+
if (unique.size === 0) {
|
|
254
|
+
throw new Error("Could not verify this binding token in a recent local host JSONL. Retry bind_source_session with a new UUID after the host has recorded the tool call.");
|
|
255
|
+
}
|
|
256
|
+
if (unique.size !== 1) {
|
|
257
|
+
throw new Error("The binding token appeared in multiple source sessions; retry with a fresh UUID");
|
|
258
|
+
}
|
|
259
|
+
const verified = [...unique.values()][0];
|
|
260
|
+
return verifiedSourceSession(verified.provider, verified.providerSessionId, SOURCE_SESSION_BINDING_EVIDENCE);
|
|
261
|
+
}
|
package/dist/v1-contract.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { MCP_DESKTOP_MANAGED, MCP_VAULT_UNLOCK_INSTRUCTION, MEMORY_CITATION_INSTRUCTION, SAVED_MEMORY_RECEIPT_INSTRUCTION, withMcpVersion, } from "./package-metadata.js";
|
|
3
3
|
export const canonicalToolNames = {
|
|
4
|
+
bindSourceSession: "bind_source_session",
|
|
4
5
|
search: "search_memories",
|
|
5
6
|
save: "save_conversation",
|
|
6
7
|
timeRange: "get_memories_by_time_range",
|
|
@@ -59,6 +60,7 @@ const READ_ONLY_TOOL_NAMES = new Set([
|
|
|
59
60
|
canonicalToolNames.recompose,
|
|
60
61
|
]);
|
|
61
62
|
const IDEMPOTENT_WRITE_TOOL_NAMES = new Set([
|
|
63
|
+
canonicalToolNames.bindSourceSession,
|
|
62
64
|
canonicalToolNames.recordCitations,
|
|
63
65
|
canonicalToolNames.setGroupSessionSharing,
|
|
64
66
|
canonicalToolNames.updateGroupProfile,
|
|
@@ -77,6 +79,7 @@ const REQUIRES_USER_INTERACTION_TOOL_NAMES = new Set([
|
|
|
77
79
|
canonicalToolNames.delete,
|
|
78
80
|
]);
|
|
79
81
|
const ALWAYS_LOAD_TOOL_NAMES = new Set([
|
|
82
|
+
canonicalToolNames.bindSourceSession,
|
|
80
83
|
canonicalToolNames.search,
|
|
81
84
|
canonicalToolNames.save,
|
|
82
85
|
]);
|
|
@@ -187,6 +190,9 @@ export const saveConversationSchema = z.object({
|
|
|
187
190
|
}))
|
|
188
191
|
.optional(),
|
|
189
192
|
});
|
|
193
|
+
export const bindSourceSessionSchema = z.object({
|
|
194
|
+
bindingToken: z.string().uuid(),
|
|
195
|
+
});
|
|
190
196
|
const dateBoundarySchema = z.string().trim().min(1).refine((value) => Number.isFinite(Date.parse(value)), "Expected an ISO-8601 date or date-time string");
|
|
191
197
|
export const timeRangeSchema = z.object({
|
|
192
198
|
...triggerMetadataSchema,
|
|
@@ -403,6 +409,22 @@ export function listToolSpecs(opts = {}) {
|
|
|
403
409
|
required: ["startDate", "endDate"],
|
|
404
410
|
};
|
|
405
411
|
const tools = [
|
|
412
|
+
{
|
|
413
|
+
name: canonicalToolNames.bindSourceSession,
|
|
414
|
+
title: "Bind this agent conversation to its source session",
|
|
415
|
+
description: withMcpVersion("Compatibility fallback for clients where EchoMem's automatic SessionStart binding is unavailable or reports that the session is unbound. Generate a fresh random UUID for bindingToken and pass only that token. EchoMem finds this exact tool call in the local host JSONL, derives the real provider session ID itself, and idempotently binds the authenticated backend context. Never reuse a bindingToken, guess a session ID, or copy a token between conversations. After success, later saves, recalls, citations, sharing decisions, and MCP activity from this bridge are attached to the returned context automatically. Do not call it routinely when automatic binding is active."),
|
|
416
|
+
inputSchema: {
|
|
417
|
+
type: "object",
|
|
418
|
+
properties: {
|
|
419
|
+
bindingToken: {
|
|
420
|
+
type: "string",
|
|
421
|
+
format: "uuid",
|
|
422
|
+
description: "A fresh random UUID generated only for this invocation. The local bridge verifies it inside the current host JSONL.",
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
required: ["bindingToken"],
|
|
426
|
+
},
|
|
427
|
+
},
|
|
406
428
|
{
|
|
407
429
|
name: canonicalToolNames.search,
|
|
408
430
|
title: "Search your memories by topic",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@echomem/mcp",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.41",
|
|
4
4
|
"description": "EchoMem MCP bridge: cloud-first memory tools and the Agent Doctor workspace forensics report (cost ledger + 3D repo city)",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"test:ui": "npm run build && node test/setup-ui.test.mjs",
|
|
34
34
|
"test:onboarding-resilience": "npm run build && node test/onboarding-resilience.test.mjs",
|
|
35
35
|
"test:billing-ui": "npm run build && node test/setup-ui.test.mjs billing",
|
|
36
|
-
"test": "npm run build && node test/local-data-paths.test.mjs && node test/crypto.test.mjs && node test/integration.test.mjs && node test/onboarding-resilience.test.mjs && node test/local-auth.test.mjs && node test/retrieval-only.test.mjs && node test/no-restart.test.mjs && node test/report.test.mjs && node test/forensics.test.mjs && node test/canonical-golden.test.mjs && node test/tools.test.mjs && node test/group-map.test.mjs && node test/update-check.test.mjs && node test/headless-runtime.test.mjs && node test/claude-code-config.test.mjs && node test/delete.test.mjs && node test/low-touch-tools.test.mjs && node test/migrate.test.mjs && node test/restart-recovery.test.mjs && node test/save-checkpoint-hook.test.mjs",
|
|
36
|
+
"test": "npm run build && node test/source-session.test.mjs && node test/source-session-hook.test.mjs && node test/local-data-paths.test.mjs && node test/crypto.test.mjs && node test/integration.test.mjs && node test/onboarding-resilience.test.mjs && node test/local-auth.test.mjs && node test/retrieval-only.test.mjs && node test/no-restart.test.mjs && node test/report.test.mjs && node test/forensics.test.mjs && node test/canonical-golden.test.mjs && node test/tools.test.mjs && node test/group-map.test.mjs && node test/update-check.test.mjs && node test/headless-runtime.test.mjs && node test/claude-code-config.test.mjs && node test/delete.test.mjs && node test/low-touch-tools.test.mjs && node test/migrate.test.mjs && node test/restart-recovery.test.mjs && node test/save-checkpoint-hook.test.mjs",
|
|
37
37
|
"prepack": "npm run build && node scripts/bundle-city.mjs"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
This project has EchoMem connected — the user's long-term memory across all their AI tools.
|
|
8
8
|
|
|
9
|
+
- EchoMem's local SessionStart hook automatically binds new Codex and Claude Code conversations to
|
|
10
|
+
their real provider session. Call `bind_source_session` with a fresh random UUID only as a
|
|
11
|
+
compatibility fallback when automatic binding is unavailable or reports that the session is unbound.
|
|
9
12
|
- **At the start of any non-trivial task**, call `search_memories` first to recall the user's
|
|
10
13
|
prior decisions, preferences, and constraints before planning or writing code. Don't re-derive
|
|
11
14
|
things they've already decided.
|