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

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,185 @@
1
+ /**
2
+ * lib/slack-router.ts — the per-operator Socket Mode router.
3
+ *
4
+ * ONE process per operator machine holds the app's Socket Mode connection
5
+ * (Slack hands each event to exactly one of an app's open connections, so a
6
+ * connection per session would silently drop messages). For every channel
7
+ * message it asks lib/slack-route.ts who is addressed and appends the message to
8
+ * each addressed session's inbox (lib/slack-state.ts); each session's listener
9
+ * delivers from there. The router never writes to a session, never runs a tool,
10
+ * and holds no state beyond an in-memory user-name cache.
11
+ */
12
+ import { spawn } from "node:child_process";
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { slackCall } from "./slack.js";
16
+ import { buildThreadIndex, routeMessage, stripAddressing, } from "./slack-route.js";
17
+ import { appendInbox, liveSessions, readPosts, slackDir } from "./slack-state.js";
18
+ const routerPidFile = () => join(slackDir(), "router.pid");
19
+ function pidAlive(pid) {
20
+ try {
21
+ process.kill(pid, 0);
22
+ return true;
23
+ }
24
+ catch (err) {
25
+ return err.code === "EPERM";
26
+ }
27
+ }
28
+ /** The running router's pid, or null. */
29
+ export function routerPid() {
30
+ try {
31
+ const pid = Number(readFileSync(routerPidFile(), "utf8").trim());
32
+ return Number.isInteger(pid) && pid > 0 && pidAlive(pid) ? pid : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * Start the router detached if none is running. `argv` is how to re-invoke this
40
+ * CLI (node + script path). Returns the pid (existing or new).
41
+ */
42
+ export function ensureRouter(argv = [process.execPath, process.argv[1] ?? "mnemom"]) {
43
+ const existing = routerPid();
44
+ if (existing)
45
+ return existing;
46
+ const child = spawn(argv[0], [...argv.slice(1), "agent", "slack", "router"], {
47
+ detached: true,
48
+ stdio: "ignore",
49
+ env: process.env,
50
+ });
51
+ child.unref();
52
+ return child.pid ?? 0;
53
+ }
54
+ /** Run the router until the process is killed. Reconnects on disconnect. */
55
+ export async function runRouter(opts) {
56
+ const log = opts.log ?? ((l) => console.error(`[slack-router] ${l}`));
57
+ const fetchImpl = opts.fetchImpl ?? fetch;
58
+ const WS = globalThis.WebSocket;
59
+ if (!WS)
60
+ throw new Error("the Slack router needs Node 22+ (global WebSocket).");
61
+ const auth = await slackCall("auth.test", opts.botToken, {}, fetchImpl);
62
+ if (!auth.ok)
63
+ throw new Error(`Slack auth.test failed: ${auth.error}`);
64
+ const botUserId = String(auth.user_id);
65
+ const other = routerPid();
66
+ if (other && other !== process.pid)
67
+ throw new Error(`a router is already running (pid ${other}).`);
68
+ mkdirSync(slackDir(), { recursive: true, mode: 0o700 });
69
+ writeFileSync(routerPidFile(), String(process.pid), { mode: 0o600 });
70
+ const names = new Map();
71
+ const userName = async (id) => {
72
+ if (names.has(id))
73
+ return names.get(id);
74
+ try {
75
+ const r = await slackCall("users.info", opts.botToken, { user: id }, fetchImpl);
76
+ const u = r.user;
77
+ const n = u?.profile?.display_name || u?.profile?.real_name || u?.name;
78
+ if (n)
79
+ names.set(id, n);
80
+ return n;
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ };
86
+ // app_mention and message.channels both fire for a mention — handle each ts once.
87
+ const seen = new Set();
88
+ const handle = async (ev) => {
89
+ const key = `${ev.channel}:${ev.ts}`;
90
+ if (seen.has(key))
91
+ return;
92
+ seen.add(key);
93
+ if (seen.size > 2000)
94
+ seen.delete(seen.values().next().value);
95
+ // Sender gate: Slack text becomes model input, so only allowlisted members
96
+ // may reach a session. An empty allowlist delivers nothing.
97
+ if (!ev.user || !opts.allow.includes(ev.user))
98
+ return;
99
+ const live = liveSessions();
100
+ const threads = buildThreadIndex(readPosts());
101
+ const targets = routeMessage(ev, { botUserId, liveSessions: live, threads });
102
+ if (!targets.length) {
103
+ if (ev.text?.includes(`<@${botUserId}>`) && !ev.bot_id) {
104
+ const hint = live.length
105
+ ? `No live session matched. Address one as \`@<app> <session>: …\` — live now: ${live.map((s) => `\`${s}\``).join(", ")}.`
106
+ : "No agent session is listening right now.";
107
+ await slackCall("chat.postMessage", opts.botToken, { channel: ev.channel, thread_ts: ev.thread_ts ?? ev.ts, text: hint }, fetchImpl).catch(() => undefined);
108
+ }
109
+ return;
110
+ }
111
+ const { channel, ts, user } = ev;
112
+ if (!channel || !ts || !user)
113
+ return; // routeMessage already requires all three
114
+ const inThread = ev.thread_ts && ev.thread_ts !== ev.ts ? threads.get(channel)?.get(ev.thread_ts) : undefined;
115
+ const user_name = await userName(user);
116
+ const text = stripAddressing(ev.text ?? "", botUserId, live);
117
+ for (const session of targets) {
118
+ const via = [];
119
+ if (inThread?.has(session))
120
+ via.push("thread");
121
+ if ((ev.text ?? "").includes(`<@${botUserId}>`))
122
+ via.push("mention");
123
+ if (!via.length)
124
+ via.push("handle");
125
+ appendInbox(session, {
126
+ channel: channel,
127
+ ts: ts,
128
+ thread_ts: ev.thread_ts,
129
+ user: user,
130
+ user_name,
131
+ text,
132
+ via,
133
+ });
134
+ log(`routed ${ev.channel}/${ev.ts} → ${session} (${via.join("+")})`);
135
+ }
136
+ };
137
+ for (;;) {
138
+ try {
139
+ const open = await slackCall("apps.connections.open", opts.appToken, {}, fetchImpl);
140
+ if (!open.ok)
141
+ throw new Error(`apps.connections.open: ${open.error}`);
142
+ await new Promise((resolve) => {
143
+ const ws = new WS(String(open.url));
144
+ ws.onmessage = (m) => {
145
+ let env;
146
+ try {
147
+ env = JSON.parse(String(m.data));
148
+ }
149
+ catch {
150
+ return;
151
+ }
152
+ if (env.envelope_id)
153
+ ws.send(JSON.stringify({ envelope_id: env.envelope_id }));
154
+ if (env.type === "disconnect") {
155
+ log(`disconnect requested (${env.reason ?? "?"}); reconnecting`);
156
+ ws.close();
157
+ return;
158
+ }
159
+ const ev = env.payload?.event;
160
+ if (env.type === "events_api" &&
161
+ ev &&
162
+ (ev.type === "message" || ev.type === "app_mention")) {
163
+ handle(ev).catch((e) => log(`handle failed: ${e instanceof Error ? e.message : e}`));
164
+ }
165
+ };
166
+ ws.onopen = () => log(`connected as <@${botUserId}>`);
167
+ ws.onclose = () => resolve();
168
+ ws.onerror = () => ws.close();
169
+ });
170
+ }
171
+ catch (e) {
172
+ log(`connection error: ${e instanceof Error ? e.message : e}`);
173
+ }
174
+ await new Promise((r) => setTimeout(r, 2000));
175
+ }
176
+ }
177
+ /** For `status`: whether a pid file points at a live router. */
178
+ export function routerStatus() {
179
+ const pid = routerPid();
180
+ return {
181
+ running: pid !== null,
182
+ pid,
183
+ pidFile: existsSync(routerPidFile()) ? routerPidFile() : "",
184
+ };
185
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * lib/slack-state.ts — the local, per-operator state behind `mnemom agent slack`.
3
+ *
4
+ * ~/.mnemom/slack/posts.jsonl every post a session made (routes thread replies)
5
+ * ~/.mnemom/slack/live/<session>.json a session currently listening (pid)
6
+ * ~/.mnemom/slack/inbox/<session>.jsonl messages the router routed to a session
7
+ *
8
+ * The router (one per operator machine) is the only writer of inboxes; each
9
+ * session's listener is the only reader of its own. No secret is stored here.
10
+ */
11
+ import { type PostRecord } from "./slack-route.js";
12
+ export declare function slackDir(): string;
13
+ export declare function recordPost(p: PostRecord): void;
14
+ export declare function readPosts(): PostRecord[];
15
+ /** A message as delivered to a session. */
16
+ export interface InboxMessage {
17
+ channel: string;
18
+ ts: string;
19
+ thread_ts?: string;
20
+ user: string;
21
+ user_name?: string;
22
+ text: string;
23
+ /** How it was addressed: thread | mention | handle. */
24
+ via: string[];
25
+ }
26
+ export declare function appendInbox(session: string, msg: InboxMessage): void;
27
+ export declare function inboxPath(session: string): string;
28
+ /** Messages appended after byte `offset`, and the new offset. */
29
+ export declare function readInboxFrom(session: string, offset: number): {
30
+ messages: InboxMessage[];
31
+ offset: number;
32
+ };
33
+ export declare function registerLive(session: string, pid?: number): void;
34
+ export declare function unregisterLive(session: string): void;
35
+ /** Sessions with a running listener; stale entries (dead pid) are pruned. */
36
+ export declare function liveSessions(): string[];
@@ -0,0 +1,119 @@
1
+ /**
2
+ * lib/slack-state.ts — the local, per-operator state behind `mnemom agent slack`.
3
+ *
4
+ * ~/.mnemom/slack/posts.jsonl every post a session made (routes thread replies)
5
+ * ~/.mnemom/slack/live/<session>.json a session currently listening (pid)
6
+ * ~/.mnemom/slack/inbox/<session>.jsonl messages the router routed to a session
7
+ *
8
+ * The router (one per operator machine) is the only writer of inboxes; each
9
+ * session's listener is the only reader of its own. No secret is stored here.
10
+ */
11
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { MNEMOM_DIR } from "./config.js";
14
+ import { normSession } from "./slack-route.js";
15
+ export function slackDir() {
16
+ return join(MNEMOM_DIR, "slack");
17
+ }
18
+ const postsFile = () => join(slackDir(), "posts.jsonl");
19
+ const liveDir = () => join(slackDir(), "live");
20
+ const inboxDir = () => join(slackDir(), "inbox");
21
+ /** Only the most recent posts matter for thread routing; cap what we read. */
22
+ const MAX_POSTS = 5000;
23
+ function ensure(dir) {
24
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
25
+ }
26
+ function readJsonl(path) {
27
+ if (!existsSync(path))
28
+ return [];
29
+ const out = [];
30
+ for (const line of readFileSync(path, "utf8").split("\n")) {
31
+ if (!line.trim())
32
+ continue;
33
+ try {
34
+ out.push(JSON.parse(line));
35
+ }
36
+ catch {
37
+ // a torn line from a concurrent append is skipped, never fatal
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+ export function recordPost(p) {
43
+ ensure(slackDir());
44
+ appendFileSync(postsFile(), JSON.stringify(p) + "\n", { mode: 0o600 });
45
+ }
46
+ export function readPosts() {
47
+ return readJsonl(postsFile()).slice(-MAX_POSTS);
48
+ }
49
+ const fileFor = (session) => `${normSession(session)}`;
50
+ export function appendInbox(session, msg) {
51
+ ensure(inboxDir());
52
+ appendFileSync(join(inboxDir(), `${fileFor(session)}.jsonl`), JSON.stringify(msg) + "\n", {
53
+ mode: 0o600,
54
+ });
55
+ }
56
+ export function inboxPath(session) {
57
+ return join(inboxDir(), `${fileFor(session)}.jsonl`);
58
+ }
59
+ /** Messages appended after byte `offset`, and the new offset. */
60
+ export function readInboxFrom(session, offset) {
61
+ const path = inboxPath(session);
62
+ if (!existsSync(path))
63
+ return { messages: [], offset: 0 };
64
+ const buf = readFileSync(path);
65
+ if (buf.length < offset)
66
+ offset = 0; // truncated/rotated
67
+ const chunk = buf.subarray(offset).toString("utf8");
68
+ const end = chunk.lastIndexOf("\n") + 1; // leave a partial trailing line for next read
69
+ const messages = [];
70
+ for (const line of chunk.slice(0, end).split("\n")) {
71
+ if (!line.trim())
72
+ continue;
73
+ try {
74
+ messages.push(JSON.parse(line));
75
+ }
76
+ catch {
77
+ /* skip */
78
+ }
79
+ }
80
+ return { messages, offset: offset + Buffer.byteLength(chunk.slice(0, end)) };
81
+ }
82
+ export function registerLive(session, pid = process.pid) {
83
+ ensure(liveDir());
84
+ writeFileSync(join(liveDir(), `${fileFor(session)}.json`), JSON.stringify({ session, pid, since: new Date().toISOString() }), { mode: 0o600 });
85
+ }
86
+ export function unregisterLive(session) {
87
+ rmSync(join(liveDir(), `${fileFor(session)}.json`), { force: true });
88
+ }
89
+ function pidAlive(pid) {
90
+ try {
91
+ process.kill(pid, 0);
92
+ return true;
93
+ }
94
+ catch (err) {
95
+ return err.code === "EPERM";
96
+ }
97
+ }
98
+ /** Sessions with a running listener; stale entries (dead pid) are pruned. */
99
+ export function liveSessions() {
100
+ if (!existsSync(liveDir()))
101
+ return [];
102
+ const out = [];
103
+ for (const f of readdirSync(liveDir())) {
104
+ if (!f.endsWith(".json"))
105
+ continue;
106
+ const path = join(liveDir(), f);
107
+ try {
108
+ const rec = JSON.parse(readFileSync(path, "utf8"));
109
+ if (rec.session && typeof rec.pid === "number" && pidAlive(rec.pid))
110
+ out.push(rec.session);
111
+ else
112
+ rmSync(path, { force: true });
113
+ }
114
+ catch {
115
+ rmSync(path, { force: true });
116
+ }
117
+ }
118
+ return out;
119
+ }
@@ -0,0 +1,69 @@
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
+ export declare const SLACK_TOKEN_ENV = "MNEMOM_SLACK_BOT_TOKEN";
17
+ export declare const SLACK_TOKEN_STORE_KEY = "slack_bot_token";
18
+ /** The app-level token (`xapp-…`, scope connections:write) the Socket Mode router needs. */
19
+ export declare const SLACK_APP_TOKEN_ENV = "MNEMOM_SLACK_APP_TOKEN";
20
+ export declare const SLACK_APP_TOKEN_STORE_KEY = "slack_app_token";
21
+ export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
22
+ export interface SlackResponse {
23
+ ok: boolean;
24
+ error?: string;
25
+ [k: string]: unknown;
26
+ }
27
+ /** env > store; "" when neither is set. `kind` selects the bot or the app-level token. */
28
+ export declare function resolveSlackToken(env: NodeJS.ProcessEnv, store: Record<string, unknown>, kind?: "bot" | "app"): {
29
+ token: string;
30
+ source: "env" | "store" | null;
31
+ };
32
+ /** The session's display name from the launcher, or undefined outside a launched session. */
33
+ export declare function sessionPosterName(env: NodeJS.ProcessEnv): string | undefined;
34
+ /** A bot token is `xoxb-…`; reject anything else before it reaches the store. */
35
+ export declare function looksLikeBotToken(token: string): boolean;
36
+ /** An app-level token is `xapp-…`. */
37
+ export declare function looksLikeAppToken(token: string): boolean;
38
+ /**
39
+ * The Slack app manifest an operator pastes at api.slack.com/apps → "From a
40
+ * manifest". One app per operator: Socket Mode delivers each event to ONE of an
41
+ * app's open connections, so a single router per app is what makes routing lossless.
42
+ * The app-level token (connections:write) cannot be minted by a manifest — the
43
+ * operator generates it under Basic Information → App-Level Tokens.
44
+ */
45
+ export declare function slackAppManifest(appName: string): Record<string, unknown>;
46
+ /** The request body for `method` (pure; unit-tested). */
47
+ export declare function encodeSlackBody(method: string, body: Record<string, unknown>): {
48
+ contentType: string;
49
+ payload: string;
50
+ };
51
+ /** POST one Web API method (bearer auth). Throws only on transport/HTTP failure. */
52
+ export declare function slackCall(method: string, token: string, body: Record<string, unknown>, fetchImpl?: FetchLike): Promise<SlackResponse>;
53
+ export interface PostInput {
54
+ token: string;
55
+ channel: string;
56
+ text: string;
57
+ username?: string;
58
+ iconEmoji?: string;
59
+ threadTs?: string;
60
+ }
61
+ /** The chat.postMessage body for a post — pure, so the wire shape is unit-testable. */
62
+ export declare function buildPostBody(input: PostInput): Record<string, unknown>;
63
+ /** Post a message; throws a human-readable error on a Slack `ok:false`. */
64
+ export declare function postMessage(input: PostInput, fetchImpl?: FetchLike): Promise<{
65
+ channel: string;
66
+ ts: string;
67
+ }>;
68
+ /** Map the Slack error codes a poster actually hits to the fix. */
69
+ export declare function explainSlackError(code: string | undefined, channel?: string): string;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.2-next.4",
3
+ "version": "0.17.2-next.6",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {