@echomem/mcp 1.4.39 → 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-page/client-extraction.js +9 -3
- package/dist/setup-page/styles-mvp.js +46 -0
- package/dist/setup.js +35 -16
- 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.",
|
|
@@ -1285,10 +1285,12 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
|
|
|
1285
1285
|
var slot = document.getElementById("setupPlan");
|
|
1286
1286
|
if (!slot) return;
|
|
1287
1287
|
var readySettings = slot.closest ? slot.closest(".readySettings") : null;
|
|
1288
|
+
var planGateDecision = slot.closest ? slot.closest(".planGateDecision") : null;
|
|
1288
1289
|
if (!canExtract) {
|
|
1289
1290
|
slot.innerHTML = "";
|
|
1290
1291
|
slot.classList.add("is-hidden");
|
|
1291
1292
|
if (readySettings) readySettings.classList.remove("is-plan-open", "is-checkout-pending");
|
|
1293
|
+
if (planGateDecision) planGateDecision.classList.remove("is-checkout-pending");
|
|
1292
1294
|
renderBillingCommitment("");
|
|
1293
1295
|
return;
|
|
1294
1296
|
}
|
|
@@ -1303,6 +1305,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
|
|
|
1303
1305
|
return;
|
|
1304
1306
|
}
|
|
1305
1307
|
if (readySettings) readySettings.classList.remove("is-plan-open", "is-checkout-pending");
|
|
1308
|
+
if (planGateDecision) planGateDecision.classList.remove("is-checkout-pending");
|
|
1306
1309
|
rememberSetupPlanChoice("");
|
|
1307
1310
|
setupPlanChoice = "paid";
|
|
1308
1311
|
var planLabel = plan.charAt(0).toUpperCase() + plan.slice(1);
|
|
@@ -1329,6 +1332,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
|
|
|
1329
1332
|
}
|
|
1330
1333
|
if (setupPlanChoice === "free") {
|
|
1331
1334
|
if (readySettings) readySettings.classList.remove("is-plan-open", "is-checkout-pending");
|
|
1335
|
+
if (planGateDecision) planGateDecision.classList.remove("is-checkout-pending");
|
|
1332
1336
|
slot.innerHTML = '<div class="setupPlanConfirmed"><span class="setupPlanConfirmedIcon" aria-hidden="true">' + setupIcon("check") + '</span><span class="setupPlanConfirmedCopy"><strong>Original Echo</strong><span>100 coding sessions and 100 memory recalls each week.</span></span><button type="button" class="textButton" id="changeSetupPlan">Change</button></div>';
|
|
1333
1337
|
renderBillingCommitment("");
|
|
1334
1338
|
var changeBtn = document.getElementById("changeSetupPlan");
|
|
@@ -1352,6 +1356,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
|
|
|
1352
1356
|
readySettings.classList.remove("is-plan-open");
|
|
1353
1357
|
readySettings.classList.add("is-checkout-pending");
|
|
1354
1358
|
}
|
|
1359
|
+
if (planGateDecision) planGateDecision.classList.add("is-checkout-pending");
|
|
1355
1360
|
var selectedPaidPlan = pendingPaidPlan === "power" ? "Power" : "Pro";
|
|
1356
1361
|
var pendingHasTrial = !billingStatus || billingStatus.trialAvailable !== false;
|
|
1357
1362
|
var pendingPrice = pendingPaidPlan === "power" ? "$100" : "$20";
|
|
@@ -1361,15 +1366,15 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
|
|
|
1361
1366
|
: "Checking automatically every few seconds");
|
|
1362
1367
|
slot.innerHTML =
|
|
1363
1368
|
'<section class="setupPlanChoice setupPlanCheckout" aria-labelledby="setupPlanHeading">' +
|
|
1364
|
-
'<div class="setupPlanIntro"><h3 id="setupPlanHeading">' + esc(pendingHasTrial ? selectedPaidPlan + " trial — $0 today" : selectedPaidPlan + " subscription") + '</h3><p>
|
|
1369
|
+
'<div class="setupPlanIntro"><h3 id="setupPlanHeading">' + esc(pendingHasTrial ? selectedPaidPlan + " trial — $0 today" : selectedPaidPlan + " subscription") + '</h3><p>Complete checkout in Stripe, then return here. This page updates automatically.</p></div>' +
|
|
1365
1370
|
'<dl class="checkoutCommitment">' +
|
|
1366
1371
|
'<div><dt>Today</dt><dd>' + esc(pendingHasTrial ? "$0" : pendingPrice) + '</dd></div>' +
|
|
1367
1372
|
'<div><dt>History import</dt><dd>' + esc(pendingLimit) + ' sessions</dd></div>' +
|
|
1368
1373
|
'<div><dt>' + esc(pendingHasTrial ? "First charge" : "Billing") + '</dt><dd>' + esc(pendingHasTrial ? pendingPrice + " on " + trialChargeDateText() : pendingPrice + "/month now") + '</dd></div>' +
|
|
1369
1374
|
'</dl>' +
|
|
1370
|
-
'<div class="checkoutActions"><button type="button" class="primary" id="continueSelectedPaidPlan">Reopen Stripe checkout</button
|
|
1375
|
+
'<div class="checkoutActions"><button type="button" class="primary" id="continueSelectedPaidPlan">Reopen Stripe checkout</button></div>' +
|
|
1371
1376
|
'<p class="checkoutPollStatus" id="setupPlanStatus" role="status" aria-live="polite">' + esc(pendingStatus) + '</p>' +
|
|
1372
|
-
'<div class="setupPlanFoot"><button type="button" class="textButton" id="changeSelectedPaidPlan">Choose another plan</button></div>' +
|
|
1377
|
+
'<div class="setupPlanFoot"><button type="button" class="textButton" id="checkPaymentStatus">Check payment status</button><button type="button" class="textButton" id="changeSelectedPaidPlan">Choose another plan</button></div>' +
|
|
1373
1378
|
'</section>';
|
|
1374
1379
|
var checkPaid = document.getElementById("checkPaymentStatus");
|
|
1375
1380
|
if (checkPaid) checkPaid.onclick = function () {
|
|
@@ -1395,6 +1400,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
|
|
|
1395
1400
|
readySettings.classList.remove("is-checkout-pending");
|
|
1396
1401
|
readySettings.classList.add("is-plan-open");
|
|
1397
1402
|
}
|
|
1403
|
+
if (planGateDecision) planGateDecision.classList.remove("is-checkout-pending");
|
|
1398
1404
|
slot.innerHTML = renderPlanHabitat();
|
|
1399
1405
|
renderBillingCommitment("");
|
|
1400
1406
|
bindPlanHabitat();
|
|
@@ -667,6 +667,52 @@ export const SETUP_PAGE_STYLES_MVP = String.raw `
|
|
|
667
667
|
color: var(--echo-ink-text);
|
|
668
668
|
text-align: left;
|
|
669
669
|
}
|
|
670
|
+
.planGateDecision.is-checkout-pending {
|
|
671
|
+
width: min(680px, 100%);
|
|
672
|
+
}
|
|
673
|
+
.planGateDecision.is-checkout-pending .setupPlanSlot {
|
|
674
|
+
margin-top: 8px;
|
|
675
|
+
}
|
|
676
|
+
.planGateDecision.is-checkout-pending .setupPlanCheckout {
|
|
677
|
+
display: grid;
|
|
678
|
+
gap: 16px;
|
|
679
|
+
margin: 0 auto;
|
|
680
|
+
overflow: visible;
|
|
681
|
+
border: 1px solid rgba(26,58,143,0.11);
|
|
682
|
+
border-radius: 20px;
|
|
683
|
+
background: rgba(255,255,255,0.78);
|
|
684
|
+
padding: clamp(22px, 4vw, 34px);
|
|
685
|
+
box-shadow: 0 22px 55px -42px rgba(20,36,84,0.5);
|
|
686
|
+
backdrop-filter: blur(10px);
|
|
687
|
+
}
|
|
688
|
+
.planGateDecision.is-checkout-pending .setupPlanIntro {
|
|
689
|
+
padding: 0;
|
|
690
|
+
}
|
|
691
|
+
.planGateDecision.is-checkout-pending .setupPlanIntro h3 {
|
|
692
|
+
font-size: clamp(25px, 3vw, 32px);
|
|
693
|
+
line-height: 1.2;
|
|
694
|
+
}
|
|
695
|
+
.planGateDecision.is-checkout-pending .setupPlanIntro p {
|
|
696
|
+
max-width: 620px;
|
|
697
|
+
margin-top: 7px;
|
|
698
|
+
font-size: 14px;
|
|
699
|
+
line-height: 1.5;
|
|
700
|
+
}
|
|
701
|
+
.planGateDecision.is-checkout-pending .checkoutActions {
|
|
702
|
+
grid-template-columns: 1fr;
|
|
703
|
+
}
|
|
704
|
+
.planGateDecision.is-checkout-pending .setupPlanFoot {
|
|
705
|
+
justify-content: flex-start;
|
|
706
|
+
gap: 18px;
|
|
707
|
+
margin-top: 0;
|
|
708
|
+
}
|
|
709
|
+
.planGateDecision.is-checkout-pending .setupPlanFoot .textButton {
|
|
710
|
+
min-height: 0;
|
|
711
|
+
padding: 2px 0;
|
|
712
|
+
color: var(--echo-ink-mute);
|
|
713
|
+
font-size: 11px;
|
|
714
|
+
font-weight: 700;
|
|
715
|
+
}
|
|
670
716
|
.mvpPricing {
|
|
671
717
|
width: 100%;
|
|
672
718
|
}
|
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.",
|
|
@@ -416,7 +417,26 @@ export function writeJsonClientConfig(configPath, entry) {
|
|
|
416
417
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
417
418
|
}
|
|
418
419
|
export function writeClaudeCodeConfig(entry) {
|
|
419
|
-
|
|
420
|
+
// EchoMem is a memory server that should load in EVERY Claude Code project, so it belongs at
|
|
421
|
+
// `user` scope (~/.claude.json, all projects) rather than `local` scope (the current project only).
|
|
422
|
+
const addArguments = ["mcp", "add-json", "-s", "user", "echomem", JSON.stringify(entry)];
|
|
423
|
+
const removeFromScope = (scope) => {
|
|
424
|
+
try {
|
|
425
|
+
execFileSync("claude", ["mcp", "remove", "echomem", "-s", scope], {
|
|
426
|
+
encoding: "utf8",
|
|
427
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
428
|
+
timeout: 10000,
|
|
429
|
+
});
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
// Older builds installed EchoMem at `local` scope. Left in place it would shadow the user-scoped
|
|
437
|
+
// entry and keep launching the stale command, so drop it first. Best-effort: `local` is per-project,
|
|
438
|
+
// so this only clears the directory setup runs from — a no-op (ignored) when nothing is there.
|
|
439
|
+
removeFromScope("local");
|
|
420
440
|
try {
|
|
421
441
|
execFileSync("claude", addArguments, {
|
|
422
442
|
encoding: "utf8",
|
|
@@ -431,14 +451,11 @@ export function writeClaudeCodeConfig(entry) {
|
|
|
431
451
|
if (!detail.includes("already exists"))
|
|
432
452
|
return "unavailable";
|
|
433
453
|
}
|
|
434
|
-
// Claude Code's CLI will not replace a same-name
|
|
435
|
-
//
|
|
454
|
+
// Claude Code's CLI will not replace a same-name server. Once the replacement entry is fully
|
|
455
|
+
// constructed, remove only EchoMem and immediately re-add it; sibling MCP servers remain.
|
|
436
456
|
try {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
440
|
-
timeout: 10000,
|
|
441
|
-
});
|
|
457
|
+
if (!removeFromScope("user"))
|
|
458
|
+
return "unavailable";
|
|
442
459
|
execFileSync("claude", addArguments, {
|
|
443
460
|
encoding: "utf8",
|
|
444
461
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -2686,8 +2703,8 @@ async function cmdSetup(flags) {
|
|
|
2686
2703
|
if (!flags["no-codex-skills"]) {
|
|
2687
2704
|
writeCodexSkillsForTargets(targets);
|
|
2688
2705
|
}
|
|
2689
|
-
if (flags["
|
|
2690
|
-
|
|
2706
|
+
if (flags["no-save-hooks"] !== true) {
|
|
2707
|
+
writeLifecycleHooksForTargets(targets);
|
|
2691
2708
|
}
|
|
2692
2709
|
console.log("");
|
|
2693
2710
|
if (flags["skip-login"] || flags["no-login"]) {
|
|
@@ -2777,7 +2794,7 @@ function writeCodexSkillsForTargets(targets) {
|
|
|
2777
2794
|
console.log(`ℹ️ Could not install EchoMem Codex skills: ${error instanceof Error ? error.message : String(error)}`);
|
|
2778
2795
|
}
|
|
2779
2796
|
}
|
|
2780
|
-
function
|
|
2797
|
+
function writeLifecycleHooksForTargets(targets) {
|
|
2781
2798
|
const clients = new Set();
|
|
2782
2799
|
if (targets.some((target) => target.id === "codex"))
|
|
2783
2800
|
clients.add("codex");
|
|
@@ -2788,8 +2805,10 @@ function writeSaveCheckpointHooksForTargets(targets) {
|
|
|
2788
2805
|
return;
|
|
2789
2806
|
}
|
|
2790
2807
|
const mode = clients.size === 2 ? "both" : [...clients][0];
|
|
2791
|
-
const
|
|
2792
|
-
|
|
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")}`);
|
|
2793
2812
|
if (clients.has("codex")) {
|
|
2794
2813
|
console.log(" Codex: start a new session and run /hooks once to review and trust the hook.");
|
|
2795
2814
|
}
|
|
@@ -3794,7 +3813,7 @@ Manual / headless:
|
|
|
3794
3813
|
${MCP_UPDATE_ALL_COMMAND} # one-shot latest update for detected clients, no browser login
|
|
3795
3814
|
${MCP_UPDATE_COMMAND} --client codex # update one client only
|
|
3796
3815
|
echomem-mcp setup --dev /abs/path/dist/index.js # point clients at a local checkout
|
|
3797
|
-
echomem-mcp setup --install-save-hooks --all Install
|
|
3816
|
+
echomem-mcp setup --install-save-hooks --all Install source-session + private-save lifecycle hooks
|
|
3798
3817
|
echomem-mcp sync-usage --days 7 --limit 50 --dry-run
|
|
3799
3818
|
|
|
3800
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
|
+
}
|