@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.
Files changed (45) hide show
  1. package/dist/commands/agent-config.js +9 -0
  2. package/dist/commands/agent-doctor.d.ts +3 -2
  3. package/dist/commands/agent-doctor.js +25 -9
  4. package/dist/commands/agent-goal.js +7 -2
  5. package/dist/commands/agent-sessions.js +2 -1
  6. package/dist/commands/agent-setup.d.ts +166 -9
  7. package/dist/commands/agent-setup.js +719 -54
  8. package/dist/commands/agent-slack.d.ts +4 -0
  9. package/dist/commands/agent-slack.js +67 -21
  10. package/dist/commands/agent.d.ts +29 -1
  11. package/dist/commands/agent.js +133 -17
  12. package/dist/commands/wrap.d.ts +2 -0
  13. package/dist/commands/wrap.js +13 -1
  14. package/dist/index.js +19 -5
  15. package/dist/lib/agent-identity-route.d.ts +21 -3
  16. package/dist/lib/agent-identity-route.js +42 -15
  17. package/dist/lib/agent-ledger.d.ts +5 -0
  18. package/dist/lib/agent.d.ts +75 -8
  19. package/dist/lib/agent.js +83 -14
  20. package/dist/lib/api.d.ts +33 -1
  21. package/dist/lib/api.js +10 -0
  22. package/dist/lib/experimental.js +14 -2
  23. package/dist/lib/goal-api.d.ts +3 -3
  24. package/dist/lib/goal-api.js +5 -2
  25. package/dist/lib/launcher-config.d.ts +18 -0
  26. package/dist/lib/launcher-config.js +47 -1
  27. package/dist/lib/model-tier.d.ts +75 -0
  28. package/dist/lib/model-tier.js +166 -0
  29. package/dist/lib/oauth.d.ts +7 -0
  30. package/dist/lib/oauth.js +92 -17
  31. package/dist/lib/served-agent-cache.d.ts +8 -1
  32. package/dist/lib/served-agent-cache.js +11 -2
  33. package/dist/lib/slack-channel.d.ts +1 -0
  34. package/dist/lib/slack-channel.js +30 -12
  35. package/dist/lib/slack-route.d.ts +72 -9
  36. package/dist/lib/slack-route.js +113 -24
  37. package/dist/lib/slack-router.d.ts +37 -2
  38. package/dist/lib/slack-router.js +98 -13
  39. package/dist/lib/slack-state.d.ts +105 -4
  40. package/dist/lib/slack-state.js +230 -11
  41. package/dist/lib/subscription-login.d.ts +12 -3
  42. package/dist/lib/subscription-login.js +19 -7
  43. package/dist/mcp/handoff-server.mjs +31 -3
  44. package/dist/rc-proxy.mjs +14 -0
  45. package/package.json +1 -1
@@ -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; each
9
- * session's listener is the only reader of its own. No secret is stored here.
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[];
@@ -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; each
9
- * session's listener is the only reader of its own. No secret is stored here.
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
- const path = inboxPath(session);
73
- if (!existsSync(path))
74
- return { messages: [], offset: 0 };
75
- const buf = readFileSync(path);
76
- if (buf.length < offset)
77
- offset = 0; // truncated/rotated
78
- const chunk = buf.subarray(offset).toString("utf8");
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 a subscription launch sends: MNEMOM_AGENT_MNEMOM_KEY, else the stored
61
- * key, else mint one (gateway scope only) and store it. The key is never printed.
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 resolveSubscriptionMnemomKey(deps: MnemomKeyDeps): Promise<string>;
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
@@ -72,30 +72,42 @@ export function resolveAgentAuth(flag, env) {
72
72
  return fromEnv ? coerceAgentAuth(fromEnv) : "api-key";
73
73
  }
