@mnemom/mnemom 0.17.25 → 0.17.27
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/dist/commands/agent-config.js +9 -0
- package/dist/commands/agent-doctor.d.ts +3 -2
- package/dist/commands/agent-doctor.js +25 -9
- package/dist/commands/agent-goal.js +7 -2
- package/dist/commands/agent-sessions.js +2 -1
- package/dist/commands/agent-setup.d.ts +166 -9
- package/dist/commands/agent-setup.js +719 -54
- package/dist/commands/agent-slack.d.ts +4 -0
- package/dist/commands/agent-slack.js +67 -21
- package/dist/commands/agent.d.ts +29 -1
- package/dist/commands/agent.js +133 -17
- package/dist/commands/wrap.d.ts +2 -0
- package/dist/commands/wrap.js +13 -1
- package/dist/index.js +19 -5
- package/dist/lib/agent-identity-route.d.ts +21 -3
- package/dist/lib/agent-identity-route.js +42 -15
- package/dist/lib/agent-ledger.d.ts +5 -0
- package/dist/lib/agent.d.ts +75 -8
- package/dist/lib/agent.js +83 -14
- package/dist/lib/api.d.ts +33 -1
- package/dist/lib/api.js +10 -0
- package/dist/lib/experimental.js +14 -2
- package/dist/lib/goal-api.d.ts +3 -3
- package/dist/lib/goal-api.js +5 -2
- package/dist/lib/launcher-config.d.ts +18 -0
- package/dist/lib/launcher-config.js +47 -1
- package/dist/lib/model-tier.d.ts +75 -0
- package/dist/lib/model-tier.js +166 -0
- package/dist/lib/oauth.d.ts +7 -0
- package/dist/lib/oauth.js +92 -17
- package/dist/lib/served-agent-cache.d.ts +8 -1
- package/dist/lib/served-agent-cache.js +11 -2
- package/dist/lib/slack-channel.d.ts +1 -0
- package/dist/lib/slack-channel.js +30 -12
- package/dist/lib/slack-route.d.ts +72 -9
- package/dist/lib/slack-route.js +113 -24
- package/dist/lib/slack-router.d.ts +37 -2
- package/dist/lib/slack-router.js +98 -13
- package/dist/lib/slack-state.d.ts +105 -4
- package/dist/lib/slack-state.js +230 -11
- package/dist/lib/subscription-login.d.ts +12 -3
- package/dist/lib/subscription-login.js +19 -7
- package/dist/mcp/handoff-server.mjs +31 -3
- package/dist/rc-proxy.mjs +14 -0
- package/package.json +1 -1
|
@@ -14,6 +14,22 @@
|
|
|
14
14
|
* 3. PLAIN HANDLE — `@pv2-lead …` typed as text routes to that session when it
|
|
15
15
|
* is a known live session (plain text is not a Slack mention, so it is only
|
|
16
16
|
* honoured for names we recognise, to avoid routing stray `@words`).
|
|
17
|
+
* 4. ALIAS — an operator-registered handle (`mnemom agent slack alias <handle>
|
|
18
|
+
* <session>`) routes to that session whether or not it is live right now:
|
|
19
|
+
* the inbox is a file and the session's listener resumes from its saved
|
|
20
|
+
* offset, so the message waits for it. The hit carries `for: <handle>`, so a
|
|
21
|
+
* session fronting for several handles can tell which one was addressed.
|
|
22
|
+
*
|
|
23
|
+
* STICKY THREADS: once a message is routed to a session by (2)-(4), the router
|
|
24
|
+
* records that routing like a post, so later replies in the same thread reach the
|
|
25
|
+
* same session under (1) without re-addressing it (bounded by a TTL in
|
|
26
|
+
* lib/slack-state.ts). The record keeps the ADDRESS that was used, and every read
|
|
27
|
+
* re-resolves it (reresolveRoutes), so a thread follows a restarted or replaced
|
|
28
|
+
* session instead of the one that happened to answer first.
|
|
29
|
+
*
|
|
30
|
+
* Aliases (4) and sticky threads are each OFF unless the operator turns them on
|
|
31
|
+
* (launcher `slack_aliases` / `slack_sticky_threads`); off, routing is exactly
|
|
32
|
+
* (1)-(3) as before.
|
|
17
33
|
*
|
|
18
34
|
* The app's own posts (bot_id set) are never routed — sessions do not hear
|
|
19
35
|
* themselves or each other through the router.
|
|
@@ -33,15 +49,23 @@ export interface SlackMessageEvent {
|
|
|
33
49
|
permalink?: string;
|
|
34
50
|
}>;
|
|
35
51
|
}
|
|
36
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* One post a session made: `ts` in `channel`, plus the thread it was in (if any).
|
|
54
|
+
* A sticky-thread route record has the same shape, with `for` set when the message
|
|
55
|
+
* reached the session through an alias handle.
|
|
56
|
+
*/
|
|
37
57
|
export interface PostRecord {
|
|
38
58
|
session: string;
|
|
39
59
|
channel: string;
|
|
40
60
|
ts: string;
|
|
41
61
|
thread_ts?: string;
|
|
62
|
+
for?: string;
|
|
42
63
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
64
|
+
/**
|
|
65
|
+
* channel → thread root ts → sessions that posted (or were routed) in that thread,
|
|
66
|
+
* each with the alias handle it was reached as (undefined for a plain post).
|
|
67
|
+
*/
|
|
68
|
+
export type ThreadIndex = Map<string, Map<string, Map<string, string | undefined>>>;
|
|
45
69
|
export declare function buildThreadIndex(posts: Iterable<PostRecord>): ThreadIndex;
|
|
46
70
|
/** Normalise a session name for comparison (same shape the launcher emits). */
|
|
47
71
|
export declare function normSession(raw: string): string;
|
|
@@ -51,19 +75,58 @@ export declare function normSession(raw: string): string;
|
|
|
51
75
|
* `agent-user-mnemom-design-helper`. Ambiguous or unknown → undefined.
|
|
52
76
|
*/
|
|
53
77
|
export declare function resolveLiveSession(name: string, live: Map<string, string>): string | undefined;
|
|
78
|
+
/** A session a message is routed to, and the alias handle it was addressed by (if any). */
|
|
79
|
+
export interface RouteHit {
|
|
80
|
+
session: string;
|
|
81
|
+
for?: string;
|
|
82
|
+
/** The normalised name the message used to reach it (a handle or a session name). */
|
|
83
|
+
addr?: string;
|
|
84
|
+
}
|
|
85
|
+
/** Normalise an alias handle (the same shape as a session name; `@` and case dropped). */
|
|
86
|
+
export declare function normHandle(raw: string): string;
|
|
87
|
+
/**
|
|
88
|
+
* What `name` addresses: an exact live session, else a registered alias (live or
|
|
89
|
+
* not — see ALIAS above), else the ONE live session whose name ends in `-<name>`.
|
|
90
|
+
* An alias outranks a suffix match because an operator registered it on purpose.
|
|
91
|
+
*/
|
|
92
|
+
export declare function resolveAddress(name: string, live: Map<string, string>, aliases?: Map<string, string>): RouteHit | undefined;
|
|
93
|
+
/** A sticky-thread route as stored: a post-shaped record plus the address it was made by. */
|
|
94
|
+
export interface StickyRoute extends PostRecord {
|
|
95
|
+
addr?: string;
|
|
96
|
+
}
|
|
54
97
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
98
|
+
* Re-resolve stored sticky routes against who is live and which aliases exist NOW,
|
|
99
|
+
* so a thread follows the session currently behind the address rather than the one
|
|
100
|
+
* it reached when the route was recorded:
|
|
101
|
+
* - the stored address resolves (live session, alias, unique live suffix) → route
|
|
102
|
+
* to that session, with the handle it resolved as;
|
|
103
|
+
* - it does not, and the route was made through an alias → drop it (the handle was
|
|
104
|
+
* removed: the operator stopped routing it);
|
|
105
|
+
* - it does not, and it was a plain session address → keep the recorded session
|
|
106
|
+
* (nothing newer answers to that name).
|
|
107
|
+
* A record with no stored address falls back to its alias handle.
|
|
58
108
|
*/
|
|
59
|
-
export declare function
|
|
109
|
+
export declare function reresolveRoutes(routes: Iterable<StickyRoute>, live: Iterable<string>, aliases: Map<string, string>): PostRecord[];
|
|
110
|
+
export interface RouteContext {
|
|
60
111
|
botUserId: string;
|
|
61
112
|
liveSessions: Iterable<string>;
|
|
62
113
|
threads: ThreadIndex;
|
|
63
|
-
|
|
114
|
+
/** normalised handle → session (from `mnemom agent slack alias`). */
|
|
115
|
+
aliases?: Map<string, string>;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The sessions `ev` is addressed to, each with the alias handle it was reached
|
|
119
|
+
* as. `liveSessions` are the names with a running listener; `botUserId` is the
|
|
120
|
+
* app's bot user (from auth.test). Returns [] for events that address no one (or
|
|
121
|
+
* that the app itself posted). A session hit both as an alias and otherwise keeps
|
|
122
|
+
* the alias handle.
|
|
123
|
+
*/
|
|
124
|
+
export declare function routeHits(ev: SlackMessageEvent, ctx: RouteContext): RouteHit[];
|
|
125
|
+
/** The session names `ev` is addressed to (routeHits without the handles). */
|
|
126
|
+
export declare function routeMessage(ev: SlackMessageEvent, ctx: RouteContext): string[];
|
|
64
127
|
/**
|
|
65
128
|
* The text a session receives: the app mention, and a live session name right
|
|
66
129
|
* after it, are stripped so the model reads the instruction, not the routing.
|
|
67
130
|
* A word after the mention that is NOT a live session is kept (it is content).
|
|
68
131
|
*/
|
|
69
|
-
export declare function stripAddressing(text: string, botUserId: string, liveSessions: Iterable<string>): string;
|
|
132
|
+
export declare function stripAddressing(text: string, botUserId: string, liveSessions: Iterable<string>, aliases?: Map<string, string>): string;
|
package/dist/lib/slack-route.js
CHANGED
|
@@ -14,6 +14,22 @@
|
|
|
14
14
|
* 3. PLAIN HANDLE — `@pv2-lead …` typed as text routes to that session when it
|
|
15
15
|
* is a known live session (plain text is not a Slack mention, so it is only
|
|
16
16
|
* honoured for names we recognise, to avoid routing stray `@words`).
|
|
17
|
+
* 4. ALIAS — an operator-registered handle (`mnemom agent slack alias <handle>
|
|
18
|
+
* <session>`) routes to that session whether or not it is live right now:
|
|
19
|
+
* the inbox is a file and the session's listener resumes from its saved
|
|
20
|
+
* offset, so the message waits for it. The hit carries `for: <handle>`, so a
|
|
21
|
+
* session fronting for several handles can tell which one was addressed.
|
|
22
|
+
*
|
|
23
|
+
* STICKY THREADS: once a message is routed to a session by (2)-(4), the router
|
|
24
|
+
* records that routing like a post, so later replies in the same thread reach the
|
|
25
|
+
* same session under (1) without re-addressing it (bounded by a TTL in
|
|
26
|
+
* lib/slack-state.ts). The record keeps the ADDRESS that was used, and every read
|
|
27
|
+
* re-resolves it (reresolveRoutes), so a thread follows a restarted or replaced
|
|
28
|
+
* session instead of the one that happened to answer first.
|
|
29
|
+
*
|
|
30
|
+
* Aliases (4) and sticky threads are each OFF unless the operator turns them on
|
|
31
|
+
* (launcher `slack_aliases` / `slack_sticky_threads`); off, routing is exactly
|
|
32
|
+
* (1)-(3) as before.
|
|
17
33
|
*
|
|
18
34
|
* The app's own posts (bot_id set) are never routed — sessions do not hear
|
|
19
35
|
* themselves or each other through the router.
|
|
@@ -30,8 +46,10 @@ export function buildThreadIndex(posts) {
|
|
|
30
46
|
for (const root of new Set([p.ts, p.thread_ts].filter(Boolean))) {
|
|
31
47
|
let sessions = byRoot.get(root);
|
|
32
48
|
if (!sessions)
|
|
33
|
-
byRoot.set(root, (sessions = new
|
|
34
|
-
|
|
49
|
+
byRoot.set(root, (sessions = new Map()));
|
|
50
|
+
// A plain post never erases the handle an earlier route recorded.
|
|
51
|
+
if (!sessions.has(p.session) || p.for)
|
|
52
|
+
sessions.set(p.session, p.for);
|
|
35
53
|
}
|
|
36
54
|
}
|
|
37
55
|
return idx;
|
|
@@ -59,12 +77,71 @@ export function resolveLiveSession(name, live) {
|
|
|
59
77
|
const hits = [...live.keys()].filter((k) => k.endsWith(`-${n}`));
|
|
60
78
|
return hits.length === 1 ? live.get(hits[0]) : undefined;
|
|
61
79
|
}
|
|
80
|
+
/** Normalise an alias handle (the same shape as a session name; `@` and case dropped). */
|
|
81
|
+
export function normHandle(raw) {
|
|
82
|
+
return normSession(raw.replace(/^@+/, ""));
|
|
83
|
+
}
|
|
62
84
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
85
|
+
* What `name` addresses: an exact live session, else a registered alias (live or
|
|
86
|
+
* not — see ALIAS above), else the ONE live session whose name ends in `-<name>`.
|
|
87
|
+
* An alias outranks a suffix match because an operator registered it on purpose.
|
|
66
88
|
*/
|
|
67
|
-
export function
|
|
89
|
+
export function resolveAddress(name, live, aliases = new Map()) {
|
|
90
|
+
// An empty name matches nothing: live and alias keys are never empty, and
|
|
91
|
+
// resolveLiveSession refuses an empty suffix.
|
|
92
|
+
const n = normSession(name);
|
|
93
|
+
const exact = live.get(n);
|
|
94
|
+
if (exact)
|
|
95
|
+
return { session: exact, addr: n };
|
|
96
|
+
const alias = aliases.get(n);
|
|
97
|
+
if (alias)
|
|
98
|
+
return { session: alias, for: n, addr: n };
|
|
99
|
+
const hit = resolveLiveSession(n, live);
|
|
100
|
+
return hit ? { session: hit, addr: n } : undefined;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Re-resolve stored sticky routes against who is live and which aliases exist NOW,
|
|
104
|
+
* so a thread follows the session currently behind the address rather than the one
|
|
105
|
+
* it reached when the route was recorded:
|
|
106
|
+
* - the stored address resolves (live session, alias, unique live suffix) → route
|
|
107
|
+
* to that session, with the handle it resolved as;
|
|
108
|
+
* - it does not, and the route was made through an alias → drop it (the handle was
|
|
109
|
+
* removed: the operator stopped routing it);
|
|
110
|
+
* - it does not, and it was a plain session address → keep the recorded session
|
|
111
|
+
* (nothing newer answers to that name).
|
|
112
|
+
* A record with no stored address falls back to its alias handle.
|
|
113
|
+
*/
|
|
114
|
+
export function reresolveRoutes(routes, live, aliases) {
|
|
115
|
+
const liveMap = new Map();
|
|
116
|
+
for (const s of live)
|
|
117
|
+
liveMap.set(normSession(s), s);
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const r of routes) {
|
|
120
|
+
const base = {
|
|
121
|
+
session: r.session,
|
|
122
|
+
channel: r.channel,
|
|
123
|
+
ts: r.ts,
|
|
124
|
+
...(r.thread_ts ? { thread_ts: r.thread_ts } : {}),
|
|
125
|
+
};
|
|
126
|
+
const addr = r.addr ?? r.for;
|
|
127
|
+
const now = addr ? resolveAddress(addr, liveMap, aliases) : undefined;
|
|
128
|
+
if (now)
|
|
129
|
+
out.push({ ...base, session: now.session, ...(now.for ? { for: now.for } : {}) });
|
|
130
|
+
else if (r.for)
|
|
131
|
+
continue;
|
|
132
|
+
else
|
|
133
|
+
out.push(base);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The sessions `ev` is addressed to, each with the alias handle it was reached
|
|
139
|
+
* as. `liveSessions` are the names with a running listener; `botUserId` is the
|
|
140
|
+
* app's bot user (from auth.test). Returns [] for events that address no one (or
|
|
141
|
+
* that the app itself posted). A session hit both as an alias and otherwise keeps
|
|
142
|
+
* the alias handle.
|
|
143
|
+
*/
|
|
144
|
+
export function routeHits(ev, ctx) {
|
|
68
145
|
if (ev.bot_id || !ev.user || !ev.channel || !ev.ts)
|
|
69
146
|
return [];
|
|
70
147
|
if (ev.subtype && ev.subtype !== "thread_broadcast" && ev.subtype !== "file_share")
|
|
@@ -72,43 +149,55 @@ export function routeMessage(ev, ctx) {
|
|
|
72
149
|
const live = new Map();
|
|
73
150
|
for (const s of ctx.liveSessions)
|
|
74
151
|
live.set(normSession(s), s);
|
|
75
|
-
const
|
|
152
|
+
const aliases = ctx.aliases ?? new Map();
|
|
153
|
+
const out = new Map();
|
|
154
|
+
const add = (h) => {
|
|
155
|
+
if (!h)
|
|
156
|
+
return;
|
|
157
|
+
const prev = out.get(h.session);
|
|
158
|
+
// An alias hit outranks a plain one for the same session; otherwise first wins.
|
|
159
|
+
if (!prev || (h.for && !prev.for))
|
|
160
|
+
out.set(h.session, h);
|
|
161
|
+
};
|
|
76
162
|
const text = ev.text ?? "";
|
|
77
|
-
// 1. thread reply
|
|
163
|
+
// 1. thread reply (posts + sticky routes)
|
|
78
164
|
if (ev.thread_ts && ev.thread_ts !== ev.ts) {
|
|
79
|
-
for (const s of ctx.threads.get(ev.channel)?.get(ev.thread_ts) ?? [])
|
|
80
|
-
|
|
165
|
+
for (const [s, f] of ctx.threads.get(ev.channel)?.get(ev.thread_ts) ?? []) {
|
|
166
|
+
add({ session: s, ...(f ? { for: f } : {}) });
|
|
167
|
+
}
|
|
81
168
|
}
|
|
82
169
|
// 2. <@BOT> name…
|
|
83
170
|
const mention = `<@${ctx.botUserId}>`;
|
|
84
171
|
let at = text.indexOf(mention);
|
|
85
172
|
while (at !== -1) {
|
|
86
173
|
const after = text.slice(at + mention.length).match(/^[\s,:]*@?([A-Za-z0-9][A-Za-z0-9-]*)/);
|
|
87
|
-
if (after)
|
|
88
|
-
|
|
89
|
-
if (hit)
|
|
90
|
-
out.add(hit);
|
|
91
|
-
}
|
|
174
|
+
if (after)
|
|
175
|
+
add(resolveAddress(after[1], live, aliases));
|
|
92
176
|
at = text.indexOf(mention, at + mention.length);
|
|
93
177
|
}
|
|
94
|
-
// 3. plain @handle for a known live session
|
|
95
|
-
for (const m of text.matchAll(HANDLE_RE))
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
178
|
+
// 3./4. plain @handle for a known live session or a registered alias
|
|
179
|
+
for (const m of text.matchAll(HANDLE_RE))
|
|
180
|
+
add(resolveAddress(m[1], live, aliases));
|
|
181
|
+
return [...out.values()].map(({ session, for: f, addr }) => ({
|
|
182
|
+
session,
|
|
183
|
+
...(f ? { for: f } : {}),
|
|
184
|
+
...(addr ? { addr } : {}),
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
/** The session names `ev` is addressed to (routeHits without the handles). */
|
|
188
|
+
export function routeMessage(ev, ctx) {
|
|
189
|
+
return routeHits(ev, ctx).map((h) => h.session);
|
|
101
190
|
}
|
|
102
191
|
/**
|
|
103
192
|
* The text a session receives: the app mention, and a live session name right
|
|
104
193
|
* after it, are stripped so the model reads the instruction, not the routing.
|
|
105
194
|
* A word after the mention that is NOT a live session is kept (it is content).
|
|
106
195
|
*/
|
|
107
|
-
export function stripAddressing(text, botUserId, liveSessions) {
|
|
196
|
+
export function stripAddressing(text, botUserId, liveSessions, aliases = new Map()) {
|
|
108
197
|
const live = new Map([...liveSessions].map((s) => [normSession(s), s]));
|
|
109
198
|
const re = new RegExp(`<@${botUserId}>[\\s,:]*(?:@?([A-Za-z0-9][A-Za-z0-9-]*)[\\s,:]*)?`, "g");
|
|
110
199
|
return text
|
|
111
|
-
.replace(re, (_whole, name) => name && !
|
|
200
|
+
.replace(re, (_whole, name) => name && !resolveAddress(name, live, aliases) ? `${name} ` : "")
|
|
112
201
|
.replace(/[ \t]{2,}/g, " ")
|
|
113
202
|
.trim();
|
|
114
203
|
}
|
|
@@ -6,10 +6,29 @@
|
|
|
6
6
|
* connection per session would silently drop messages). For every channel
|
|
7
7
|
* message it asks lib/slack-route.ts who is addressed and appends the message to
|
|
8
8
|
* each addressed session's inbox (lib/slack-state.ts); each session's listener
|
|
9
|
-
* delivers from there.
|
|
10
|
-
*
|
|
9
|
+
* delivers from there. A routing made by address (mention, handle, alias) is
|
|
10
|
+
* recorded so later replies in that thread stick to the same session. The router
|
|
11
|
+
* never writes to a session, never runs a tool, and holds no state beyond an
|
|
12
|
+
* in-memory user-name cache.
|
|
11
13
|
*/
|
|
12
14
|
import { type FetchLike } from "./slack.js";
|
|
15
|
+
/** How often the router settles held messages (delivered / expired). */
|
|
16
|
+
export declare const PENDING_SWEEP_MS = 30000;
|
|
17
|
+
/**
|
|
18
|
+
* Settle the held messages (see settlePending) and tell the sender of each expired
|
|
19
|
+
* one, in its thread, that it was not delivered. Best effort: a failed post is
|
|
20
|
+
* logged, never retried (the message is already dropped from the queue).
|
|
21
|
+
*/
|
|
22
|
+
export declare function sweepPending(opts: {
|
|
23
|
+
botToken: string;
|
|
24
|
+
fetchImpl?: FetchLike;
|
|
25
|
+
log?: (line: string) => void;
|
|
26
|
+
now?: number;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
delivered: number;
|
|
29
|
+
expired: number;
|
|
30
|
+
held: number;
|
|
31
|
+
}>;
|
|
13
32
|
/** The running router's pid, or null. */
|
|
14
33
|
export declare function routerPid(): number | null;
|
|
15
34
|
/**
|
|
@@ -23,6 +42,22 @@ export declare function runRouter(opts: {
|
|
|
23
42
|
appToken: string;
|
|
24
43
|
/** Slack member ids allowed to reach a session; everyone else is dropped. */
|
|
25
44
|
allow: string[];
|
|
45
|
+
/**
|
|
46
|
+
* Route `mnemom agent slack alias` handles, and hold a message for a session that
|
|
47
|
+
* is not listening (tell the sender it is waiting; report it undelivered after
|
|
48
|
+
* PENDING_TTL_MS). Off (the default): aliases are ignored and routing is unchanged.
|
|
49
|
+
*/
|
|
50
|
+
aliases?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* A thread a session was addressed in keeps routing replies to it, re-resolved to
|
|
53
|
+
* whoever answers to that address now. Off (the default): only threads a session
|
|
54
|
+
* posted in route replies.
|
|
55
|
+
*/
|
|
56
|
+
stickyThreads?: boolean;
|
|
57
|
+
/** How often to settle held messages (default PENDING_SWEEP_MS). */
|
|
58
|
+
sweepMs?: number;
|
|
59
|
+
/** Aborting stops the held-message sweep (tests; the CLI router runs until killed). */
|
|
60
|
+
signal?: AbortSignal;
|
|
26
61
|
log?: (line: string) => void;
|
|
27
62
|
fetchImpl?: FetchLike;
|
|
28
63
|
}): Promise<never>;
|
package/dist/lib/slack-router.js
CHANGED
|
@@ -6,15 +6,37 @@
|
|
|
6
6
|
* connection per session would silently drop messages). For every channel
|
|
7
7
|
* message it asks lib/slack-route.ts who is addressed and appends the message to
|
|
8
8
|
* each addressed session's inbox (lib/slack-state.ts); each session's listener
|
|
9
|
-
* delivers from there.
|
|
10
|
-
*
|
|
9
|
+
* delivers from there. A routing made by address (mention, handle, alias) is
|
|
10
|
+
* recorded so later replies in that thread stick to the same session. The router
|
|
11
|
+
* never writes to a session, never runs a tool, and holds no state beyond an
|
|
12
|
+
* in-memory user-name cache.
|
|
11
13
|
*/
|
|
12
14
|
import { spawn } from "node:child_process";
|
|
13
15
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
14
16
|
import { join } from "node:path";
|
|
15
17
|
import { fetchThread, formatThread, messageBody, slackCall, } from "./slack.js";
|
|
16
|
-
import { buildThreadIndex,
|
|
17
|
-
import { appendInbox, liveSessions, readPosts, slackDir } from "./slack-state.js";
|
|
18
|
+
import { buildThreadIndex, normSession, routeHits, stripAddressing, } from "./slack-route.js";
|
|
19
|
+
import { appendInbox, inboxSize, listAliases, liveSessions, PENDING_TTL_MS, readAliases, readPosts, readThreadRecords, recordPending, recordRoute, settlePending, slackDir, } from "./slack-state.js";
|
|
20
|
+
/** How often the router settles held messages (delivered / expired). */
|
|
21
|
+
export const PENDING_SWEEP_MS = 30_000;
|
|
22
|
+
const ttlText = () => `${Math.round(PENDING_TTL_MS / 60_000)} min`;
|
|
23
|
+
/**
|
|
24
|
+
* Settle the held messages (see settlePending) and tell the sender of each expired
|
|
25
|
+
* one, in its thread, that it was not delivered. Best effort: a failed post is
|
|
26
|
+
* logged, never retried (the message is already dropped from the queue).
|
|
27
|
+
*/
|
|
28
|
+
export async function sweepPending(opts) {
|
|
29
|
+
const r = settlePending(opts.now ?? Date.now());
|
|
30
|
+
for (const p of r.expired) {
|
|
31
|
+
const res = await slackCall("chat.postMessage", opts.botToken, {
|
|
32
|
+
channel: p.channel,
|
|
33
|
+
thread_ts: p.thread_ts,
|
|
34
|
+
text: `<@${p.user}> not delivered: \`${p.session}\` did not pick this up within ${ttlText()}. Send it again once that session is running, or address another one.`,
|
|
35
|
+
}, opts.fetchImpl ?? fetch).catch((e) => ({ ok: false, error: e instanceof Error ? e.message : String(e) }));
|
|
36
|
+
opts.log?.(`undelivered ${p.channel}/${p.ts} → ${p.session}${res.ok ? "" : ` (notice failed: ${String(res.error)})`}`);
|
|
37
|
+
}
|
|
38
|
+
return { delivered: r.delivered.length, expired: r.expired.length, held: r.held.length };
|
|
39
|
+
}
|
|
18
40
|
const routerPidFile = () => join(slackDir(), "router.pid");
|
|
19
41
|
function pidAlive(pid) {
|
|
20
42
|
try {
|
|
@@ -53,6 +75,8 @@ export function ensureRouter(argv = [process.execPath, process.argv[1] ?? "mnemo
|
|
|
53
75
|
}
|
|
54
76
|
/** Run the router until the process is killed. Reconnects on disconnect. */
|
|
55
77
|
export async function runRouter(opts) {
|
|
78
|
+
const aliasesOn = opts.aliases === true;
|
|
79
|
+
const stickyOn = opts.stickyThreads === true;
|
|
56
80
|
// Lines carry Slack-supplied values (channel, handles, disconnect reason);
|
|
57
81
|
// strip CR/LF so a crafted message cannot forge extra log lines.
|
|
58
82
|
const log = opts.log ?? ((l) => console.error(`[slack-router] ${l.replace(/\n|\r/g, "")}`));
|
|
@@ -69,6 +93,14 @@ export async function runRouter(opts) {
|
|
|
69
93
|
throw new Error(`a router is already running (pid ${other}).`);
|
|
70
94
|
mkdirSync(slackDir(), { recursive: true, mode: 0o700 });
|
|
71
95
|
writeFileSync(routerPidFile(), String(process.pid), { mode: 0o600 });
|
|
96
|
+
log(`aliases ${aliasesOn ? "on" : "off"}, sticky threads ${stickyOn ? "on" : "off"}`);
|
|
97
|
+
if (aliasesOn) {
|
|
98
|
+
const sweep = setInterval(() => {
|
|
99
|
+
sweepPending({ botToken: opts.botToken, fetchImpl, log }).catch((e) => log(`pending sweep failed: ${e instanceof Error ? e.message : e}`));
|
|
100
|
+
}, opts.sweepMs ?? PENDING_SWEEP_MS);
|
|
101
|
+
sweep.unref();
|
|
102
|
+
opts.signal?.addEventListener("abort", () => clearInterval(sweep));
|
|
103
|
+
}
|
|
72
104
|
const names = new Map();
|
|
73
105
|
const userName = async (id) => {
|
|
74
106
|
if (names.has(id))
|
|
@@ -99,12 +131,14 @@ export async function runRouter(opts) {
|
|
|
99
131
|
if (!ev.user || !opts.allow.includes(ev.user))
|
|
100
132
|
return;
|
|
101
133
|
const live = liveSessions();
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
134
|
+
const aliases = aliasesOn ? readAliases() : new Map();
|
|
135
|
+
const threads = buildThreadIndex(stickyOn ? readThreadRecords(Date.now(), { live, aliases }) : readPosts());
|
|
136
|
+
const hits = routeHits(ev, { botUserId, liveSessions: live, threads, aliases });
|
|
137
|
+
if (!hits.length) {
|
|
105
138
|
if (ev.text?.includes(`<@${botUserId}>`) && !ev.bot_id) {
|
|
106
|
-
const
|
|
107
|
-
|
|
139
|
+
const handles = aliasesOn ? listAliases().map((a) => `\`@${a.handle}\``) : [];
|
|
140
|
+
const hint = live.length || handles.length
|
|
141
|
+
? `No live session matched. Address one as \`@<app> <session>: …\` — live now: ${live.length ? live.map((s) => `\`${s}\``).join(", ") : "none"}${handles.length ? `; handles: ${handles.join(", ")}` : ""}.`
|
|
108
142
|
: "No agent session is listening right now.";
|
|
109
143
|
// Ephemeral: only the sender sees the hint, so a mistyped address never
|
|
110
144
|
// leaves a permanent post in the thread for everyone else.
|
|
@@ -119,7 +153,7 @@ export async function runRouter(opts) {
|
|
|
119
153
|
const user_name = await userName(user);
|
|
120
154
|
const text = messageBody({
|
|
121
155
|
ts,
|
|
122
|
-
text: stripAddressing(ev.text ?? "", botUserId, live),
|
|
156
|
+
text: stripAddressing(ev.text ?? "", botUserId, live, aliases),
|
|
123
157
|
files: ev.files,
|
|
124
158
|
});
|
|
125
159
|
// A thread reply carries the thread so far: "review this" means nothing
|
|
@@ -143,12 +177,24 @@ export async function runRouter(opts) {
|
|
|
143
177
|
log(`thread context unavailable for ${channel}/${ev.thread_ts}: ${e instanceof Error ? e.message : e}`);
|
|
144
178
|
}
|
|
145
179
|
}
|
|
146
|
-
|
|
180
|
+
const mentioned = (ev.text ?? "").includes(`<@${botUserId}>`);
|
|
181
|
+
// Which sessions THIS message addresses (as opposed to inherits from the thread).
|
|
182
|
+
const addressed = new Map(routeHits({ ...ev, thread_ts: undefined }, { botUserId, liveSessions: live, threads, aliases }).map((h) => [h.session, h]));
|
|
183
|
+
const liveNow = new Set(live.map(normSession));
|
|
184
|
+
const waitingFor = [];
|
|
185
|
+
for (const { session, for: handle } of hits) {
|
|
186
|
+
// Not listening: hold it (aliases on) so it is delivered when the session
|
|
187
|
+
// reads its inbox, or reported undelivered — never left unread in silence.
|
|
188
|
+
const held = aliasesOn && !liveNow.has(normSession(session));
|
|
189
|
+
const at = new Date().toISOString();
|
|
190
|
+
const start = held ? inboxSize(session) : 0;
|
|
147
191
|
const via = [];
|
|
148
192
|
if (inThread?.has(session))
|
|
149
193
|
via.push("thread");
|
|
150
|
-
if (
|
|
194
|
+
if (mentioned)
|
|
151
195
|
via.push("mention");
|
|
196
|
+
if (addressed.get(session)?.for)
|
|
197
|
+
via.push("alias");
|
|
152
198
|
if (!via.length)
|
|
153
199
|
via.push("handle");
|
|
154
200
|
appendInbox(session, {
|
|
@@ -159,9 +205,48 @@ export async function runRouter(opts) {
|
|
|
159
205
|
user_name,
|
|
160
206
|
text,
|
|
161
207
|
via,
|
|
208
|
+
...(handle ? { for: handle } : {}),
|
|
162
209
|
...(context ? { context } : {}),
|
|
210
|
+
...(held ? { queued_at: at } : {}),
|
|
163
211
|
});
|
|
164
|
-
|
|
212
|
+
if (held) {
|
|
213
|
+
recordPending({
|
|
214
|
+
session,
|
|
215
|
+
channel,
|
|
216
|
+
ts,
|
|
217
|
+
thread_ts: ev.thread_ts ?? ts,
|
|
218
|
+
user,
|
|
219
|
+
start,
|
|
220
|
+
end: inboxSize(session),
|
|
221
|
+
at,
|
|
222
|
+
});
|
|
223
|
+
waitingFor.push(session);
|
|
224
|
+
}
|
|
225
|
+
// Addressed here (not merely inherited from the thread) → the thread sticks.
|
|
226
|
+
// The address is kept so later replies re-resolve it (see reresolveRoutes).
|
|
227
|
+
const addr = addressed.get(session)?.addr;
|
|
228
|
+
if (stickyOn && addressed.has(session) && !inThread?.has(session)) {
|
|
229
|
+
recordRoute({
|
|
230
|
+
session,
|
|
231
|
+
channel,
|
|
232
|
+
ts,
|
|
233
|
+
thread_ts: ev.thread_ts,
|
|
234
|
+
...(handle ? { for: handle } : {}),
|
|
235
|
+
...(addr ? { addr } : {}),
|
|
236
|
+
at,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
log(`routed ${ev.channel}/${ev.ts} → ${session} (${via.join("+")}${handle ? ` for @${handle}` : ""}${held ? ", held" : ""})`);
|
|
240
|
+
}
|
|
241
|
+
if (waitingFor.length) {
|
|
242
|
+
// Ephemeral: the sender is looking now; the durable answer is the
|
|
243
|
+
// not-delivered reply sweepPending posts if the session never reads it.
|
|
244
|
+
await slackCall("chat.postEphemeral", opts.botToken, {
|
|
245
|
+
channel,
|
|
246
|
+
user,
|
|
247
|
+
thread_ts: ev.thread_ts ?? ts,
|
|
248
|
+
text: `Waiting for ${waitingFor.map((s) => `\`${s}\``).join(", ")}: not listening right now. It gets this when it next reads Slack; if it has not within ${ttlText()}, I will reply here that it was not delivered.`,
|
|
249
|
+
}, fetchImpl).catch(() => undefined);
|
|
165
250
|
}
|
|
166
251
|
};
|
|
167
252
|
for (;;) {
|