@mnemom/mnemom 0.17.2-next.2 → 0.17.2-next.4

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.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The client for the gateway's implicit-goal control route (implicit-goal CLI, cli-telemetry
3
+ * §1.2 / §1.4; the route is gateway/src/goal-intent-control.ts):
4
+ *
5
+ * GET https://<gateway_host>/mnemom/goal/intent?conversation_id=…&thread=…
6
+ * POST https://<gateway_host>/mnemom/goal/intent/pin {conversation_id, thread, pinned}
7
+ * POST https://<gateway_host>/mnemom/goal/intent/reset {conversation_id, thread, statement?, requirements?}
8
+ *
9
+ * Auth is the SAME credential the launch used: `x-api-key` (the Anthropic key from
10
+ * `resolveAnthropicKey`), `x-mnemom-agent: <record.agent_slug>`, and `x-mnemom-api-key` when the
11
+ * launch had a Mnemom key. The gateway hashes them exactly as a proxied request and only LOOKS
12
+ * UP the agent. Key material is never printed: errors carry the status, the gateway's error code
13
+ * and its message, nothing else.
14
+ *
15
+ * Pure apart from the injected `fetch`, so every arm is unit-tested.
16
+ */
17
+ /**
18
+ * A thread id the gateway accepts (PLAN K3 plus `all` for reads, plus the `m.<sha16>` per-process
19
+ * main ids the gateway's goal-thread.ts THREAD_ID_RE also accepts). Validated locally so a typo
20
+ * never costs a round trip.
21
+ */
22
+ export const GOAL_THREAD_RE = /^(?:main|all|(?:a|t|af|q|m)\.[A-Za-z0-9._:-]{1,80})$/;
23
+ export function isValidGoalThread(thread) {
24
+ return GOAL_THREAD_RE.test(thread);
25
+ }
26
+ /** A non-2xx answer (status > 0) or a transport failure (status 0). Never carries key material. */
27
+ export class GoalControlError extends Error {
28
+ status;
29
+ code;
30
+ constructor(status, code, message) {
31
+ super(message);
32
+ this.name = "GoalControlError";
33
+ this.status = status;
34
+ this.code = code;
35
+ }
36
+ }
37
+ /** `https://<host>` for a bare host; a host that already names a scheme is kept (trailing / dropped). */
38
+ export function gatewayOrigin(host) {
39
+ const h = host.trim().replace(/\/+$/, "");
40
+ return /^https?:\/\//i.test(h) ? h : `https://${h}`;
41
+ }
42
+ export const GOAL_CONTROL_PATH = "/mnemom/goal/intent";
43
+ /** The route URL for one op (the show query carries the conversation id and thread). */
44
+ export function goalControlUrl(record, op, thread = "main") {
45
+ const base = `${gatewayOrigin(record.gateway_host)}${GOAL_CONTROL_PATH}`;
46
+ if (op === "show") {
47
+ const q = new URLSearchParams({ conversation_id: record.conversation_id, thread });
48
+ return `${base}?${q.toString()}`;
49
+ }
50
+ return `${base}/${op}`;
51
+ }
52
+ /** The launch's identity headers (never logged). */
53
+ export function goalControlHeaders(record, creds) {
54
+ const headers = {
55
+ "x-api-key": creds.anthropicKey,
56
+ "x-mnemom-agent": record.agent_slug,
57
+ accept: "application/json",
58
+ };
59
+ if (creds.mnemomKey)
60
+ headers["x-mnemom-api-key"] = creds.mnemomKey;
61
+ return headers;
62
+ }
63
+ /** `{error:{code,message}}` (the gateway's envelope) → its parts; anything else → nulls. */
64
+ function errorParts(body) {
65
+ if (!body || typeof body !== "object")
66
+ return { code: null, message: null };
67
+ const e = body.error;
68
+ if (!e || typeof e !== "object")
69
+ return { code: null, message: null };
70
+ const { code, message } = e;
71
+ return {
72
+ code: typeof code === "string" ? code : null,
73
+ message: typeof message === "string" ? message : null,
74
+ };
75
+ }
76
+ /**
77
+ * Call the control route. Resolves with the parsed 2xx body; rejects with a GoalControlError
78
+ * (status 0 for a transport failure or a 2xx that is not JSON).
79
+ */
80
+ export async function goalControlFetch(record, op, body, deps) {
81
+ const thread = body.thread ?? "main";
82
+ const url = goalControlUrl(record, op, thread);
83
+ const headers = goalControlHeaders(record, deps.creds);
84
+ const init = { method: op === "show" ? "GET" : "POST", headers };
85
+ if (op !== "show") {
86
+ headers["content-type"] = "application/json";
87
+ init.body = JSON.stringify({ conversation_id: record.conversation_id, ...body, thread });
88
+ }
89
+ let res;
90
+ try {
91
+ res = await deps.fetch(url, init);
92
+ }
93
+ catch (e) {
94
+ const why = e instanceof Error ? e.message : String(e);
95
+ throw new GoalControlError(0, null, `could not reach ${gatewayOrigin(record.gateway_host)}: ${why}`);
96
+ }
97
+ const text = await res.text().catch(() => "");
98
+ let parsed;
99
+ try {
100
+ parsed = text ? JSON.parse(text) : null;
101
+ }
102
+ catch {
103
+ parsed = null;
104
+ }
105
+ if (!res.ok) {
106
+ const { code, message } = errorParts(parsed);
107
+ throw new GoalControlError(res.status, code, message ?? `HTTP ${res.status}`);
108
+ }
109
+ if (parsed === null || typeof parsed !== "object") {
110
+ throw new GoalControlError(0, null, `unexpected non-JSON response (HTTP ${res.status})`);
111
+ }
112
+ return parsed;
113
+ }
@@ -33,6 +33,12 @@ export interface LauncherConfig {
33
33
  goalMode?: GoalModeDefault;
34
34
  /** Context folding default (on when unset; `false` disables it for every launch). */
35
35
  context?: boolean;
36
+ /**
37
+ * Context-handoff default (EXPERIMENTAL, off when unset): `true` registers the `handoff`
38
+ * MCP tool and sends `CONTEXT_HANDOFF_ENABLED: "true"` in the per-session config header for
39
+ * every launch. Off means byte-identical to a launch with no MNE-7564 change at all.
40
+ */
41
+ contextHandoff?: boolean;
36
42
  guardrails?: {
37
43
  maxTurns?: number;
38
44
  budgetUsd?: number;
@@ -145,6 +145,14 @@ export const CONFIG_KEYS = [
145
145
  c.context = coerceBool("context", v);
146
146
  },
147
147
  },
148
+ {
149
+ key: "context_handoff",
150
+ describe: "EXPERIMENTAL: register the `handoff` MCP tool + tell the gateway CONTEXT_HANDOFF_ENABLED=true (off by default)",
151
+ get: (c) => c.contextHandoff,
152
+ set: (c, v) => {
153
+ c.contextHandoff = coerceBool("context_handoff", v);
154
+ },
155
+ },
148
156
  {
149
157
  key: "guardrails.max_turns",
150
158
  describe: "Contract guardrail: turn ceiling (positive integer)",
@@ -213,6 +221,8 @@ export function parseLauncherConfig(text) {
213
221
  cfg.goalMode = coerceGoalMode(str(raw.goal_mode) ?? "");
214
222
  if (raw.context !== undefined)
215
223
  cfg.context = coerceBool("context", str(raw.context) ?? "");
224
+ if (raw.context_handoff !== undefined)
225
+ cfg.contextHandoff = coerceBool("context_handoff", str(raw.context_handoff) ?? "");
216
226
  const g = raw.guardrails;
217
227
  if (g !== undefined) {
218
228
  if (typeof g !== "object" || g === null || Array.isArray(g)) {
@@ -273,6 +283,8 @@ export function serializeLauncherConfig(cfg) {
273
283
  doc.goal_mode = cfg.goalMode;
274
284
  if (cfg.context !== undefined)
275
285
  doc.context = cfg.context;
286
+ if (cfg.contextHandoff !== undefined)
287
+ doc.context_handoff = cfg.contextHandoff;
276
288
  const g = cfg.guardrails;
277
289
  if (g && (g.maxTurns !== undefined || g.budgetUsd !== undefined || g.stallTurns !== undefined)) {
278
290
  const gr = {};
@@ -322,4 +334,7 @@ export function applyConfigToEnv(cfg, env = process.env) {
322
334
  if (cfg.context !== undefined && !(env.MNEMOM_AGENT_CONTEXT ?? "").trim()) {
323
335
  env.MNEMOM_AGENT_CONTEXT = cfg.context ? "1" : "0";
324
336
  }
337
+ if (cfg.contextHandoff !== undefined && !(env.MNEMOM_AGENT_CONTEXT_HANDOFF ?? "").trim()) {
338
+ env.MNEMOM_AGENT_CONTEXT_HANDOFF = cfg.contextHandoff ? "1" : "0";
339
+ }
325
340
  }
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+ // mnemom-handoff — the stdio MCP server `mnemom agent` registers (behind the OFF-by-default
3
+ // `context_handoff` setting, MNE-7564) exposing exactly ONE tool: `handoff(note)`.
4
+ //
5
+ // WHY .mjs, NOT .ts
6
+ // This file must exist BYTE-IDENTICAL at the same relative path in both the source tree and
7
+ // the built `dist/` tree, exactly like `rc-proxy.mjs` next to it: `commands/agent.ts`
8
+ // resolves it relative to its OWN `import.meta.url`, which is the SOURCE path under `vitest`
9
+ // (dev/test — no `tsc` has run) and the DIST path once installed. A `tsc`-compiled `.ts` file
10
+ // only exists post-build, so it would be unresolvable in dev/test. Consequently this file is
11
+ // self-contained (only Node builtins + global `fetch`, no local imports) — see
12
+ // `scripts/copy-assets.mjs`, which copies it (and rc-proxy.mjs) verbatim into `dist/`.
13
+ //
14
+ // Deliberately hand-rolled rather than `@modelcontextprotocol/sdk`: MCP over stdio is
15
+ // newline-delimited JSON-RPC 2.0, small enough to hand-roll for one tool in a CLI that
16
+ // otherwise carries four small deps. Only `initialize`, `tools/list` and `tools/call` are
17
+ // implemented — the whole surface Claude Code's MCP client needs for a stdio tool.
18
+ //
19
+ // Config rides on env vars the launcher sets via the `--mcp-config` file's `env` block
20
+ // (`buildHandoffMcpConfig`, cli/src/lib/agent.ts) — never argv, so nothing here is visible to
21
+ // `ps`. The Anthropic key itself is never IN that file, for any launch shape: the launcher hands
22
+ // this process `MNEMOM_HANDOFF_ANTHROPIC_KEY_ENV_NAME` naming a var this process already
23
+ // inherits from its OWN parent env (every stdio MCP server inherits the full parent env —
24
+ // verified experimentally for MNE-7564), and this process reads the key from THAT var at
25
+ // request time instead. A terminal launch names `ANTHROPIC_CUSTOM_HEADERS` (parsed for its
26
+ // `x-api-key:` line); Remote Control names a dedicated `MNEMOM_HANDOFF_KEY` its launcher sets
27
+ // directly, since RC's child env strips every ANTHROPIC_* var (see `HandoffAnthropicKeySource`
28
+ // in cli/src/lib/agent.ts for the full rationale). This process never logs either var's name or
29
+ // value.
30
+
31
+ import { createInterface } from "node:readline";
32
+ import { pathToFileURL } from "node:url";
33
+
34
+ /** The gateway route this tool calls (gateway/src/context-handoff.ts, MNE-7563). */
35
+ export const HANDOFF_PATH = "/mnemom/context/handoff";
36
+
37
+ const TOOL_NAME = "handoff";
38
+
39
+ const TOOL_DESCRIPTION =
40
+ "Hand off to your successor context. Call this at a natural stopping point in your own " +
41
+ "work, OR as soon as you see a '[mnemom context warning]' line from the gateway telling you " +
42
+ "the fold budget is nearly spent — that line means the gateway will fold on its own soon, so " +
43
+ "calling `handoff` first lets YOU choose what your successor reads. A good note is short and " +
44
+ "covers: the goal, what is already done, the next concrete action, and any open questions or " +
45
+ "blockers. The note is stored byte-exact and rendered to your successor verbatim — it is not " +
46
+ "summarized, so write it as you want it read.";
47
+
48
+ const TOOL_INPUT_SCHEMA = {
49
+ type: "object",
50
+ properties: {
51
+ note: {
52
+ type: "string",
53
+ description:
54
+ "Goal, what's done, the next action, and open questions — byte-exact to your successor.",
55
+ },
56
+ },
57
+ required: ["note"],
58
+ additionalProperties: false,
59
+ };
60
+
61
+ /** Required env vars the launcher always sets when it registers this server. */
62
+ const REQUIRED_ENV = [
63
+ "MNEMOM_HANDOFF_GATEWAY_HOST",
64
+ "MNEMOM_HANDOFF_ANTHROPIC_KEY_ENV_NAME",
65
+ "MNEMOM_HANDOFF_AGENT",
66
+ "MNEMOM_HANDOFF_CONVERSATION_ID",
67
+ ];
68
+
69
+ /**
70
+ * Recover the Anthropic key from an env var NAME this process itself inherited from its parent
71
+ * (never a value written into the `--mcp-config` file). `ANTHROPIC_CUSTOM_HEADERS` is a
72
+ * multi-line header BLOCK (`buildCustomHeaders`, cli/src/lib/agent.ts) whose first line is
73
+ * `x-api-key: <key>` — extract just that. Any other name is treated as carrying the key value
74
+ * directly.
75
+ * @param {string} name
76
+ * @param {NodeJS.ProcessEnv} env
77
+ * @returns {string} the key, or "" if the named var is absent/unusable.
78
+ */
79
+ function resolveKeyFromEnvName(name, env) {
80
+ const value = env[name] ?? "";
81
+ if (name === "ANTHROPIC_CUSTOM_HEADERS") {
82
+ const m = value.match(/^x-api-key:\s*(.+)$/im);
83
+ return m ? m[1].trim() : "";
84
+ }
85
+ return value.trim();
86
+ }
87
+
88
+ /**
89
+ * Read the tool's config from env. Throws (message never contains the key value) when a
90
+ * required var is missing, or when the named key-source var resolves to no usable key.
91
+ * @param {NodeJS.ProcessEnv} env
92
+ * @returns {{gatewayHost: string, anthropicKey: string, agent: string, conversationId: string, sessionConfigB64?: string}}
93
+ */
94
+ export function configFromEnv(env) {
95
+ const missing = REQUIRED_ENV.filter((k) => !(env[k] ?? "").trim());
96
+ if (missing.length) {
97
+ throw new Error(`mnemom-handoff: missing required env var(s): ${missing.join(", ")}`);
98
+ }
99
+ const keyEnvName = env.MNEMOM_HANDOFF_ANTHROPIC_KEY_ENV_NAME.trim();
100
+ const anthropicKey = resolveKeyFromEnvName(keyEnvName, env);
101
+ if (!anthropicKey) {
102
+ throw new Error(
103
+ `mnemom-handoff: MNEMOM_HANDOFF_ANTHROPIC_KEY_ENV_NAME names '${keyEnvName}', but this ` +
104
+ "process's own inherited environment has no usable value for it.",
105
+ );
106
+ }
107
+ return {
108
+ gatewayHost: env.MNEMOM_HANDOFF_GATEWAY_HOST.trim(),
109
+ anthropicKey,
110
+ agent: env.MNEMOM_HANDOFF_AGENT.trim(),
111
+ conversationId: env.MNEMOM_HANDOFF_CONVERSATION_ID.trim(),
112
+ sessionConfigB64: env.MNEMOM_HANDOFF_SESSION_CONFIG?.trim() || undefined,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Build the fetch call. Exported so a test can assert the wire shape without mocking fetch.
118
+ * @param {{gatewayHost: string, anthropicKey: string, agent: string, conversationId: string, sessionConfigB64?: string}} cfg
119
+ * @param {string} note
120
+ */
121
+ export function buildHandoffRequest(cfg, note) {
122
+ const headers = {
123
+ "content-type": "application/json",
124
+ "x-api-key": cfg.anthropicKey,
125
+ "x-mnemom-agent": cfg.agent,
126
+ };
127
+ if (cfg.sessionConfigB64) headers["x-mnemom-session-config"] = cfg.sessionConfigB64;
128
+ return {
129
+ url: `${cfg.gatewayHost.replace(/\/+$/, "")}${HANDOFF_PATH}`,
130
+ init: {
131
+ method: "POST",
132
+ headers,
133
+ body: JSON.stringify({ conversation_id: cfg.conversationId, note }),
134
+ },
135
+ };
136
+ }
137
+
138
+ /** Pull `{code, message}` out of the gateway's canonical error envelope; tolerant of anything else. */
139
+ function extractGatewayError(body) {
140
+ if (!body || typeof body !== "object") return {};
141
+ const err = body.error;
142
+ if (!err || typeof err !== "object") return {};
143
+ return {
144
+ code: typeof err.code === "string" ? err.code : undefined,
145
+ message: typeof err.message === "string" ? err.message : undefined,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Call the route and render the result. NEVER throws: a network failure, a non-2xx status, or
151
+ * an unparseable body all come back as `{ok:false, message}` for the caller to surface as an
152
+ * MCP tool error — the tool must never crash the server or the session over a bad response.
153
+ * @param {{gatewayHost: string, anthropicKey: string, agent: string, conversationId: string, sessionConfigB64?: string}} cfg
154
+ * @param {string} note
155
+ * @param {typeof fetch} fetchImpl
156
+ */
157
+ export async function callHandoff(cfg, note, fetchImpl) {
158
+ const { url, init } = buildHandoffRequest(cfg, note);
159
+ let res;
160
+ try {
161
+ res = await fetchImpl(url, init);
162
+ } catch (err) {
163
+ return {
164
+ ok: false,
165
+ message: `could not reach the gateway: ${err instanceof Error ? err.message : String(err)}`,
166
+ };
167
+ }
168
+
169
+ const text = await res.text().catch(() => "");
170
+ let body;
171
+ try {
172
+ body = text ? JSON.parse(text) : undefined;
173
+ } catch {
174
+ body = undefined;
175
+ }
176
+
177
+ if (!res.ok) {
178
+ const { code, message } = extractGatewayError(body);
179
+ const detail = message ?? (text || `HTTP ${res.status}`);
180
+ return {
181
+ ok: false,
182
+ message: `handoff request failed (${res.status}${code ? ` ${code}` : ""}): ${detail}`,
183
+ };
184
+ }
185
+
186
+ const parsed = body ?? {};
187
+ const pending = parsed.pending === true;
188
+ const level = typeof parsed.level === "string" ? parsed.level : "none";
189
+ const generation = typeof parsed.generation === "number" ? parsed.generation : 0;
190
+ const line = pending
191
+ ? `handoff queued (level: ${level}, generation ${generation}) — the gateway will fold at your next request`
192
+ : `handoff acknowledged (level: ${level}, generation ${generation}) — no fold scheduled`;
193
+ return { ok: true, line };
194
+ }
195
+
196
+ class MethodNotFoundError extends Error {}
197
+
198
+ /** True for a JSON-RPC NOTIFICATION (no `id` key at all) — never gets a response. */
199
+ function isNotification(msg) {
200
+ return !("id" in msg);
201
+ }
202
+
203
+ function textContent(text) {
204
+ return { content: [{ type: "text", text }] };
205
+ }
206
+
207
+ function errorContent(message) {
208
+ return { content: [{ type: "text", text: message }], isError: true };
209
+ }
210
+
211
+ /**
212
+ * Run the server against injected streams (tests use in-memory ones; `main()` wires real
213
+ * stdio). Handles one message per line; a line that isn't valid JSON, or errors while handling
214
+ * a request, is reported back as a JSON-RPC error for that message's id and never crashes the
215
+ * loop — one bad message must not take the whole tool down.
216
+ * @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream, config: object, fetchImpl?: typeof fetch}} opts
217
+ */
218
+ export function runHandoffServer(opts) {
219
+ const fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
220
+ const write = (msg) => {
221
+ opts.output.write(`${JSON.stringify(msg)}\n`);
222
+ };
223
+ const respond = (id, result) => {
224
+ if (id === undefined) return; // notification — no response
225
+ write({ jsonrpc: "2.0", id, result });
226
+ };
227
+ const respondError = (id, code, message) => {
228
+ if (id === undefined) return;
229
+ write({ jsonrpc: "2.0", id, error: { code, message } });
230
+ };
231
+
232
+ const rl = createInterface({ input: opts.input, terminal: false });
233
+
234
+ rl.on("line", (raw) => {
235
+ const line = raw.trim();
236
+ if (!line) return;
237
+ let msg;
238
+ try {
239
+ msg = JSON.parse(line);
240
+ } catch {
241
+ return; // not JSON-RPC we can respond to — drop it
242
+ }
243
+ const id = "id" in msg ? msg.id : undefined;
244
+ const method = typeof msg.method === "string" ? msg.method : "";
245
+
246
+ handle(method, msg.params, isNotification(msg))
247
+ .then((result) => respond(id, result))
248
+ .catch((err) => {
249
+ const code = err instanceof MethodNotFoundError ? -32601 : -32603;
250
+ respondError(id, code, err instanceof Error ? err.message : String(err));
251
+ });
252
+ });
253
+
254
+ async function handle(method, params, notification) {
255
+ switch (method) {
256
+ case "initialize":
257
+ return {
258
+ protocolVersion: "2024-11-05",
259
+ capabilities: { tools: {} },
260
+ serverInfo: { name: "mnemom-handoff", version: "1.0.0" },
261
+ };
262
+ case "notifications/initialized":
263
+ return undefined; // no-op; notification anyway (never responded to)
264
+ case "tools/list":
265
+ return {
266
+ tools: [
267
+ { name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: TOOL_INPUT_SCHEMA },
268
+ ],
269
+ };
270
+ case "tools/call":
271
+ return callTool(params);
272
+ case "ping":
273
+ return {};
274
+ default:
275
+ if (notification) return undefined;
276
+ throw new MethodNotFoundError(`unknown method '${method}'`);
277
+ }
278
+ }
279
+
280
+ async function callTool(params) {
281
+ const p = params ?? {};
282
+ if (p.name !== TOOL_NAME) {
283
+ return errorContent(`unknown tool '${String(p.name)}'`);
284
+ }
285
+ const args = p.arguments ?? {};
286
+ const note = args.note;
287
+ if (typeof note !== "string" || note.trim() === "") {
288
+ return errorContent("handoff requires a non-empty string `note` argument.");
289
+ }
290
+ const outcome = await callHandoff(opts.config, note, fetchImpl);
291
+ return outcome.ok ? textContent(outcome.line) : errorContent(outcome.message);
292
+ }
293
+
294
+ return {
295
+ close: () => rl.close(),
296
+ };
297
+ }
298
+
299
+ async function main() {
300
+ let config;
301
+ try {
302
+ config = configFromEnv(process.env);
303
+ } catch (err) {
304
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
305
+ process.exit(1);
306
+ return;
307
+ }
308
+ runHandoffServer({ input: process.stdin, output: process.stdout, config });
309
+ }
310
+
311
+ /**
312
+ * Only auto-start when run directly (`node handoff-server.mjs`), never on import (tests).
313
+ * `argv1`/`moduleUrl` default to the real `process.argv[1]`/`import.meta.url` at the call site
314
+ * (main()'s own check, below) but are accepted as params — like `configFromEnv(env)` above —
315
+ * so a test can drive every branch (no entry, an unconvertible entry, a real match/mismatch)
316
+ * without touching the real process's argv.
317
+ * @param {string | undefined} [argv1]
318
+ * @param {string} [moduleUrl]
319
+ */
320
+ export function isMain(argv1 = process.argv[1], moduleUrl = import.meta.url) {
321
+ if (!argv1) return false;
322
+ try {
323
+ return pathToFileURL(argv1).href === moduleUrl;
324
+ } catch {
325
+ return false;
326
+ }
327
+ }
328
+
329
+ if (isMain()) {
330
+ void main();
331
+ }