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.
Files changed (56) hide show
  1. package/dist/assets/index-Cu8BkIfo.js +305 -0
  2. package/dist/assets/{index-BqDJqf2C.js → index-D6RZKWks.js} +1 -1
  3. package/dist/assets/index-tfpeJAIG.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +11 -5
  6. package/dist-server/drivers/agents-proxy.js +21 -6
  7. package/dist-server/enterprise/server/index.js +0 -1
  8. package/dist-server/index.js +15942 -6859
  9. package/dist-server/local-computer.js +0 -1
  10. package/dist-server/mcp-server.js +8 -1
  11. package/dist-server/openmausbot.js +9303 -786
  12. package/dist-server/pair-cli.js +9303 -786
  13. package/dist-server/proxy-paths.js +0 -1
  14. package/dist-server/server/box.js +98 -41
  15. package/dist-server/server/browser-engine.js +1 -1
  16. package/dist-server/server/browser-runtime.js +11 -3
  17. package/dist-server/server/browser-tool-shape.js +72 -0
  18. package/dist-server/server/claude-accounts.js +1 -0
  19. package/dist-server/server/cli-setup.js +1 -1
  20. package/dist-server/server/composio.js +18 -0
  21. package/dist-server/server/config.js +16 -4
  22. package/dist-server/server/container-computer.js +0 -12
  23. package/dist-server/server/drivers/acp/core.js +4 -19
  24. package/dist-server/server/drivers/agents-proxy.js +27 -2
  25. package/dist-server/server/drivers/boxagent.js +48 -4
  26. package/dist-server/server/drivers/chat-mcp-tools.js +368 -0
  27. package/dist-server/server/drivers/chat-tool-approval.js +42 -0
  28. package/dist-server/server/drivers/claude.js +86 -38
  29. package/dist-server/server/drivers/codex.js +225 -75
  30. package/dist-server/server/drivers/grok.js +4 -0
  31. package/dist-server/server/drivers/minimax.js +11 -3
  32. package/dist-server/server/drivers/openai-chat-protocol.js +92 -0
  33. package/dist-server/server/drivers/openai-chat.js +332 -88
  34. package/dist-server/server/drivers/openai-compat.js +4 -0
  35. package/dist-server/server/drivers/pi.js +1 -9
  36. package/dist-server/server/harness/registry.js +2 -2
  37. package/dist-server/server/index.js +267 -50
  38. package/dist-server/server/managed-desktop.js +205 -0
  39. package/dist-server/server/model-context-window.js +20 -0
  40. package/dist-server/server/proxy-paths.js +0 -1
  41. package/dist-server/server/redact.js +1 -0
  42. package/dist-server/server/room-handoffs.js +57 -9
  43. package/dist-server/server/store.js +13 -1
  44. package/dist-server/server/tts/chatterbox.js +83 -0
  45. package/dist-server/server/tts/index.js +45 -12
  46. package/dist-server/server/workspace.js +12 -2
  47. package/dist-server/shared/ask-question.js +28 -0
  48. package/dist-server/shared/computer-contention.js +17 -0
  49. package/dist-server/vps-container-mcp.js +11 -5
  50. package/enterprise/server/index.js +0 -1
  51. package/package.json +1 -1
  52. package/dist/assets/index-DjwAroIZ.js +0 -305
  53. package/dist/assets/index-Pbb6Ao0s.css +0 -1
  54. package/dist-server/computer-proxy.js +0 -1196
  55. package/dist-server/server/computer-proxy.js +0 -1070
  56. 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 || this.now() - root.createdAt > ROOM_HANDOFF_LIMITS.lifetimeMs)
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(reason === "key"
10
- ? "Add an ElevenLabs key in Settings on the computer to turn on voice."
11
- : "Pick a voice in the agent profile.");
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" ? "system" : "elevenlabs";
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
- return voiceProvider(cfg) === "system" ? systemVoices.systemVoicesAvailable() : Boolean(cfg.tts?.key);
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
- if (voiceProvider(cfg) === "system") {
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
- if (voiceProvider(cfg) === "system") {
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: voiceProvider(cfg),
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
- if (voiceProvider(cfg) === "system")
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
- if (voiceProvider(cfg) === "system") {
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, read the current file and retry only your intended change."
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}`;