@mnemom/mnemom 0.17.2-next.3 → 0.17.2-next.5
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 +20 -0
- package/dist/commands/agent-config.js +3 -0
- package/dist/commands/agent-goal.d.ts +79 -0
- package/dist/commands/agent-goal.js +276 -0
- package/dist/commands/agent-sessions.d.ts +13 -3
- package/dist/commands/agent-sessions.js +46 -4
- package/dist/commands/agent-slack.d.ts +26 -0
- package/dist/commands/agent-slack.js +212 -0
- package/dist/commands/agent.d.ts +43 -2
- package/dist/commands/agent.js +208 -16
- package/dist/index.d.ts +7 -0
- package/dist/index.js +94 -2
- package/dist/lib/agent-ledger.d.ts +8 -0
- package/dist/lib/agent.d.ts +117 -4
- package/dist/lib/agent.js +106 -6
- package/dist/lib/goal-api.d.ts +133 -0
- package/dist/lib/goal-api.js +113 -0
- package/dist/lib/launcher-config.d.ts +12 -0
- package/dist/lib/launcher-config.js +64 -0
- package/dist/lib/launcher-store.d.ts +21 -0
- package/dist/lib/launcher-store.js +41 -0
- package/dist/lib/oauth.d.ts +26 -7
- package/dist/lib/oauth.js +53 -14
- package/dist/lib/slack-channel.d.ts +36 -0
- package/dist/lib/slack-channel.js +154 -0
- package/dist/lib/slack-launch.d.ts +15 -0
- package/dist/lib/slack-launch.js +42 -0
- package/dist/lib/slack-route.d.ts +58 -0
- package/dist/lib/slack-route.js +99 -0
- package/dist/lib/slack-router.d.ts +34 -0
- package/dist/lib/slack-router.js +185 -0
- package/dist/lib/slack-state.d.ts +36 -0
- package/dist/lib/slack-state.js +119 -0
- package/dist/lib/slack.d.ts +69 -0
- package/dist/lib/slack.js +178 -0
- package/dist/mcp/handoff-server.mjs +331 -0
- package/dist/rc-proxy.mjs +113 -37
- package/package.json +1 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/slack.ts — the Slack Web API seam behind `mnemom agent slack`.
|
|
3
|
+
*
|
|
4
|
+
* One shared Slack app (bot token, scopes `chat:write` + `chat:write.customize`)
|
|
5
|
+
* posts on behalf of every governed session; `chat:write.customize` lets each post
|
|
6
|
+
* carry the session's own display name (MNEMOM_AGENT_SESSION_NAME, set by the
|
|
7
|
+
* launcher in the child CLI's env) so a channel reads as N named agents, not one bot.
|
|
8
|
+
*
|
|
9
|
+
* Where the bot may post is governed in Slack itself: it can only post to channels
|
|
10
|
+
* it has been /invited to (`not_in_channel` otherwise).
|
|
11
|
+
*
|
|
12
|
+
* The token resolves env (MNEMOM_SLACK_BOT_TOKEN) > the launcher's secret store
|
|
13
|
+
* (~/.mnemom/launcher.json `slack_bot_token`). It is deliberately NEVER exported
|
|
14
|
+
* into the child CLI's environment, so it does not sit in the model's context.
|
|
15
|
+
*/
|
|
16
|
+
import { SESSION_NAME_ENV } from "./agent.js";
|
|
17
|
+
export const SLACK_TOKEN_ENV = "MNEMOM_SLACK_BOT_TOKEN";
|
|
18
|
+
export const SLACK_TOKEN_STORE_KEY = "slack_bot_token";
|
|
19
|
+
/** The app-level token (`xapp-…`, scope connections:write) the Socket Mode router needs. */
|
|
20
|
+
export const SLACK_APP_TOKEN_ENV = "MNEMOM_SLACK_APP_TOKEN";
|
|
21
|
+
export const SLACK_APP_TOKEN_STORE_KEY = "slack_app_token";
|
|
22
|
+
const SLACK_API = "https://slack.com/api";
|
|
23
|
+
/** Slack truncates `username` beyond 80 chars; keep ours inside that. */
|
|
24
|
+
const MAX_USERNAME = 80;
|
|
25
|
+
/** env > store; "" when neither is set. `kind` selects the bot or the app-level token. */
|
|
26
|
+
export function resolveSlackToken(env, store, kind = "bot") {
|
|
27
|
+
const envKey = kind === "bot" ? SLACK_TOKEN_ENV : SLACK_APP_TOKEN_ENV;
|
|
28
|
+
const storeKey = kind === "bot" ? SLACK_TOKEN_STORE_KEY : SLACK_APP_TOKEN_STORE_KEY;
|
|
29
|
+
const fromEnv = (env[envKey] ?? "").trim();
|
|
30
|
+
if (fromEnv)
|
|
31
|
+
return { token: fromEnv, source: "env" };
|
|
32
|
+
const stored = store[storeKey];
|
|
33
|
+
if (typeof stored === "string" && stored.trim())
|
|
34
|
+
return { token: stored.trim(), source: "store" };
|
|
35
|
+
return { token: "", source: null };
|
|
36
|
+
}
|
|
37
|
+
/** The session's display name from the launcher, or undefined outside a launched session. */
|
|
38
|
+
export function sessionPosterName(env) {
|
|
39
|
+
const name = (env[SESSION_NAME_ENV] ?? "").replace(/[\r\n]+/g, " ").trim();
|
|
40
|
+
return name ? name.slice(0, MAX_USERNAME) : undefined;
|
|
41
|
+
}
|
|
42
|
+
/** A bot token is `xoxb-…`; reject anything else before it reaches the store. */
|
|
43
|
+
export function looksLikeBotToken(token) {
|
|
44
|
+
return /^xoxb-[A-Za-z0-9-]+$/.test(token.trim());
|
|
45
|
+
}
|
|
46
|
+
/** An app-level token is `xapp-…`. */
|
|
47
|
+
export function looksLikeAppToken(token) {
|
|
48
|
+
return /^xapp-[A-Za-z0-9-]+$/.test(token.trim());
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The Slack app manifest an operator pastes at api.slack.com/apps → "From a
|
|
52
|
+
* manifest". One app per operator: Socket Mode delivers each event to ONE of an
|
|
53
|
+
* app's open connections, so a single router per app is what makes routing lossless.
|
|
54
|
+
* The app-level token (connections:write) cannot be minted by a manifest — the
|
|
55
|
+
* operator generates it under Basic Information → App-Level Tokens.
|
|
56
|
+
*/
|
|
57
|
+
export function slackAppManifest(appName) {
|
|
58
|
+
const name = appName.trim().slice(0, 35) || "mnemom-agents";
|
|
59
|
+
return {
|
|
60
|
+
display_information: {
|
|
61
|
+
name,
|
|
62
|
+
description: "Governed Mnemom agent sessions — each posts under its own session name.",
|
|
63
|
+
background_color: "#1d1c1d",
|
|
64
|
+
},
|
|
65
|
+
features: { bot_user: { display_name: name, always_online: true } },
|
|
66
|
+
oauth_config: {
|
|
67
|
+
scopes: {
|
|
68
|
+
bot: [
|
|
69
|
+
"chat:write",
|
|
70
|
+
"chat:write.customize",
|
|
71
|
+
"app_mentions:read",
|
|
72
|
+
"channels:history",
|
|
73
|
+
"groups:history",
|
|
74
|
+
"channels:read",
|
|
75
|
+
"channels:join",
|
|
76
|
+
"users:read",
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
settings: {
|
|
81
|
+
event_subscriptions: { bot_events: ["app_mention", "message.channels", "message.groups"] },
|
|
82
|
+
interactivity: { is_enabled: false },
|
|
83
|
+
org_deploy_enabled: false,
|
|
84
|
+
socket_mode_enabled: true,
|
|
85
|
+
token_rotation_enabled: false,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Methods that accept a JSON body. Slack's read methods (conversations.info,
|
|
91
|
+
* users.info, …) REJECT a JSON body with `invalid_arguments`, so every other
|
|
92
|
+
* method is sent form-encoded (objects/arrays JSON-stringified per field).
|
|
93
|
+
*/
|
|
94
|
+
const JSON_METHODS = new Set(["chat.postMessage", "chat.update", "chat.postEphemeral"]);
|
|
95
|
+
/** The request body for `method` (pure; unit-tested). */
|
|
96
|
+
export function encodeSlackBody(method, body) {
|
|
97
|
+
if (JSON_METHODS.has(method)) {
|
|
98
|
+
return { contentType: "application/json; charset=utf-8", payload: JSON.stringify(body) };
|
|
99
|
+
}
|
|
100
|
+
const form = new URLSearchParams();
|
|
101
|
+
for (const [k, v] of Object.entries(body)) {
|
|
102
|
+
if (v === undefined || v === null)
|
|
103
|
+
continue;
|
|
104
|
+
form.set(k, typeof v === "object" ? JSON.stringify(v) : String(v));
|
|
105
|
+
}
|
|
106
|
+
return { contentType: "application/x-www-form-urlencoded", payload: form.toString() };
|
|
107
|
+
}
|
|
108
|
+
/** POST one Web API method (bearer auth). Throws only on transport/HTTP failure. */
|
|
109
|
+
export async function slackCall(method, token, body, fetchImpl = fetch) {
|
|
110
|
+
const { contentType, payload } = encodeSlackBody(method, body);
|
|
111
|
+
const res = await fetchImpl(`${SLACK_API}/${method}`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
114
|
+
body: payload,
|
|
115
|
+
});
|
|
116
|
+
if (!res.ok)
|
|
117
|
+
throw new Error(`Slack ${method}: HTTP ${res.status}`);
|
|
118
|
+
return (await res.json());
|
|
119
|
+
}
|
|
120
|
+
/** The chat.postMessage body for a post — pure, so the wire shape is unit-testable. */
|
|
121
|
+
export function buildPostBody(input) {
|
|
122
|
+
const body = {
|
|
123
|
+
channel: input.channel.trim(),
|
|
124
|
+
text: input.text,
|
|
125
|
+
unfurl_links: false,
|
|
126
|
+
};
|
|
127
|
+
if (input.username)
|
|
128
|
+
body.username = input.username;
|
|
129
|
+
if (input.iconEmoji)
|
|
130
|
+
body.icon_emoji = normalizeEmoji(input.iconEmoji);
|
|
131
|
+
if (input.threadTs)
|
|
132
|
+
body.thread_ts = input.threadTs;
|
|
133
|
+
return body;
|
|
134
|
+
}
|
|
135
|
+
function normalizeEmoji(raw) {
|
|
136
|
+
// Index scan, not /^:+|:+$/ — that pattern backtracks polynomially on long ':' runs.
|
|
137
|
+
let v = raw.trim();
|
|
138
|
+
let a = 0;
|
|
139
|
+
let b = v.length;
|
|
140
|
+
while (a < b && v[a] === ":")
|
|
141
|
+
a++;
|
|
142
|
+
while (b > a && v[b - 1] === ":")
|
|
143
|
+
b--;
|
|
144
|
+
v = v.slice(a, b);
|
|
145
|
+
return `:${v}:`;
|
|
146
|
+
}
|
|
147
|
+
/** Post a message; throws a human-readable error on a Slack `ok:false`. */
|
|
148
|
+
export async function postMessage(input, fetchImpl = fetch) {
|
|
149
|
+
const res = await slackCall("chat.postMessage", input.token, buildPostBody(input), fetchImpl);
|
|
150
|
+
if (!res.ok)
|
|
151
|
+
throw new Error(explainSlackError(res.error, input.channel));
|
|
152
|
+
return { channel: String(res.channel ?? input.channel), ts: String(res.ts ?? "") };
|
|
153
|
+
}
|
|
154
|
+
/** Map the Slack error codes a poster actually hits to the fix. */
|
|
155
|
+
export function explainSlackError(code, channel) {
|
|
156
|
+
const where = channel ? ` ${channel}` : "";
|
|
157
|
+
switch (code) {
|
|
158
|
+
case "not_in_channel":
|
|
159
|
+
return `Slack: the bot is not in${where} — invite it there first (/invite @<app> in the channel).`;
|
|
160
|
+
case "channel_not_found":
|
|
161
|
+
return `Slack: channel${where} not found (or private and the bot is not a member).`;
|
|
162
|
+
case "is_archived":
|
|
163
|
+
return `Slack: channel${where} is archived.`;
|
|
164
|
+
case "missing_scope":
|
|
165
|
+
return "Slack: the bot token is missing a scope — the app needs chat:write and chat:write.customize.";
|
|
166
|
+
case "invalid_auth":
|
|
167
|
+
case "not_authed":
|
|
168
|
+
case "token_revoked":
|
|
169
|
+
case "account_inactive":
|
|
170
|
+
return `Slack: the bot token was rejected (${code}) — re-run \`mnemom agent slack login\`.`;
|
|
171
|
+
case "msg_too_long":
|
|
172
|
+
return "Slack: message too long.";
|
|
173
|
+
case "ratelimited":
|
|
174
|
+
return "Slack: rate limited — try again shortly.";
|
|
175
|
+
default:
|
|
176
|
+
return `Slack: ${code ?? "unknown error"}`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -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
|
+
}
|