74
74
  /**
75
- * The Mnemom key a subscription launch sends: MNEMOM_AGENT_MNEMOM_KEY, else the stored
76
- * key, else mint one (gateway scope only) and store it. The key is never printed.
75
+ * The Mnemom key this machine already has — MNEMOM_AGENT_MNEMOM_KEY, else the stored key — or
76
+ * undefined. NEVER mints. An unpinned api-key launch sends it (`x-mnemom-api-key`) alongside its
77
+ * Anthropic key when there is one, so the gateway can serve the key owner's claimed agent of that
78
+ * name instead of a fresh one per rotated Anthropic key; with none it launches exactly as before.
77
79
  */
78
- export async function resolveSubscriptionMnemomKey(deps) {
80
+ export function readMnemomKey(deps) {
79
81
  const fromEnv = headerSafe(deps.env[MNEMOM_KEY_ENV] ?? "");
80
82
  if (fromEnv)
81
83
  return fromEnv;
82
84
  const stored = headerSafe(deps.readStored() ?? "");
83
- if (stored)
84
- return stored;
85
+ return stored || undefined;
86
+ }
87
+ /**
88
+ * The Mnemom key a subscription launch (or a pinned launch, which the gateway honours only with
89
+ * a verified Mnemom key) sends: MNEMOM_AGENT_MNEMOM_KEY, else the stored key, else mint one
90
+ * (gateway scope only) and store it. The key is never printed. `purpose` names why in the
91
+ * one-line notes ("subscription login" by default).
92
+ */
93
+ export async function resolveSubscriptionMnemomKey(deps, purpose = "subscription login") {
94
+ const existing = readMnemomKey(deps);
95
+ if (existing)
96
+ return existing;
85
97
  let minted;
86
98
  try {
87
99
  minted = headerSafe(await deps.mint());
88
100
  }
89
101
  catch (err) {
90
102
  const msg = err instanceof Error ? err.message : String(err);
91
- throw new Error(`mnemom agent: subscription login needs a Mnemom API key and minting one failed (${msg}).\n` +
103
+ throw new Error(`mnemom agent: ${purpose} needs a Mnemom API key and minting one failed (${msg}).\n` +
92
104
  ` Create one with \`mnemom api-key create --name mnemom-agent --scopes gateway\` and set ${MNEMOM_KEY_ENV}.`, { cause: err });
93
105
  }
94
106
  if (!minted)
95
107
  throw new Error("mnemom agent: the API returned an empty Mnemom key.");
96
108
  try {
97
109
  deps.store(minted);
98
- deps.say("mnemom agent: minted a gateway-only Mnemom API key for subscription login (saved to ~/.mnemom/launcher.json, 0600).");
110
+ deps.say(`mnemom agent: minted a gateway-only Mnemom API key for ${purpose} (saved to ~/.mnemom/launcher.json, 0600).`);
99
111
  }
100
112
  catch {
101
113
  deps.say("mnemom agent: minted a gateway-only Mnemom API key for this launch but could not save it — the next launch mints another.");
@@ -85,11 +85,31 @@ function resolveKeyFromEnvName(name, env) {
85
85
  return value.trim();
86
86
  }
87
87
 
88
+ /** A full Mnemom agent id (mirrors AGENT_ID_RE in cli/src/lib/agent.ts). */
89
+ const AGENT_ID_RE = /^mnm-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
90
+
91
+ /**
92
+ * The Mnemom key the launch's own requests carry, when it has one: the `x-mnemom-api-key:` line
93
+ * of the inherited `ANTHROPIC_CUSTOM_HEADERS` (terminal), else the dedicated
94
+ * `MNEMOM_HANDOFF_MNEMOM_KEY` the Remote Control launcher sets. "" when there is none. A pinned
95
+ * call needs it: the gateway honours `x-mnemom-agent-id` only with a verified Mnemom key.
96
+ * @param {string} keyEnvName
97
+ * @param {NodeJS.ProcessEnv} env
98
+ * @returns {string}
99
+ */
100
+ function resolveMnemomKey(keyEnvName, env) {
101
+ if (keyEnvName === "ANTHROPIC_CUSTOM_HEADERS") {
102
+ const m = (env.ANTHROPIC_CUSTOM_HEADERS ?? "").match(/^x-mnemom-api-key:\s*(.+)$/im);
103
+ return m ? m[1].trim() : "";
104
+ }
105
+ return (env.MNEMOM_HANDOFF_MNEMOM_KEY ?? "").trim();
106
+ }
107
+
88
108
  /**
89
109
  * Read the tool's config from env. Throws (message never contains the key value) when a
90
110
  * required var is missing, or when the named key-source var resolves to no usable key.
91
111
  * @param {NodeJS.ProcessEnv} env
92
- * @returns {{gatewayHost: string, anthropicKey: string, agent: string, conversationId: string, sessionConfigB64?: string}}
112
+ * @returns {{gatewayHost: string, anthropicKey: string, mnemomKey?: string, agent: string, agentId?: string, conversationId: string, sessionConfigB64?: string}}
93
113
  */
94
114
  export function configFromEnv(env) {
95
115
  const missing = REQUIRED_ENV.filter((k) => !(env[k] ?? "").trim());
@@ -104,10 +124,16 @@ export function configFromEnv(env) {
104
124
  "process's own inherited environment has no usable value for it.",
105
125
  );
106
126
  }
127
+ const agentId = (env.MNEMOM_HANDOFF_AGENT_ID ?? "").trim().toLowerCase();
128
+ if (agentId && !AGENT_ID_RE.test(agentId)) {
129
+ throw new Error("mnemom-handoff: MNEMOM_HANDOFF_AGENT_ID is not a full mnm-<uuid> agent id.");
130
+ }
107
131
  return {
108
132
  gatewayHost: env.MNEMOM_HANDOFF_GATEWAY_HOST.trim(),
109
133
  anthropicKey,
134
+ mnemomKey: resolveMnemomKey(keyEnvName, env) || undefined,
110
135
  agent: env.MNEMOM_HANDOFF_AGENT.trim(),
136
+ agentId: agentId || undefined,
111
137
  conversationId: env.MNEMOM_HANDOFF_CONVERSATION_ID.trim(),
112
138
  sessionConfigB64: env.MNEMOM_HANDOFF_SESSION_CONFIG?.trim() || undefined,
113
139
  };
@@ -115,7 +141,7 @@ export function configFromEnv(env) {
115
141
 
116
142
  /**
117
143
  * Build the fetch call. Exported so a test can assert the wire shape without mocking fetch.
118
- * @param {{gatewayHost: string, anthropicKey: string, agent: string, conversationId: string, sessionConfigB64?: string}} cfg
144
+ * @param {{gatewayHost: string, anthropicKey: string, mnemomKey?: string, agent: string, agentId?: string, conversationId: string, sessionConfigB64?: string}} cfg
119
145
  * @param {string} note
120
146
  */
121
147
  export function buildHandoffRequest(cfg, note) {
@@ -124,6 +150,8 @@ export function buildHandoffRequest(cfg, note) {
124
150
  "x-api-key": cfg.anthropicKey,
125
151
  "x-mnemom-agent": cfg.agent,
126
152
  };
153
+ if (cfg.mnemomKey) headers["x-mnemom-api-key"] = cfg.mnemomKey;
154
+ if (cfg.agentId) headers["x-mnemom-agent-id"] = cfg.agentId;
127
155
  if (cfg.sessionConfigB64) headers["x-mnemom-session-config"] = cfg.sessionConfigB64;
128
156
  return {
129
157
  url: `${cfg.gatewayHost.replace(/\/+$/, "")}${HANDOFF_PATH}`,
@@ -150,7 +178,7 @@ function extractGatewayError(body) {
150
178
  * Call the route and render the result. NEVER throws: a network failure, a non-2xx status, or
151
179
  * an unparseable body all come back as `{ok:false, message}` for the caller to surface as an
152
180
  * MCP tool error — the tool must never crash the server or the session over a bad response.
153
- * @param {{gatewayHost: string, anthropicKey: string, agent: string, conversationId: string, sessionConfigB64?: string}} cfg
181
+ * @param {{gatewayHost: string, anthropicKey: string, mnemomKey?: string, agent: string, agentId?: string, conversationId: string, sessionConfigB64?: string}} cfg
154
182
  * @param {string} note
155
183
  * @param {typeof fetch} fetchImpl
156
184
  */
package/dist/rc-proxy.mjs CHANGED
@@ -62,6 +62,9 @@
62
62
  // CONFIG (env, none secret)
63
63
  // MNEMOM_AGENT_RC_DOOR gateway door URL (required), e.g. https://gateway.mnemom.ai/anthropic
64
64
  // MNEMOM_AGENT_RC_AGENT x-mnemom-agent value (required)
65
+ // MNEMOM_AGENT_RC_AGENT_ID x-mnemom-agent-id value (optional; a full mnm-<uuid> pin — the
66
+ // gateway then serves exactly that agent or refuses; needs the
67
+ // Mnemom key on stdin line 2)
65
68
  // MNEMOM_AGENT_RC_CONVERSATION_ID x-mnemom-conversation-id value (required)
66
69
  // MNEMOM_AGENT_RC_CONTRACT_B64 base64 contract for x-mnemom-contract (optional)
67
70
  // MNEMOM_AGENT_RC_GOAL_MODE x-mnemom-goal-mode value (optional; `--implicit` — no contract,
@@ -111,6 +114,12 @@ try {
111
114
  }
112
115
  if (GATEWAY.protocol !== "https:") die("MNEMOM_AGENT_RC_DOOR must be https");
113
116
  const AGENT = env.MNEMOM_AGENT_RC_AGENT || die("MNEMOM_AGENT_RC_AGENT is required");
117
+ const AGENT_ID = (env.MNEMOM_AGENT_RC_AGENT_ID || "").trim().toLowerCase();
118
+ if (
119
+ AGENT_ID &&
120
+ !/^mnm-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(AGENT_ID)
121
+ )
122
+ die("MNEMOM_AGENT_RC_AGENT_ID must be a full mnm-<uuid> agent id");
114
123
  const CONV =
115
124
  env.MNEMOM_AGENT_RC_CONVERSATION_ID || die("MNEMOM_AGENT_RC_CONVERSATION_ID is required");
116
125
  const CONTRACT_B64 = env.MNEMOM_AGENT_RC_CONTRACT_B64 || "";
@@ -168,6 +177,8 @@ try {
168
177
  } catch {
169
178
  die("expected three lines of secrets on stdin (anthropic, mnemom, openai)");
170
179
  }
180
+ if (AGENT_ID && !secrets.mnemom)
181
+ die("a pinned agent (MNEMOM_AGENT_RC_AGENT_ID) needs the Mnemom key (stdin line 2)");
171
182
  if (SUBSCRIPTION) {
172
183
  if (!secrets.mnemom) die("subscription login needs the Mnemom key (stdin line 2)");
173
184
  } else if (!secrets.anthropic) die("the Anthropic key (stdin line 1) is empty");
@@ -519,6 +530,9 @@ function handle(req, res, viaPlain) {
519
530
  headers["x-api-key"] = secrets.anthropic;
520
531
  }
521
532
  headers["x-mnemom-agent"] = AGENT;
533
+ // The pin is this proxy's to state: a client-supplied one is dropped, like x-mnemom-turn.
534
+ delete headers["x-mnemom-agent-id"];
535
+ if (AGENT_ID) headers["x-mnemom-agent-id"] = AGENT_ID;
522
536
  headers["x-mnemom-conversation-id"] = CONV;
523
537
  if (secrets.mnemom) headers["x-mnemom-api-key"] = secrets.mnemom;
524
538
  if (PROVIDER) headers["x-mnemom-provider"] = PROVIDER;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.25",
3
+ "version": "0.17.27",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {