@mnemom/mnemom 0.17.25 → 0.17.26
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 +22 -7
- package/dist/commands/agent-goal.js +7 -2
- package/dist/commands/agent-sessions.js +2 -1
- package/dist/commands/agent-setup.d.ts +104 -4
- package/dist/commands/agent-setup.js +414 -18
- 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 +89 -12
- 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 +62 -1
- package/dist/lib/agent.js +55 -0
- 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/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
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 (;;) {
|
|
@@ -2,16 +2,47 @@
|
|
|
2
2
|
* lib/slack-state.ts — the local, per-operator state behind `mnemom agent slack`.
|
|
3
3
|
*
|
|
4
4
|
* ~/.mnemom/slack/posts.jsonl every post a session made (routes thread replies)
|
|
5
|
+
* ~/.mnemom/slack/routes.jsonl every addressed routing the router made (sticky threads)
|
|
5
6
|
* ~/.mnemom/slack/live/<session>.json a session currently listening (pid)
|
|
7
|
+
* ~/.mnemom/slack/aliases/<handle>.json an operator-registered handle → session
|
|
6
8
|
* ~/.mnemom/slack/inbox/<session>.jsonl messages the router routed to a session
|
|
9
|
+
* ~/.mnemom/slack/offsets/<session>.off how far into its inbox a session has read
|
|
10
|
+
* ~/.mnemom/slack/pending.jsonl messages held for a session that was not
|
|
11
|
+
* listening (slack_aliases on): delivered when its
|
|
12
|
+
* offset passes them, else reported undelivered
|
|
7
13
|
*
|
|
8
|
-
* The router (one per operator machine) is the only writer of inboxes;
|
|
9
|
-
* session's listener is the only reader of its own. No secret is
|
|
14
|
+
* The router (one per operator machine) is the only writer of inboxes and routes;
|
|
15
|
+
* each session's listener is the only reader of its own inbox. No secret is
|
|
16
|
+
* stored here.
|
|
10
17
|
*/
|
|
11
|
-
import { type PostRecord } from "./slack-route.js";
|
|
18
|
+
import { type PostRecord, type StickyRoute } from "./slack-route.js";
|
|
12
19
|
export declare function slackDir(): string;
|
|
20
|
+
/** A sticky-thread route stops routing replies this long after it was made. */
|
|
21
|
+
export declare const STICKY_TTL_MS: number;
|
|
22
|
+
/**
|
|
23
|
+
* A message held for a session that was not listening is dropped (and its sender
|
|
24
|
+
* told it was not delivered) this long after it arrived.
|
|
25
|
+
*/
|
|
26
|
+
export declare const PENDING_TTL_MS: number;
|
|
13
27
|
export declare function recordPost(p: PostRecord): void;
|
|
14
28
|
export declare function readPosts(): PostRecord[];
|
|
29
|
+
/** A routing the router made by address (mention/handle/alias), kept so the thread sticks. */
|
|
30
|
+
export interface RouteRecord extends StickyRoute {
|
|
31
|
+
/** ISO time the routing was made; the record expires STICKY_TTL_MS later. */
|
|
32
|
+
at: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function recordRoute(r: RouteRecord): void;
|
|
35
|
+
/** Sticky routes still within their TTL at `now`, oldest first (capped like posts). */
|
|
36
|
+
export declare function readRoutes(now?: number): RouteRecord[];
|
|
37
|
+
/**
|
|
38
|
+
* The records that route thread replies: posts plus unexpired sticky routes. With
|
|
39
|
+
* `resolve`, each sticky route is re-resolved against who is live and which aliases
|
|
40
|
+
* exist now (reresolveRoutes), so a thread follows a restarted or replaced session.
|
|
41
|
+
*/
|
|
42
|
+
export declare function readThreadRecords(now?: number, resolve?: {
|
|
43
|
+
live: Iterable<string>;
|
|
44
|
+
aliases: Map<string, string>;
|
|
45
|
+
}): PostRecord[];
|
|
15
46
|
/**
|
|
16
47
|
* The post `session` itself made at `ts` (optionally in `channel`), from the local
|
|
17
48
|
* post index — the ownership check behind delete: a session may only remove its
|
|
@@ -26,13 +57,66 @@ export interface InboxMessage {
|
|
|
26
57
|
user: string;
|
|
27
58
|
user_name?: string;
|
|
28
59
|
text: string;
|
|
29
|
-
/** How it was addressed: thread | mention | handle. */
|
|
60
|
+
/** How it was addressed: thread | mention | handle | alias. */
|
|
30
61
|
via: string[];
|
|
62
|
+
/** The alias handle it was addressed to (the session may front for several). */
|
|
63
|
+
for?: string;
|
|
31
64
|
/** The thread so far (parent + recent replies), when the message is a thread reply. */
|
|
32
65
|
context?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Set (ISO time) when the router held the message because the session was not
|
|
68
|
+
* listening. A held message older than PENDING_TTL_MS is never delivered: its
|
|
69
|
+
* sender was already told it was not.
|
|
70
|
+
*/
|
|
71
|
+
queued_at?: string;
|
|
33
72
|
}
|
|
73
|
+
/** `messages` without the held ones that have expired (see InboxMessage.queued_at). */
|
|
74
|
+
export declare function freshInbox(messages: InboxMessage[], now?: number): InboxMessage[];
|
|
34
75
|
export declare function appendInbox(session: string, msg: InboxMessage): void;
|
|
35
76
|
export declare function inboxPath(session: string): string;
|
|
77
|
+
/** The inbox's size in bytes (0 when absent): the offset just past its last message. */
|
|
78
|
+
export declare function inboxSize(session: string): number;
|
|
79
|
+
/** How far into its inbox `session` has read (0 when it never saved an offset). */
|
|
80
|
+
export declare function readOffset(session: string): number;
|
|
81
|
+
/** `session` has read its inbox up to byte `offset`. */
|
|
82
|
+
export declare function writeOffset(session: string, offset: number): void;
|
|
83
|
+
/** True when `session` has saved an offset at all (it has read its inbox before). */
|
|
84
|
+
export declare function hasOffset(session: string): boolean;
|
|
85
|
+
/** A held message: where it came from, and the inbox offset just past it. */
|
|
86
|
+
export interface PendingRecord {
|
|
87
|
+
session: string;
|
|
88
|
+
channel: string;
|
|
89
|
+
ts: string;
|
|
90
|
+
/** The thread to answer in (the message's own ts when it was not in a thread). */
|
|
91
|
+
thread_ts: string;
|
|
92
|
+
user: string;
|
|
93
|
+
/** Inbox byte offset where the held message starts. */
|
|
94
|
+
start: number;
|
|
95
|
+
/** Inbox byte offset just past the held message: read once the offset reaches it. */
|
|
96
|
+
end: number;
|
|
97
|
+
/** ISO time it was held. */
|
|
98
|
+
at: string;
|
|
99
|
+
}
|
|
100
|
+
export declare function recordPending(p: PendingRecord): void;
|
|
101
|
+
export declare function readPending(): PendingRecord[];
|
|
102
|
+
/**
|
|
103
|
+
* Where `session`'s listener must start reading to deliver what is held for it:
|
|
104
|
+
* the start of its earliest held message it has not read, or undefined when
|
|
105
|
+
* nothing unread is held (the listener then starts at the end of its inbox and
|
|
106
|
+
* reads nothing). Cost: a read of pending.jsonl, which is absent with aliases off.
|
|
107
|
+
*/
|
|
108
|
+
export declare function heldFrom(session: string): number | undefined;
|
|
109
|
+
/**
|
|
110
|
+
* Settle the held messages at `now`: one whose session has read past it is
|
|
111
|
+
* delivered; one older than PENDING_TTL_MS that is still unread is expired; the
|
|
112
|
+
* rest stay held. Rewrites pending.jsonl with the ones still held. The router is
|
|
113
|
+
* the only caller (and the only writer of pending.jsonl).
|
|
114
|
+
*/
|
|
115
|
+
export declare function settlePending(now?: number): {
|
|
116
|
+
delivered: PendingRecord[];
|
|
117
|
+
expired: PendingRecord[];
|
|
118
|
+
held: PendingRecord[];
|
|
119
|
+
};
|
|
36
120
|
/** Messages appended after byte `offset`, and the new offset. */
|
|
37
121
|
export declare function readInboxFrom(session: string, offset: number): {
|
|
38
122
|
messages: InboxMessage[];
|
|
@@ -40,5 +124,22 @@ export declare function readInboxFrom(session: string, offset: number): {
|
|
|
40
124
|
};
|
|
41
125
|
export declare function registerLive(session: string, pid?: number): void;
|
|
42
126
|
export declare function unregisterLive(session: string): void;
|
|
127
|
+
export interface AliasRecord {
|
|
128
|
+
handle: string;
|
|
129
|
+
session: string;
|
|
130
|
+
since: string;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Register `handle` → `session` (idempotent; re-running with the same pair keeps the
|
|
134
|
+
* first `since`). The session need not be live: an alias is how a handle reaches a
|
|
135
|
+
* session that is launched later or restarts.
|
|
136
|
+
*/
|
|
137
|
+
export declare function setAlias(handle: string, session: string): AliasRecord;
|
|
138
|
+
/** Remove `handle`; true when something was removed. */
|
|
139
|
+
export declare function removeAlias(handle: string): boolean;
|
|
140
|
+
/** Every registered alias, by handle (sorted). Corrupt files are ignored, not fatal. */
|
|
141
|
+
export declare function listAliases(): AliasRecord[];
|
|
142
|
+
/** normalised handle → session, the shape routeHits takes. */
|
|
143
|
+
export declare function readAliases(): Map<string, string>;
|
|
43
144
|
/** Sessions with a running listener; stale entries (dead pid) are pruned. */
|
|
44
145
|
export declare function liveSessions(): string[];
|
package/dist/lib/slack-state.js
CHANGED
|
@@ -2,24 +2,42 @@
|
|
|
2
2
|
* lib/slack-state.ts — the local, per-operator state behind `mnemom agent slack`.
|
|
3
3
|
*
|
|
4
4
|
* ~/.mnemom/slack/posts.jsonl every post a session made (routes thread replies)
|
|
5
|
+
* ~/.mnemom/slack/routes.jsonl every addressed routing the router made (sticky threads)
|
|
5
6
|
* ~/.mnemom/slack/live/<session>.json a session currently listening (pid)
|
|
7
|
+
* ~/.mnemom/slack/aliases/<handle>.json an operator-registered handle → session
|
|
6
8
|
* ~/.mnemom/slack/inbox/<session>.jsonl messages the router routed to a session
|
|
9
|
+
* ~/.mnemom/slack/offsets/<session>.off how far into its inbox a session has read
|
|
10
|
+
* ~/.mnemom/slack/pending.jsonl messages held for a session that was not
|
|
11
|
+
* listening (slack_aliases on): delivered when its
|
|
12
|
+
* offset passes them, else reported undelivered
|
|
7
13
|
*
|
|
8
|
-
* The router (one per operator machine) is the only writer of inboxes;
|
|
9
|
-
* session's listener is the only reader of its own. No secret is
|
|
14
|
+
* The router (one per operator machine) is the only writer of inboxes and routes;
|
|
15
|
+
* each session's listener is the only reader of its own inbox. No secret is
|
|
16
|
+
* stored here.
|
|
10
17
|
*/
|
|
11
|
-
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
18
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
12
19
|
import { join } from "node:path";
|
|
13
20
|
import { MNEMOM_DIR } from "./config.js";
|
|
14
|
-
import { normSession } from "./slack-route.js";
|
|
21
|
+
import { normHandle, normSession, reresolveRoutes, } from "./slack-route.js";
|
|
15
22
|
export function slackDir() {
|
|
16
23
|
return join(MNEMOM_DIR, "slack");
|
|
17
24
|
}
|
|
18
25
|
const postsFile = () => join(slackDir(), "posts.jsonl");
|
|
26
|
+
const routesFile = () => join(slackDir(), "routes.jsonl");
|
|
19
27
|
const liveDir = () => join(slackDir(), "live");
|
|
28
|
+
const aliasDir = () => join(slackDir(), "aliases");
|
|
20
29
|
const inboxDir = () => join(slackDir(), "inbox");
|
|
30
|
+
const offsetDir = () => join(slackDir(), "offsets");
|
|
31
|
+
const pendingFile = () => join(slackDir(), "pending.jsonl");
|
|
21
32
|
/** Only the most recent posts matter for thread routing; cap what we read. */
|
|
22
33
|
const MAX_POSTS = 5000;
|
|
34
|
+
/** A sticky-thread route stops routing replies this long after it was made. */
|
|
35
|
+
export const STICKY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
36
|
+
/**
|
|
37
|
+
* A message held for a session that was not listening is dropped (and its sender
|
|
38
|
+
* told it was not delivered) this long after it arrived.
|
|
39
|
+
*/
|
|
40
|
+
export const PENDING_TTL_MS = 60 * 60 * 1000;
|
|
23
41
|
function ensure(dir) {
|
|
24
42
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
25
43
|
}
|
|
@@ -46,6 +64,31 @@ export function recordPost(p) {
|
|
|
46
64
|
export function readPosts() {
|
|
47
65
|
return readJsonl(postsFile()).slice(-MAX_POSTS);
|
|
48
66
|
}
|
|
67
|
+
export function recordRoute(r) {
|
|
68
|
+
ensure(slackDir());
|
|
69
|
+
appendFileSync(routesFile(), JSON.stringify(r) + "\n", { mode: 0o600 });
|
|
70
|
+
}
|
|
71
|
+
/** Sticky routes still within their TTL at `now`, oldest first (capped like posts). */
|
|
72
|
+
export function readRoutes(now = Date.now()) {
|
|
73
|
+
return readJsonl(routesFile())
|
|
74
|
+
.filter((r) => {
|
|
75
|
+
const at = Date.parse(r.at);
|
|
76
|
+
return Number.isFinite(at) && now - at <= STICKY_TTL_MS && now >= at;
|
|
77
|
+
})
|
|
78
|
+
.slice(-MAX_POSTS);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The records that route thread replies: posts plus unexpired sticky routes. With
|
|
82
|
+
* `resolve`, each sticky route is re-resolved against who is live and which aliases
|
|
83
|
+
* exist now (reresolveRoutes), so a thread follows a restarted or replaced session.
|
|
84
|
+
*/
|
|
85
|
+
export function readThreadRecords(now = Date.now(), resolve) {
|
|
86
|
+
const routes = readRoutes(now);
|
|
87
|
+
return [
|
|
88
|
+
...readPosts(),
|
|
89
|
+
...(resolve ? reresolveRoutes(routes, resolve.live, resolve.aliases) : routes),
|
|
90
|
+
];
|
|
91
|
+
}
|
|
49
92
|
/**
|
|
50
93
|
* The post `session` itself made at `ts` (optionally in `channel`), from the local
|
|
51
94
|
* post index — the ownership check behind delete: a session may only remove its
|
|
@@ -57,6 +100,15 @@ export function findOwnPost(session, ts, channel) {
|
|
|
57
100
|
.reverse()
|
|
58
101
|
.find((p) => p.ts === ts && normSession(p.session) === me && (!channel || p.channel === channel));
|
|
59
102
|
}
|
|
103
|
+
/** `messages` without the held ones that have expired (see InboxMessage.queued_at). */
|
|
104
|
+
export function freshInbox(messages, now = Date.now()) {
|
|
105
|
+
return messages.filter((m) => {
|
|
106
|
+
if (!m.queued_at)
|
|
107
|
+
return true;
|
|
108
|
+
const at = Date.parse(m.queued_at);
|
|
109
|
+
return Number.isFinite(at) && now - at <= PENDING_TTL_MS;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
60
112
|
const fileFor = (session) => `${normSession(session)}`;
|
|
61
113
|
export function appendInbox(session, msg) {
|
|
62
114
|
ensure(inboxDir());
|
|
@@ -67,15 +119,117 @@ export function appendInbox(session, msg) {
|
|
|
67
119
|
export function inboxPath(session) {
|
|
68
120
|
return join(inboxDir(), `${fileFor(session)}.jsonl`);
|
|
69
121
|
}
|
|
122
|
+
/** The inbox's size in bytes (0 when absent): the offset just past its last message. */
|
|
123
|
+
export function inboxSize(session) {
|
|
124
|
+
try {
|
|
125
|
+
return statSync(inboxPath(session)).size;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Normalised like the inbox file name, so a session name can never escape offsets/.
|
|
132
|
+
const offsetFile = (session) => join(offsetDir(), `${fileFor(session)}.off`);
|
|
133
|
+
/** How far into its inbox `session` has read (0 when it never saved an offset). */
|
|
134
|
+
export function readOffset(session) {
|
|
135
|
+
try {
|
|
136
|
+
return Number(readFileSync(offsetFile(session), "utf8")) || 0;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** `session` has read its inbox up to byte `offset`. */
|
|
143
|
+
export function writeOffset(session, offset) {
|
|
144
|
+
ensure(offsetDir());
|
|
145
|
+
writeFileSync(offsetFile(session), String(offset), { mode: 0o600 });
|
|
146
|
+
}
|
|
147
|
+
/** True when `session` has saved an offset at all (it has read its inbox before). */
|
|
148
|
+
export function hasOffset(session) {
|
|
149
|
+
return existsSync(offsetFile(session));
|
|
150
|
+
}
|
|
151
|
+
export function recordPending(p) {
|
|
152
|
+
ensure(slackDir());
|
|
153
|
+
appendFileSync(pendingFile(), JSON.stringify(p) + "\n", { mode: 0o600 });
|
|
154
|
+
}
|
|
155
|
+
export function readPending() {
|
|
156
|
+
return readJsonl(pendingFile());
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Where `session`'s listener must start reading to deliver what is held for it:
|
|
160
|
+
* the start of its earliest held message it has not read, or undefined when
|
|
161
|
+
* nothing unread is held (the listener then starts at the end of its inbox and
|
|
162
|
+
* reads nothing). Cost: a read of pending.jsonl, which is absent with aliases off.
|
|
163
|
+
*/
|
|
164
|
+
export function heldFrom(session) {
|
|
165
|
+
if (!existsSync(pendingFile()))
|
|
166
|
+
return undefined;
|
|
167
|
+
const me = normSession(session);
|
|
168
|
+
const read = readOffset(session);
|
|
169
|
+
const size = inboxSize(session);
|
|
170
|
+
let from;
|
|
171
|
+
for (const p of readPending()) {
|
|
172
|
+
if (normSession(p.session) !== me || !(p.end > read) || p.end > size)
|
|
173
|
+
continue;
|
|
174
|
+
const s = Number.isFinite(p.start) ? Math.max(p.start, read) : read;
|
|
175
|
+
from = from === undefined ? s : Math.min(from, s);
|
|
176
|
+
}
|
|
177
|
+
return from;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Settle the held messages at `now`: one whose session has read past it is
|
|
181
|
+
* delivered; one older than PENDING_TTL_MS that is still unread is expired; the
|
|
182
|
+
* rest stay held. Rewrites pending.jsonl with the ones still held. The router is
|
|
183
|
+
* the only caller (and the only writer of pending.jsonl).
|
|
184
|
+
*/
|
|
185
|
+
export function settlePending(now = Date.now()) {
|
|
186
|
+
const delivered = [];
|
|
187
|
+
const expired = [];
|
|
188
|
+
const held = [];
|
|
189
|
+
const all = readPending();
|
|
190
|
+
if (!all.length)
|
|
191
|
+
return { delivered, expired, held };
|
|
192
|
+
for (const p of all) {
|
|
193
|
+
const at = Date.parse(p.at);
|
|
194
|
+
if (readOffset(p.session) >= p.end)
|
|
195
|
+
delivered.push(p);
|
|
196
|
+
else if (!Number.isFinite(at) || now - at > PENDING_TTL_MS)
|
|
197
|
+
expired.push(p);
|
|
198
|
+
else
|
|
199
|
+
held.push(p);
|
|
200
|
+
}
|
|
201
|
+
writeFileSync(pendingFile(), held.map((p) => JSON.stringify(p) + "\n").join(""), {
|
|
202
|
+
mode: 0o600,
|
|
203
|
+
});
|
|
204
|
+
return { delivered, expired, held };
|
|
205
|
+
}
|
|
70
206
|
/** Messages appended after byte `offset`, and the new offset. */
|
|
71
207
|
export function readInboxFrom(session, offset) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
208
|
+
// Open, don't check-then-open: the router appends concurrently. Read only the
|
|
209
|
+
// bytes after `offset`, never the whole history.
|
|
210
|
+
let fd;
|
|
211
|
+
try {
|
|
212
|
+
fd = openSync(inboxPath(session), "r");
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
if (e.code === "ENOENT")
|
|
216
|
+
return { messages: [], offset: 0 };
|
|
217
|
+
throw e;
|
|
218
|
+
}
|
|
219
|
+
let chunk;
|
|
220
|
+
try {
|
|
221
|
+
const size = fstatSync(fd).size;
|
|
222
|
+
if (size < offset)
|
|
223
|
+
offset = 0; // truncated/rotated
|
|
224
|
+
const buf = Buffer.alloc(size - offset);
|
|
225
|
+
// One positional read. A short read (the file shrank under us) is safe: the
|
|
226
|
+
// offset only advances past complete lines, so the rest is read next poll.
|
|
227
|
+
const got = buf.length ? readSync(fd, buf, 0, buf.length, offset) : 0;
|
|
228
|
+
chunk = buf.subarray(0, got).toString("utf8");
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
closeSync(fd);
|
|
232
|
+
}
|
|
79
233
|
const end = chunk.lastIndexOf("\n") + 1; // leave a partial trailing line for next read
|
|
80
234
|
const messages = [];
|
|
81
235
|
for (const line of chunk.slice(0, end).split("\n")) {
|
|
@@ -106,6 +260,71 @@ function pidAlive(pid) {
|
|
|
106
260
|
return err.code === "EPERM";
|
|
107
261
|
}
|
|
108
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* Register `handle` → `session` (idempotent; re-running with the same pair keeps the
|
|
265
|
+
* first `since`). The session need not be live: an alias is how a handle reaches a
|
|
266
|
+
* session that is launched later or restarts.
|
|
267
|
+
*/
|
|
268
|
+
export function setAlias(handle, session) {
|
|
269
|
+
const h = normHandle(handle);
|
|
270
|
+
const s = session.trim();
|
|
271
|
+
if (!h)
|
|
272
|
+
throw new Error(`alias: '${handle}' is not a usable handle (letters, digits, dashes).`);
|
|
273
|
+
if (!normSession(s))
|
|
274
|
+
throw new Error(`alias: '${session}' is not a usable session name.`);
|
|
275
|
+
ensure(aliasDir());
|
|
276
|
+
const path = join(aliasDir(), `${h}.json`);
|
|
277
|
+
const prev = readAlias(path);
|
|
278
|
+
const rec = {
|
|
279
|
+
handle: h,
|
|
280
|
+
session: s,
|
|
281
|
+
since: prev && prev.session === s ? prev.since : new Date().toISOString(),
|
|
282
|
+
};
|
|
283
|
+
writeFileSync(path, JSON.stringify(rec), { mode: 0o600 });
|
|
284
|
+
return rec;
|
|
285
|
+
}
|
|
286
|
+
/** Remove `handle`; true when something was removed. */
|
|
287
|
+
export function removeAlias(handle) {
|
|
288
|
+
const h = normHandle(handle);
|
|
289
|
+
if (!h)
|
|
290
|
+
return false;
|
|
291
|
+
const path = join(aliasDir(), `${h}.json`);
|
|
292
|
+
if (!existsSync(path))
|
|
293
|
+
return false;
|
|
294
|
+
rmSync(path, { force: true });
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
function readAlias(path) {
|
|
298
|
+
try {
|
|
299
|
+
const rec = JSON.parse(readFileSync(path, "utf8"));
|
|
300
|
+
if (typeof rec.handle === "string" && typeof rec.session === "string" && rec.session) {
|
|
301
|
+
return { handle: rec.handle, session: rec.session, since: String(rec.since ?? "") };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
/* corrupt → absent */
|
|
306
|
+
}
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
/** Every registered alias, by handle (sorted). Corrupt files are ignored, not fatal. */
|
|
310
|
+
export function listAliases() {
|
|
311
|
+
if (!existsSync(aliasDir()))
|
|
312
|
+
return [];
|
|
313
|
+
const out = [];
|
|
314
|
+
for (const f of readdirSync(aliasDir()).sort()) {
|
|
315
|
+
if (!f.endsWith(".json"))
|
|
316
|
+
continue;
|
|
317
|
+
const rec = readAlias(join(aliasDir(), f));
|
|
318
|
+
// The file name is the normalised handle; a record whose handle disagrees is ignored.
|
|
319
|
+
if (rec && `${normHandle(rec.handle)}.json` === f)
|
|
320
|
+
out.push(rec);
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
/** normalised handle → session, the shape routeHits takes. */
|
|
325
|
+
export function readAliases() {
|
|
326
|
+
return new Map(listAliases().map((a) => [normHandle(a.handle), a.session]));
|
|
327
|
+
}
|
|
109
328
|
/** Sessions with a running listener; stale entries (dead pid) are pruned. */
|
|
110
329
|
export function liveSessions() {
|
|
111
330
|
if (!existsSync(liveDir()))
|
|
@@ -57,10 +57,19 @@ export interface MnemomKeyDeps {
|
|
|
57
57
|
say(line: string): void;
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
|
-
* The Mnemom key
|
|
61
|
-
*
|
|
60
|
+
* The Mnemom key this machine already has — MNEMOM_AGENT_MNEMOM_KEY, else the stored key — or
|
|
61
|
+
* undefined. NEVER mints. An unpinned api-key launch sends it (`x-mnemom-api-key`) alongside its
|
|
62
|
+
* Anthropic key when there is one, so the gateway can serve the key owner's claimed agent of that
|
|
63
|
+
* name instead of a fresh one per rotated Anthropic key; with none it launches exactly as before.
|
|
62
64
|
*/
|
|
63
|
-
export declare function
|
|
65
|
+
export declare function readMnemomKey(deps: Pick<MnemomKeyDeps, "env" | "readStored">): string | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* The Mnemom key a subscription launch (or a pinned launch, which the gateway honours only with
|
|
68
|
+
* a verified Mnemom key) sends: MNEMOM_AGENT_MNEMOM_KEY, else the stored key, else mint one
|
|
69
|
+
* (gateway scope only) and store it. The key is never printed. `purpose` names why in the
|
|
70
|
+
* one-line notes ("subscription login" by default).
|
|
71
|
+
*/
|
|
72
|
+
export declare function resolveSubscriptionMnemomKey(deps: MnemomKeyDeps, purpose?: string): Promise<string>;
|
|
64
73
|
/**
|
|
65
74
|
* Remote Control on a subscription login is open to accounts entitled to Remote Control
|
|
66
75
|
* (internal Mnemom accounts): the RC proxy then forwards the user's own OAuth bearer to the
|