@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.
Files changed (38) hide show
  1. package/README.md +20 -0
  2. package/dist/commands/agent-config.js +3 -0
  3. package/dist/commands/agent-goal.d.ts +79 -0
  4. package/dist/commands/agent-goal.js +276 -0
  5. package/dist/commands/agent-sessions.d.ts +13 -3
  6. package/dist/commands/agent-sessions.js +46 -4
  7. package/dist/commands/agent-slack.d.ts +26 -0
  8. package/dist/commands/agent-slack.js +212 -0
  9. package/dist/commands/agent.d.ts +43 -2
  10. package/dist/commands/agent.js +208 -16
  11. package/dist/index.d.ts +7 -0
  12. package/dist/index.js +94 -2
  13. package/dist/lib/agent-ledger.d.ts +8 -0
  14. package/dist/lib/agent.d.ts +117 -4
  15. package/dist/lib/agent.js +106 -6
  16. package/dist/lib/goal-api.d.ts +133 -0
  17. package/dist/lib/goal-api.js +113 -0
  18. package/dist/lib/launcher-config.d.ts +12 -0
  19. package/dist/lib/launcher-config.js +64 -0
  20. package/dist/lib/launcher-store.d.ts +21 -0
  21. package/dist/lib/launcher-store.js +41 -0
  22. package/dist/lib/oauth.d.ts +26 -7
  23. package/dist/lib/oauth.js +53 -14
  24. package/dist/lib/slack-channel.d.ts +36 -0
  25. package/dist/lib/slack-channel.js +154 -0
  26. package/dist/lib/slack-launch.d.ts +15 -0
  27. package/dist/lib/slack-launch.js +42 -0
  28. package/dist/lib/slack-route.d.ts +58 -0
  29. package/dist/lib/slack-route.js +99 -0
  30. package/dist/lib/slack-router.d.ts +34 -0
  31. package/dist/lib/slack-router.js +185 -0
  32. package/dist/lib/slack-state.d.ts +36 -0
  33. package/dist/lib/slack-state.js +119 -0
  34. package/dist/lib/slack.d.ts +69 -0
  35. package/dist/lib/slack.js +178 -0
  36. package/dist/mcp/handoff-server.mjs +331 -0
  37. package/dist/rc-proxy.mjs +113 -37
  38. package/package.json +1 -1
