openmausbot 0.1.80 → 0.1.82
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 +6 -5
- package/dist/assets/{index-D6RZKWks.js → index-5HVwp5m2.js} +1 -1
- package/dist/assets/index-BLLsDK2F.js +309 -0
- package/dist/assets/index-CKysBq-V.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +41 -10
- package/dist-server/drivers/agents-proxy.js +50 -11
- package/dist-server/index.js +3525 -1296
- package/dist-server/openmausbot.js +412 -84
- package/dist-server/pair-cli.js +412 -84
- package/dist-server/server/box-delete-journal.js +471 -0
- package/dist-server/server/box.js +345 -75
- package/dist-server/server/cli-phone-setup.js +2 -2
- package/dist-server/server/cli.js +84 -12
- package/dist-server/server/config.js +48 -18
- package/dist-server/server/container-computer.js +7 -0
- package/dist-server/server/contracts.js +5 -0
- package/dist-server/server/default-model-selection.js +1 -0
- package/dist-server/server/drivers/acp/core.js +113 -19
- package/dist-server/server/drivers/acp/opencode-go.js +19 -0
- package/dist-server/server/drivers/agents-proxy.js +49 -11
- package/dist-server/server/drivers/chat-mcp-tools.js +6 -1
- package/dist-server/server/drivers/claude.js +33 -2
- package/dist-server/server/drivers/codex.js +93 -8
- package/dist-server/server/drivers/native.js +6 -1
- package/dist-server/server/harness/bus.js +4 -0
- package/dist-server/server/harness/registry.js +1 -0
- package/dist-server/server/index.js +869 -226
- package/dist-server/server/local-vm-lease.js +4 -0
- package/dist-server/server/mcp-http.js +337 -0
- package/dist-server/server/mcp-probe.js +67 -18
- package/dist-server/server/mcp-registry.js +153 -58
- package/dist-server/server/member-turn.js +13 -0
- package/dist-server/server/message-db.js +74 -8
- package/dist-server/server/notify.js +8 -3
- package/dist-server/server/recall-disclosure.js +4 -0
- package/dist-server/server/recent-work.js +142 -0
- package/dist-server/server/request-auth.js +3 -1
- package/dist-server/server/routines.js +36 -1
- package/dist-server/server/screen-frame-gate.js +4 -94
- package/dist-server/server/sessions.js +22 -3
- package/dist-server/server/store.js +16 -1
- package/dist-server/server/surface.js +41 -19
- package/dist-server/server/team-setup-requests.js +1 -1
- package/dist-server/server/thread-log-rotation.js +104 -0
- package/dist-server/server/tts/fish.js +155 -0
- package/dist-server/server/tts/index.js +25 -3
- package/dist-server/server/turn-resources.js +7 -2
- package/dist-server/server/vps-computer.js +26 -4
- package/dist-server/server/vps-ssh.js +88 -0
- package/dist-server/server/workspace-backup.js +11 -0
- package/dist-server/server/workspace.js +2 -1
- package/dist-server/shared/credential-request.js +10 -0
- package/dist-server/shared/tool-surface.js +62 -0
- package/dist-server/vps-container-mcp.js +41 -10
- package/package.json +1 -1
- package/dist/assets/index-Cu8BkIfo.js +0 -305
- package/dist/assets/index-tfpeJAIG.css +0 -1
|
@@ -20,6 +20,11 @@ function routineRequestOwnerKey(owner) {
|
|
|
20
20
|
}
|
|
21
21
|
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
|
|
22
22
|
const CATCH_UP_MS = 12 * 60 * 60_000;
|
|
23
|
+
/** How long before a due routine the computer is asked to stay awake. */
|
|
24
|
+
const WAKE_HORIZON_MS = 60 * 60_000;
|
|
25
|
+
/** How long a run may sit deferred behind a busy target before the user
|
|
26
|
+
* hears about it once. Surfacing only; dispatch semantics stay unchanged. */
|
|
27
|
+
export const ROUTINE_DEFERRAL_NOTICE_MS = 30 * 60_000;
|
|
23
28
|
const MAX_DATE_MS = 8_640_000_000_000_000;
|
|
24
29
|
const LOCAL_DAY_MS = 24 * 60 * 60_000;
|
|
25
30
|
const INTERVAL_RESTRICTION_SEARCH_MS = 9 * LOCAL_DAY_MS;
|
|
@@ -561,6 +566,21 @@ export class RoutineManager {
|
|
|
561
566
|
listRoutines() {
|
|
562
567
|
return this.routines.map(cloneRoutine);
|
|
563
568
|
}
|
|
569
|
+
/** Whether this computer should stay awake for routines: a run is in
|
|
570
|
+
* flight, or an enabled routine is due within the horizon. The schedule
|
|
571
|
+
* runs inside this process — a sleeping computer runs nothing — so the
|
|
572
|
+
* desktop shell holds a power assertion while this says so, and releases
|
|
573
|
+
* it the rest of the time. */
|
|
574
|
+
wakeHold(horizonMs = WAKE_HORIZON_MS) {
|
|
575
|
+
const now = this.now();
|
|
576
|
+
if (this.runs.some((run) => ["queued", "running", "waiting"].includes(run.status)))
|
|
577
|
+
return { hold: true, reason: "running" };
|
|
578
|
+
const due = this.routines
|
|
579
|
+
.filter((routine) => routine.enabled && routine.nextRunAt != null && routine.nextRunAt <= now + horizonMs)
|
|
580
|
+
.map((routine) => routine.nextRunAt)
|
|
581
|
+
.sort((a, b) => a - b)[0];
|
|
582
|
+
return due === undefined ? { hold: false } : { hold: true, reason: "due", at: due };
|
|
583
|
+
}
|
|
564
584
|
listRuns(from, to) {
|
|
565
585
|
return this.runs
|
|
566
586
|
.filter((r) => (from == null || r.scheduledFor >= from) && (to == null || r.scheduledFor <= to))
|
|
@@ -1151,8 +1171,23 @@ export class RoutineManager {
|
|
|
1151
1171
|
}
|
|
1152
1172
|
}
|
|
1153
1173
|
const state = this.targetState(run);
|
|
1154
|
-
if (state === "busy")
|
|
1174
|
+
if (state === "busy") {
|
|
1175
|
+
// A queued run behind a busy target is deferred, not silent. Stamp
|
|
1176
|
+
// the wait once so receipts and cards can say how long it has been
|
|
1177
|
+
// held; the run still dispatches the moment the target frees.
|
|
1178
|
+
if (run.deferredAt == null) {
|
|
1179
|
+
run.deferredAt = now;
|
|
1180
|
+
this.save();
|
|
1181
|
+
this.emitRun(run);
|
|
1182
|
+
}
|
|
1183
|
+
if (run.deferredNoticeAt == null && now - run.deferredAt >= ROUTINE_DEFERRAL_NOTICE_MS) {
|
|
1184
|
+
run.deferredNoticeAt = now;
|
|
1185
|
+
this.save();
|
|
1186
|
+
this.emitRun(run);
|
|
1187
|
+
this.options.onRunDeferred?.(cloneRun(run));
|
|
1188
|
+
}
|
|
1155
1189
|
continue;
|
|
1190
|
+
}
|
|
1156
1191
|
if (state === "missing") {
|
|
1157
1192
|
this.failRun(run, this.missingTargetMessage(run.target));
|
|
1158
1193
|
continue;
|
|
@@ -11,80 +11,10 @@
|
|
|
11
11
|
// is what keeps its shell-only replies from re-picturing the same idle
|
|
12
12
|
// desktop.
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
* for the local Mac, Local VM and VPS (docs/computer-use-integration.md).
|
|
19
|
-
*
|
|
20
|
-
* Deliberately NOT here: computer_exec (a shell — no screenshot unless
|
|
21
|
-
* asked, and the ask is not visible at the poke site); the read-only text
|
|
22
|
-
* tools (computer_status, browser_state, browser_snapshot, browser_read,
|
|
23
|
-
* observation_metrics, start_session, get_*, list_*, check_permissions);
|
|
24
|
-
* the waits (wait_for, wait_for_navigation, browser_wait_for — they change
|
|
25
|
-
* nothing, and the action they follow already counted); and the takeover
|
|
26
|
-
* pleas (computer_request_help, browser_request_takeover — whatever changed
|
|
27
|
-
* during those was the person's doing, and captures are withheld under
|
|
28
|
-
* their lease anyway). */
|
|
29
|
-
const SCREEN_TOUCHING_TOOLS = new Set([
|
|
30
|
-
// cloud box / remote computer
|
|
31
|
-
"screenshot",
|
|
32
|
-
"click",
|
|
33
|
-
"type_text",
|
|
34
|
-
"press_key",
|
|
35
|
-
"scroll",
|
|
36
|
-
"computer_batch",
|
|
37
|
-
"open_url",
|
|
38
|
-
"browser_click",
|
|
39
|
-
"browser_fill",
|
|
40
|
-
// built-in browser: the Electron surface's names, kept because a bot on an
|
|
41
|
-
// older remote harness can still call them
|
|
42
|
-
"browser_navigate",
|
|
43
|
-
"browser_type",
|
|
44
|
-
"browser_press",
|
|
45
|
-
"browser_scroll",
|
|
46
|
-
"browser_hover",
|
|
47
|
-
"browser_drag",
|
|
48
|
-
"browser_select_option",
|
|
49
|
-
"browser_back",
|
|
50
|
-
"browser_forward",
|
|
51
|
-
"browser_screenshot",
|
|
52
|
-
// built-in browser: agent-browser's names. The engine swap replaced the
|
|
53
|
-
// Electron surface without renaming these here, so every browser turn
|
|
54
|
-
// stopped counting as screen work and no page ever reached the transcript.
|
|
55
|
-
// Read-only tools stay out by the same rule as everywhere else in this
|
|
56
|
-
// list: agent_browser_snapshot, _read and _get_text return text, and the
|
|
57
|
-
// waits change nothing the action before them did not already count.
|
|
58
|
-
"agent_browser_open",
|
|
59
|
-
"agent_browser_click",
|
|
60
|
-
"agent_browser_fill",
|
|
61
|
-
"agent_browser_type",
|
|
62
|
-
"agent_browser_press",
|
|
63
|
-
"agent_browser_select",
|
|
64
|
-
"agent_browser_check",
|
|
65
|
-
"agent_browser_screenshot",
|
|
66
|
-
// Cua Driver (local Mac, Local VM, VPS)
|
|
67
|
-
"double_click",
|
|
68
|
-
"right_click",
|
|
69
|
-
"drag",
|
|
70
|
-
"hotkey",
|
|
71
|
-
"move_cursor",
|
|
72
|
-
"launch_app",
|
|
73
|
-
"bring_to_front",
|
|
74
|
-
"zoom",
|
|
75
|
-
]);
|
|
76
|
-
// Keep legacy MCP namespaces accepted; desktop server__tool names follow
|
|
77
|
-
// MCP_NAME in mcp-registry.ts (lowercase letters, digits, underscores, hyphens).
|
|
78
|
-
const TOOL_NAMESPACE = /^(?:mcp__.+?|[a-z][a-z0-9_-]{0,31})__/;
|
|
79
|
-
/** The same tool reaches the poke site as `mcp__computer__click`,
|
|
80
|
-
* `computer__click`, bare `click`, or pi's `computer_click`.
|
|
81
|
-
* A single-underscore server prefix is stripped
|
|
82
|
-
* at most once, so pi's `computer_computer_exec` lands on `computer_exec`
|
|
83
|
-
* — still a shell — and never on a bare `exec`. */
|
|
84
|
-
export function screenTouchingTool(toolName) {
|
|
85
|
-
const bare = toolName.toLowerCase().replace(TOOL_NAMESPACE, "");
|
|
86
|
-
return SCREEN_TOUCHING_TOOLS.has(bare) || SCREEN_TOUCHING_TOOLS.has(bare.replace(/^(?:computer|browser)_/, ""));
|
|
87
|
-
}
|
|
14
|
+
// Tool-name classification lives in shared/tool-surface.ts so the renderer
|
|
15
|
+
// reads a tool the same way the poller does.
|
|
16
|
+
import { screenSurfaceForTool, screenTouchingTool } from "../shared/tool-surface.js";
|
|
17
|
+
export { screenSurfaceForTool, screenTouchingTool };
|
|
88
18
|
/** sha256 over the base64 frame — the same fingerprint the model-side
|
|
89
19
|
* observation dedupe uses (server/computer-observation.ts). */
|
|
90
20
|
export function screenFrameHash(png) {
|
|
@@ -96,23 +26,3 @@ export function screenFrameHash(png) {
|
|
|
96
26
|
export function settledFrameIsNews(shownFrameHash, png) {
|
|
97
27
|
return shownFrameHash === undefined || screenFrameHash(png) !== shownFrameHash;
|
|
98
28
|
}
|
|
99
|
-
/** Which surface a screen-touching tool acted on. A bot can hold a computer
|
|
100
|
-
* and a browser at once, and they are different pictures: agent-browser runs
|
|
101
|
-
* its own headless Chrome on the host, not inside the bot's desktop. Picking
|
|
102
|
-
* by the tool is what keeps a browsed page from being illustrated with an
|
|
103
|
-
* idle Local VM. */
|
|
104
|
-
export function screenSurfaceForTool(toolName) {
|
|
105
|
-
const name = toolName.toLowerCase();
|
|
106
|
-
// The computer server's browser_click/fill act inside the desktop, not
|
|
107
|
-
// in agent-browser. Keep the server identity before stripping prefixes.
|
|
108
|
-
if (name.startsWith("mcp__computer__") || name.startsWith("computer_"))
|
|
109
|
-
return "computer";
|
|
110
|
-
if (name.startsWith("mcp__browser__") || name.startsWith("browser__"))
|
|
111
|
-
return "browser";
|
|
112
|
-
const bare = name.replace(TOOL_NAMESPACE, "");
|
|
113
|
-
// Codex reports bare names. These two belong to computer-proxy; the
|
|
114
|
-
// standalone browser now uses the unambiguous agent_browser_* names.
|
|
115
|
-
if (bare === "browser_click" || bare === "browser_fill")
|
|
116
|
-
return "computer";
|
|
117
|
-
return bare.startsWith("agent_browser_") || bare.startsWith("browser_") ? "browser" : "computer";
|
|
118
|
-
}
|
|
@@ -95,6 +95,18 @@ export function generatePairingCode() {
|
|
|
95
95
|
}
|
|
96
96
|
return code;
|
|
97
97
|
}
|
|
98
|
+
/** The prefix that tells the two encodings apart on the wire. A credential is
|
|
99
|
+
* matched byte for byte; only a typed code is normalized. */
|
|
100
|
+
export const PAIRING_CREDENTIAL_PREFIX = "omb_pair_";
|
|
101
|
+
/** The same pairing window as a 256-bit secret, for a QR a native app scans
|
|
102
|
+
* rather than a code a person reads out. 9 + 43 characters: the Android
|
|
103
|
+
* companion checks that length exactly (android/core Connection.kt). */
|
|
104
|
+
export function generatePairingCredential() {
|
|
105
|
+
return `${PAIRING_CREDENTIAL_PREFIX}${randomBytes(32).toString("base64url")}`;
|
|
106
|
+
}
|
|
107
|
+
export function isPairingCredential(value) {
|
|
108
|
+
return value.startsWith(PAIRING_CREDENTIAL_PREFIX);
|
|
109
|
+
}
|
|
98
110
|
/** Accept what a human typed: dashes, spaces, lowercase, lookalikes. */
|
|
99
111
|
export function normalizePairingCode(input) {
|
|
100
112
|
return input
|
|
@@ -245,17 +257,19 @@ export class SessionRegistry {
|
|
|
245
257
|
this.prune();
|
|
246
258
|
const now = this.now();
|
|
247
259
|
const code = generatePairingCode();
|
|
260
|
+
const credential = generatePairingCredential();
|
|
248
261
|
const scopes = input.scopes?.length ? [...new Set(input.scopes)] : [...SCOPES];
|
|
249
262
|
const pairing = {
|
|
250
263
|
id: randomUUID(),
|
|
251
264
|
codeHash: sha256(code),
|
|
265
|
+
credentialHash: sha256(credential),
|
|
252
266
|
scopes,
|
|
253
267
|
label: (input.label ?? "").trim().slice(0, 80),
|
|
254
268
|
createdAt: now,
|
|
255
269
|
expiresAt: now + (input.ttlMs ?? PAIRING_CODE_TTL_MS),
|
|
256
270
|
};
|
|
257
271
|
this.pairings.push(pairing);
|
|
258
|
-
return { id: pairing.id, code, expiresAt: pairing.expiresAt };
|
|
272
|
+
return { id: pairing.id, code, credential, expiresAt: pairing.expiresAt };
|
|
259
273
|
}
|
|
260
274
|
openPairings() {
|
|
261
275
|
this.prune();
|
|
@@ -303,7 +317,12 @@ export class SessionRegistry {
|
|
|
303
317
|
exchange(input) {
|
|
304
318
|
this.prune();
|
|
305
319
|
const now = this.now();
|
|
306
|
-
|
|
320
|
+
// A credential is hashed as presented. Normalizing it would be actively
|
|
321
|
+
// unsafe: normalizePairingCode folds 0 to O and 1 to I, which both
|
|
322
|
+
// destroys a base64url secret and maps distinct secrets onto one digest.
|
|
323
|
+
const presented = isPairingCredential(input.code)
|
|
324
|
+
? sha256(input.code)
|
|
325
|
+
: sha256(normalizePairingCode(input.code));
|
|
307
326
|
const attemptId = typeof input.attemptId === "string" && /^[\w-]{8,64}$/.test(input.attemptId) ? input.attemptId : null;
|
|
308
327
|
const replay = attemptId ? this.replays.find((r) => r.attemptId === attemptId && sameDigest(r.codeHash, presented)) : undefined;
|
|
309
328
|
if (replay)
|
|
@@ -313,7 +332,7 @@ export class SessionRegistry {
|
|
|
313
332
|
const seconds = Math.ceil(lock.retryAfterMs / 1000);
|
|
314
333
|
return { ok: false, status: 429, error: `too many failed pairing attempts from your address; try again in ${seconds}s` };
|
|
315
334
|
}
|
|
316
|
-
const index = this.pairings.findIndex((p) => sameDigest(p.codeHash, presented));
|
|
335
|
+
const index = this.pairings.findIndex((p) => sameDigest(p.codeHash, presented) || sameDigest(p.credentialHash, presented));
|
|
317
336
|
if (index < 0) {
|
|
318
337
|
this.recordFailure(input.source);
|
|
319
338
|
return { ok: false, status: 401, error: "pairing code is wrong or has expired; create a new one on the server" };
|
|
@@ -27,7 +27,7 @@ export function isProjectEmoji(value) {
|
|
|
27
27
|
const TASK_PATCH_FIELDS = [
|
|
28
28
|
"title", "projectId", "modelSelection", "approvalMode", "autoApprove", "alwaysAllow",
|
|
29
29
|
"unread", "rewound", "archivedAt", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
|
|
30
|
-
"routineRunId",
|
|
30
|
+
"routineRunId", "surface",
|
|
31
31
|
];
|
|
32
32
|
/** Everything the BOT authored is scrubbed of content-shaped secrets before
|
|
33
33
|
* it is stored: its reply text, a tool title (an ACP engine's title can be
|
|
@@ -1296,6 +1296,21 @@ export class Store {
|
|
|
1296
1296
|
this.emit({ type: "bot", botId: id });
|
|
1297
1297
|
return bot;
|
|
1298
1298
|
}
|
|
1299
|
+
/** Voice ids belong to one provider's catalog. Changing the workspace
|
|
1300
|
+
* provider invalidates every per-agent selection as one durable mutation,
|
|
1301
|
+
* before clients are told to pick replacement voices. */
|
|
1302
|
+
clearVoiceSelections() {
|
|
1303
|
+
const changed = this.bots.filter((bot) => bot.voice !== undefined && bot.voice !== "");
|
|
1304
|
+
if (!changed.length)
|
|
1305
|
+
return [];
|
|
1306
|
+
const next = this.bots.map((bot) => bot.voice === undefined || bot.voice === "" ? bot : { ...bot, voice: undefined });
|
|
1307
|
+
this.saveBots(next);
|
|
1308
|
+
for (const bot of changed) {
|
|
1309
|
+
delete bot.voice;
|
|
1310
|
+
this.emit({ type: "bot", botId: bot.id });
|
|
1311
|
+
}
|
|
1312
|
+
return changed;
|
|
1313
|
+
}
|
|
1299
1314
|
/** Commit a validated profile change before publishing its fields. Unlike
|
|
1300
1315
|
* runtime revocation, a failed user edit must leave the old profile intact. */
|
|
1301
1316
|
patchBotProfile(id, patch) {
|
|
@@ -20,12 +20,15 @@ export function surfaceOfComputerKind(kind) {
|
|
|
20
20
|
}
|
|
21
21
|
const NO_BROWSER_NOTE = " This bot is set to work in the built-in browser, but the built-in browser is switched off in App Settings, so you have no browser and no computer this turn — say so instead of guessing.";
|
|
22
22
|
const OFF_NOTE = " This bot's \"Works on\" setting is Off, so no computer and no built-in browser are mounted this turn: you cannot open a page, click, or type on any screen. If the user asks for something that needs one, tell them Works on is Off in this bot's settings — never claim you are opening a browser you do not have.";
|
|
23
|
-
/** Decide what a turn mounts
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
23
|
+
/** Decide what a turn mounts. One place per turn: a computer destination
|
|
24
|
+
* mounts only that computer (web work happens in its own browser), a browser
|
|
25
|
+
* destination mounts only the built-in browser, Off mounts nothing. A
|
|
26
|
+
* conversation pin — set by the person from the composer, or by the
|
|
27
|
+
* conversation's own first turn on Auto — wins over the bot's default, so a
|
|
28
|
+
* thread never changes place under someone; only Off overrides it. Auto
|
|
29
|
+
* without a pin leaves the computer choice to the dispatch, which then
|
|
30
|
+
* mounts the browser only when no computer was reached. A pin the turn
|
|
31
|
+
* cannot honour is retained and reported instead of being swapped silently. */
|
|
29
32
|
export function resolveSurface(input) {
|
|
30
33
|
const { destination, browserOn } = input;
|
|
31
34
|
// Off is the whole answer: no computer and no browser. It used to withhold
|
|
@@ -38,25 +41,27 @@ export function resolveSurface(input) {
|
|
|
38
41
|
if (destination === "off") {
|
|
39
42
|
return { computer: "off", browser: false, pinned: null, clearPin: false, note: OFF_NOTE };
|
|
40
43
|
}
|
|
44
|
+
const pin = input.pinnedSurface ?? null;
|
|
45
|
+
if (pin === "browser") {
|
|
46
|
+
if (browserOn)
|
|
47
|
+
return { computer: "off", browser: true, pinned: "browser", clearPin: false, note: "" };
|
|
48
|
+
// A missing browser is not permission to act on the host instead. Keep
|
|
49
|
+
// the chosen place so a retry or unrelated provider failure cannot move
|
|
50
|
+
// the task to a different signed-in computer.
|
|
51
|
+
return { computer: "off", browser: false, pinned: pin, clearPin: false,
|
|
52
|
+
note: " This conversation is pinned to the built-in browser, but its tools are unavailable this turn. No computer is mounted instead. Do not claim to have used it; a different place must be selected before acting there." };
|
|
53
|
+
}
|
|
54
|
+
if (pin)
|
|
55
|
+
return { computer: pin, browser: false, pinned: pin, clearPin: false, note: "" };
|
|
41
56
|
if (destination === "browser") {
|
|
42
57
|
return browserOn
|
|
43
58
|
? { computer: "off", browser: true, pinned: null, clearPin: false, note: "" }
|
|
44
59
|
: { computer: "off", browser: false, pinned: null, clearPin: false, note: NO_BROWSER_NOTE };
|
|
45
60
|
}
|
|
46
61
|
if (destination !== undefined) {
|
|
47
|
-
return { computer: destination, browser:
|
|
48
|
-
}
|
|
49
|
-
const pin = input.pinnedSurface ?? null;
|
|
50
|
-
if (pin === null) {
|
|
51
|
-
return { computer: undefined, browser: browserOn, pinned: null, clearPin: false, note: "" };
|
|
52
|
-
}
|
|
53
|
-
const reachable = pin === "browser" ? browserOn : input.available?.[pin] === true;
|
|
54
|
-
if (!reachable) {
|
|
55
|
-
return { computer: undefined, browser: browserOn, pinned: null, clearPin: true, note: "" };
|
|
62
|
+
return { computer: destination, browser: false, pinned: null, clearPin: false, note: "" };
|
|
56
63
|
}
|
|
57
|
-
return
|
|
58
|
-
? { computer: "off", browser: true, pinned: "browser", clearPin: false, note: "" }
|
|
59
|
-
: { computer: pin, browser: false, pinned: pin, clearPin: false, note: "" };
|
|
64
|
+
return { computer: undefined, browser: browserOn, pinned: null, clearPin: false, note: "" };
|
|
60
65
|
}
|
|
61
66
|
/** How the prompt and the app name a surface. Deliberately the same words
|
|
62
67
|
* the panel uses, so "tell them it is on the cloud computer" points at a
|
|
@@ -73,6 +78,10 @@ export function surfaceLabel(surface) {
|
|
|
73
78
|
return "the built-in browser";
|
|
74
79
|
}
|
|
75
80
|
}
|
|
81
|
+
/** Said once per task, so the person hears the place before the first click
|
|
82
|
+
* lands there. Same words as the panel and the composer chip. */
|
|
83
|
+
const RESTATE_SENTENCE = " Before your first action on a screen or page in a task, say in one short sentence where you are working, using that same name.";
|
|
84
|
+
const SURFACE_AUTHORITY = " For browser and computer tasks, use OpenMausBot's mounted browser/computer tools first: inspect the target, perform the action, and verify its result before claiming success. Discover deferred tools by their server/name when needed. Do not substitute the provider's own desktop, a shell-launched browser, or another automation path for the selected OpenMausBot surface. Ordinary code and file tasks may still use their normal tools. Announcing an action is not performing it. A request naming another place does not move these tools: this computer is the user's host, Local VM is an isolated desktop, the cloud computer is remote, and the built-in browser is a separate browser. If the requested place differs from the mounted one, explain the mismatch and ask the user to change the conversation's computer selector; never act on a different computer or describe a host window as a VM.";
|
|
76
85
|
/** The one paragraph that says where this turn's work happens. Assembled
|
|
77
86
|
* from what was actually mounted, never from the setting, so the model is
|
|
78
87
|
* only ever told about tools it can call. */
|
|
@@ -91,9 +100,22 @@ export function surfacePrompt(mounted, opts = {}) {
|
|
|
91
100
|
text =
|
|
92
101
|
" Everything you do on screen happens in the built-in browser tab; there is no desktop, file or shell computer this turn. If you need the user to sign in, tell them it is in the Browser tab of the Computer panel.";
|
|
93
102
|
}
|
|
103
|
+
if (text)
|
|
104
|
+
text += RESTATE_SENTENCE + (opts.canSelect
|
|
105
|
+
? SURFACE_AUTHORITY.replace("explain the mismatch and ask the user to change the conversation's computer selector", "inspect connected choices with select_computer and select the requested available place; on a pending result end this turn so OpenMausBot can reconnect the correct tools and continue the original request")
|
|
106
|
+
: SURFACE_AUTHORITY);
|
|
107
|
+
else if (!opts.note && !opts.canSelect) {
|
|
108
|
+
text = " No computer or built-in browser tools are mounted this turn. You cannot open apps, click, or inspect a screen through OpenMausBot. If asked for screen work, explain this and ask the user to choose and connect a computer in the Computer panel; do not claim to have opened or checked it.";
|
|
109
|
+
}
|
|
94
110
|
if (opts.pinned) {
|
|
95
|
-
text += ` This
|
|
111
|
+
text += ` This conversation is pinned to ${surfaceLabel(opts.pinned)}; changing places requires ${opts.canSelect ? "select_computer or " : ""}the conversation's computer selector, not a different tool name.`;
|
|
96
112
|
}
|
|
113
|
+
if (opts.canSelect)
|
|
114
|
+
text += " For a screen task, use select_computer with no arguments when you need to inspect the actual available targets. Choose the requested place from its result; if the user left the place open, use a suitable available target or surface auto instead of asking them to operate the menu. Browser-only work can stay in Browser; when it needs desktop apps or capabilities the current Browser lacks, select an available Local VM without asking the user to switch it manually. Choose before taking actions, and do not repeat actions already completed if the task must continue elsewhere. OpenMausBot can start an existing configured computer and highlight the selected target. If the right tools are already mounted, use them directly. On a pending switch, end this turn: the original request resumes automatically with the new tools, and then you must carry out the task. Only ask for input for a genuine blocker, such as missing setup, required sign-in or an approval. Never silently replace an explicitly requested VM with the host desktop.";
|
|
115
|
+
if (opts.canSelect)
|
|
116
|
+
text += " If no suitable computer is running but its provider is configured, select_computer can provision one for this computer task; reuse existing resources first. Do not provision merely for ordinary chat or inspection.";
|
|
117
|
+
if (opts.canSelect && !computer && !mounted.browser)
|
|
118
|
+
text += " No computer or browser tools are mounted yet; select_computer is the way to connect them before screen work, not a reason to claim you already performed it.";
|
|
97
119
|
return text + (opts.note ?? "");
|
|
98
120
|
}
|
|
99
121
|
/** Tool names that touch a screen, for engines that report bare names.
|
|
@@ -194,7 +194,7 @@ export class TeamSetupRequestService {
|
|
|
194
194
|
const chief = this.chief(request.botId);
|
|
195
195
|
const lines = [`Why: ${request.reason}`];
|
|
196
196
|
if (request.deletion)
|
|
197
|
-
lines.push(`Delete @${request.deletion.name} (${request.deletion.botId}).`, "Permanently removes this bot, all its conversations, memory, instructions, and
|
|
197
|
+
lines.push(`Delete @${request.deletion.name} (${request.deletion.botId}).`, "Permanently removes this bot, all its conversations, memory, instructions, skills, and any computer owned only by it. Generated project files and shared team computers remain. Active work or an unavailable provider can block deletion safely.");
|
|
198
198
|
if (request.newTeams.length)
|
|
199
199
|
lines.push(`Create teams: ${request.newTeams.map((name) => JSON.stringify(name)).join(", ")}.`, `Authorize @${chief.name} to coordinate and propose setup changes in these new teams.`);
|
|
200
200
|
for (const operation of request.operations) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Per-thread log rotation — the size cap for the two NDJSON tees each
|
|
2
|
+
// thread grows: events/<threadId>.ndjson (the bus's canonical stream,
|
|
3
|
+
// server/harness/bus.ts) and native/<threadId>.ndjson (the provider-native
|
|
4
|
+
// tee, server/drivers/native.ts). Retention sweeps only reach threads that
|
|
5
|
+
// closed or archived; a thread that stays open grows both files without
|
|
6
|
+
// bound (#1280), and a busy thread can stay open for weeks.
|
|
7
|
+
//
|
|
8
|
+
// When a capped file outgrows its limit it is rewritten in place, keeping
|
|
9
|
+
// the newest half of its bytes and cutting only on a line boundary — the
|
|
10
|
+
// same recent tail readThreadEvents already serves. One filename per
|
|
11
|
+
// thread and log, before and after: every reader and every cleanup path
|
|
12
|
+
// (thread, bot and group delete; the retention sweep) already targets that
|
|
13
|
+
// one name, so rotation can never orphan a fragment. What rotates away is
|
|
14
|
+
// gone; the cap is opt-in and off by default.
|
|
15
|
+
import { closeSync, fstatSync, openSync, readSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
|
|
16
|
+
let capProvider = () => null;
|
|
17
|
+
/** Bound once at server boot: config.json is read per process (a change
|
|
18
|
+
* restarts the server, like every other hand-edited knob), so a binding
|
|
19
|
+
* made at startup stays current for the life of the process. */
|
|
20
|
+
export function bindThreadLogCapProvider(provider) {
|
|
21
|
+
capProvider = provider;
|
|
22
|
+
}
|
|
23
|
+
export function currentThreadLogCap() {
|
|
24
|
+
try {
|
|
25
|
+
return capProvider();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Check a freshly appended log against the configured cap. Best effort and
|
|
32
|
+
* never throwing: a rotation that cannot complete leaves the file exactly
|
|
33
|
+
* as it was, and logging must never break the run it is observing. */
|
|
34
|
+
export function capThreadLog(file, maxBytes) {
|
|
35
|
+
if (typeof maxBytes !== "number" || !Number.isFinite(maxBytes) || maxBytes <= 0)
|
|
36
|
+
return;
|
|
37
|
+
let size;
|
|
38
|
+
try {
|
|
39
|
+
size = statSync(file).size;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (size <= maxBytes)
|
|
45
|
+
return;
|
|
46
|
+
trimToTail(file, Math.max(1, Math.floor(maxBytes / 2)));
|
|
47
|
+
}
|
|
48
|
+
function trimToTail(file, keepBytes) {
|
|
49
|
+
const staging = `${file}.trim`;
|
|
50
|
+
try {
|
|
51
|
+
const source = openSync(file, "r");
|
|
52
|
+
try {
|
|
53
|
+
const size = fstatSync(source).size;
|
|
54
|
+
const start = Math.max(0, size - keepBytes);
|
|
55
|
+
const window = Buffer.allocUnsafe(size - start);
|
|
56
|
+
// readSync may hand back fewer bytes than asked for, so fill the
|
|
57
|
+
// window in a loop. A zero count is an early EOF — the file shrank
|
|
58
|
+
// under us — and the original is left untouched.
|
|
59
|
+
let filled = 0;
|
|
60
|
+
while (filled < window.length) {
|
|
61
|
+
const count = readSync(source, window, filled, window.length - filled, start + filled);
|
|
62
|
+
if (count === 0)
|
|
63
|
+
return;
|
|
64
|
+
filled += count;
|
|
65
|
+
}
|
|
66
|
+
// The window starts mid-line unless a record happened to end exactly
|
|
67
|
+
// at `start`; drop everything up to the first newline so the rewritten
|
|
68
|
+
// file opens on a record boundary. A window without a usable newline
|
|
69
|
+
// is a single record bigger than the keep window — leave the file
|
|
70
|
+
// alone rather than corrupt the only copy.
|
|
71
|
+
const firstNewline = window.indexOf(0x0a);
|
|
72
|
+
if (firstNewline === -1 || firstNewline + 1 >= window.length)
|
|
73
|
+
return;
|
|
74
|
+
const target = openSync(staging, "w", 0o600);
|
|
75
|
+
try {
|
|
76
|
+
// writeSync can also stop short of the requested length; keep
|
|
77
|
+
// going until every remaining byte lands, and treat zero progress
|
|
78
|
+
// as a failure so the outer catch clears the staging file.
|
|
79
|
+
let written = firstNewline + 1;
|
|
80
|
+
while (written < window.length) {
|
|
81
|
+
const count = writeSync(target, window, written, window.length - written);
|
|
82
|
+
if (count === 0)
|
|
83
|
+
throw new Error(`no progress writing ${staging}`);
|
|
84
|
+
written += count;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
closeSync(target);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
closeSync(source);
|
|
93
|
+
}
|
|
94
|
+
renameSync(staging, file);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
try {
|
|
98
|
+
rmSync(staging, { force: true });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* an inert staging file is the worst case */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Fish Audio text to speech. The API key stays on the harness: the renderer
|
|
2
|
+
// receives only voice metadata and synthesized audio bytes.
|
|
3
|
+
const API = (process.env.OMB_FISH_AUDIO_API || "https://api.fish.audio").replace(/\/+$/, "");
|
|
4
|
+
const MODEL = "s2.1-pro";
|
|
5
|
+
const MAX_ERROR_LENGTH = 240;
|
|
6
|
+
const PAGE_SIZE = 100;
|
|
7
|
+
const MAX_OWNED_PAGES = 100;
|
|
8
|
+
async function safeBody(res) {
|
|
9
|
+
try {
|
|
10
|
+
const text = await res.text();
|
|
11
|
+
if (!text)
|
|
12
|
+
return null;
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(text);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return { message: text };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function serviceDetail(body) {
|
|
25
|
+
if (!body || typeof body !== "object")
|
|
26
|
+
return "";
|
|
27
|
+
const record = body;
|
|
28
|
+
const detail = record.detail;
|
|
29
|
+
const message = (typeof detail === "string" && detail) ||
|
|
30
|
+
(detail && typeof detail === "object" && typeof detail.message === "string"
|
|
31
|
+
? detail.message
|
|
32
|
+
: "") ||
|
|
33
|
+
(typeof record.message === "string" && record.message) ||
|
|
34
|
+
(typeof record.error === "string" && record.error) ||
|
|
35
|
+
"";
|
|
36
|
+
return message.trim().slice(0, MAX_ERROR_LENGTH);
|
|
37
|
+
}
|
|
38
|
+
function errorMessage(status, action, body) {
|
|
39
|
+
const detail = serviceDetail(body);
|
|
40
|
+
if (status === 401 || status === 403) {
|
|
41
|
+
return "Fish Audio rejected that API key, or the key cannot access voice models.";
|
|
42
|
+
}
|
|
43
|
+
if (status === 402)
|
|
44
|
+
return detail || "Fish Audio says this account has insufficient balance.";
|
|
45
|
+
if (status === 429)
|
|
46
|
+
return detail || "Fish Audio is rate-limiting this account — wait a moment and try again.";
|
|
47
|
+
return detail ? `${action} failed: ${detail}` : `${action} failed (${status})`;
|
|
48
|
+
}
|
|
49
|
+
/** Verify the exact permission the voice picker needs. */
|
|
50
|
+
export async function verifyKey(key) {
|
|
51
|
+
try {
|
|
52
|
+
// The public catalog is intentionally readable without authentication,
|
|
53
|
+
// so it cannot prove a supplied key. `self=true` crosses the account
|
|
54
|
+
// boundary and returns 401 for an absent or invalid Bearer token.
|
|
55
|
+
const res = await fetch(`${API}/model?page_size=1&self=true`, {
|
|
56
|
+
headers: { authorization: `Bearer ${key}` },
|
|
57
|
+
signal: AbortSignal.timeout(20_000),
|
|
58
|
+
});
|
|
59
|
+
if (res.ok)
|
|
60
|
+
return { ok: true };
|
|
61
|
+
return { ok: false, message: errorMessage(res.status, "checking that key", await safeBody(res)) };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return { ok: false, message: "Couldn't reach Fish Audio to check that key — check your connection." };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function modelPage(key, query) {
|
|
68
|
+
let response;
|
|
69
|
+
try {
|
|
70
|
+
response = await fetch(`${API}/model?${query}`, {
|
|
71
|
+
headers: { authorization: `Bearer ${key}` },
|
|
72
|
+
signal: AbortSignal.timeout(20_000),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new Error("Couldn't reach Fish Audio to list voices — check your connection.");
|
|
77
|
+
}
|
|
78
|
+
const body = await safeBody(response);
|
|
79
|
+
if (!response.ok)
|
|
80
|
+
throw new Error(errorMessage(response.status, "listing voices", body));
|
|
81
|
+
if (!body || typeof body !== "object")
|
|
82
|
+
return { items: [], hasMore: false };
|
|
83
|
+
const page = body;
|
|
84
|
+
return {
|
|
85
|
+
items: Array.isArray(page.items) ? page.items : [],
|
|
86
|
+
hasMore: page.has_more === true,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export async function listVoices(key) {
|
|
90
|
+
// Fish defaults to ten results and excludes private account voices from
|
|
91
|
+
// the public catalog. Load the popular public page and every page belonging
|
|
92
|
+
// to this account, so a user's 101st custom voice remains selectable.
|
|
93
|
+
const [publicPage, owned] = await Promise.all([
|
|
94
|
+
modelPage(key, `page_size=${PAGE_SIZE}&sort_by=task_count`),
|
|
95
|
+
(async () => {
|
|
96
|
+
const items = [];
|
|
97
|
+
for (let pageNumber = 1; pageNumber <= MAX_OWNED_PAGES; pageNumber += 1) {
|
|
98
|
+
const page = await modelPage(key, `page_size=${PAGE_SIZE}&self=true&sort_by=created_at&page_number=${pageNumber}`);
|
|
99
|
+
items.push(...page.items);
|
|
100
|
+
if (!page.hasMore)
|
|
101
|
+
return items;
|
|
102
|
+
}
|
|
103
|
+
throw new Error("Fish Audio returned too many voice pages. Narrow the account's voice library and try again.");
|
|
104
|
+
})(),
|
|
105
|
+
]);
|
|
106
|
+
const items = [...publicPage.items, ...owned];
|
|
107
|
+
const voices = items.flatMap((item) => {
|
|
108
|
+
if (!item || typeof item !== "object")
|
|
109
|
+
return [];
|
|
110
|
+
const model = item;
|
|
111
|
+
// `/model` includes SVC models and account models that are still
|
|
112
|
+
// training or have failed. Only trained TTS models are valid
|
|
113
|
+
// `reference_id` values for `/v1/tts`.
|
|
114
|
+
if (model.type !== "tts" || model.state !== "trained")
|
|
115
|
+
return [];
|
|
116
|
+
const id = typeof model._id === "string" ? model._id.trim() : "";
|
|
117
|
+
if (!id)
|
|
118
|
+
return [];
|
|
119
|
+
const label = typeof model.title === "string" && model.title.trim() ? model.title.trim() : "Voice";
|
|
120
|
+
const description = typeof model.description === "string" && model.description.trim()
|
|
121
|
+
? model.description.trim().slice(0, MAX_ERROR_LENGTH)
|
|
122
|
+
: undefined;
|
|
123
|
+
return [{ id, label, description }];
|
|
124
|
+
});
|
|
125
|
+
return [...new Map(voices.map((voice) => [voice.id, voice])).values()];
|
|
126
|
+
}
|
|
127
|
+
export async function synthesize(text, voiceId, key) {
|
|
128
|
+
let res;
|
|
129
|
+
try {
|
|
130
|
+
res = await fetch(`${API}/v1/tts`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: {
|
|
133
|
+
authorization: `Bearer ${key}`,
|
|
134
|
+
"content-type": "application/json",
|
|
135
|
+
accept: "audio/mpeg",
|
|
136
|
+
model: MODEL,
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
text,
|
|
140
|
+
reference_id: voiceId,
|
|
141
|
+
format: "mp3",
|
|
142
|
+
sample_rate: 44_100,
|
|
143
|
+
mp3_bitrate: 64,
|
|
144
|
+
latency: "normal",
|
|
145
|
+
}),
|
|
146
|
+
signal: AbortSignal.timeout(60_000),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new Error("Couldn't reach Fish Audio to generate speech — check your connection.");
|
|
151
|
+
}
|
|
152
|
+
if (!res.ok)
|
|
153
|
+
throw new Error(errorMessage(res.status, "speaking", await safeBody(res)));
|
|
154
|
+
return { bytes: new Uint8Array(await res.arrayBuffer()), mime: "audio/mpeg" };
|
|
155
|
+
}
|