@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
|
@@ -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
|
|
@@ -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
|
|
76
|
-
*
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
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:
|
|
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(
|
|
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;
|