@@ -0,0 +1,42 @@
1
+ /**
2
+ * lib/slack-launch.ts — what `mnemom agent --slack` adds to a launch.
3
+ *
4
+ * Writes a one-server MCP config for this session's channel server
5
+ * (`mnemom agent slack listen`, lib/slack-channel.ts), makes sure the
6
+ * per-operator router is running, and returns the CLI args that load it as a
7
+ * Claude Code channel. A custom (non-allowlisted) channel server loads only via
8
+ * `--dangerously-load-development-channels`, which shows a confirmation dialog;
9
+ * orgs on Team/Enterprise must also enable `channelsEnabled` in managed settings.
10
+ */
11
+ import { mkdirSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { SESSION_NAME_ENV } from "./agent.js";
14
+ import { ensureRouter } from "./slack-router.js";
15
+ import { slackDir } from "./slack-state.js";
16
+ import { normSession } from "./slack-route.js";
17
+ export const SLACK_CHANNEL_SERVER = "mnemom-slack";
18
+ /** The MCP config document for `session` (pure; unit-tested). */
19
+ export function slackMcpConfig(session, cli) {
20
+ return {
21
+ mcpServers: {
22
+ [SLACK_CHANNEL_SERVER]: {
23
+ command: cli[0],
24
+ args: [...cli.slice(1), "agent", "slack", "listen"],
25
+ env: { [SESSION_NAME_ENV]: session },
26
+ },
27
+ },
28
+ };
29
+ }
30
+ /** The args that load the channel server for `session`; starts the router. */
31
+ export function slackChannelArgs(session, cli = [process.execPath, process.argv[1] ?? "mnemom"]) {
32
+ mkdirSync(slackDir(), { recursive: true, mode: 0o700 });
33
+ const path = join(slackDir(), `mcp-${normSession(session)}.json`);
34
+ writeFileSync(path, JSON.stringify(slackMcpConfig(session, cli), null, 2), { mode: 0o600 });
35
+ ensureRouter(cli);
36
+ return [
37
+ "--mcp-config",
38
+ path,
39
+ "--dangerously-load-development-channels",
40
+ `server:${SLACK_CHANNEL_SERVER}`,
41
+ ];
42
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * lib/slack-route.ts — who a Slack message is addressed to (pure; no I/O).
3
+ *
4
+ * Every governed session posts through ONE per-operator Slack app, so a message
5
+ * arriving on that app's Socket Mode connection has to be routed to the session(s)
6
+ * it addresses. A session is not a Slack user and cannot be @mentioned natively,
7
+ * so three conventions stand in for a mention (all may apply to one message; the
8
+ * result is the de-duplicated union):
9
+ *
10
+ * 1. THREAD REPLY — a reply in a thread whose parent (or any earlier reply) a
11
+ * session posted routes to that session. The post index records each post.
12
+ * 2. APP MENTION + NAME — `<@APPBOT> pv2-lead: …` (a real mention of the app)
13
+ * routes to the session named by the first word after the mention.
14
+ * 3. PLAIN HANDLE — `@pv2-lead …` typed as text routes to that session when it
15
+ * is a known live session (plain text is not a Slack mention, so it is only
16
+ * honoured for names we recognise, to avoid routing stray `@words`).
17
+ *
18
+ * The app's own posts (bot_id set) are never routed — sessions do not hear
19
+ * themselves or each other through the router.
20
+ */
21
+ export interface SlackMessageEvent {
22
+ type?: string;
23
+ subtype?: string;
24
+ channel?: string;
25
+ user?: string;
26
+ bot_id?: string;
27
+ text?: string;
28
+ ts?: string;
29
+ thread_ts?: string;
30
+ }
31
+ /** One post a session made: `ts` in `channel`, plus the thread it was in (if any). */
32
+ export interface PostRecord {
33
+ session: string;
34
+ channel: string;
35
+ ts: string;
36
+ thread_ts?: string;
37
+ }
38
+ /** channel → thread root ts → sessions that posted in that thread. */
39
+ export type ThreadIndex = Map<string, Map<string, Set<string>>>;
40
+ export declare function buildThreadIndex(posts: Iterable<PostRecord>): ThreadIndex;
41
+ /** Normalise a session name for comparison (same shape the launcher emits). */
42
+ export declare function normSession(raw: string): string;
43
+ /**
44
+ * The sessions `ev` is addressed to. `liveSessions` are the names with a running
45
+ * listener; `botUserId` is the app's bot user (from auth.test). Returns [] for
46
+ * events that address no one (or that the app itself posted).
47
+ */
48
+ export declare function routeMessage(ev: SlackMessageEvent, ctx: {
49
+ botUserId: string;
50
+ liveSessions: Iterable<string>;
51
+ threads: ThreadIndex;
52
+ }): string[];
53
+ /**
54
+ * The text a session receives: the app mention, and a live session name right
55
+ * after it, are stripped so the model reads the instruction, not the routing.
56
+ * A word after the mention that is NOT a live session is kept (it is content).
57
+ */
58
+ export declare function stripAddressing(text: string, botUserId: string, liveSessions: Iterable<string>): string;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * lib/slack-route.ts — who a Slack message is addressed to (pure; no I/O).
3
+ *
4
+ * Every governed session posts through ONE per-operator Slack app, so a message
5
+ * arriving on that app's Socket Mode connection has to be routed to the session(s)
6
+ * it addresses. A session is not a Slack user and cannot be @mentioned natively,
7
+ * so three conventions stand in for a mention (all may apply to one message; the
8
+ * result is the de-duplicated union):
9
+ *
10
+ * 1. THREAD REPLY — a reply in a thread whose parent (or any earlier reply) a
11
+ * session posted routes to that session. The post index records each post.
12
+ * 2. APP MENTION + NAME — `<@APPBOT> pv2-lead: …` (a real mention of the app)
13
+ * routes to the session named by the first word after the mention.
14
+ * 3. PLAIN HANDLE — `@pv2-lead …` typed as text routes to that session when it
15
+ * is a known live session (plain text is not a Slack mention, so it is only
16
+ * honoured for names we recognise, to avoid routing stray `@words`).
17
+ *
18
+ * The app's own posts (bot_id set) are never routed — sessions do not hear
19
+ * themselves or each other through the router.
20
+ */
21
+ export function buildThreadIndex(posts) {
22
+ const idx = new Map();
23
+ for (const p of posts) {
24
+ let byRoot = idx.get(p.channel);
25
+ if (!byRoot)
26
+ idx.set(p.channel, (byRoot = new Map()));
27
+ // Index the post's own ts as well as the thread it asked for: Slack posts a
28
+ // reply to a deleted parent at the top level, and a reply under THAT post
29
+ // must still reach the session that wrote it.
30
+ for (const root of new Set([p.ts, p.thread_ts].filter(Boolean))) {
31
+ let sessions = byRoot.get(root);
32
+ if (!sessions)
33
+ byRoot.set(root, (sessions = new Set()));
34
+ sessions.add(p.session);
35
+ }
36
+ }
37
+ return idx;
38
+ }
39
+ /** Normalise a session name for comparison (same shape the launcher emits). */
40
+ export function normSession(raw) {
41
+ return raw
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9]+/g, "-")
44
+ .replace(/^-+|-+$/g, "");
45
+ }
46
+ const HANDLE_RE = /(?:^|[\s(])@([a-z0-9][a-z0-9-]*[a-z0-9])(?=$|[\s,.:;!?)])/gi;
47
+ /**
48
+ * The sessions `ev` is addressed to. `liveSessions` are the names with a running
49
+ * listener; `botUserId` is the app's bot user (from auth.test). Returns [] for
50
+ * events that address no one (or that the app itself posted).
51
+ */
52
+ export function routeMessage(ev, ctx) {
53
+ if (ev.bot_id || !ev.user || !ev.channel || !ev.ts)
54
+ return [];
55
+ if (ev.subtype && ev.subtype !== "thread_broadcast" && ev.subtype !== "file_share")
56
+ return [];
57
+ const live = new Map();
58
+ for (const s of ctx.liveSessions)
59
+ live.set(normSession(s), s);
60
+ const out = new Set();
61
+ const text = ev.text ?? "";
62
+ // 1. thread reply
63
+ if (ev.thread_ts && ev.thread_ts !== ev.ts) {
64
+ for (const s of ctx.threads.get(ev.channel)?.get(ev.thread_ts) ?? [])
65
+ out.add(s);
66
+ }
67
+ // 2. <@BOT> name…
68
+ const mention = `<@${ctx.botUserId}>`;
69
+ let at = text.indexOf(mention);
70
+ while (at !== -1) {
71
+ const after = text.slice(at + mention.length).match(/^[\s,:]*@?([A-Za-z0-9][A-Za-z0-9-]*)/);
72
+ if (after) {
73
+ const hit = live.get(normSession(after[1]));
74
+ if (hit)
75
+ out.add(hit);
76
+ }
77
+ at = text.indexOf(mention, at + mention.length);
78
+ }
79
+ // 3. plain @handle for a known live session
80
+ for (const m of text.matchAll(HANDLE_RE)) {
81
+ const hit = live.get(normSession(m[1]));
82
+ if (hit)
83
+ out.add(hit);
84
+ }
85
+ return [...out];
86
+ }
87
+ /**
88
+ * The text a session receives: the app mention, and a live session name right
89
+ * after it, are stripped so the model reads the instruction, not the routing.
90
+ * A word after the mention that is NOT a live session is kept (it is content).
91
+ */
92
+ export function stripAddressing(text, botUserId, liveSessions) {
93
+ const live = new Set([...liveSessions].map(normSession));
94
+ const re = new RegExp(`<@${botUserId}>[\\s,:]*(?:@?([A-Za-z0-9][A-Za-z0-9-]*)[\\s,:]*)?`, "g");
95
+ return text
96
+ .replace(re, (_whole, name) => name && !live.has(normSession(name)) ? `${name} ` : "")
97
+ .replace(/[ \t]{2,}/g, " ")
98
+ .trim();
99
+ }
@@ -0,0 +1,34 @@
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 { type FetchLike } from "./slack.js";
13
+ /** The running router's pid, or null. */
14
+ export declare function routerPid(): number | null;
15
+ /**
16
+ * Start the router detached if none is running. `argv` is how to re-invoke this
17
+ * CLI (node + script path). Returns the pid (existing or new).
18
+ */
19
+ export declare function ensureRouter(argv?: string[]): number;
20
+ /** Run the router until the process is killed. Reconnects on disconnect. */
21
+ export declare function runRouter(opts: {
22
+ botToken: string;
23
+ appToken: string;
24
+ /** Slack member ids allowed to reach a session; everyone else is dropped. */
25
+ allow: string[];
26
+ log?: (line: string) => void;
27
+ fetchImpl?: FetchLike;
28
+ }): Promise<never>;
29
+ /** For `status`: whether a pid file points at a live router. */
30
+ export declare function routerStatus(): {
31
+ running: boolean;
32
+ pid: number | null;
33
+ pidFile: string;
34
+ };
@@ -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;