@echomem/mcp 1.4.40 → 1.4.42
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 +382 -247
- package/dist/package-metadata.js +2 -1
- package/dist/setup.js +146 -47
- package/dist/source-session-hook.js +103 -0
- package/dist/source-session.js +261 -0
- package/dist/v1-contract.js +75 -3
- 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.",
|
|
@@ -415,13 +416,70 @@ export function writeJsonClientConfig(configPath, entry) {
|
|
|
415
416
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
416
417
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
417
418
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
419
|
+
function readClaudeCodeConfigFile(configPath) {
|
|
420
|
+
try {
|
|
421
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
422
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
423
|
+
? parsed
|
|
424
|
+
: {};
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return {};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function echoMemEntryFromServers(value) {
|
|
431
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
432
|
+
return undefined;
|
|
433
|
+
const entry = value.echomem;
|
|
434
|
+
return entry && typeof entry === "object" && !Array.isArray(entry)
|
|
435
|
+
? entry
|
|
436
|
+
: undefined;
|
|
437
|
+
}
|
|
438
|
+
function claudeEntriesMatch(actual, expected) {
|
|
439
|
+
if (!actual || actual.command !== expected.command)
|
|
440
|
+
return false;
|
|
441
|
+
const actualArgs = Array.isArray(actual.args) ? actual.args : [];
|
|
442
|
+
const expectedArgs = Array.isArray(expected.args) ? expected.args : [];
|
|
443
|
+
if (actualArgs.length !== expectedArgs.length || actualArgs.some((value, index) => value !== expectedArgs[index])) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
const expectedEnv = expected.env;
|
|
447
|
+
if (!expectedEnv || typeof expectedEnv !== "object" || Array.isArray(expectedEnv))
|
|
448
|
+
return true;
|
|
449
|
+
const actualEnv = actual.env;
|
|
450
|
+
if (!actualEnv || typeof actualEnv !== "object" || Array.isArray(actualEnv))
|
|
451
|
+
return false;
|
|
452
|
+
return Object.entries(expectedEnv).every(([key, value]) => actualEnv[key] === value);
|
|
453
|
+
}
|
|
454
|
+
function claudeCodeLocalEchoMemProjects(configPath) {
|
|
455
|
+
const projects = readClaudeCodeConfigFile(configPath).projects;
|
|
456
|
+
if (!projects || typeof projects !== "object" || Array.isArray(projects))
|
|
457
|
+
return [];
|
|
458
|
+
return Object.entries(projects)
|
|
459
|
+
.filter(([, value]) => {
|
|
460
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
461
|
+
return false;
|
|
462
|
+
return Boolean(echoMemEntryFromServers(value.mcpServers));
|
|
463
|
+
})
|
|
464
|
+
.map(([projectPath]) => projectPath)
|
|
465
|
+
.sort();
|
|
466
|
+
}
|
|
467
|
+
export function writeClaudeCodeConfig(entry, options = {}) {
|
|
468
|
+
// EchoMem belongs at user scope so every Claude Code project resolves the same durable runtime.
|
|
469
|
+
// Older CLI versions wrote local/project entries, which take precedence over user scope and can
|
|
470
|
+
// keep launching a deleted npm cache or stale runtime. Migrate those only after user scope is safe.
|
|
471
|
+
const configPath = options.configPath ?? home(".claude.json");
|
|
472
|
+
const emptyResult = () => ({
|
|
473
|
+
state: "unavailable",
|
|
474
|
+
removedLocalProjects: [],
|
|
475
|
+
skippedLocalProjects: [],
|
|
476
|
+
failedLocalProjects: [],
|
|
477
|
+
restoredPreviousUserEntry: false,
|
|
478
|
+
});
|
|
479
|
+
const runClaude = (args, cwd) => {
|
|
423
480
|
try {
|
|
424
|
-
execFileSync("claude",
|
|
481
|
+
execFileSync("claude", args, {
|
|
482
|
+
cwd,
|
|
425
483
|
encoding: "utf8",
|
|
426
484
|
stdio: ["ignore", "pipe", "pipe"],
|
|
427
485
|
timeout: 10000,
|
|
@@ -432,39 +490,61 @@ export function writeClaudeCodeConfig(entry) {
|
|
|
432
490
|
return false;
|
|
433
491
|
}
|
|
434
492
|
};
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
return "unavailable";
|
|
452
|
-
}
|
|
453
|
-
// Claude Code's CLI will not replace a same-name server. Once the replacement entry is fully
|
|
454
|
-
// constructed, remove only EchoMem and immediately re-add it; sibling MCP servers remain.
|
|
455
|
-
try {
|
|
456
|
-
if (!removeFromScope("user"))
|
|
457
|
-
return "unavailable";
|
|
458
|
-
execFileSync("claude", addArguments, {
|
|
459
|
-
encoding: "utf8",
|
|
460
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
461
|
-
timeout: 10000,
|
|
462
|
-
});
|
|
463
|
-
return "wrote";
|
|
493
|
+
const addUser = (value) => runClaude([
|
|
494
|
+
"mcp", "add-json", "-s", "user", "echomem", JSON.stringify(value),
|
|
495
|
+
]);
|
|
496
|
+
const removeUser = () => runClaude(["mcp", "remove", "echomem", "-s", "user"]);
|
|
497
|
+
const before = readClaudeCodeConfigFile(configPath);
|
|
498
|
+
const previousUserEntry = echoMemEntryFromServers(before.mcpServers);
|
|
499
|
+
let restoredPreviousUserEntry = false;
|
|
500
|
+
// Avoid interrupting active/new sessions when the correct global entry is already installed.
|
|
501
|
+
if (!claudeEntriesMatch(previousUserEntry, entry)) {
|
|
502
|
+
if (previousUserEntry && !removeUser())
|
|
503
|
+
return emptyResult();
|
|
504
|
+
if (!addUser(entry)) {
|
|
505
|
+
if (previousUserEntry)
|
|
506
|
+
restoredPreviousUserEntry = addUser(previousUserEntry);
|
|
507
|
+
return { ...emptyResult(), restoredPreviousUserEntry };
|
|
508
|
+
}
|
|
464
509
|
}
|
|
465
|
-
|
|
466
|
-
|
|
510
|
+
const installedUserEntry = echoMemEntryFromServers(readClaudeCodeConfigFile(configPath).mcpServers);
|
|
511
|
+
if (!claudeEntriesMatch(installedUserEntry, entry)) {
|
|
512
|
+
return { ...emptyResult(), restoredPreviousUserEntry };
|
|
513
|
+
}
|
|
514
|
+
const removedLocalProjects = [];
|
|
515
|
+
const skippedLocalProjects = [];
|
|
516
|
+
const failedLocalProjects = [];
|
|
517
|
+
for (const projectPath of claudeCodeLocalEchoMemProjects(configPath)) {
|
|
518
|
+
// A deleted directory cannot currently shadow user scope. Do not recreate it or hand-edit
|
|
519
|
+
// ~/.claude.json, which also contains Claude account/session state.
|
|
520
|
+
if (!fs.existsSync(projectPath)) {
|
|
521
|
+
skippedLocalProjects.push(projectPath);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
let projectCwd = projectPath;
|
|
525
|
+
try {
|
|
526
|
+
projectCwd = fs.realpathSync(projectPath);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
/* The existence check above already established the safe fallback path. */
|
|
530
|
+
}
|
|
531
|
+
if (runClaude(["mcp", "remove", "echomem", "-s", "local"], projectCwd)) {
|
|
532
|
+
removedLocalProjects.push(projectPath);
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
failedLocalProjects.push(projectPath);
|
|
536
|
+
}
|
|
467
537
|
}
|
|
538
|
+
const remainingActiveProjects = claudeCodeLocalEchoMemProjects(configPath)
|
|
539
|
+
.filter((projectPath) => fs.existsSync(projectPath));
|
|
540
|
+
const unresolved = [...new Set([...failedLocalProjects, ...remainingActiveProjects])].sort();
|
|
541
|
+
return {
|
|
542
|
+
state: unresolved.length > 0 ? "needs-repair" : "wrote",
|
|
543
|
+
removedLocalProjects,
|
|
544
|
+
skippedLocalProjects,
|
|
545
|
+
failedLocalProjects: unresolved,
|
|
546
|
+
restoredPreviousUserEntry,
|
|
547
|
+
};
|
|
468
548
|
}
|
|
469
549
|
function readJsonClientEntry(configPath) {
|
|
470
550
|
try {
|
|
@@ -2667,6 +2747,7 @@ async function cmdSetup(flags) {
|
|
|
2667
2747
|
const entry = buildServerEntry({ devEntryPath: typeof flags.dev === "string" ? flags.dev : undefined });
|
|
2668
2748
|
const requested = typeof flags.client === "string" ? flags.client : undefined;
|
|
2669
2749
|
const targets = selectSetupTargets(requested, Boolean(flags.all));
|
|
2750
|
+
const configurationFailures = [];
|
|
2670
2751
|
if (targets.length === 0) {
|
|
2671
2752
|
console.log("No client auto-detected. Add this MCP server entry manually:\n");
|
|
2672
2753
|
console.log(JSON.stringify({ echomem: entry }, null, 2));
|
|
@@ -2687,23 +2768,39 @@ async function cmdSetup(flags) {
|
|
|
2687
2768
|
}
|
|
2688
2769
|
else {
|
|
2689
2770
|
const result = c.id === "claude-code" ? writeClaudeCodeConfig(entry) : "unavailable";
|
|
2690
|
-
if (result === "wrote") {
|
|
2771
|
+
if (result !== "unavailable" && result.state === "wrote") {
|
|
2691
2772
|
console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
|
|
2773
|
+
if (result.removedLocalProjects.length > 0) {
|
|
2774
|
+
console.log(`✅ Removed ${result.removedLocalProjects.length} stale Claude Code project-local EchoMem ${result.removedLocalProjects.length === 1 ? "entry" : "entries"}.`);
|
|
2775
|
+
}
|
|
2776
|
+
if (result.skippedLocalProjects.length > 0) {
|
|
2777
|
+
console.log(`ℹ️ Ignored ${result.skippedLocalProjects.length} EchoMem local ${result.skippedLocalProjects.length === 1 ? "entry" : "entries"} for deleted project directories; they cannot shadow the user entry.`);
|
|
2778
|
+
}
|
|
2692
2779
|
}
|
|
2693
2780
|
else {
|
|
2694
|
-
|
|
2781
|
+
const failedProjects = result === "unavailable" ? [] : result.failedLocalProjects;
|
|
2782
|
+
configurationFailures.push(failedProjects.length > 0
|
|
2783
|
+
? `${c.label} still has project-local EchoMem overrides in: ${failedProjects.join(", ")}`
|
|
2784
|
+
: `${c.label} user-scoped EchoMem entry could not be verified`);
|
|
2695
2785
|
}
|
|
2696
2786
|
}
|
|
2697
2787
|
}
|
|
2698
2788
|
}
|
|
2789
|
+
if (configurationFailures.length > 0) {
|
|
2790
|
+
throw new Error([
|
|
2791
|
+
"EchoMem MCP configuration is incomplete; onboarding was stopped before login/import.",
|
|
2792
|
+
...configurationFailures.map((failure) => `- ${failure}`),
|
|
2793
|
+
`Retry with: ${MCP_UPDATE_COMMAND} --client claude-code`,
|
|
2794
|
+
].join("\n"));
|
|
2795
|
+
}
|
|
2699
2796
|
if (!flags["no-agents-md"]) {
|
|
2700
2797
|
writeMemoryGuidanceForTargets(targets);
|
|
2701
2798
|
}
|
|
2702
2799
|
if (!flags["no-codex-skills"]) {
|
|
2703
2800
|
writeCodexSkillsForTargets(targets);
|
|
2704
2801
|
}
|
|
2705
|
-
if (flags["
|
|
2706
|
-
|
|
2802
|
+
if (flags["no-save-hooks"] !== true) {
|
|
2803
|
+
writeLifecycleHooksForTargets(targets);
|
|
2707
2804
|
}
|
|
2708
2805
|
console.log("");
|
|
2709
2806
|
if (flags["skip-login"] || flags["no-login"]) {
|
|
@@ -2793,7 +2890,7 @@ function writeCodexSkillsForTargets(targets) {
|
|
|
2793
2890
|
console.log(`ℹ️ Could not install EchoMem Codex skills: ${error instanceof Error ? error.message : String(error)}`);
|
|
2794
2891
|
}
|
|
2795
2892
|
}
|
|
2796
|
-
function
|
|
2893
|
+
function writeLifecycleHooksForTargets(targets) {
|
|
2797
2894
|
const clients = new Set();
|
|
2798
2895
|
if (targets.some((target) => target.id === "codex"))
|
|
2799
2896
|
clients.add("codex");
|
|
@@ -2804,8 +2901,10 @@ function writeSaveCheckpointHooksForTargets(targets) {
|
|
|
2804
2901
|
return;
|
|
2805
2902
|
}
|
|
2806
2903
|
const mode = clients.size === 2 ? "both" : [...clients][0];
|
|
2807
|
-
const
|
|
2808
|
-
|
|
2904
|
+
const sourcePaths = installSourceSessionHooks(mode);
|
|
2905
|
+
const savePaths = installSaveCheckpointHooks(mode);
|
|
2906
|
+
const paths = [...new Set([...sourcePaths, ...savePaths])];
|
|
2907
|
+
console.log(`✅ Installed EchoMem source-session and private-save hooks:\n${paths.map((p) => ` - ${p}`).join("\n")}`);
|
|
2809
2908
|
if (clients.has("codex")) {
|
|
2810
2909
|
console.log(" Codex: start a new session and run /hooks once to review and trust the hook.");
|
|
2811
2910
|
}
|
|
@@ -3810,7 +3909,7 @@ Manual / headless:
|
|
|
3810
3909
|
${MCP_UPDATE_ALL_COMMAND} # one-shot latest update for detected clients, no browser login
|
|
3811
3910
|
${MCP_UPDATE_COMMAND} --client codex # update one client only
|
|
3812
3911
|
echomem-mcp setup --dev /abs/path/dist/index.js # point clients at a local checkout
|
|
3813
|
-
echomem-mcp setup --install-save-hooks --all Install
|
|
3912
|
+
echomem-mcp setup --install-save-hooks --all Install source-session + private-save lifecycle hooks
|
|
3814
3913
|
echomem-mcp sync-usage --days 7 --limit 50 --dry-run
|
|
3815
3914
|
|
|
3816
3915
|
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
|
+
}
|