diff --git a/index.mjs b/index.mjs index 422d0b9..bc5a3ca 100644 --- a/index.mjs +++ b/index.mjs @@ -4048,6 +4048,39 @@ if (_isMain) { }) process.stdout.write(`stored compact summary: memory id ${id}\n`) + } else if (hasFlag('--record-conversation')) { + // The per-turn counterpart to scripts/transcript-sweep.mjs: a hook pipes one + // turn in on stdin as it happens, instead of the sweep catching it later. + // + // This branch has now been dropped by a refactor twice, and both times the + // symptom was the same: the CLI falls through to usage, exits 1, and turns + // silently stop entering the conversations table. The first loss ran 17 days + // before anyone connected the ~150 daily engram_failed warnings to it. + // + // hasFlag, not getFlag: callers pass the flag with no value, so getFlag + // would swallow the next token (--platform). + const chunks = [] + for await (const chunk of process.stdin) chunks.push(chunk) + const content = Buffer.concat(chunks).toString('utf-8') + if (!content || content.trim().length === 0) { + process.stderr.write('Error: --record-conversation requires content via stdin\n') + process.exit(1) + } + const id = await recordConversationAsync({ + platform: getFlag('--platform') || 'claude-code', + chatId: getFlag('--chat-id') || 'unknown', + messageId: getFlag('--message-id') || null, + fromId: getFlag('--from-id') || null, + fromName: getFlag('--from-name') || '', + role: getFlag('--role') || 'user', + content, + isReply: hasFlag('--is-reply'), + replyToId: getFlag('--reply-to-id') || null, + }) + // id === null means UNIQUE-dedup (already recorded) — still a success for + // the caller (exit 0), not a failure. + process.stdout.write(`recorded: ${id || 'dedup'}\n`) + } else if (getFlag('--compress') !== null) { const chatId = getFlag('--compress') const days = parseInt(getFlag('--days') || '30', 10) @@ -4083,6 +4116,8 @@ if (_isMain) { ' node index.mjs --context "query" Build injection context', ' node index.mjs --recall "query" Recall memory list', ' node index.mjs --recall "" --limit 20 List recent 20 memories', + ' node index.mjs --record-conversation Record a conversation turn (content via stdin)', + ' [--platform P] [--chat-id C] [--role user|assistant] [--from-name N] [--from-id I] [--is-reply]', ' node index.mjs --store "content" Manually store a memory', ' [--importance 1-10] [--category general|people|project|...]', ' [--type working|short_term|long_term|permanent]', diff --git a/mcp-server.mjs b/mcp-server.mjs index c3c0f5c..c1e68b4 100644 --- a/mcp-server.mjs +++ b/mcp-server.mjs @@ -59,7 +59,16 @@ if (existsSync(envPath)) { readFileSync(envPath, 'utf-8').split('\n').forEach(line => { const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*?)\r?$/) // Existing env wins — launcher-set values still override the file. - if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].trim() + if (m && process.env[m[1]] === undefined) { + process.env[m[1]] = m[2].trim() + } else if (m && m[2].trim() === '') { + // ...except an explicitly empty value, which CLEARS the variable. Without + // this, `MNEME_QUARANTINE_HOSTS=` cannot turn quarantine back off once + // something upstream has set it — the file says off, the env says on, and + // writes keep landing in a table recall never reads. "Existing env wins" + // is right for supplying a value and wrong for withdrawing one. + delete process.env[m[1]] + } }) } @@ -604,7 +613,23 @@ if (useHttp) { const sessionId = req.headers['mcp-session-id'] let entry = sessionId ? sessions.get(sessionId) : null - if (entry) entry.lastUsed = Date.now() // 复用 session:刷新活跃时间 + if (entry) { + entry.lastUsed = Date.now() // 复用 session:刷新活跃时间 + // A session's host is bound at creation and every write on it is stamped + // with that host. If a later request on the same session presents a token + // mapping to a different host, the binding and the evidence disagree — + // exactly the case channel-derived provenance exists to prevent. Reject + // under enforce; keep the binding but say so loudly otherwise, because + // the silent version is provenance drifting with no signal at all. + if (auth.authed && entry.host !== auth.host) { + if (AUTH_MODE === 'enforce') { + res.writeHead(401, { 'Content-Type': 'text/plain' }) + res.end(`Unauthorized: session bound to host '${entry.host}', token maps to '${auth.host}'`) + return + } + console.error(`[mneme] host mismatch on session ${String(sessionId).slice(0, 8)}: bound=${entry.host}, token=${auth.host} (keeping binding)`) + } + } if (!entry) { // New session: open transport + connect a fresh server instance