openmausbot 0.1.78 → 0.1.80
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/assets/index-Cu8BkIfo.js +305 -0
- package/dist/assets/{index-BqDJqf2C.js → index-D6RZKWks.js} +1 -1
- package/dist/assets/index-tfpeJAIG.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +11 -5
- package/dist-server/drivers/agents-proxy.js +21 -6
- package/dist-server/enterprise/server/index.js +0 -1
- package/dist-server/index.js +15942 -6859
- package/dist-server/local-computer.js +0 -1
- package/dist-server/mcp-server.js +8 -1
- package/dist-server/openmausbot.js +9303 -786
- package/dist-server/pair-cli.js +9303 -786
- package/dist-server/proxy-paths.js +0 -1
- package/dist-server/server/box.js +98 -41
- package/dist-server/server/browser-engine.js +1 -1
- package/dist-server/server/browser-runtime.js +11 -3
- package/dist-server/server/browser-tool-shape.js +72 -0
- package/dist-server/server/claude-accounts.js +1 -0
- package/dist-server/server/cli-setup.js +1 -1
- package/dist-server/server/composio.js +18 -0
- package/dist-server/server/config.js +16 -4
- package/dist-server/server/container-computer.js +0 -12
- package/dist-server/server/drivers/acp/core.js +4 -19
- package/dist-server/server/drivers/agents-proxy.js +27 -2
- package/dist-server/server/drivers/boxagent.js +48 -4
- package/dist-server/server/drivers/chat-mcp-tools.js +368 -0
- package/dist-server/server/drivers/chat-tool-approval.js +42 -0
- package/dist-server/server/drivers/claude.js +86 -38
- package/dist-server/server/drivers/codex.js +225 -75
- package/dist-server/server/drivers/grok.js +4 -0
- package/dist-server/server/drivers/minimax.js +11 -3
- package/dist-server/server/drivers/openai-chat-protocol.js +92 -0
- package/dist-server/server/drivers/openai-chat.js +332 -88
- package/dist-server/server/drivers/openai-compat.js +4 -0
- package/dist-server/server/drivers/pi.js +1 -9
- package/dist-server/server/harness/registry.js +2 -2
- package/dist-server/server/index.js +267 -50
- package/dist-server/server/managed-desktop.js +205 -0
- package/dist-server/server/model-context-window.js +20 -0
- package/dist-server/server/proxy-paths.js +0 -1
- package/dist-server/server/redact.js +1 -0
- package/dist-server/server/room-handoffs.js +57 -9
- package/dist-server/server/store.js +13 -1
- package/dist-server/server/tts/chatterbox.js +83 -0
- package/dist-server/server/tts/index.js +45 -12
- package/dist-server/server/workspace.js +12 -2
- package/dist-server/shared/ask-question.js +28 -0
- package/dist-server/shared/computer-contention.js +17 -0
- package/dist-server/vps-container-mcp.js +11 -5
- package/enterprise/server/index.js +0 -1
- package/package.json +1 -1
- package/dist/assets/index-DjwAroIZ.js +0 -305
- package/dist/assets/index-Pbb6Ao0s.css +0 -1
- package/dist-server/computer-proxy.js +0 -1196
- package/dist-server/server/computer-proxy.js +0 -1070
- package/dist-server/server/remote-computer.js +0 -170
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
const uuid = z.string().uuid();
|
|
6
|
+
const model = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._/:+-]{0,199}$/).refine(value => !value.includes("::"));
|
|
7
|
+
const connectionSchema = z.object({
|
|
8
|
+
portalOrigin: z.string().max(2048), organizationId: uuid, organizationName: z.string().trim().min(1).max(100),
|
|
9
|
+
email: z.string().email().max(320), deviceId: uuid, token: z.string().regex(/^omg_[A-Za-z0-9_-]{43}$/),
|
|
10
|
+
expiresAt: z.number().finite().int().positive(),
|
|
11
|
+
providers: z.array(z.object({ id: z.enum(["anthropic", "openai", "openrouter"]), configured: z.boolean(), models: z.array(model).max(500) }).strict()).max(3),
|
|
12
|
+
}).strict();
|
|
13
|
+
const signature = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
14
|
+
const expiredMessage = "Company access has ended. Reconnect your organization or explicitly choose a personal model; personal billing will not be used automatically.";
|
|
15
|
+
/** Validate only messages received over Electron's private utility-parent port.
|
|
16
|
+
* Never expose this input to renderer HTTP or merge it into saved AppConfig.
|
|
17
|
+
*/
|
|
18
|
+
export function parseManagedDesktopConnection(raw, now = Date.now()) {
|
|
19
|
+
if (raw === null)
|
|
20
|
+
return null;
|
|
21
|
+
const parsed = connectionSchema.safeParse(raw);
|
|
22
|
+
if (!parsed.success)
|
|
23
|
+
throw new Error("Invalid Company connection from the desktop parent.");
|
|
24
|
+
const value = parsed.data, url = new URL(value.portalOrigin);
|
|
25
|
+
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash ||
|
|
26
|
+
(url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))) {
|
|
27
|
+
throw new Error("Company connection must use an HTTPS origin or loopback fixture.");
|
|
28
|
+
}
|
|
29
|
+
if (value.expiresAt <= now)
|
|
30
|
+
throw new Error("Company connection expired. Reconnect your organization.");
|
|
31
|
+
if (new Set(value.providers.map(provider => provider.id)).size !== value.providers.length)
|
|
32
|
+
throw new Error("Duplicate Company provider configuration.");
|
|
33
|
+
return { ...value, portalOrigin: url.origin, email: value.email.toLowerCase(), providers: value.providers.map(provider => ({ ...provider, models: [...new Set(provider.models)] })).sort((a, b) => a.id.localeCompare(b.id)) };
|
|
34
|
+
}
|
|
35
|
+
export function companyInstanceId(connection, provider) {
|
|
36
|
+
// Device identity prevents an old company's native resume cursor from being
|
|
37
|
+
// inherited by a different enrollment using the same organization/email.
|
|
38
|
+
return `company.${signature([connection.portalOrigin, connection.organizationId, connection.deviceId]).slice(0, 24)}.${provider}`;
|
|
39
|
+
}
|
|
40
|
+
export function companyInstanceConfigs(connection, runtimeDirectory) {
|
|
41
|
+
const entries = {};
|
|
42
|
+
for (const provider of connection.providers) {
|
|
43
|
+
if (!provider.configured || !provider.models.length)
|
|
44
|
+
continue;
|
|
45
|
+
const id = companyInstanceId(connection, provider.id), base = `${connection.portalOrigin}/api/desktop/gateway/${provider.id}`;
|
|
46
|
+
const displayName = `Company · ${connection.organizationName} · ${provider.id === "anthropic" ? "Claude" : provider.id === "openai" ? "Codex" : "OpenRouter"}`;
|
|
47
|
+
if (provider.id === "anthropic")
|
|
48
|
+
entries[id] = {
|
|
49
|
+
driver: "claudeAgent", displayName,
|
|
50
|
+
config: { configDir: join(runtimeDirectory, id, "claude"), managed: true },
|
|
51
|
+
environment: { ANTHROPIC_API_KEY: connection.token, ANTHROPIC_AUTH_TOKEN: connection.token, ANTHROPIC_BASE_URL: base, ANTHROPIC_MODEL: provider.models[0] },
|
|
52
|
+
};
|
|
53
|
+
if (provider.id === "openai")
|
|
54
|
+
entries[id] = {
|
|
55
|
+
driver: "codex", displayName, config: { managed: { url: `${base}/v1`, models: provider.models } },
|
|
56
|
+
environment: { OPENMAUSBOT_COMPANY_API_KEY: connection.token, CODEX_HOME: join(runtimeDirectory, id, "codex") },
|
|
57
|
+
};
|
|
58
|
+
if (provider.id === "openrouter")
|
|
59
|
+
entries[id] = {
|
|
60
|
+
driver: "openai-compat", displayName, config: { url: `${base}/v1`, apiKeyEnv: "OPENMAUSBOT_COMPANY_API_KEY", model: provider.models[0], provider: "" },
|
|
61
|
+
environment: { OPENMAUSBOT_COMPANY_API_KEY: connection.token },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
|
66
|
+
/** One memory-only Company overlay, separate from all personal settings.
|
|
67
|
+
* Revocation fences wrappers synchronously, then stops only their instances.
|
|
68
|
+
*/
|
|
69
|
+
export class ManagedDesktopProviders {
|
|
70
|
+
connection = null;
|
|
71
|
+
currentSignature = signature(null);
|
|
72
|
+
revision = 0;
|
|
73
|
+
ids = new Set();
|
|
74
|
+
tail = Promise.resolve();
|
|
75
|
+
timer;
|
|
76
|
+
now;
|
|
77
|
+
options;
|
|
78
|
+
constructor(options) { this.options = options; this.now = options.now ?? Date.now; }
|
|
79
|
+
info(instanceId) {
|
|
80
|
+
if (!this.ids.has(instanceId) || !this.connection || this.connection.expiresAt <= this.now())
|
|
81
|
+
return;
|
|
82
|
+
return { organizationId: this.connection.organizationId, organizationName: this.connection.organizationName };
|
|
83
|
+
}
|
|
84
|
+
/** Ids this overlay has ever created in this process, so a personal instance
|
|
85
|
+
* that happens to be named company.* is never treated as managed. */
|
|
86
|
+
known = new Set();
|
|
87
|
+
owns(instanceId) { return this.known.has(instanceId); }
|
|
88
|
+
apply(raw, force = false) {
|
|
89
|
+
const connection = parseManagedDesktopConnection(raw, this.now()), nextSignature = signature(connection);
|
|
90
|
+
if (!force && nextSignature === this.currentSignature)
|
|
91
|
+
return this.tail;
|
|
92
|
+
this.currentSignature = nextSignature;
|
|
93
|
+
this.connection = connection;
|
|
94
|
+
const revision = ++this.revision;
|
|
95
|
+
clearTimeout(this.timer);
|
|
96
|
+
const replace = async () => {
|
|
97
|
+
const previous = [...this.ids];
|
|
98
|
+
await this.options.beforeReplace?.(previous);
|
|
99
|
+
for (const id of previous)
|
|
100
|
+
await this.options.registry.dispose(id);
|
|
101
|
+
this.ids.clear();
|
|
102
|
+
if (revision !== this.revision || !connection || connection.expiresAt <= this.now()) {
|
|
103
|
+
this.options.afterReplace?.([]);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Keep native sessions across server/app restarts, but outside portable
|
|
107
|
+
// workspace backups. Device-scoped ids prevent a new enrollment from
|
|
108
|
+
// inheriting another account's native conversation or credentials.
|
|
109
|
+
const directory = this.ensureDirectory(["providers", "company"]);
|
|
110
|
+
const configs = companyInstanceConfigs(connection, directory);
|
|
111
|
+
for (const [id, entry] of Object.entries(configs)) {
|
|
112
|
+
if (this.options.registry.entries().some(existing => existing.instanceId === id))
|
|
113
|
+
throw new Error("Company instance conflicts with an existing account.");
|
|
114
|
+
if (entry.driver === "claudeAgent" || entry.driver === "codex") {
|
|
115
|
+
this.ensureDirectory(["providers", "company", id, entry.driver === "claudeAgent" ? "claude" : "codex"]);
|
|
116
|
+
}
|
|
117
|
+
this.ids.add(id);
|
|
118
|
+
this.known.add(id);
|
|
119
|
+
}
|
|
120
|
+
const active = () => revision === this.revision && connection.expiresAt > this.now();
|
|
121
|
+
await this.options.registry.load(configs, instance => {
|
|
122
|
+
const provider = connection.providers.find(provider => companyInstanceId(connection, provider.id) === instance.instanceId);
|
|
123
|
+
const models = { default: provider.models[0], options: provider.models.map(id => ({ id, label: `${id} (Company)` })) };
|
|
124
|
+
return {
|
|
125
|
+
...instance, models,
|
|
126
|
+
refreshModels: async () => { },
|
|
127
|
+
startAuthentication: undefined, getAuthentication: undefined, completeAuthentication: undefined, cancelAuthentication: undefined, signOut: undefined,
|
|
128
|
+
snapshot: async () => {
|
|
129
|
+
if (!active())
|
|
130
|
+
return { state: "unavailable", reason: expiredMessage };
|
|
131
|
+
const snapshot = await instance.snapshot();
|
|
132
|
+
if (!active())
|
|
133
|
+
return { state: "unavailable", reason: expiredMessage };
|
|
134
|
+
return { ...snapshot, authenticated: snapshot.state === "available", billing: "metered", account: { email: connection.email, organization: connection.organizationName, method: "api-key" } };
|
|
135
|
+
},
|
|
136
|
+
adapter: {
|
|
137
|
+
...instance.adapter,
|
|
138
|
+
sendTurn: async (input) => {
|
|
139
|
+
if (!active())
|
|
140
|
+
throw new Error(expiredMessage);
|
|
141
|
+
if (!input.model || !provider.models.includes(input.model))
|
|
142
|
+
throw new Error("This model is not enabled by your Company administrator.");
|
|
143
|
+
return instance.adapter.sendTurn(input);
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
if (revision === this.revision) {
|
|
149
|
+
this.options.afterReplace?.([...this.ids]);
|
|
150
|
+
this.scheduleExpiry(connection, revision);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
this.tail = this.tail.catch(() => { }).then(replace).catch(async (error) => {
|
|
154
|
+
// A partial replacement must neither retain usable Company wrappers
|
|
155
|
+
// nor memoize failure forever. Do not invalidate a newer queued grant.
|
|
156
|
+
if (revision === this.revision) {
|
|
157
|
+
this.revision++;
|
|
158
|
+
this.connection = null;
|
|
159
|
+
this.currentSignature = null;
|
|
160
|
+
clearTimeout(this.timer);
|
|
161
|
+
}
|
|
162
|
+
await Promise.allSettled([...this.ids].map(id => this.options.registry.dispose(id)));
|
|
163
|
+
this.ids.clear();
|
|
164
|
+
this.options.afterReplace?.([]);
|
|
165
|
+
throw error;
|
|
166
|
+
});
|
|
167
|
+
return this.tail;
|
|
168
|
+
}
|
|
169
|
+
/** Called after the ordinary fleet is rebuilt; never add Company to cfg. */
|
|
170
|
+
restore() { return this.apply(this.connection && this.connection.expiresAt > this.now() ? this.connection : null, true); }
|
|
171
|
+
ensureDirectory(parts) {
|
|
172
|
+
// Only the configured workspace root may already be an operator-chosen
|
|
173
|
+
// alias. None of our owned descendants may redirect into personal homes.
|
|
174
|
+
mkdirSync(this.options.dataDirectory, { recursive: true, mode: 0o700 });
|
|
175
|
+
let directory = this.options.dataDirectory;
|
|
176
|
+
for (const part of parts) {
|
|
177
|
+
directory = join(directory, part);
|
|
178
|
+
try {
|
|
179
|
+
mkdirSync(directory, { mode: 0o700 });
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error.code !== "EEXIST")
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
const stat = lstatSync(directory);
|
|
186
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
187
|
+
throw new Error("Company native storage must be an owned workspace directory.");
|
|
188
|
+
}
|
|
189
|
+
return directory;
|
|
190
|
+
}
|
|
191
|
+
scheduleExpiry(connection, revision) {
|
|
192
|
+
this.timer = setTimeout(() => {
|
|
193
|
+
if (revision !== this.revision)
|
|
194
|
+
return;
|
|
195
|
+
if (connection.expiresAt > this.now())
|
|
196
|
+
this.scheduleExpiry(connection, revision);
|
|
197
|
+
else
|
|
198
|
+
void this.apply(null).catch(() => { });
|
|
199
|
+
}, Math.min(2_147_483_647, Math.max(1, connection.expiresAt - this.now())));
|
|
200
|
+
this.timer.unref?.();
|
|
201
|
+
}
|
|
202
|
+
async close() {
|
|
203
|
+
await this.apply(null, true);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The model's window, when the driver did not say. A conservative pattern
|
|
2
|
+
// table over the model id: a wrong window would put a wrong percentage next
|
|
3
|
+
// to the context figure, so unknown models get no window and the chip shows
|
|
4
|
+
// the absolute count alone.
|
|
5
|
+
export function modelContextWindow(model) {
|
|
6
|
+
if (!model)
|
|
7
|
+
return undefined;
|
|
8
|
+
const id = model.toLowerCase();
|
|
9
|
+
if (/claude/.test(id) || /^(opus|sonnet|haiku|fable)\b/.test(id))
|
|
10
|
+
return /\[1m\]|-1m\b|1m-context/.test(id) ? 1_000_000 : 200_000;
|
|
11
|
+
if (/^(gpt-5|o[34]\b|codex)/.test(id))
|
|
12
|
+
return 272_000;
|
|
13
|
+
if (id.startsWith("gpt-4.1"))
|
|
14
|
+
return 1_000_000;
|
|
15
|
+
if (/gemini/.test(id))
|
|
16
|
+
return 1_000_000;
|
|
17
|
+
if (id.startsWith("grok"))
|
|
18
|
+
return 256_000;
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
@@ -32,7 +32,6 @@ export function resolveProxy(relative) {
|
|
|
32
32
|
* the check that would have caught the 0.1.24 breakage. */
|
|
33
33
|
export const SPAWNED_PROXIES = {
|
|
34
34
|
browser: resolveProxy("browser-proxy"),
|
|
35
|
-
computer: resolveProxy("computer-proxy"),
|
|
36
35
|
localComputer: resolveProxy("local-computer-proxy"),
|
|
37
36
|
permission: resolveProxy("permission-proxy"),
|
|
38
37
|
containerMcp: resolveProxy("container-mcp"),
|
|
@@ -34,6 +34,7 @@ const mask = (value) => (REDACTION_MARKER.test(value) ? value : `«redacted ${va
|
|
|
34
34
|
// generic "long hex/base64" heuristic would rewrite real code in the
|
|
35
35
|
// transcript, so only shapes that are unmistakably credentials match.
|
|
36
36
|
const KEY_PREFIXES = [
|
|
37
|
+
/\bom[dg]_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g, // desktop device and model-only credentials
|
|
37
38
|
/\bsk-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g, // anthropic / openai / stripe
|
|
38
39
|
/\bxai-[A-Za-z0-9_-]{20,}/g, // xai (grok)
|
|
39
40
|
/\bgsk_[A-Za-z0-9]{40,}/g, // groq
|
|
@@ -8,11 +8,13 @@ const nodeSchema = z.object({
|
|
|
8
8
|
key: z.string(), text: z.string(), createdAt: z.number(),
|
|
9
9
|
status: z.enum(["source", "queued", "running", "waiting", "resume", "completed", "failed", "cancelled"]),
|
|
10
10
|
result: z.string().default(""), reported: z.boolean().default(false),
|
|
11
|
-
executions: z.number().int().nonnegative().default(0),
|
|
11
|
+
executions: z.number().int().nonnegative().default(0), startedAt: z.number().optional(),
|
|
12
12
|
approvalGranted: z.boolean().default(false),
|
|
13
13
|
kind: z.enum(["work", "assignment"]).default("work"),
|
|
14
14
|
});
|
|
15
|
-
export const ROOM_HANDOFF_LIMITS = { depth: 4, requests: 24, executions: 48, lifetimeMs: 30 * 60_000 };
|
|
15
|
+
export const ROOM_HANDOFF_LIMITS = { depth: 4, requests: 24, executions: 48, lifetimeMs: 30 * 60_000, minRunwayMs: 10 * 60_000, queueMs: 60 * 60_000 };
|
|
16
|
+
/** Renders elapsed milliseconds as whole minutes, or seconds under one minute. */
|
|
17
|
+
const duration = (ms) => ms >= 60_000 ? `${Math.floor(ms / 60_000)}m` : `${Math.floor(ms / 1000)}s`;
|
|
16
18
|
const terminal = (n) => ["completed", "failed", "cancelled"].includes(n.status);
|
|
17
19
|
/** A bounded tree of addressed room turns. Waiting for children never holds a
|
|
18
20
|
* room/provider queue; reporting is data, and only the named parent is resumed.
|
|
@@ -65,6 +67,41 @@ export class RoomHandoffs {
|
|
|
65
67
|
}
|
|
66
68
|
return path;
|
|
67
69
|
}
|
|
70
|
+
/** The earliest moment this node may be failed for lifetime: the tree
|
|
71
|
+
* ceiling, a running node's own start plus a minimum runway, or, for work
|
|
72
|
+
* parked in a busy teammate's queue, its own queue window (#1238). */
|
|
73
|
+
deadline(n) {
|
|
74
|
+
const anchor = n.status === "running" ? n.startedAt ?? n.createdAt : n.createdAt;
|
|
75
|
+
const ceiling = n.status === "queued" && n.executions === 0
|
|
76
|
+
? anchor + ROOM_HANDOFF_LIMITS.queueMs
|
|
77
|
+
: this.root(n).createdAt + ROOM_HANDOFF_LIMITS.lifetimeMs;
|
|
78
|
+
return Math.max(ceiling, anchor + ROOM_HANDOFF_LIMITS.minRunwayMs);
|
|
79
|
+
}
|
|
80
|
+
/** An ancestor past its ceiling is not failed while a descendant is still
|
|
81
|
+
* running inside its own runway; cancelling would cascade into that work. */
|
|
82
|
+
protectsRunner(n) {
|
|
83
|
+
return this.children(n.id).some(c => !terminal(c) && ((c.status === "running" && this.now() <= this.deadline(c)) || this.protectsRunner(c)));
|
|
84
|
+
}
|
|
85
|
+
/** A parent still owes the follow-up execution that decides on its
|
|
86
|
+
* children's results; the ceiling defers to that execution's own runway.
|
|
87
|
+
* A child parked in a queue has produced nothing to decide on yet. */
|
|
88
|
+
owesFollowUp(n) {
|
|
89
|
+
if (n.status === "resume")
|
|
90
|
+
return true;
|
|
91
|
+
if (n.status !== "waiting")
|
|
92
|
+
return false;
|
|
93
|
+
const children = this.children(n.id);
|
|
94
|
+
return (children.length > 0 && children.every(c => terminal(c))) ||
|
|
95
|
+
children.some(c => this.owesFollowUp(c) || (c.status === "queued" && c.executions === 0));
|
|
96
|
+
}
|
|
97
|
+
/** Names the budget, the node's status, and the elapsed time. Work that
|
|
98
|
+
* never started reports the queue window it waited out, not the tree's. */
|
|
99
|
+
lifetimeError(n) {
|
|
100
|
+
if (n.status === "queued" && n.executions === 0) {
|
|
101
|
+
return `Room handoff queue budget exhausted: never started while waiting for a busy teammate after ${duration(this.now() - n.createdAt)} of the ${duration(ROOM_HANDOFF_LIMITS.queueMs)} queue window`;
|
|
102
|
+
}
|
|
103
|
+
return `Room handoff lifetime budget exhausted: node was ${n.status} after ${duration(this.now() - this.root(n).createdAt)} of the ${duration(ROOM_HANDOFF_LIMITS.lifetimeMs)} tree lifetime`;
|
|
104
|
+
}
|
|
68
105
|
enqueue(source, generation, parentId, target, key, text, approvalGranted = false, rework = false, sourceText = "") {
|
|
69
106
|
if (this.loadError)
|
|
70
107
|
throw new Error(this.loadError);
|
|
@@ -100,8 +137,14 @@ export class RoomHandoffs {
|
|
|
100
137
|
throw new Error("Room handoff depth limit reached");
|
|
101
138
|
const root = fresh ? parent : this.root(parent);
|
|
102
139
|
const count = [...this.nodes.values()].filter(n => n.rootId === parent.rootId && n.parentId).length;
|
|
103
|
-
if (count >= ROOM_HANDOFF_LIMITS.requests
|
|
140
|
+
if (count >= ROOM_HANDOFF_LIMITS.requests)
|
|
104
141
|
throw new Error("Room handoff budget exhausted");
|
|
142
|
+
// Refuse work the tree's lifetime budget cannot honestly serve: a node
|
|
143
|
+
// accepted in the root's last minutes would be doomed at enqueue time.
|
|
144
|
+
const remaining = ROOM_HANDOFF_LIMITS.lifetimeMs - (this.now() - root.createdAt);
|
|
145
|
+
if (remaining < ROOM_HANDOFF_LIMITS.minRunwayMs) {
|
|
146
|
+
throw new Error(`Room handoff budget exhausted: only ${duration(Math.max(remaining, 0))} of the ${duration(ROOM_HANDOFF_LIMITS.lifetimeMs)} tree lifetime remains`);
|
|
147
|
+
}
|
|
105
148
|
// Retain a bounded audit history without evicting active requests.
|
|
106
149
|
if (this.nodes.size >= 1000) {
|
|
107
150
|
const oldRoots = [...this.nodes.values()].filter(n => !n.parentId && terminal(n)).sort((a, b) => a.createdAt - b.createdAt);
|
|
@@ -210,14 +253,18 @@ export class RoomHandoffs {
|
|
|
210
253
|
tick() {
|
|
211
254
|
if (this.loadError)
|
|
212
255
|
return;
|
|
256
|
+
// Validate and expire deepest nodes first so each one is failed with its
|
|
257
|
+
// own status; an ancestor's cancellation then only sweeps what is left.
|
|
258
|
+
for (const n of [...this.nodes.values()].reverse()) {
|
|
259
|
+
if (terminal(n))
|
|
260
|
+
continue;
|
|
261
|
+
const error = this.hooks.validate(n, n.parentId ? this.nodes.get(n.parentId) : undefined);
|
|
262
|
+
if (error || (this.now() > this.deadline(n) && !this.protectsRunner(n) && !this.owesFollowUp(n))) {
|
|
263
|
+
this.cancelTree(n, error ?? this.lifetimeError(n), "failed");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
213
266
|
for (const n of this.nodes.values()) {
|
|
214
267
|
const parent = n.parentId ? this.nodes.get(n.parentId) : undefined;
|
|
215
|
-
if (!terminal(n)) {
|
|
216
|
-
const error = this.hooks.validate(n, parent);
|
|
217
|
-
if (error || this.now() - this.root(n).createdAt > ROOM_HANDOFF_LIMITS.lifetimeMs) {
|
|
218
|
-
this.cancelTree(n, error ?? "Room request timed out", "failed");
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
268
|
if (terminal(n) && parent && !n.reported) {
|
|
222
269
|
this.hooks.report(n, parent);
|
|
223
270
|
n.reported = true;
|
|
@@ -253,6 +300,7 @@ export class RoomHandoffs {
|
|
|
253
300
|
const childCount = this.children(n.id).length;
|
|
254
301
|
root.executions += executionCost;
|
|
255
302
|
n.status = "running";
|
|
303
|
+
n.startedAt = this.now();
|
|
256
304
|
this.publish(n, root);
|
|
257
305
|
const controller = new AbortController();
|
|
258
306
|
this.controllers.set(n.id, controller);
|
|
@@ -26,7 +26,7 @@ export function isProjectEmoji(value) {
|
|
|
26
26
|
}
|
|
27
27
|
const TASK_PATCH_FIELDS = [
|
|
28
28
|
"title", "projectId", "modelSelection", "approvalMode", "autoApprove", "alwaysAllow",
|
|
29
|
-
"unread", "rewound", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
|
|
29
|
+
"unread", "rewound", "archivedAt", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
|
|
30
30
|
"routineRunId",
|
|
31
31
|
];
|
|
32
32
|
/** Everything the BOT authored is scrubbed of content-shaped secrets before
|
|
@@ -1485,12 +1485,24 @@ export class Store {
|
|
|
1485
1485
|
const turnInput = clean(turn.input);
|
|
1486
1486
|
const nextCachedInput = Math.min(clean(prev.cachedInput), prevInput)
|
|
1487
1487
|
+ Math.min(clean(turn.cachedInput), turnInput);
|
|
1488
|
+
const contextTokens = clean(turn.context?.tokens);
|
|
1489
|
+
const contextWindow = clean(turn.context?.window);
|
|
1488
1490
|
task.usage = {
|
|
1489
1491
|
input: prevInput + turnInput,
|
|
1490
1492
|
output: prev.output + clean(turn.output),
|
|
1491
1493
|
...(cachedKnown ? { cachedInput: nextCachedInput } : {}),
|
|
1492
1494
|
costUsd: cost === null ? prevCost : (prevCost ?? 0) + cost,
|
|
1493
1495
|
turns: prev.turns + 1,
|
|
1496
|
+
lastTurn: {
|
|
1497
|
+
input: turnInput, output: clean(turn.output),
|
|
1498
|
+
...(typeof turn.cachedInput === "number" ? { cachedInput: Math.min(clean(turn.cachedInput), turnInput) } : {}),
|
|
1499
|
+
costUsd: cost,
|
|
1500
|
+
},
|
|
1501
|
+
// a turn that reported no context keeps the previous reading rather
|
|
1502
|
+
// than pretending the window emptied
|
|
1503
|
+
...(contextTokens > 0
|
|
1504
|
+
? { context: { tokens: contextTokens, ...(contextWindow > 0 ? { window: contextWindow } : {}) } }
|
|
1505
|
+
: prev.context ? { context: prev.context } : {}),
|
|
1494
1506
|
};
|
|
1495
1507
|
this.saveBots();
|
|
1496
1508
|
this.emit({ type: "bot", botId });
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const DEFAULT_MODEL = "chatterbox-turbo";
|
|
2
|
+
/** What the picker falls back to when the server has no /v1/models. Most
|
|
3
|
+
* Chatterbox servers speak with one built-in voice unless told otherwise,
|
|
4
|
+
* so one honest entry beats a list of names the server may reject. */
|
|
5
|
+
export const FALLBACK_VOICES = [
|
|
6
|
+
{ id: "default", label: "Default", description: "the server's built-in Chatterbox voice" },
|
|
7
|
+
];
|
|
8
|
+
/** Accept "http://127.0.0.1:4123", the same with a trailing slash, and a
|
|
9
|
+
* base that already ends in /v1 — the address is typed by hand, so meet
|
|
10
|
+
* it halfway rather than 404ing over a slash. */
|
|
11
|
+
export function apiRoot(baseUrl) {
|
|
12
|
+
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
|
13
|
+
return /\/v1$/i.test(trimmed) ? trimmed : `${trimmed}/v1`;
|
|
14
|
+
}
|
|
15
|
+
async function safeJson(res) {
|
|
16
|
+
try {
|
|
17
|
+
return await res.json();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Prefer the server's own words over anything we can invent — wrapper
|
|
24
|
+
* services vary, and their "model not loaded" says more than a 500. */
|
|
25
|
+
function message(status, what, body) {
|
|
26
|
+
const theirs = (typeof body?.error === "string" && body.error.trim()) ||
|
|
27
|
+
(typeof body?.detail === "string" && body.detail.trim()) ||
|
|
28
|
+
(typeof body?.message === "string" && body.message.trim()) ||
|
|
29
|
+
"";
|
|
30
|
+
return theirs ? `${what} failed: ${theirs}` : `${what} failed (${status})`;
|
|
31
|
+
}
|
|
32
|
+
/** One bounded request shape for both calls: a dead server is the common
|
|
33
|
+
* failure, and it must be named in the user's words, quickly. */
|
|
34
|
+
async function request(url, init, what, baseUrl) {
|
|
35
|
+
try {
|
|
36
|
+
return await fetch(url, init);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
|
|
40
|
+
throw new Error(`${what} timed out — the Chatterbox server at ${baseUrl.trim()} did not answer`);
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`couldn't reach the Chatterbox server at ${baseUrl.trim()} — check that it is running`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function listChatterboxVoices(baseUrl) {
|
|
46
|
+
try {
|
|
47
|
+
const res = await request(`${apiRoot(baseUrl)}/models`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000) }, "listing voices", baseUrl);
|
|
48
|
+
if (res.ok) {
|
|
49
|
+
const body = await safeJson(res);
|
|
50
|
+
const voices = (body?.data ?? [])
|
|
51
|
+
.map((m) => ({ id: String(m?.id ?? ""), label: String(m?.id ?? "") }))
|
|
52
|
+
.filter((v) => v.id);
|
|
53
|
+
if (voices.length)
|
|
54
|
+
return voices;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// fall through: the built-in entry below beats an empty picker
|
|
59
|
+
}
|
|
60
|
+
return FALLBACK_VOICES;
|
|
61
|
+
}
|
|
62
|
+
/** Ask for wav because every browser plays it and every OpenAI-compatible
|
|
63
|
+
* server can produce it; the server's own content-type wins when it says
|
|
64
|
+
* something else, so returned bytes are never mislabeled. */
|
|
65
|
+
export async function synthesizeChatterbox(text, voiceId, baseUrl, model) {
|
|
66
|
+
const res = await request(`${apiRoot(baseUrl)}/audio/speech`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "content-type": "application/json", accept: "audio/wav" },
|
|
69
|
+
body: JSON.stringify({ model: model?.trim() || DEFAULT_MODEL, input: text, voice: voiceId, response_format: "wav" }),
|
|
70
|
+
signal: AbortSignal.timeout(60_000),
|
|
71
|
+
}, "speaking", baseUrl);
|
|
72
|
+
if (!res.ok)
|
|
73
|
+
throw new Error(message(res.status, "speaking", await safeJson(res)));
|
|
74
|
+
const header = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
75
|
+
const mime = header === "audio/mpeg" || header === "audio/mp3"
|
|
76
|
+
? "audio/mpeg"
|
|
77
|
+
: header === "audio/x-wav"
|
|
78
|
+
? "audio/wav"
|
|
79
|
+
: header.startsWith("audio/")
|
|
80
|
+
? header
|
|
81
|
+
: "audio/wav";
|
|
82
|
+
return { bytes: new Uint8Array(await res.arrayBuffer()), mime };
|
|
83
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as chatterbox from "./chatterbox.js";
|
|
1
2
|
import * as elevenlabs from "./elevenlabs.js";
|
|
2
3
|
import * as systemVoices from "./system-voices.js";
|
|
3
4
|
export class NoVoiceConfigured extends Error {
|
|
@@ -5,52 +6,73 @@ export class NoVoiceConfigured extends Error {
|
|
|
5
6
|
// runs under `node --experimental-strip-types`, which is strip-ONLY, so a
|
|
6
7
|
// parameter property is rejected at load time even though it typechecks
|
|
7
8
|
reason;
|
|
8
|
-
constructor(reason) {
|
|
9
|
-
super(
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
constructor(reason, hint) {
|
|
10
|
+
super(hint ??
|
|
11
|
+
(reason === "key"
|
|
12
|
+
? "Add an ElevenLabs key in Settings on the computer to turn on voice."
|
|
13
|
+
: "Pick a voice in the agent profile."));
|
|
12
14
|
this.reason = reason;
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
17
|
export function voiceProvider(cfg) {
|
|
16
|
-
return cfg.tts?.provider === "system"
|
|
18
|
+
return cfg.tts?.provider === "system" || cfg.tts?.provider === "chatterbox" ? cfg.tts.provider : "elevenlabs";
|
|
17
19
|
}
|
|
18
20
|
/** The system provider needs no credential — it is only ever offered where
|
|
19
21
|
* the platform actually has it, so "configured" means "this engine can
|
|
20
22
|
* speak", not "a key is on file". */
|
|
21
23
|
export function providerConfigured(cfg) {
|
|
22
|
-
|
|
24
|
+
const provider = voiceProvider(cfg);
|
|
25
|
+
if (provider === "system")
|
|
26
|
+
return systemVoices.systemVoicesAvailable();
|
|
27
|
+
if (provider === "chatterbox")
|
|
28
|
+
return Boolean(cfg.tts?.baseUrl?.trim());
|
|
29
|
+
return Boolean(cfg.tts?.key);
|
|
23
30
|
}
|
|
24
31
|
export function voiceConfigured(cfg) {
|
|
25
|
-
|
|
32
|
+
const provider = voiceProvider(cfg);
|
|
33
|
+
if (provider === "system") {
|
|
26
34
|
return systemVoices.systemVoicesAvailable() && Boolean(cfg.tts?.voice);
|
|
27
35
|
}
|
|
36
|
+
if (provider === "chatterbox")
|
|
37
|
+
return Boolean(cfg.tts?.baseUrl?.trim() && cfg.tts?.voice);
|
|
28
38
|
return Boolean(cfg.tts?.key && cfg.tts?.voice);
|
|
29
39
|
}
|
|
30
40
|
/** A per-bot voice is a complete choice too; it should not be blocked just
|
|
31
41
|
* because the app-wide fallback has not been selected yet. */
|
|
32
42
|
export function voiceReady(cfg, voiceId) {
|
|
33
|
-
|
|
43
|
+
const provider = voiceProvider(cfg);
|
|
44
|
+
if (provider === "system") {
|
|
34
45
|
return systemVoices.systemVoicesAvailable() && Boolean(voiceId || cfg.tts?.voice);
|
|
35
46
|
}
|
|
47
|
+
if (provider === "chatterbox")
|
|
48
|
+
return Boolean(cfg.tts?.baseUrl?.trim() && (voiceId || cfg.tts?.voice));
|
|
36
49
|
return Boolean(cfg.tts?.key && (voiceId || cfg.tts?.voice));
|
|
37
50
|
}
|
|
38
51
|
/** What the settings panel needs. Never includes the key — same write-only
|
|
39
|
-
* rule as every other credential.
|
|
52
|
+
* rule as every other credential. baseUrl and model are Chatterbox
|
|
53
|
+
* settings, not credentials, so they come back in full. */
|
|
40
54
|
export function describeVoice(cfg) {
|
|
55
|
+
const provider = voiceProvider(cfg);
|
|
41
56
|
return {
|
|
42
57
|
configured: providerConfigured(cfg),
|
|
43
58
|
ready: voiceConfigured(cfg),
|
|
44
59
|
voice: cfg.tts?.voice ?? "",
|
|
45
|
-
provider
|
|
60
|
+
provider,
|
|
61
|
+
baseUrl: provider === "chatterbox" ? (cfg.tts?.baseUrl ?? "") : "",
|
|
62
|
+
model: provider === "chatterbox" ? (cfg.tts?.model ?? "") : "",
|
|
46
63
|
};
|
|
47
64
|
}
|
|
48
65
|
export function verifyKey(key) {
|
|
49
66
|
return elevenlabs.verifyKey(key);
|
|
50
67
|
}
|
|
51
68
|
export async function listVoices(cfg, run) {
|
|
52
|
-
|
|
69
|
+
const provider = voiceProvider(cfg);
|
|
70
|
+
if (provider === "system")
|
|
53
71
|
return systemVoices.listSystemVoices(run);
|
|
72
|
+
if (provider === "chatterbox") {
|
|
73
|
+
const baseUrl = cfg.tts?.baseUrl?.trim();
|
|
74
|
+
return baseUrl ? chatterbox.listChatterboxVoices(baseUrl) : [];
|
|
75
|
+
}
|
|
54
76
|
const key = cfg.tts?.key;
|
|
55
77
|
if (!key)
|
|
56
78
|
return [];
|
|
@@ -59,7 +81,8 @@ export async function listVoices(cfg, run) {
|
|
|
59
81
|
/** Synthesize one utterance. Throws NoVoiceConfigured when there is nothing
|
|
60
82
|
* to speak with, which the route turns into a 409 the client can explain. */
|
|
61
83
|
export function speak(cfg, text, voiceId, run) {
|
|
62
|
-
|
|
84
|
+
const provider = voiceProvider(cfg);
|
|
85
|
+
if (provider === "system") {
|
|
63
86
|
const voice = voiceId || cfg.tts?.voice;
|
|
64
87
|
// An injected runner is the cross-platform test seam for `/usr/bin/say`;
|
|
65
88
|
// production calls omit it and remain strictly Darwin-gated.
|
|
@@ -69,6 +92,16 @@ export function speak(cfg, text, voiceId, run) {
|
|
|
69
92
|
throw new NoVoiceConfigured("voice");
|
|
70
93
|
return systemVoices.synthesizeSystem(text, voice, run);
|
|
71
94
|
}
|
|
95
|
+
if (provider === "chatterbox") {
|
|
96
|
+
const baseUrl = cfg.tts?.baseUrl?.trim();
|
|
97
|
+
if (!baseUrl) {
|
|
98
|
+
throw new NoVoiceConfigured("key", "Add the address of your Chatterbox server in Settings on the computer to turn on voice.");
|
|
99
|
+
}
|
|
100
|
+
const voice = voiceId || cfg.tts?.voice;
|
|
101
|
+
if (!voice)
|
|
102
|
+
throw new NoVoiceConfigured("voice");
|
|
103
|
+
return chatterbox.synthesizeChatterbox(text, voice, baseUrl, cfg.tts?.model);
|
|
104
|
+
}
|
|
72
105
|
const key = cfg.tts?.key;
|
|
73
106
|
if (!key)
|
|
74
107
|
throw new NoVoiceConfigured("key");
|
|
@@ -16,6 +16,10 @@ import { redactSecretsInText } from "./redact.js";
|
|
|
16
16
|
import { DATA_DIR } from "./config.js";
|
|
17
17
|
export const WORKSPACES_DIR = join(DATA_DIR, "workspaces");
|
|
18
18
|
export const TASK_WORKSPACES_DIR = join(DATA_DIR, "task-workspaces");
|
|
19
|
+
/** MCP support does not imply native filesystem tools or a local working directory. */
|
|
20
|
+
export function supportsWorkspaceFiles(driverKind) {
|
|
21
|
+
return !["grok", "openai-compat", "minimax", "boxAgent"].includes(driverKind);
|
|
22
|
+
}
|
|
19
23
|
/** Default task files are private to the thread, outside the bot's shared
|
|
20
24
|
* memory folder. This is directory organization, not a shell sandbox. */
|
|
21
25
|
export function ensureTaskWorkspace(botId, threadId) {
|
|
@@ -519,9 +523,15 @@ export function memorySystemPrompt(botId, opts = {}) {
|
|
|
519
523
|
const memory = loadMemory(botId);
|
|
520
524
|
const memoryFile = join(workspaceDir(botId), "MEMORY.md");
|
|
521
525
|
const topicDir = join(workspaceDir(botId), "memory");
|
|
526
|
+
if (opts.fileTools === false && !opts.managedWrites) {
|
|
527
|
+
if (!memory)
|
|
528
|
+
return "";
|
|
529
|
+
return ` Your saved memory is supplied as context; this turn has no memory editing tools.\n\nYour memory (MEMORY.md):\n${memory.text}${memory.truncated ? " [Only the initial memory excerpt is visible.]" : ""}`;
|
|
530
|
+
}
|
|
522
531
|
const writeGuidance = opts.managedWrites
|
|
523
532
|
? " This memory is shared across your independent threads. Use memory_update for every change to MEMORY.md, never direct file tools or whole-file overwrites." +
|
|
524
|
-
" Append new facts, or replace/remove an exact unique old_text passage. If it conflicts,
|
|
533
|
+
" Append new facts, or replace/remove an exact unique old_text passage. If it conflicts, " +
|
|
534
|
+
(opts.fileTools === false ? "use session_search to find the current passage" : "read the current file") + " and retry only your intended change."
|
|
525
535
|
: " When you learn something worth keeping, update it with your file tools; remove notes that turn out to be wrong.";
|
|
526
536
|
const guidance = ` Your private long-term memory file is ${JSON.stringify(memoryFile)}.` +
|
|
527
537
|
" It stays separate from a custom project working folder." +
|
|
@@ -533,7 +543,7 @@ export function memorySystemPrompt(botId, opts = {}) {
|
|
|
533
543
|
return guidance;
|
|
534
544
|
const truncatedNote = memory.truncated
|
|
535
545
|
? ` [MEMORY.md is ${memory.lines} lines and ${memory.bytes} bytes; only the first ${MEMORY_MAX_LINES} lines / ${MEMORY_MAX_BYTES} bytes are shown above and the rest is not visible to you. ${opts.managedWrites
|
|
536
|
-
? "Consolidate it now with memory_update: replace or remove older entries, or move detail to a memory/<topic>.md file."
|
|
546
|
+
? "Consolidate it now with memory_update: replace or remove older entries" + (opts.fileTools === false ? "." : ", or move detail to a memory/<topic>.md file.")
|
|
537
547
|
: "Trim it with your file tools: merge or remove older entries, or move detail to a memory/<topic>.md file."}]`
|
|
538
548
|
: "";
|
|
539
549
|
return `${guidance}\n\nYour memory (MEMORY.md):\n${memory.text}${truncatedNote}`;
|