@geminixiang/mama 0.2.0-beta.1 → 0.2.0-beta.3
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/README.md +133 -78
- package/dist/adapter.d.ts +22 -10
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js.map +1 -1
- package/dist/adapters/discord/bot.d.ts +10 -7
- package/dist/adapters/discord/bot.d.ts.map +1 -1
- package/dist/adapters/discord/bot.js +228 -69
- package/dist/adapters/discord/bot.js.map +1 -1
- package/dist/adapters/discord/context.d.ts.map +1 -1
- package/dist/adapters/discord/context.js +92 -34
- package/dist/adapters/discord/context.js.map +1 -1
- package/dist/adapters/shared.d.ts +23 -0
- package/dist/adapters/shared.d.ts.map +1 -0
- package/dist/adapters/shared.js +57 -0
- package/dist/adapters/shared.js.map +1 -0
- package/dist/adapters/slack/bot.d.ts +19 -11
- package/dist/adapters/slack/bot.d.ts.map +1 -1
- package/dist/adapters/slack/bot.js +356 -96
- package/dist/adapters/slack/bot.js.map +1 -1
- package/dist/adapters/slack/branch-manager.d.ts +21 -0
- package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
- package/dist/adapters/slack/branch-manager.js +96 -0
- package/dist/adapters/slack/branch-manager.js.map +1 -0
- package/dist/adapters/slack/context.d.ts.map +1 -1
- package/dist/adapters/slack/context.js +100 -67
- package/dist/adapters/slack/context.js.map +1 -1
- package/dist/adapters/slack/session.d.ts +3 -0
- package/dist/adapters/slack/session.d.ts.map +1 -0
- package/dist/adapters/slack/session.js +16 -0
- package/dist/adapters/slack/session.js.map +1 -0
- package/dist/adapters/telegram/bot.d.ts +4 -2
- package/dist/adapters/telegram/bot.d.ts.map +1 -1
- package/dist/adapters/telegram/bot.js +141 -74
- package/dist/adapters/telegram/bot.js.map +1 -1
- package/dist/adapters/telegram/context.d.ts.map +1 -1
- package/dist/adapters/telegram/context.js +49 -109
- package/dist/adapters/telegram/context.js.map +1 -1
- package/dist/adapters/telegram/html.d.ts +3 -0
- package/dist/adapters/telegram/html.d.ts.map +1 -0
- package/dist/adapters/telegram/html.js +98 -0
- package/dist/adapters/telegram/html.js.map +1 -0
- package/dist/agent.d.ts +4 -11
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +116 -196
- package/dist/agent.js.map +1 -1
- package/dist/bindings.d.ts +1 -20
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +1 -21
- package/dist/bindings.js.map +1 -1
- package/dist/config.d.ts +9 -27
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +89 -63
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +13 -3
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +102 -18
- package/dist/context.js.map +1 -1
- package/dist/events.d.ts +18 -6
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +86 -35
- package/dist/events.js.map +1 -1
- package/dist/execution-resolver.d.ts.map +1 -1
- package/dist/execution-resolver.js +1 -3
- package/dist/execution-resolver.js.map +1 -1
- package/dist/instrument.d.ts.map +1 -1
- package/dist/instrument.js +5 -11
- package/dist/instrument.js.map +1 -1
- package/dist/{login.d.ts → login/index.d.ts} +2 -2
- package/dist/login/index.d.ts.map +1 -0
- package/dist/{login.js → login/index.js} +2 -2
- package/dist/login/index.js.map +1 -0
- package/dist/{link-server.d.ts → login/portal.d.ts} +6 -4
- package/dist/login/portal.d.ts.map +1 -0
- package/dist/login/portal.js +1453 -0
- package/dist/login/portal.js.map +1 -0
- package/dist/{link-token.d.ts → login/session.d.ts} +1 -1
- package/dist/login/session.d.ts.map +1 -0
- package/dist/{link-token.js → login/session.js} +1 -1
- package/dist/login/session.js.map +1 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +175 -119
- package/dist/main.js.map +1 -1
- package/dist/provisioner.d.ts +17 -43
- package/dist/provisioner.d.ts.map +1 -1
- package/dist/provisioner.js +84 -50
- package/dist/provisioner.js.map +1 -1
- package/dist/sandbox/host.d.ts +0 -2
- package/dist/sandbox/host.d.ts.map +1 -1
- package/dist/sandbox/host.js +1 -5
- package/dist/sandbox/host.js.map +1 -1
- package/dist/sentry.d.ts.map +1 -1
- package/dist/sentry.js +2 -0
- package/dist/sentry.js.map +1 -1
- package/dist/session-policy.d.ts +13 -0
- package/dist/session-policy.d.ts.map +1 -0
- package/dist/session-policy.js +23 -0
- package/dist/session-policy.js.map +1 -0
- package/dist/session-store.d.ts +27 -1
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +162 -9
- package/dist/session-store.js.map +1 -1
- package/dist/session-view/command.d.ts +5 -0
- package/dist/session-view/command.d.ts.map +1 -0
- package/dist/session-view/command.js +11 -0
- package/dist/session-view/command.js.map +1 -0
- package/dist/session-view/portal.d.ts +9 -0
- package/dist/session-view/portal.d.ts.map +1 -0
- package/dist/session-view/portal.js +766 -0
- package/dist/session-view/portal.js.map +1 -0
- package/dist/session-view/service.d.ts +34 -0
- package/dist/session-view/service.d.ts.map +1 -0
- package/dist/session-view/service.js +380 -0
- package/dist/session-view/service.js.map +1 -0
- package/dist/session-view/store.d.ts +16 -0
- package/dist/session-view/store.d.ts.map +1 -0
- package/dist/session-view/store.js +38 -0
- package/dist/session-view/store.js.map +1 -0
- package/dist/store.d.ts +3 -6
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +15 -35
- package/dist/store.js.map +1 -1
- package/dist/tools/event.d.ts +3 -0
- package/dist/tools/event.d.ts.map +1 -1
- package/dist/tools/event.js +27 -8
- package/dist/tools/event.js.map +1 -1
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/ui-copy.d.ts +1 -0
- package/dist/ui-copy.d.ts.map +1 -1
- package/dist/ui-copy.js +3 -0
- package/dist/ui-copy.js.map +1 -1
- package/dist/vault-routing.d.ts +1 -2
- package/dist/vault-routing.d.ts.map +1 -1
- package/dist/vault-routing.js +1 -7
- package/dist/vault-routing.js.map +1 -1
- package/package.json +1 -1
- package/dist/link-server.d.ts.map +0 -1
- package/dist/link-server.js +0 -839
- package/dist/link-server.js.map +0 -1
- package/dist/link-token.d.ts.map +0 -1
- package/dist/link-token.js.map +0 -1
- package/dist/login.d.ts.map +0 -1
- package/dist/login.js.map +0 -1
- package/dist/vault.test.d.ts +0 -2
- package/dist/vault.test.d.ts.map +0 -1
- package/dist/vault.test.js +0 -67
- package/dist/vault.test.js.map +0 -1
package/dist/main.js
CHANGED
|
@@ -1,30 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import "./instrument.js";
|
|
3
3
|
import { join, resolve } from "path";
|
|
4
|
-
import { homedir } from "os";
|
|
5
4
|
import { mkdirSync, readFileSync, statSync } from "fs";
|
|
5
|
+
import { homedir } from "os";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { dirname, join as pathJoin } from "path";
|
|
8
8
|
import { DiscordBot } from "./adapters/discord/index.js";
|
|
9
9
|
import { TelegramBot } from "./adapters/telegram/index.js";
|
|
10
10
|
import { SlackBot as SlackBotClass } from "./adapters/slack/index.js";
|
|
11
11
|
import { createRunner } from "./agent.js";
|
|
12
|
-
import { createManagedSessionFile, createManagedSessionFileAtPath, getChannelSessionDir, getThreadSessionFile, } from "./session-store.js";
|
|
12
|
+
import { createManagedSessionFile, createManagedSessionFileAtPath, getChannelSessionDir, getThreadSessionFile, resolveGenericSessionScope, } from "./session-store.js";
|
|
13
13
|
import { downloadChannel } from "./download.js";
|
|
14
14
|
import { createEventsWatcher } from "./events.js";
|
|
15
15
|
import * as log from "./log.js";
|
|
16
16
|
import { FileUserBindingStore } from "./bindings.js";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import { InMemoryLinkTokenStore } from "./
|
|
17
|
+
import { parseLoginCommand } from "./login/index.js";
|
|
18
|
+
import { startLinkServer } from "./login/portal.js";
|
|
19
|
+
import { InMemoryLinkTokenStore } from "./login/session.js";
|
|
20
|
+
import { parseSessionViewCommand } from "./session-view/command.js";
|
|
21
|
+
import { resolveExistingSessionFile } from "./session-view/service.js";
|
|
22
|
+
import { InMemorySessionViewTokenStore } from "./session-view/store.js";
|
|
20
23
|
import { DockerContainerManager } from "./provisioner.js";
|
|
24
|
+
import { loadAgentConfig } from "./config.js";
|
|
21
25
|
import { SandboxError, parseSandboxArg, validateSandbox } from "./sandbox.js";
|
|
22
|
-
import { formatNothingRunning, formatStopping } from "./ui-copy.js";
|
|
23
26
|
import { FileVaultManager } from "./vault.js";
|
|
24
|
-
import { ensureSettingsFile } from "./config.js";
|
|
25
27
|
import { createManagedVaultEntry, ensureSandboxVaultEntry, resolveActorVaultKey, } from "./vault-routing.js";
|
|
26
28
|
import { addLifecycleBreadcrumb, applyRunScope } from "./sentry.js";
|
|
27
29
|
import { ChannelStore } from "./store.js";
|
|
30
|
+
import { formatNothingRunning, formatStopped, formatStopping } from "./ui-copy.js";
|
|
31
|
+
import { hasMaterializedSlackBranchSession, resolveSlackSessionScope, waitForSlackBranchBootstrap, } from "./adapters/slack/branch-manager.js";
|
|
28
32
|
import * as Sentry from "@sentry/node";
|
|
29
33
|
// ============================================================================
|
|
30
34
|
// Config
|
|
@@ -53,13 +57,7 @@ const MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;
|
|
|
53
57
|
const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;
|
|
54
58
|
const MOM_TELEGRAM_BOT_TOKEN = process.env.MOM_TELEGRAM_BOT_TOKEN;
|
|
55
59
|
const MOM_DISCORD_BOT_TOKEN = process.env.MOM_DISCORD_BOT_TOKEN;
|
|
56
|
-
/** Base URL of the web login portal, e.g. https://platform.trygemini.xyz */
|
|
57
60
|
const MOM_LINK_URL = process.env.MOM_LINK_URL;
|
|
58
|
-
/**
|
|
59
|
-
* Port for the link callback HTTP server.
|
|
60
|
-
* Defaults to 8181 when MOM_LINK_URL is set (behind a reverse proxy).
|
|
61
|
-
* If neither is set, the server is not started.
|
|
62
|
-
*/
|
|
63
61
|
const MOM_LINK_PORT = process.env.MOM_LINK_PORT
|
|
64
62
|
? parseInt(process.env.MOM_LINK_PORT, 10)
|
|
65
63
|
: MOM_LINK_URL
|
|
@@ -108,12 +106,6 @@ function parseArgs() {
|
|
|
108
106
|
};
|
|
109
107
|
}
|
|
110
108
|
const WORLD_WRITABLE_MODE = 0o002;
|
|
111
|
-
/**
|
|
112
|
-
* Create stateDir if missing and refuse to use it if another local user could
|
|
113
|
-
* tamper with its contents. stateDir holds vaults, bindings, and settings —
|
|
114
|
-
* a world-writable or foreign-owned directory there would let a local attacker
|
|
115
|
-
* swap in credentials or change routing.
|
|
116
|
-
*/
|
|
117
109
|
function ensureSecureStateDir(path) {
|
|
118
110
|
let stat;
|
|
119
111
|
try {
|
|
@@ -177,27 +169,14 @@ if (parsedArgs.downloadChannel) {
|
|
|
177
169
|
}
|
|
178
170
|
// Normal bot mode - require working dir
|
|
179
171
|
if (!parsedArgs.workingDir) {
|
|
180
|
-
console.error("Usage: mama [--sandbox=host|container:<name>|image:<image>|firecracker:<vm-id>:<host-path>]
|
|
172
|
+
console.error("Usage: mama [--state-dir=<dir>] [--sandbox=host|container:<name>|image:<image>|firecracker:<vm-id>:<host-path>] <working-directory>");
|
|
181
173
|
console.error(" mama --download <channel-id>");
|
|
182
174
|
process.exit(1);
|
|
183
175
|
}
|
|
184
176
|
const { workingDir, sandbox } = { workingDir: parsedArgs.workingDir, sandbox: parsedArgs.sandbox };
|
|
185
|
-
// stateDir holds operator-managed files (vaults, settings, bindings).
|
|
186
|
-
// Defaults to ~/.mama to keep secrets outside the project workspace mounted into sandboxes.
|
|
187
177
|
const stateDir = parsedArgs.stateDir ?? join(homedir(), ".mama");
|
|
188
|
-
ensureSecureStateDir(stateDir);
|
|
189
|
-
// Share stateDir with instrument.ts (for Sentry config loading)
|
|
190
178
|
process.env.MAMA_STATE_DIR = stateDir;
|
|
191
|
-
|
|
192
|
-
const { created: settingsCreated, config: agentSettings } = ensureSettingsFile(stateDir);
|
|
193
|
-
if (settingsCreated) {
|
|
194
|
-
console.log(`Created default settings: ${join(stateDir, "settings.json")}`);
|
|
195
|
-
console.log("Review and update provider/model before starting.");
|
|
196
|
-
}
|
|
197
|
-
if (!agentSettings.provider || !agentSettings.model) {
|
|
198
|
-
console.error(`Error: 'provider' and 'model' must be set in ${join(stateDir, "settings.json")}`);
|
|
199
|
-
process.exit(1);
|
|
200
|
-
}
|
|
179
|
+
ensureSecureStateDir(stateDir);
|
|
201
180
|
// Validate platform tokens
|
|
202
181
|
const hasSlack = !!(MOM_SLACK_APP_TOKEN && MOM_SLACK_BOT_TOKEN);
|
|
203
182
|
const hasTelegram = !!MOM_TELEGRAM_BOT_TOKEN;
|
|
@@ -218,37 +197,31 @@ catch (error) {
|
|
|
218
197
|
const vaultManager = new FileVaultManager(stateDir);
|
|
219
198
|
if (vaultManager.isEnabled()) {
|
|
220
199
|
console.log(sandbox.type === "container"
|
|
221
|
-
? " Vault system enabled.
|
|
222
|
-
: "
|
|
200
|
+
? " Vault system enabled. Container vault active."
|
|
201
|
+
: sandbox.type === "image" || sandbox.type === "firecracker"
|
|
202
|
+
? " Vault system enabled. Per-user credential routing active."
|
|
203
|
+
: " Vault system enabled. Host mode will not inject vault env.");
|
|
223
204
|
}
|
|
224
205
|
const bindingStore = new FileUserBindingStore(stateDir);
|
|
225
206
|
if (bindingStore.isEnabled()) {
|
|
226
207
|
console.log(sandbox.type === "container"
|
|
227
|
-
? " Binding store enabled.
|
|
228
|
-
:
|
|
208
|
+
? " Binding store enabled. Container mode uses the container vault."
|
|
209
|
+
: sandbox.type === "image" || sandbox.type === "firecracker"
|
|
210
|
+
? " Binding store enabled. Platform user → vault routing active."
|
|
211
|
+
: " Binding store enabled. Host mode will not inject vault env.");
|
|
229
212
|
}
|
|
230
|
-
const
|
|
213
|
+
const startupConfig = loadAgentConfig(workingDir);
|
|
214
|
+
const sandboxLimits = startupConfig.sandboxCpus || startupConfig.sandboxMemory
|
|
215
|
+
? { cpus: startupConfig.sandboxCpus, memory: startupConfig.sandboxMemory }
|
|
216
|
+
: undefined;
|
|
217
|
+
const provisioner = sandbox.type === "image"
|
|
218
|
+
? new DockerContainerManager(sandbox.image, workingDir, { limits: sandboxLimits })
|
|
219
|
+
: undefined;
|
|
231
220
|
const linkTokenStore = new InMemoryLinkTokenStore();
|
|
232
|
-
|
|
221
|
+
const sessionViewTokenStore = new InMemorySessionViewTokenStore();
|
|
233
222
|
setInterval(() => linkTokenStore.purge(), 5 * 60 * 1000).unref();
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const inFlightRuns = new Set();
|
|
237
|
-
/** Flag to stop accepting new events during shutdown */
|
|
238
|
-
let isShuttingDown = false;
|
|
239
|
-
/** Maximum number of cached sessions */
|
|
240
|
-
const MAX_SESSIONS = 500;
|
|
241
|
-
/** Idle timeout before a non-running session can be evicted (10 minutes) */
|
|
242
|
-
const IDLE_TIMEOUT_MS = 600000;
|
|
243
|
-
if (provisioner) {
|
|
244
|
-
await provisioner.reconcile();
|
|
245
|
-
await provisioner.stopIdle(IDLE_TIMEOUT_MS);
|
|
246
|
-
}
|
|
247
|
-
// Stop idle containers every hour (same cadence as session eviction)
|
|
248
|
-
if (provisioner) {
|
|
249
|
-
setInterval(() => provisioner.stopIdle(IDLE_TIMEOUT_MS), IDLE_TIMEOUT_MS).unref();
|
|
250
|
-
}
|
|
251
|
-
function normalizeLoginBaseUrl() {
|
|
223
|
+
setInterval(() => sessionViewTokenStore.purge(), 5 * 60 * 1000).unref();
|
|
224
|
+
function normalizePortalBaseUrl() {
|
|
252
225
|
if (MOM_LINK_URL) {
|
|
253
226
|
return MOM_LINK_URL.replace(/\/+$/, "");
|
|
254
227
|
}
|
|
@@ -257,22 +230,118 @@ function normalizeLoginBaseUrl() {
|
|
|
257
230
|
}
|
|
258
231
|
return undefined;
|
|
259
232
|
}
|
|
233
|
+
function isPrivateConversation(event) {
|
|
234
|
+
return event.conversationKind === "direct" || event.type === "dm";
|
|
235
|
+
}
|
|
260
236
|
function ensureLoginVault(platform, platformUserId) {
|
|
261
237
|
const vaultId = resolveActorVaultKey(sandbox, vaultManager, bindingStore, platform, platformUserId);
|
|
262
238
|
ensureSandboxVaultEntry(sandbox, vaultManager, platform, platformUserId, vaultId);
|
|
263
|
-
if (sandbox.type !== "
|
|
264
|
-
vaultManager.addEntry(vaultId, createManagedVaultEntry(platform, platformUserId, vaultId
|
|
239
|
+
if (sandbox.type !== "container" && sandbox.type !== "image") {
|
|
240
|
+
vaultManager.addEntry(vaultId, createManagedVaultEntry(platform, platformUserId, vaultId));
|
|
265
241
|
}
|
|
266
242
|
return vaultId;
|
|
267
243
|
}
|
|
268
|
-
async function
|
|
244
|
+
async function replyWithContext(responseCtx, text) {
|
|
245
|
+
await responseCtx.setTyping(false);
|
|
246
|
+
await responseCtx.setWorking(false);
|
|
247
|
+
await responseCtx.respond(text);
|
|
248
|
+
}
|
|
249
|
+
async function handleLoginCommand(platform, platformUserId, conversationId, responseCtx, commandText, privateConversation) {
|
|
250
|
+
const parsed = parseLoginCommand(commandText);
|
|
251
|
+
if (!parsed)
|
|
252
|
+
return false;
|
|
253
|
+
if (!privateConversation) {
|
|
254
|
+
await replyWithContext(responseCtx, "為了保護你的憑證,`/login` 只能在與機器人的私訊中使用。請先私訊機器人,再重新執行 `/login`。");
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
const baseUrl = normalizePortalBaseUrl();
|
|
258
|
+
if (!baseUrl) {
|
|
259
|
+
await replyWithContext(responseCtx, "Login is not configured. Set `MOM_LINK_URL` or `MOM_LINK_PORT` on the server.");
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
let vaultId;
|
|
263
|
+
try {
|
|
264
|
+
vaultId = ensureLoginVault(platform, platformUserId);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
log.logWarning(`[${conversationId}] Failed to prepare login vault for ${platform}/${platformUserId}`, error instanceof Error ? error.message : String(error));
|
|
268
|
+
await replyWithContext(responseCtx, "Login setup failed on the server. 請稍後重試,或聯絡管理員檢查 vault 儲存權限。");
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
const token = linkTokenStore.create(platform, platformUserId, conversationId, vaultId, "");
|
|
272
|
+
const vaultLabel = sandbox.type === "container" ? `container vault (${vaultId})` : "your vault";
|
|
273
|
+
await replyWithContext(responseCtx, `Open this link to store credentials in ${vaultLabel} (expires in 15 minutes):\n${baseUrl}/link?token=${token.token}`);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
async function handleSessionViewCommand(platform, platformUserId, conversationId, sessionKey, bot, responseCtx, commandText, privateConversation) {
|
|
277
|
+
if (!parseSessionViewCommand(commandText))
|
|
278
|
+
return false;
|
|
279
|
+
const allowSharedPrivateDelivery = platform === "slack" || platform === "discord";
|
|
280
|
+
const sendSessionViewReply = async (text) => {
|
|
281
|
+
if (privateConversation) {
|
|
282
|
+
await replyWithContext(responseCtx, text);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (platform === "slack" && bot instanceof SlackBotClass) {
|
|
286
|
+
await bot.postEphemeral(conversationId, platformUserId, text);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (platform === "discord" && bot instanceof DiscordBot) {
|
|
290
|
+
await bot.sendDirectMessage(platformUserId, text);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
await replyWithContext(responseCtx, text);
|
|
294
|
+
};
|
|
295
|
+
if (!privateConversation && !allowSharedPrivateDelivery) {
|
|
296
|
+
await sendSessionViewReply("為了保護對話內容,`/session` 目前只能在與機器人的私訊 / DM 中使用。");
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
const baseUrl = normalizePortalBaseUrl();
|
|
300
|
+
if (!baseUrl) {
|
|
301
|
+
await sendSessionViewReply("Session viewer is not configured. Set `MOM_LINK_URL` or `MOM_LINK_PORT` on the server.");
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
const sessionFile = resolveExistingSessionFile(workingDir, conversationId, sessionKey);
|
|
305
|
+
if (!sessionFile) {
|
|
306
|
+
await sendSessionViewReply("目前還沒有可查看的 session。先和機器人對話一次,建立 session 後再試。");
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
const token = sessionViewTokenStore.create(platform, platformUserId, conversationId, sessionKey, sessionFile);
|
|
310
|
+
const linkText = `Open this read-only session link (expires in 24 hours):\n${baseUrl}/session?token=${token.token}`;
|
|
311
|
+
await sendSessionViewReply(linkText);
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
const conversationStates = new Map();
|
|
315
|
+
/** Track in-flight runs for graceful shutdown */
|
|
316
|
+
const inFlightRuns = new Set();
|
|
317
|
+
/** Flag to stop accepting new events during shutdown */
|
|
318
|
+
let isShuttingDown = false;
|
|
319
|
+
/** Maximum number of cached sessions */
|
|
320
|
+
const MAX_SESSIONS = 500;
|
|
321
|
+
/** Idle timeout before a non-running session can be evicted (1 hour) */
|
|
322
|
+
const IDLE_TIMEOUT_MS = 3600000;
|
|
323
|
+
/** Idle timeout for managed image containers (10 minutes) */
|
|
324
|
+
const IMAGE_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
325
|
+
if (provisioner) {
|
|
326
|
+
await provisioner.reconcile();
|
|
327
|
+
await provisioner.stopIdle(IMAGE_IDLE_TIMEOUT_MS);
|
|
328
|
+
setInterval(() => provisioner.stopIdle(IMAGE_IDLE_TIMEOUT_MS), IMAGE_IDLE_TIMEOUT_MS).unref();
|
|
329
|
+
}
|
|
330
|
+
async function resolveSessionScope(platformName, conversationDir, sessionKey) {
|
|
331
|
+
if (platformName === "slack") {
|
|
332
|
+
return resolveSlackSessionScope({ conversationDir, sessionKey });
|
|
333
|
+
}
|
|
334
|
+
return resolveGenericSessionScope({ conversationDir, sessionKey });
|
|
335
|
+
}
|
|
336
|
+
async function getState(conversationId, platformName, sessionKey) {
|
|
269
337
|
const key = sessionKey ?? conversationId;
|
|
270
338
|
let state = conversationStates.get(key);
|
|
271
339
|
if (!state) {
|
|
272
340
|
const conversationDir = join(workingDir, conversationId);
|
|
341
|
+
const sessionScope = await resolveSessionScope(platformName, conversationDir, key);
|
|
273
342
|
state = {
|
|
274
343
|
running: false,
|
|
275
|
-
runner: await createRunner(sandbox, key, conversationId, conversationDir, workingDir, vaultManager, bindingStore, provisioner
|
|
344
|
+
runner: await createRunner(sandbox, key, conversationId, conversationDir, workingDir, sessionScope, vaultManager, bindingStore, provisioner),
|
|
276
345
|
stopRequested: false,
|
|
277
346
|
lastAccessedAt: Date.now(),
|
|
278
347
|
};
|
|
@@ -359,7 +428,7 @@ const handler = {
|
|
|
359
428
|
state.stopRequested = true;
|
|
360
429
|
state.runner.abort();
|
|
361
430
|
}
|
|
362
|
-
//
|
|
431
|
+
// Conversation sessions rotate via current pointer. Thread sessions reset in place.
|
|
363
432
|
const conversationDir = join(workingDir, conversationId);
|
|
364
433
|
if (sessionKey.includes(":")) {
|
|
365
434
|
createManagedSessionFileAtPath(getThreadSessionFile(conversationDir, sessionKey), conversationDir);
|
|
@@ -372,62 +441,50 @@ const handler = {
|
|
|
372
441
|
log.logInfo(`[${conversationId}] Session reset: ${sessionKey}`);
|
|
373
442
|
await bot.postMessage(conversationId, "Conversation reset. Send a new message to start fresh.");
|
|
374
443
|
},
|
|
375
|
-
async handleLogin(platform, platformUserId, conversationId, bot, commandText, isPrivateConversation) {
|
|
376
|
-
const parsed = parseLoginCommand(commandText);
|
|
377
|
-
if (!parsed) {
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
if (!isPrivateConversation) {
|
|
381
|
-
await bot.postMessage(conversationId, "为了保护你的凭证,`/login` 只能在与机器人的私聊中使用。请先私信机器人,再重新执行 `/login`。");
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
const baseUrl = normalizeLoginBaseUrl();
|
|
385
|
-
if (!baseUrl) {
|
|
386
|
-
await bot.postMessage(conversationId, "Login is not configured. Set `MOM_LINK_URL` or `MOM_LINK_PORT` on the server.");
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
let vaultId;
|
|
390
|
-
try {
|
|
391
|
-
vaultId = ensureLoginVault(platform, platformUserId);
|
|
392
|
-
}
|
|
393
|
-
catch (error) {
|
|
394
|
-
log.logWarning(`[${conversationId}] Failed to prepare login vault for ${platform}/${platformUserId}`, error instanceof Error ? error.message : String(error));
|
|
395
|
-
await bot.postMessage(conversationId, "Login setup failed on the server. 请稍后重试,或联系管理员检查 vault 存储权限。");
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
const loginLabel = "credential";
|
|
399
|
-
const vaultLabel = sandbox.type === "container" ? "the shared container vault" : "your vault";
|
|
400
|
-
await bot.postMessage(conversationId, `Open this link to store ${loginLabel} in ${vaultLabel} ` +
|
|
401
|
-
`(expires in 15 minutes):\n${baseUrl}/link?token=${linkTokenStore.create(platform, platformUserId, conversationId, vaultId, "").token}`);
|
|
402
|
-
},
|
|
403
444
|
async handleEvent(event, bot, adapters, _isEvent) {
|
|
445
|
+
const conversationId = event.conversationId;
|
|
404
446
|
// Don't accept new events during shutdown
|
|
405
447
|
if (isShuttingDown) {
|
|
406
|
-
log.logInfo(`[${
|
|
448
|
+
log.logInfo(`[${conversationId}] Rejected event during shutdown: ${event.text.substring(0, 50)}`);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const sessionKey = event.sessionKey ?? `${conversationId}:${event.thread_ts ?? event.ts}`;
|
|
452
|
+
const privateConversation = isPrivateConversation(event);
|
|
453
|
+
const handledLogin = await handleLoginCommand(adapters.platform.name, event.user, conversationId, adapters.responseCtx, event.text, privateConversation);
|
|
454
|
+
if (handledLogin)
|
|
407
455
|
return;
|
|
456
|
+
const handledSessionView = await handleSessionViewCommand(adapters.platform.name, event.user, conversationId, sessionKey, bot, adapters.responseCtx, event.text, privateConversation);
|
|
457
|
+
if (handledSessionView)
|
|
458
|
+
return;
|
|
459
|
+
const conversationDir = join(workingDir, conversationId);
|
|
460
|
+
const waitedForParent = adapters.platform.name === "slack"
|
|
461
|
+
? await waitForSlackBranchBootstrap({
|
|
462
|
+
parentSessionKey: conversationId,
|
|
463
|
+
sessionKey,
|
|
464
|
+
hasThreadSession: () => hasMaterializedSlackBranchSession(conversationDir, sessionKey),
|
|
465
|
+
isParentRunning: () => conversationStates.get(conversationId)?.running === true,
|
|
466
|
+
})
|
|
467
|
+
: false;
|
|
468
|
+
if (waitedForParent) {
|
|
469
|
+
log.logInfo(`[${conversationId}] Delayed thread bootstrap until parent session sealed: ${sessionKey}`);
|
|
408
470
|
}
|
|
409
|
-
const
|
|
410
|
-
const state = await getState(event.conversationId, sessionKey);
|
|
471
|
+
const state = await getState(conversationId, adapters.platform.name, sessionKey);
|
|
411
472
|
// Start run
|
|
412
473
|
state.running = true;
|
|
413
474
|
state.stopRequested = false;
|
|
414
475
|
state.startedAt = Date.now();
|
|
415
476
|
state.lastActivityAt = Date.now();
|
|
416
|
-
log.logInfo(`[${
|
|
477
|
+
log.logInfo(`[${conversationId}] Starting run: ${event.text.substring(0, 50)}`);
|
|
417
478
|
// Wrap in-flight run tracking
|
|
418
479
|
Sentry.metrics.count("agent.run.started", 1, {
|
|
419
|
-
attributes: { channel:
|
|
480
|
+
attributes: { channel: conversationId },
|
|
420
481
|
});
|
|
421
482
|
Sentry.metrics.gauge("agent.sessions.active", inFlightRuns.size + 1);
|
|
422
|
-
const runPromise = Sentry.startSpan({
|
|
423
|
-
name: "agent.run",
|
|
424
|
-
op: "agent",
|
|
425
|
-
attributes: { channelId: event.conversationId, sessionKey },
|
|
426
|
-
}, async () => {
|
|
483
|
+
const runPromise = Sentry.startSpan({ name: "agent.run", op: "agent", attributes: { conversationId, sessionKey } }, async () => {
|
|
427
484
|
return Sentry.withScope(async (scope) => {
|
|
428
485
|
const { message, responseCtx, platform } = adapters;
|
|
429
486
|
applyRunScope(scope, {
|
|
430
|
-
conversationId
|
|
487
|
+
conversationId,
|
|
431
488
|
sessionKey,
|
|
432
489
|
messageId: message.id,
|
|
433
490
|
platform: platform.name,
|
|
@@ -437,7 +494,7 @@ const handler = {
|
|
|
437
494
|
isEvent: _isEvent,
|
|
438
495
|
});
|
|
439
496
|
addLifecycleBreadcrumb("agent.run.started", {
|
|
440
|
-
channel_id:
|
|
497
|
+
channel_id: conversationId,
|
|
441
498
|
platform: platform.name,
|
|
442
499
|
has_attachments: (message.attachments?.length ?? 0) > 0,
|
|
443
500
|
});
|
|
@@ -450,37 +507,37 @@ const handler = {
|
|
|
450
507
|
Sentry.metrics.distribution("agent.run.duration", durationMs, {
|
|
451
508
|
unit: "millisecond",
|
|
452
509
|
attributes: {
|
|
453
|
-
channel:
|
|
510
|
+
channel: conversationId,
|
|
454
511
|
platform: platform.name,
|
|
455
512
|
stop_reason: result.stopReason,
|
|
456
513
|
},
|
|
457
514
|
});
|
|
458
515
|
Sentry.metrics.count("agent.run.completed", 1, {
|
|
459
516
|
attributes: {
|
|
460
|
-
channel:
|
|
517
|
+
channel: conversationId,
|
|
461
518
|
platform: platform.name,
|
|
462
519
|
stop_reason: result.stopReason,
|
|
463
520
|
},
|
|
464
521
|
});
|
|
465
522
|
addLifecycleBreadcrumb("agent.run.completed", {
|
|
466
|
-
channel_id:
|
|
523
|
+
channel_id: conversationId,
|
|
467
524
|
platform: platform.name,
|
|
468
525
|
stop_reason: result.stopReason,
|
|
469
526
|
duration_ms: durationMs,
|
|
470
527
|
});
|
|
471
528
|
if (result.stopReason === "aborted" && state.stopRequested) {
|
|
472
529
|
if (state.stopMessageTs) {
|
|
473
|
-
await bot.updateMessage(
|
|
530
|
+
await bot.updateMessage(conversationId, state.stopMessageTs, formatStopped(bot));
|
|
474
531
|
state.stopMessageTs = undefined;
|
|
475
532
|
}
|
|
476
533
|
else {
|
|
477
|
-
await bot.postMessage(
|
|
534
|
+
await bot.postMessage(conversationId, formatStopped(bot));
|
|
478
535
|
}
|
|
479
536
|
}
|
|
480
537
|
}
|
|
481
538
|
catch (err) {
|
|
482
539
|
scope.setContext("agent_run_error", {
|
|
483
|
-
conversationId
|
|
540
|
+
conversationId,
|
|
484
541
|
sessionKey,
|
|
485
542
|
platform: adapters.platform.name,
|
|
486
543
|
messageId: adapters.message.id,
|
|
@@ -488,9 +545,9 @@ const handler = {
|
|
|
488
545
|
});
|
|
489
546
|
Sentry.captureException(err);
|
|
490
547
|
Sentry.metrics.count("agent.run.errors", 1, {
|
|
491
|
-
attributes: { channel:
|
|
548
|
+
attributes: { channel: conversationId, platform: adapters.platform.name },
|
|
492
549
|
});
|
|
493
|
-
log.logWarning(`[${
|
|
550
|
+
log.logWarning(`[${conversationId}] Run error`, err instanceof Error ? err.message : String(err));
|
|
494
551
|
}
|
|
495
552
|
finally {
|
|
496
553
|
state.running = false;
|
|
@@ -520,14 +577,6 @@ const sandboxDesc = sandbox.type === "host"
|
|
|
520
577
|
? `image:${sandbox.image}`
|
|
521
578
|
: `firecracker:${sandbox.vmId}`;
|
|
522
579
|
log.logStartup(workingDir, sandboxDesc);
|
|
523
|
-
// Start link callback server if port is configured
|
|
524
|
-
if (MOM_LINK_PORT) {
|
|
525
|
-
startLinkServer(MOM_LINK_PORT, linkTokenStore, vaultManager, async (platform, conversationId, msg) => {
|
|
526
|
-
const bot = botsByPlatform[platform];
|
|
527
|
-
if (bot)
|
|
528
|
-
await bot.postMessage(conversationId, msg);
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
580
|
// Create platform bots
|
|
532
581
|
const bots = [];
|
|
533
582
|
const botsByPlatform = {};
|
|
@@ -561,6 +610,13 @@ if (hasDiscord) {
|
|
|
561
610
|
botsByPlatform.discord = discordBot;
|
|
562
611
|
log.logInfo("Platform: Discord");
|
|
563
612
|
}
|
|
613
|
+
if (MOM_LINK_PORT) {
|
|
614
|
+
startLinkServer(MOM_LINK_PORT, linkTokenStore, vaultManager, async (platform, conversationId, message) => {
|
|
615
|
+
const bot = botsByPlatform[platform];
|
|
616
|
+
if (bot)
|
|
617
|
+
await bot.postMessage(conversationId, message);
|
|
618
|
+
}, sessionViewTokenStore);
|
|
619
|
+
}
|
|
564
620
|
// Start events watcher with explicit platform routing
|
|
565
621
|
const eventsWatcher = createEventsWatcher(workingDir, botsByPlatform);
|
|
566
622
|
const slackBot = botsByPlatform.slack;
|