@mnemom/mnemom 0.17.34-next.4 → 0.17.35
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 +3 -0
- package/dist/commands/agent-goal.d.ts +8 -3
- package/dist/commands/agent-goal.js +22 -8
- package/dist/commands/agent-run.d.ts +10 -2
- package/dist/commands/agent-run.js +19 -6
- package/dist/commands/agent-slack.js +32 -0
- package/dist/commands/durable-inbox.d.ts +51 -0
- package/dist/commands/durable-inbox.js +134 -0
- package/dist/commands/durable-questions.d.ts +64 -0
- package/dist/commands/durable-questions.js +161 -0
- package/dist/durable/seat-proxy.d.ts +12 -1
- package/dist/durable/seat-proxy.js +55 -9
- package/dist/durable/seat-socket.d.ts +20 -2
- package/dist/durable/seat-socket.js +58 -0
- package/dist/durable/turn-chain.d.ts +8 -0
- package/dist/durable/turn-chain.js +25 -4
- package/dist/host/client.d.ts +41 -0
- package/dist/host/client.js +7 -0
- package/dist/host/control.d.ts +7 -0
- package/dist/host/control.js +15 -0
- package/dist/host/daemon.d.ts +20 -0
- package/dist/host/daemon.js +74 -1
- package/dist/host/inbox-delivery.d.ts +174 -0
- package/dist/host/inbox-delivery.js +453 -0
- package/dist/host/run.d.ts +18 -0
- package/dist/host/run.js +61 -0
- package/dist/host/s1-names.d.ts +2 -0
- package/dist/host/s1-names.js +2 -0
- package/dist/host/tmux.d.ts +7 -0
- package/dist/host/tmux.js +19 -0
- package/dist/index.js +59 -1
- package/dist/lib/api.d.ts +5 -0
- package/dist/lib/api.js +16 -0
- package/dist/lib/durable/api.d.ts +11 -0
- package/dist/lib/durable/api.js +28 -3
- package/dist/lib/durable/gate.d.ts +1 -1
- package/dist/lib/durable/gate.js +8 -1
- package/dist/lib/durable/live.d.ts +5 -0
- package/dist/lib/durable/live.js +19 -0
- package/dist/lib/experimental.js +3 -0
- package/dist/lib/goal-api.d.ts +11 -4
- package/dist/lib/goal-api.js +4 -3
- package/dist/lib/launcher-config.d.ts +5 -0
- package/dist/lib/launcher-config.js +17 -2
- package/dist/lib/prompt.d.ts +4 -2
- package/dist/lib/prompt.js +15 -5
- package/dist/lib/runner/job.d.ts +2 -2
- package/dist/lib/runner/job.js +16 -3
- package/dist/lib/runner/pr-body.d.ts +17 -0
- package/dist/lib/runner/pr-body.js +42 -0
- package/dist/lib/runner/proxy.d.ts +18 -3
- package/dist/lib/runner/proxy.js +24 -5
- package/dist/lib/scan/record.d.ts +60 -9
- package/dist/lib/scan/record.js +157 -39
- package/dist/lib/scan/report.d.ts +6 -8
- package/dist/lib/scan/report.js +21 -7
- package/dist/lib/scan/share.d.ts +5 -1
- package/dist/lib/scan/share.js +15 -5
- package/dist/lib/slack-durable.d.ts +123 -0
- package/dist/lib/slack-durable.js +237 -0
- package/dist/lib/slack-router.d.ts +7 -0
- package/dist/lib/slack-router.js +50 -4
- package/dist/vendor/durable-contract/index.js +992 -34
- package/dist/vendor/shared/durable-contract/admission.d.ts +81 -2
- package/dist/vendor/shared/durable-contract/admission.js +319 -2
- package/dist/vendor/shared/durable-contract/cli-surface.js +21 -0
- package/dist/vendor/shared/durable-contract/codes.d.ts +60 -0
- package/dist/vendor/shared/durable-contract/codes.js +22 -0
- package/dist/vendor/shared/durable-contract/events.d.ts +4 -2
- package/dist/vendor/shared/durable-contract/events.js +55 -1
- package/dist/vendor/shared/durable-contract/freeze-record.d.ts +14 -0
- package/dist/vendor/shared/durable-contract/inbox.d.ts +299 -0
- package/dist/vendor/shared/durable-contract/inbox.js +462 -0
- package/dist/vendor/shared/durable-contract/index.d.ts +2 -0
- package/dist/vendor/shared/durable-contract/index.js +2 -0
- package/dist/vendor/shared/durable-contract/question.d.ts +218 -0
- package/dist/vendor/shared/durable-contract/question.js +373 -0
- package/dist/vendor/shared/durable-contract/routes.d.ts +44 -2
- package/dist/vendor/shared/durable-contract/routes.js +43 -1
- package/dist/vendor/shared/durable-contract/turn.d.ts +15 -1
- package/dist/vendor/shared/durable-contract/turn.js +33 -1
- package/dist/vendor/shared/durable-contract/vectors.d.ts +45 -0
- package/dist/vendor/shared/durable-contract/vectors.js +49 -0
- package/package.json +1 -1
- package/plugins/integrity-manifest.json +6 -2
- package/plugins/mnemom/hooks/hooks.json +12 -0
- package/plugins/mnemom/hooks/seat-ask.mjs +221 -0
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* index.ts wires in. Key material is never printed.
|
|
18
18
|
*/
|
|
19
19
|
import { type SessionRecord } from "../lib/agent-ledger.js";
|
|
20
|
+
import { type LauncherConfig } from "../lib/launcher-config.js";
|
|
20
21
|
import { type FetchLike, type GoalControlCreds, type GoalShowResponse } from "../lib/goal-api.js";
|
|
21
22
|
export interface GoalCommandDeps {
|
|
22
23
|
/** The ledger, newest first. */
|
|
@@ -30,10 +31,14 @@ export interface GoalCommandDeps {
|
|
|
30
31
|
err: (line: string) => void;
|
|
31
32
|
}
|
|
32
33
|
/**
|
|
33
|
-
* The
|
|
34
|
-
*
|
|
34
|
+
* The credential the launch would resolve, never prompted for. The auth mode resolves as the
|
|
35
|
+
* launch does (MNEMOM_AGENT_AUTH, else launcher.toml `auth` folded in under env, else api-key;
|
|
36
|
+
* host/seat-credential.ts does the same). api-key: the Anthropic key (env, then
|
|
37
|
+
* ~/.mnemom/launcher.json) plus the Mnemom key sent beside it. subscription: the launch had no
|
|
38
|
+
* Anthropic key and anchored on its Mnemom key, which the gateway control route accepts as the
|
|
39
|
+
* session credential. Lazy import: commands/agent.ts is the launcher and carries a heavy import graph.
|
|
35
40
|
*/
|
|
36
|
-
export declare function resolveLaunchCreds(): Promise<GoalControlCreds>;
|
|
41
|
+
export declare function resolveLaunchCreds(env?: NodeJS.ProcessEnv, loadConfig?: () => LauncherConfig): Promise<GoalControlCreds>;
|
|
37
42
|
export declare const liveGoalDeps: GoalCommandDeps;
|
|
38
43
|
export interface GoalShowOptions {
|
|
39
44
|
thread?: string;
|
|
@@ -18,22 +18,36 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { readSessionRecords } from "../lib/agent-ledger.js";
|
|
20
20
|
import { MNEMOM_DIR } from "../lib/config.js";
|
|
21
|
+
import { applyConfigToEnv, loadLauncherConfig, } from "../lib/launcher-config.js";
|
|
21
22
|
import { fmt, formatRelativeAge } from "../lib/format.js";
|
|
22
23
|
import { GoalControlError, goalControlFetch, isValidGoalThread, } from "../lib/goal-api.js";
|
|
23
24
|
import { askYesNo, isInteractive } from "../lib/prompt.js";
|
|
24
25
|
import { localGoalFlag, resolveSessionRef } from "./agent-sessions.js";
|
|
25
26
|
/**
|
|
26
|
-
* The
|
|
27
|
-
*
|
|
27
|
+
* The credential the launch would resolve, never prompted for. The auth mode resolves as the
|
|
28
|
+
* launch does (MNEMOM_AGENT_AUTH, else launcher.toml `auth` folded in under env, else api-key;
|
|
29
|
+
* host/seat-credential.ts does the same). api-key: the Anthropic key (env, then
|
|
30
|
+
* ~/.mnemom/launcher.json) plus the Mnemom key sent beside it. subscription: the launch had no
|
|
31
|
+
* Anthropic key and anchored on its Mnemom key, which the gateway control route accepts as the
|
|
32
|
+
* session credential. Lazy import: commands/agent.ts is the launcher and carries a heavy import graph.
|
|
28
33
|
*/
|
|
29
|
-
export async function resolveLaunchCreds() {
|
|
34
|
+
export async function resolveLaunchCreds(env = process.env, loadConfig = loadLauncherConfig) {
|
|
30
35
|
const { defaultMnemomKeyDeps, resolveAnthropicKey } = await import("./agent.js");
|
|
31
|
-
const { readMnemomKey } = await import("../lib/subscription-login.js");
|
|
36
|
+
const { readMnemomKey, resolveAgentAuth } = await import("../lib/subscription-login.js");
|
|
37
|
+
const folded = { ...env };
|
|
38
|
+
applyConfigToEnv(loadConfig(), folded);
|
|
39
|
+
// The Mnemom key the launch sent (env, else the store; never minted here) — a pinned session's
|
|
40
|
+
// control calls need it, and every call is then asked as the launch was.
|
|
41
|
+
const mnemomKey = readMnemomKey(defaultMnemomKeyDeps());
|
|
42
|
+
if (resolveAgentAuth(undefined, folded) === "subscription") {
|
|
43
|
+
if (!mnemomKey) {
|
|
44
|
+
throw new Error("auth = subscription and no Mnemom key in env (MNEMOM_AGENT_MNEMOM_KEY) or ~/.mnemom/launcher.json — " +
|
|
45
|
+
"the goal commands authenticate with the Mnemom key the session launched with");
|
|
46
|
+
}
|
|
47
|
+
return { mnemomKey };
|
|
48
|
+
}
|
|
32
49
|
try {
|
|
33
|
-
const anthropicKey = await resolveAnthropicKey(
|
|
34
|
-
// The Mnemom key the launch sent beside it (env, else the store; never minted here) — a
|
|
35
|
-
// pinned session's control calls need it, and every call is then asked as the launch was.
|
|
36
|
-
const mnemomKey = readMnemomKey(defaultMnemomKeyDeps());
|
|
50
|
+
const anthropicKey = await resolveAnthropicKey(folded, { allowPrompt: false });
|
|
37
51
|
return mnemomKey ? { anthropicKey, mnemomKey } : { anthropicKey };
|
|
38
52
|
}
|
|
39
53
|
catch {
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The GitHub token (GH_TOKEN, GITHUB_TOKEN, else `gh auth token`) is the runner's own: it reaches
|
|
11
11
|
* git only through GIT_CONFIG_* env (never argv) and the phases never see it (phaseEnv).
|
|
12
|
+
*
|
|
13
|
+
* So is the Anthropic key (ruling 34.30.1): the phases' model calls are billed to the runner's own
|
|
14
|
+
* ANTHROPIC_API_KEY, which only the model proxy holds and sets on each upstream request. A runner
|
|
15
|
+
* without one refuses at start, before any credential exchange or claim. The key is read once per
|
|
16
|
+
* process: after rotating it, restart a `--watch` runner.
|
|
12
17
|
*/
|
|
13
18
|
import { type FetchFn, type RunnerApi } from "../lib/runner/executor-client.js";
|
|
14
19
|
import { type GithubBases } from "../lib/runner/github-base.js";
|
|
@@ -27,8 +32,10 @@ export interface RunDeps {
|
|
|
27
32
|
stop: AbortSignal;
|
|
28
33
|
/** The text of this host's executor-state.json, or null when there is none. */
|
|
29
34
|
readExecutorState?(): Promise<string | null>;
|
|
30
|
-
jobDeps(api: RunnerApi, executorId: string | null, github: GithubBases): JobDeps;
|
|
35
|
+
jobDeps(api: RunnerApi, executorId: string | null, github: GithubBases, modelKey: string): JobDeps;
|
|
31
36
|
}
|
|
37
|
+
/** The refusal of a runner with no ANTHROPIC_API_KEY (34.30.1); a config error, exit 2. */
|
|
38
|
+
export declare const RUNNER_MODEL_KEY_MISSING = "runner_model_key_missing";
|
|
32
39
|
/** Resolves the process exit code; never throws for a usage, config or Mnemom error. */
|
|
33
40
|
export declare function agentRunCommand(flags: RunFlags, deps: RunDeps): Promise<number>;
|
|
34
41
|
export declare const GITHUB_API: string;
|
|
@@ -45,7 +52,7 @@ export declare function cleanupWorkspace(ws: Workspace): Promise<void>;
|
|
|
45
52
|
/** Commits on the job branch that no origin ref has: the clone's base is origin's default branch. */
|
|
46
53
|
export declare function commitsAhead(git: Git, ws: Workspace): Promise<number>;
|
|
47
54
|
/** Push the job branch and open its PR against the repo's default branch; resolves its URL. */
|
|
48
|
-
export declare function openPr(git: Git, fetch: FetchFn, token: string | null, repo: string, ws: Workspace, title: string, apiBase?: string): Promise<string>;
|
|
55
|
+
export declare function openPr(git: Git, fetch: FetchFn, token: string | null, repo: string, ws: Workspace, title: string, body: string, apiBase?: string): Promise<string>;
|
|
49
56
|
/** merge_on_green: GitHub's auto-merge on the PR (squash). */
|
|
50
57
|
export declare function enableAutoMerge(fetch: FetchFn, token: string | null, prUrl: string, bases?: GithubBases): Promise<void>;
|
|
51
58
|
/** Run one phase's process in the job's working copy; stdout goes to our stderr, never stdout. */
|
|
@@ -55,6 +62,7 @@ export declare function liveJobDeps(api: RunnerApi, opts: {
|
|
|
55
62
|
env: NodeJS.ProcessEnv;
|
|
56
63
|
executorId: string | null;
|
|
57
64
|
github: GithubBases;
|
|
65
|
+
modelKey: string;
|
|
58
66
|
}): JobDeps;
|
|
59
67
|
/** The process wiring: signals, stdout and stderr. Resolves the exit code. */
|
|
60
68
|
export declare function runAgentRun(flags: RunFlags): Promise<number>;
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The GitHub token (GH_TOKEN, GITHUB_TOKEN, else `gh auth token`) is the runner's own: it reaches
|
|
11
11
|
* git only through GIT_CONFIG_* env (never argv) and the phases never see it (phaseEnv).
|
|
12
|
+
*
|
|
13
|
+
* So is the Anthropic key (ruling 34.30.1): the phases' model calls are billed to the runner's own
|
|
14
|
+
* ANTHROPIC_API_KEY, which only the model proxy holds and sets on each upstream request. A runner
|
|
15
|
+
* without one refuses at start, before any credential exchange or claim. The key is read once per
|
|
16
|
+
* process: after rotating it, restart a `--watch` runner.
|
|
12
17
|
*/
|
|
13
18
|
import { spawn, execFile } from "node:child_process";
|
|
14
19
|
import { randomUUID } from "node:crypto";
|
|
@@ -28,6 +33,8 @@ import { resolveRunPlan } from "../lib/runner/options.js";
|
|
|
28
33
|
import { startModelProxy } from "../lib/runner/proxy.js";
|
|
29
34
|
import { resolveGithubToken } from "./agent-fuel.js";
|
|
30
35
|
import { executorStatePath } from "../vendor/durable-contract/index.js";
|
|
36
|
+
/** The refusal of a runner with no ANTHROPIC_API_KEY (34.30.1); a config error, exit 2. */
|
|
37
|
+
export const RUNNER_MODEL_KEY_MISSING = "runner_model_key_missing";
|
|
31
38
|
/** Resolves the process exit code; never throws for a usage, config or Mnemom error. */
|
|
32
39
|
export async function agentRunCommand(flags, deps) {
|
|
33
40
|
const resolved = resolveRunPlan(flags, deps.env);
|
|
@@ -38,6 +45,12 @@ export async function agentRunCommand(flags, deps) {
|
|
|
38
45
|
const plan = resolved.plan;
|
|
39
46
|
for (const w of plan.warnings)
|
|
40
47
|
deps.err(w);
|
|
48
|
+
const modelKey = deps.env.ANTHROPIC_API_KEY?.trim();
|
|
49
|
+
if (!modelKey) {
|
|
50
|
+
deps.err(`mnemom agent run: refused (${RUNNER_MODEL_KEY_MISSING}): this runner has no ANTHROPIC_API_KEY for its phases' model calls; nothing was claimed`);
|
|
51
|
+
deps.err(" fix: set ANTHROPIC_API_KEY on this machine or as a CI secret");
|
|
52
|
+
return USAGE_EXIT_CODE;
|
|
53
|
+
}
|
|
41
54
|
const gh = resolveGithubBases(deps.env);
|
|
42
55
|
if (gh.warning)
|
|
43
56
|
deps.err(gh.warning);
|
|
@@ -70,7 +83,7 @@ export async function agentRunCommand(flags, deps) {
|
|
|
70
83
|
}
|
|
71
84
|
const api = runnerApi({ apiUrl: deps.apiUrl, executorKey, fetch: deps.fetch });
|
|
72
85
|
const loop = { failMode: plan.failMode, stop: deps.stop, emit: deps.emit };
|
|
73
|
-
const job = deps.jobDeps(api, executorId, gh.bases);
|
|
86
|
+
const job = deps.jobDeps(api, executorId, gh.bases, modelKey);
|
|
74
87
|
return plan.form === "once" ? runOnce(job, loop) : runWatch(job, loop);
|
|
75
88
|
}
|
|
76
89
|
// ── GitHub and git, for the live driver ──────────────────────────────────────
|
|
@@ -146,14 +159,14 @@ export async function commitsAhead(git, ws) {
|
|
|
146
159
|
return n;
|
|
147
160
|
}
|
|
148
161
|
/** Push the job branch and open its PR against the repo's default branch; resolves its URL. */
|
|
149
|
-
export async function openPr(git, fetch, token, repo, ws, title, apiBase = GITHUB_BASES.api) {
|
|
162
|
+
export async function openPr(git, fetch, token, repo, ws, title, body, apiBase = GITHUB_BASES.api) {
|
|
150
163
|
await git(ws.dir, ["push", "--quiet", "origin", `HEAD:refs/heads/${ws.branch}`]);
|
|
151
164
|
const meta = await github(fetch, token, "GET", `/repos/${repo}`, undefined, apiBase);
|
|
152
165
|
const base = meta.json.default_branch;
|
|
153
166
|
if (meta.status !== 200 || typeof base !== "string") {
|
|
154
167
|
throw new Error(`reading ${repo} answered ${meta.status}`);
|
|
155
168
|
}
|
|
156
|
-
const pr = await github(fetch, token, "POST", `/repos/${repo}/pulls`, { title, head: ws.branch, base }, apiBase);
|
|
169
|
+
const pr = await github(fetch, token, "POST", `/repos/${repo}/pulls`, { title, body, head: ws.branch, base }, apiBase);
|
|
157
170
|
const url = pr.json.html_url;
|
|
158
171
|
if (pr.status !== 201 || typeof url !== "string") {
|
|
159
172
|
throw new Error(`opening the PR answered ${pr.status}`);
|
|
@@ -214,10 +227,10 @@ export function liveJobDeps(api, opts) {
|
|
|
214
227
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
215
228
|
log: (line) => process.stderr.write(`mnemom agent run: ${line}\n`),
|
|
216
229
|
gatewayUrl: `${getGatewayUrl()}/anthropic`,
|
|
217
|
-
startProxy: (state) => startModelProxy(`${getGatewayUrl()}/anthropic`, state),
|
|
230
|
+
startProxy: (state) => startModelProxy(`${getGatewayUrl()}/anthropic`, state, opts.modelKey),
|
|
218
231
|
prepareWorkspace: (repo, sessionId) => prepareWorkspace(git, `${gh.web}/${repo}.git`, sessionId),
|
|
219
232
|
runPhase: (inv, ws, modelUrl, signal) => runPhaseProcess(inv, ws, modelUrl, signal, opts.env),
|
|
220
|
-
openPr: (repo, ws, title) => openPr(git, opts.fetch, token, repo, ws, title, gh.api),
|
|
233
|
+
openPr: (repo, ws, title, body) => openPr(git, opts.fetch, token, repo, ws, title, body, gh.api),
|
|
221
234
|
commitsAhead: (ws) => commitsAhead(git, ws),
|
|
222
235
|
enableAutoMerge: (prUrl) => enableAutoMerge(opts.fetch, token, prUrl, gh),
|
|
223
236
|
cleanup: cleanupWorkspace,
|
|
@@ -238,7 +251,7 @@ export function runAgentRun(flags) {
|
|
|
238
251
|
err: (line) => process.stderr.write(`${line}\n`),
|
|
239
252
|
stop: stop.signal,
|
|
240
253
|
readExecutorState: () => readFile(executorStatePath(homedir()), "utf8").catch(() => null),
|
|
241
|
-
jobDeps: (api, executorId, github) => liveJobDeps(api, { fetch, env: process.env, executorId, github }),
|
|
254
|
+
jobDeps: (api, executorId, github, modelKey) => liveJobDeps(api, { fetch, env: process.env, executorId, github, modelKey }),
|
|
242
255
|
}).finally(() => {
|
|
243
256
|
process.off("SIGTERM", onSignal);
|
|
244
257
|
process.off("SIGINT", onSignal);
|
|
@@ -26,6 +26,7 @@ import { mergeStore, readStore } from "../lib/launcher-store.js";
|
|
|
26
26
|
import { askInput, isInteractive } from "../lib/prompt.js";
|
|
27
27
|
import { explainSlackError, looksLikeAppToken, looksLikeBotToken, deleteMessage, postMessage, resolveSlackToken, sessionPosterName, slackAppManifest, slackCall, SLACK_APP_TOKEN_STORE_KEY, SLACK_TOKEN_STORE_KEY, } from "../lib/slack.js";
|
|
28
28
|
import { routerStatus, runRouter } from "../lib/slack-router.js";
|
|
29
|
+
import { DurableSlack, slackDurableConfig } from "../lib/slack-durable.js";
|
|
29
30
|
import { normHandle } from "../lib/slack-route.js";
|
|
30
31
|
import { findOwnPost, freshInbox, listAliases, liveSessions, readInboxFrom, readOffset, recordPost, removeAlias, setAlias, writeOffset, } from "../lib/slack-state.js";
|
|
31
32
|
const out = (line = "") => console.log(line);
|
|
@@ -96,6 +97,9 @@ async function login() {
|
|
|
96
97
|
const me = (await askInput("Your Slack member ID — only allowlisted members can reach a session (U…): ")).trim();
|
|
97
98
|
if (me) {
|
|
98
99
|
configKeySpec("slack_allow")?.set(cfg, me);
|
|
100
|
+
// The member id asked for is the person's own: it is also the owner's (durable inbox).
|
|
101
|
+
if (!cfg.slackOwner?.length)
|
|
102
|
+
configKeySpec("slack_owner")?.set(cfg, me);
|
|
99
103
|
writeLauncherConfig(cfg);
|
|
100
104
|
}
|
|
101
105
|
}
|
|
@@ -129,6 +133,8 @@ async function status() {
|
|
|
129
133
|
out(`sticky threads: ${cfg.slackStickyThreads ? "on" : "off"}`);
|
|
130
134
|
const allow = cfg.slackAllow ?? [];
|
|
131
135
|
out(`allowed senders: ${allow.length ? allow.join(", ") : "none — no message will reach a session"}`);
|
|
136
|
+
const owner = cfg.slackOwner ?? [];
|
|
137
|
+
out(`owner: ${owner.length ? owner.join(", ") : "not set — `mnemom agent config set slack_owner <your member id>` (durable inbox)"}`);
|
|
132
138
|
}
|
|
133
139
|
export const POST_USAGE = `Usage: mnemom agent slack post [options] [--] <text…|->
|
|
134
140
|
|
|
@@ -305,6 +311,7 @@ async function router() {
|
|
|
305
311
|
if (!app)
|
|
306
312
|
throw new Error("mnemom agent slack router: no app-level token — run `mnemom agent slack login`.");
|
|
307
313
|
const cfg = loadLauncherConfig();
|
|
314
|
+
const durable = await routerDurable();
|
|
308
315
|
await runRouter({
|
|
309
316
|
botToken: botToken(),
|
|
310
317
|
appToken: app,
|
|
@@ -312,6 +319,31 @@ async function router() {
|
|
|
312
319
|
// Both off unless the operator turned them on: off, routing is unchanged.
|
|
313
320
|
aliases: cfg.slackAliases === true,
|
|
314
321
|
stickyThreads: cfg.slackStickyThreads === true,
|
|
322
|
+
...(durable ? { durable } : {}),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* S1 amendment 60: with durable on, messages routed to a locked durable session go to its durable
|
|
327
|
+
* inbox (lib/slack-durable.ts). Off: undefined, and the router is unchanged.
|
|
328
|
+
*/
|
|
329
|
+
async function routerDurable() {
|
|
330
|
+
const { loadExperimentalState } = await import("../lib/cli-config.js");
|
|
331
|
+
const { durableOn } = await import("../lib/durable/gate.js");
|
|
332
|
+
if (!durableOn(loadExperimentalState().enabled))
|
|
333
|
+
return undefined;
|
|
334
|
+
const { liveDurableApi } = await import("../lib/durable/live.js");
|
|
335
|
+
const api = liveDurableApi();
|
|
336
|
+
const conf = slackDurableConfig(process.env, loadLauncherConfig().slackOwner ?? []);
|
|
337
|
+
console.error(`[slack-router] durable inbox on: ${conf.ownerSlackIds.length} owner id(s), ${Object.keys(conf.agentSenders).length} agent sender(s)`);
|
|
338
|
+
if (conf.ownerSlackIds.length === 0)
|
|
339
|
+
console.error("[slack-router] no owner Slack id: your own messages to a locked durable session will not be sent. Fix: mnemom agent config set slack_owner <your member id>");
|
|
340
|
+
return new DurableSlack({
|
|
341
|
+
listSessions: (f) => api.listSessions(f),
|
|
342
|
+
send: (cid, agentId, body, credential) => api.inbox("own_inbox_send", cid, agentId, body, credential),
|
|
343
|
+
env: process.env,
|
|
344
|
+
...conf,
|
|
345
|
+
cli: ["mnemom"],
|
|
346
|
+
now: () => Date.now(),
|
|
315
347
|
});
|
|
316
348
|
}
|
|
317
349
|
async function listen() {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable inbox verbs (S1 amendment 60, signed off by Wassim 2026-11-02; ROUTES.own_inbox_*):
|
|
3
|
+
*
|
|
4
|
+
* mnemom agent sessions send <name> [text|-] [--idempotency-key k] [--json]
|
|
5
|
+
* mnemom agent sessions send <name> [text|-] --sender-key-env VAR --conversation <cid> --agent-id <id>
|
|
6
|
+
* mnemom agent sessions senders list|add|remove <name> [<agent>] [--label l] [--json]
|
|
7
|
+
*
|
|
8
|
+
* `send` is the owner door into a locked session: the api admits the message, the host daemon
|
|
9
|
+
* delivers it into the seat as the next prompt once the seat is idle, and the turn answering it is
|
|
10
|
+
* admitted unattended (A60 (e)). An allowlisted agent (chief) sends with its own agent-bound key,
|
|
11
|
+
* read from the environment variable --sender-key-env names (never from argv, so it never shows in
|
|
12
|
+
* `ps`); the api then records the message as from that agent on behalf of the owner. That agent cannot
|
|
13
|
+
* list the owner's sessions, so it addresses the session by --conversation and --agent-id.
|
|
14
|
+
*
|
|
15
|
+
* The body is exactly {text, idempotency_key}: the sender is never named by the caller. Every
|
|
16
|
+
* dependency is injected (`InboxVerbDeps`), so each branch is unit-tested.
|
|
17
|
+
*/
|
|
18
|
+
import type { ApiAnswer, DurableCredential } from "../lib/durable/api.js";
|
|
19
|
+
import { type DurableVerbDeps, type VerbApi } from "./durable-verbs.js";
|
|
20
|
+
export type InboxRoute = "own_inbox_send" | "own_inbox_list" | "own_inbox_senders" | "own_inbox_sender_add" | "own_inbox_sender_remove";
|
|
21
|
+
/** The slice of DurableApi the inbox verbs use (a fake in tests). */
|
|
22
|
+
export interface InboxApi extends Pick<VerbApi, "listSessions" | "agentsByName"> {
|
|
23
|
+
inbox(route: InboxRoute, cid: string, agentId: string, body?: unknown, credential?: DurableCredential): Promise<ApiAnswer>;
|
|
24
|
+
}
|
|
25
|
+
export interface InboxVerbDeps extends Omit<DurableVerbDeps, "api"> {
|
|
26
|
+
api: InboxApi;
|
|
27
|
+
/** The environment (the sender key is read from it by name). */
|
|
28
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
29
|
+
/** All of stdin, for `send <name> -` (and `send <name>` with no text). */
|
|
30
|
+
readStdin: () => Promise<string>;
|
|
31
|
+
newId?: () => string;
|
|
32
|
+
}
|
|
33
|
+
export interface SendOpts {
|
|
34
|
+
idempotencyKey?: string;
|
|
35
|
+
senderKeyEnv?: string;
|
|
36
|
+
conversation?: string;
|
|
37
|
+
agentId?: string;
|
|
38
|
+
json?: boolean;
|
|
39
|
+
}
|
|
40
|
+
/** The default idempotency key of one CLI send: a retry of the same command is a new message. */
|
|
41
|
+
export declare const cliIdempotencyKey: (id: string) => string;
|
|
42
|
+
/** The sender agent's key from the named environment variable; a usage refusal when unusable. */
|
|
43
|
+
export declare function senderCredential(envName: string | undefined, env: Readonly<Record<string, string | undefined>>): DurableCredential | undefined;
|
|
44
|
+
export declare function sendCommand(name: string, text: string | undefined, opts: SendOpts, deps: InboxVerbDeps): Promise<number>;
|
|
45
|
+
/** An agent id from a name or id: one agent of that name in the caller's orgs, else the word as an id. */
|
|
46
|
+
export declare function resolveAgentId(api: InboxApi, ref: string): Promise<string>;
|
|
47
|
+
export type SendersVerb = "list" | "add" | "remove";
|
|
48
|
+
export declare function sendersCommand(verb: SendersVerb, name: string, agent: string | undefined, opts: {
|
|
49
|
+
label?: string;
|
|
50
|
+
json?: boolean;
|
|
51
|
+
}, deps: InboxVerbDeps): Promise<number>;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable inbox verbs (S1 amendment 60, signed off by Wassim 2026-11-02; ROUTES.own_inbox_*):
|
|
3
|
+
*
|
|
4
|
+
* mnemom agent sessions send <name> [text|-] [--idempotency-key k] [--json]
|
|
5
|
+
* mnemom agent sessions send <name> [text|-] --sender-key-env VAR --conversation <cid> --agent-id <id>
|
|
6
|
+
* mnemom agent sessions senders list|add|remove <name> [<agent>] [--label l] [--json]
|
|
7
|
+
*
|
|
8
|
+
* `send` is the owner door into a locked session: the api admits the message, the host daemon
|
|
9
|
+
* delivers it into the seat as the next prompt once the seat is idle, and the turn answering it is
|
|
10
|
+
* admitted unattended (A60 (e)). An allowlisted agent (chief) sends with its own agent-bound key,
|
|
11
|
+
* read from the environment variable --sender-key-env names (never from argv, so it never shows in
|
|
12
|
+
* `ps`); the api then records the message as from that agent on behalf of the owner. That agent cannot
|
|
13
|
+
* list the owner's sessions, so it addresses the session by --conversation and --agent-id.
|
|
14
|
+
*
|
|
15
|
+
* The body is exactly {text, idempotency_key}: the sender is never named by the caller. Every
|
|
16
|
+
* dependency is injected (`InboxVerbDeps`), so each branch is unit-tested.
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { INBOX_AGENT_ID_RE, INBOX_IDEMPOTENCY_KEY_RE, INBOX_SENDER_LABEL_RE, inboxBodyOk, inboxSendResponseOk, inboxSenderChangeResponseOk, inboxSendersResponseOk, } from "../vendor/shared/durable-contract/inbox.js";
|
|
20
|
+
import { DurableRefusal } from "../lib/durable/refusal.js";
|
|
21
|
+
import { handleVerbError, refusalFromAnswer, resolveTarget, } from "./durable-verbs.js";
|
|
22
|
+
const usage = (message) => new DurableRefusal({ code: "invalid_argument", message, fix: null, exit: 2 });
|
|
23
|
+
/** The default idempotency key of one CLI send: a retry of the same command is a new message. */
|
|
24
|
+
export const cliIdempotencyKey = (id) => `cli:${id}`;
|
|
25
|
+
/** The session a send goes to: by name through the registry, or by --conversation/--agent-id. */
|
|
26
|
+
async function sendTarget(name, opts, api) {
|
|
27
|
+
if (opts.conversation !== undefined || opts.agentId !== undefined) {
|
|
28
|
+
if (!opts.conversation || !opts.agentId)
|
|
29
|
+
throw usage("--conversation and --agent-id go together");
|
|
30
|
+
if (!INBOX_AGENT_ID_RE.test(opts.agentId))
|
|
31
|
+
throw usage(`--agent-id is not an agent id (got '${opts.agentId}')`);
|
|
32
|
+
return { cid: opts.conversation, agentId: opts.agentId };
|
|
33
|
+
}
|
|
34
|
+
if (opts.senderKeyEnv !== undefined)
|
|
35
|
+
throw usage("--sender-key-env sends as another agent, which cannot look the session up: give --conversation and --agent-id too");
|
|
36
|
+
const row = await resolveTarget(api, name);
|
|
37
|
+
return { cid: row.conversation_id, agentId: row.agent_id };
|
|
38
|
+
}
|
|
39
|
+
/** The sender agent's key from the named environment variable; a usage refusal when unusable. */
|
|
40
|
+
export function senderCredential(envName, env) {
|
|
41
|
+
if (envName === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(envName))
|
|
44
|
+
throw usage(`--sender-key-env takes an environment variable name (got '${envName}')`);
|
|
45
|
+
const key = env[envName]?.trim();
|
|
46
|
+
if (!key)
|
|
47
|
+
throw usage(`--sender-key-env ${envName}: that variable is not set`);
|
|
48
|
+
return { type: "api-key", key };
|
|
49
|
+
}
|
|
50
|
+
export async function sendCommand(name, text, opts, deps) {
|
|
51
|
+
const json = opts.json === true;
|
|
52
|
+
try {
|
|
53
|
+
const body = text === undefined || text === "-" ? await deps.readStdin() : text;
|
|
54
|
+
if (!inboxBodyOk(body))
|
|
55
|
+
throw usage("the message must be 1..16384 UTF-8 bytes with no control character but tab and newline (give it as an argument or on stdin)");
|
|
56
|
+
const key = opts.idempotencyKey ?? cliIdempotencyKey((deps.newId ?? randomUUID)());
|
|
57
|
+
if (!INBOX_IDEMPOTENCY_KEY_RE.test(key))
|
|
58
|
+
throw usage(`--idempotency-key must be 1..200 letters, digits or _ . : - (got '${key.slice(0, 40)}')`);
|
|
59
|
+
const credential = senderCredential(opts.senderKeyEnv, deps.env);
|
|
60
|
+
const t = await sendTarget(name, opts, deps.api);
|
|
61
|
+
const a = await deps.api.inbox("own_inbox_send", t.cid, t.agentId, { text: body, idempotency_key: key }, credential);
|
|
62
|
+
if (a.status !== 200 || !inboxSendResponseOk(a.body))
|
|
63
|
+
throw refusalFromAnswer(a, name, deps.launcherKey());
|
|
64
|
+
const r = a.body;
|
|
65
|
+
const say = json ? deps.err : deps.out;
|
|
66
|
+
const how = r.deduped
|
|
67
|
+
? "already sent (same idempotency key)"
|
|
68
|
+
: r.coalesced
|
|
69
|
+
? "already waiting (same message still open)"
|
|
70
|
+
: "queued";
|
|
71
|
+
say(`${name}: ${how}; inbox ${r.inbox_id} (${r.sha.slice(0, 12)}), ${r.status}${r.sender_kind === "agent" ? ", sent as an allowlisted agent on behalf of the owner" : ""}`);
|
|
72
|
+
if (json)
|
|
73
|
+
deps.out(JSON.stringify(a.body));
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
return handleVerbError(e, json, deps);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** An agent id from a name or id: one agent of that name in the caller's orgs, else the word as an id. */
|
|
81
|
+
export async function resolveAgentId(api, ref) {
|
|
82
|
+
const byName = await api.agentsByName(ref).catch(() => []);
|
|
83
|
+
if (byName.length > 1)
|
|
84
|
+
throw usage(`more than one agent is named ${ref}: give its id (mnemom agent list)`);
|
|
85
|
+
const id = byName[0]?.id ?? ref;
|
|
86
|
+
if (!INBOX_AGENT_ID_RE.test(id))
|
|
87
|
+
throw usage(`'${ref}' is not an agent name or id`);
|
|
88
|
+
return id;
|
|
89
|
+
}
|
|
90
|
+
export async function sendersCommand(verb, name, agent, opts, deps) {
|
|
91
|
+
const json = opts.json === true;
|
|
92
|
+
const say = json ? deps.err : deps.out;
|
|
93
|
+
try {
|
|
94
|
+
if (verb !== "list" && agent === undefined)
|
|
95
|
+
throw usage(`mnemom agent sessions senders ${verb} <name> <agent>`);
|
|
96
|
+
if (opts.label !== undefined && (verb !== "add" || !INBOX_SENDER_LABEL_RE.test(opts.label)))
|
|
97
|
+
throw usage("--label goes with `add`: one line of printable ASCII, no brackets, 1..128");
|
|
98
|
+
const row = await resolveTarget(deps.api, name);
|
|
99
|
+
if (verb === "list") {
|
|
100
|
+
const a = await deps.api.inbox("own_inbox_senders", row.conversation_id, row.agent_id);
|
|
101
|
+
if (a.status !== 200 || !inboxSendersResponseOk(a.body))
|
|
102
|
+
throw refusalFromAnswer(a, name, deps.launcherKey(), row);
|
|
103
|
+
const b = a.body;
|
|
104
|
+
if (json)
|
|
105
|
+
deps.out(JSON.stringify(a.body));
|
|
106
|
+
else if (b.senders.length === 0)
|
|
107
|
+
say(`${name}: only the owner may send to agent ${b.recipient_agent_id}'s inbox`);
|
|
108
|
+
else {
|
|
109
|
+
say(`${name}: agents that may send to agent ${b.recipient_agent_id}'s inbox:`);
|
|
110
|
+
for (const s of b.senders)
|
|
111
|
+
say(` ${s.sender_agent_id}${s.label ? ` ${s.label}` : ""} (since ${s.created_at})`);
|
|
112
|
+
}
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
const agentId = await resolveAgentId(deps.api, agent);
|
|
116
|
+
const route = verb === "add" ? "own_inbox_sender_add" : "own_inbox_sender_remove";
|
|
117
|
+
const body = verb === "add" && opts.label !== undefined
|
|
118
|
+
? { agent_id: agentId, label: opts.label }
|
|
119
|
+
: { agent_id: agentId };
|
|
120
|
+
const a = await deps.api.inbox(route, row.conversation_id, row.agent_id, body);
|
|
121
|
+
if (a.status !== 200 || !inboxSenderChangeResponseOk(a.body))
|
|
122
|
+
throw refusalFromAnswer(a, name, deps.launcherKey(), row);
|
|
123
|
+
const changed = a.body.changed;
|
|
124
|
+
say(verb === "add"
|
|
125
|
+
? `${name}: agent ${agentId} ${changed ? "may now send" : "could already send"} to this agent's inbox`
|
|
126
|
+
: `${name}: agent ${agentId} ${changed ? "may no longer send" : "was not allowed to send"} to this agent's inbox`);
|
|
127
|
+
if (json)
|
|
128
|
+
deps.out(JSON.stringify(a.body));
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
return handleVerbError(e, json, deps);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable question verbs (S1 amendment 61, signed off by Wassim 2026-11-02; ROUTES.own_questions and
|
|
3
|
+
* ROUTES.own_answer):
|
|
4
|
+
*
|
|
5
|
+
* mnemom agent sessions questions <name> [--all] [--json]
|
|
6
|
+
* mnemom agent sessions answer <name> <qid|latest> [choice...] [--dismiss] [--json]
|
|
7
|
+
*
|
|
8
|
+
* A locked seat that calls AskUserQuestion records the question at the api (question.asked) instead
|
|
9
|
+
* of drawing a dialog nobody sees. `questions` lists them (the open ones by default), with the option
|
|
10
|
+
* the seat recommends marked; `answer` answers one (question.answered, via coding_agent) or dismisses
|
|
11
|
+
* it. Each choice is a 1-based option number (comma separated for a multi-select question), an
|
|
12
|
+
* option label, or free text; with several questions, one choice per question in order. The seat
|
|
13
|
+
* gets the answer either live (its hook is still waiting) or as its next inbox message.
|
|
14
|
+
*
|
|
15
|
+
* Every dependency is injected (`QuestionVerbDeps`), so each branch is unit-tested.
|
|
16
|
+
*/
|
|
17
|
+
import { type QuestionItem, type QuestionState } from "../vendor/shared/durable-contract/question.js";
|
|
18
|
+
import type { ApiAnswer } from "../lib/durable/api.js";
|
|
19
|
+
import { type DurableVerbDeps, type VerbApi } from "./durable-verbs.js";
|
|
20
|
+
export type QuestionRoute = "own_questions" | "own_answer";
|
|
21
|
+
/** The slice of DurableApi the question verbs use (a fake in tests). */
|
|
22
|
+
export interface QuestionApi extends Pick<VerbApi, "listSessions"> {
|
|
23
|
+
questions(route: QuestionRoute, cid: string, agentId: string, body?: unknown): Promise<ApiAnswer>;
|
|
24
|
+
}
|
|
25
|
+
export interface QuestionVerbDeps extends Omit<DurableVerbDeps, "api"> {
|
|
26
|
+
api: QuestionApi;
|
|
27
|
+
}
|
|
28
|
+
/** One own_questions item as the CLI reads it (shape checked by questionListResponseOk). */
|
|
29
|
+
export interface QuestionRow {
|
|
30
|
+
approval_id: string;
|
|
31
|
+
state: QuestionState;
|
|
32
|
+
questions: QuestionItem[];
|
|
33
|
+
recommended: Array<number | null>;
|
|
34
|
+
answers: Record<string, string> | null;
|
|
35
|
+
answered_via: string | null;
|
|
36
|
+
created_at: string;
|
|
37
|
+
expires_at: string;
|
|
38
|
+
answered_at: string | null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Whether the owner can still answer `q`. The api reports a question the seat released as
|
|
42
|
+
* `handed_off` whether or not it was answered since (S1 question.ts: answered and handed_off can
|
|
43
|
+
* both hold), so a handed-off question is still waiting for the owner while it has no answered_at
|
|
44
|
+
* and has not expired. Its answer then reaches the seat as an inbox message (S1 §61 (c)/(e)).
|
|
45
|
+
*/
|
|
46
|
+
export declare function awaitingAnswer(q: QuestionRow, now: number): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Text the seat's model wrote, made safe for the owner's terminal: every C0/C1 control character
|
|
49
|
+
* (ESC, CSI, OSC, BEL, CR, ...) and DEL is shown escaped (`\x1b`), so a question cannot move the
|
|
50
|
+
* cursor, rewrite or hide a line, or write the clipboard. S1 question.ts only refuses NUL.
|
|
51
|
+
*/
|
|
52
|
+
export declare function terminalSafe(s: string): string;
|
|
53
|
+
/** The human lines for one question row. */
|
|
54
|
+
export declare function renderQuestion(q: QuestionRow, now?: number): string[];
|
|
55
|
+
export declare function questionsCommand(name: string, opts: {
|
|
56
|
+
all?: boolean;
|
|
57
|
+
json?: boolean;
|
|
58
|
+
}, deps: QuestionVerbDeps): Promise<number>;
|
|
59
|
+
/** The answers map for one question row from the owner's choices (one per question, in order). */
|
|
60
|
+
export declare function answersFromChoices(q: QuestionRow, choices: readonly string[]): Record<string, string>;
|
|
61
|
+
export declare function answerCommand(name: string, qid: string, choices: readonly string[], opts: {
|
|
62
|
+
dismiss?: boolean;
|
|
63
|
+
json?: boolean;
|
|
64
|
+
}, deps: QuestionVerbDeps): Promise<number>;
|