openmausbot 0.1.70 → 0.1.72

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 (82) hide show
  1. package/dist/assets/index-BFPYw6P-.js +296 -0
  2. package/dist/assets/{index-D1RGhLUK.js → index-Bpai86ua.js} +1 -1
  3. package/dist/assets/index-D1odUdTi.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/listener.js +12 -2
  6. package/dist-server/container-mcp.js +28 -2
  7. package/dist-server/drivers/agents-proxy.js +202 -16
  8. package/dist-server/drivers/pi-mcp-extension.ts +1 -1
  9. package/dist-server/index.js +9443 -3023
  10. package/dist-server/local-computer.js +6 -1
  11. package/dist-server/mcp-gate.js +543 -0
  12. package/dist-server/mcp-server.js +38 -0
  13. package/dist-server/openmausbot.js +1893 -583
  14. package/dist-server/pair-cli.js +1893 -583
  15. package/dist-server/proxy-paths.js +1 -0
  16. package/dist-server/server/agent-tool-policy.js +29 -0
  17. package/dist-server/server/bot-overview.js +28 -0
  18. package/dist-server/server/bot-package.js +55 -2
  19. package/dist-server/server/browser-engine.js +2 -1
  20. package/dist-server/server/browser-live.js +12 -7
  21. package/dist-server/server/calendar-calls.js +1 -0
  22. package/dist-server/server/cli.js +102 -1
  23. package/dist-server/server/config.js +94 -6
  24. package/dist-server/server/drivers/acp/core.js +2 -0
  25. package/dist-server/server/drivers/acp/cursor.js +1 -1
  26. package/dist-server/server/drivers/acp/grok.js +5 -1
  27. package/dist-server/server/drivers/agents-proxy.js +195 -16
  28. package/dist-server/server/drivers/boxagent.js +1 -1
  29. package/dist-server/server/drivers/claude.js +301 -23
  30. package/dist-server/server/drivers/codex.js +13 -4
  31. package/dist-server/server/drivers/pi.js +1 -1
  32. package/dist-server/server/fleet-agent.js +161 -0
  33. package/dist-server/server/fleet-cli.js +225 -0
  34. package/dist-server/server/fleet-client.js +45 -0
  35. package/dist-server/server/fleet.js +410 -0
  36. package/dist-server/server/harness/bus.js +1 -1
  37. package/dist-server/server/index.js +836 -201
  38. package/dist-server/server/local-computer.js +5 -1
  39. package/dist-server/server/mcp-gate-config.js +56 -0
  40. package/dist-server/server/mcp-gate.js +227 -0
  41. package/dist-server/server/mcp-registry.js +71 -3
  42. package/dist-server/server/mcp-trim.js +182 -0
  43. package/dist-server/server/memory-journal.js +440 -0
  44. package/dist-server/server/memory-store.js +308 -0
  45. package/dist-server/server/message-db.js +90 -3
  46. package/dist-server/server/package-export.js +25 -0
  47. package/dist-server/server/peer-approval.js +1 -1
  48. package/dist-server/server/prices.js +16 -0
  49. package/dist-server/server/provider-auth-sessions.js +3 -0
  50. package/dist-server/server/provider-key-check.js +66 -0
  51. package/dist-server/server/proxy-paths.js +1 -0
  52. package/dist-server/server/redact.js +15 -0
  53. package/dist-server/server/resume-recovery.js +46 -0
  54. package/dist-server/server/routines.js +1 -0
  55. package/dist-server/server/screen-frame-gate.js +9 -6
  56. package/dist-server/server/skill-learn.js +15 -1
  57. package/dist-server/server/skill-library.js +5 -5
  58. package/dist-server/server/spend.js +61 -0
  59. package/dist-server/server/store.js +7 -3
  60. package/dist-server/server/system-prompt.js +28 -6
  61. package/dist-server/server/tailscale.js +2 -2
  62. package/dist-server/server/team-backup.js +35 -0
  63. package/dist-server/server/tool-summary.js +39 -0
  64. package/dist-server/server/tts/speech-text.js +3 -1
  65. package/dist-server/server/turn-context.js +18 -0
  66. package/dist-server/server/usage-ledger.js +239 -0
  67. package/dist-server/server/webhook-ingress.js +7 -2
  68. package/dist-server/server/workspace-backup-http.js +264 -0
  69. package/dist-server/server/workspace-backup-maintenance.js +40 -0
  70. package/dist-server/server/workspace-backup-policy.js +28 -0
  71. package/dist-server/server/workspace-backup.js +1022 -0
  72. package/dist-server/server/workspace.js +321 -22
  73. package/dist-server/shared/approval-mode.js +1 -1
  74. package/dist-server/shared/learn-request.js +6 -0
  75. package/dist-server/shared/team-backup.js +13 -2
  76. package/dist-server/shared/workspace-backup-client.js +15 -0
  77. package/dist-server/shared/workspace-backup.js +1 -0
  78. package/dist-server/vps-container-mcp.js +28 -2
  79. package/package.json +1 -1
  80. package/skills/create-verification-skill/SKILL.md +2 -3
  81. package/dist/assets/index-B2F__IRS.css +0 -1
  82. package/dist/assets/index-CLj3t5Ba.js +0 -280
@@ -0,0 +1,308 @@
1
+ // The person-facing half of bot memory: browse it, edit it, delete it.
2
+ //
3
+ // workspace.ts owns the files (MEMORY.md and memory/ under the bot's
4
+ // workspace) and the load budget. This module owns the RULES for reaching
5
+ // those files from outside the process. A `path` arrives from HTTP, so it
6
+ // is matched against an allowlist of the three shapes memory actually has
7
+ // rather than sanitised — a rejected name is a bug report, not a path to
8
+ // be repaired. Anything else would be an arbitrary-file-read on the disk.
9
+ //
10
+ // Every write goes through writeFileAtomic at 0600 and through the secret
11
+ // scrubber first: memory is agent-authored as often as person-authored,
12
+ // and a bot that pastes a key into its own notes must not leave it on disk
13
+ // in the clear. Writes also carry an optional expected hash so an editor
14
+ // that opened the file a minute ago cannot silently overwrite an entry the
15
+ // bot wrote in between — today's whole-file PUT races memory_update.
16
+ //
17
+ // No knowledge of turns or HTTP here; the journal and the routes sit on top.
18
+ import { spawn } from "node:child_process";
19
+ import { createHash } from "node:crypto";
20
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync } from "node:fs";
21
+ import { dirname, join, sep } from "node:path";
22
+ import { writeFileAtomic } from "./atomic.js";
23
+ import { redactSecretsInText } from "./redact.js";
24
+ import { MEMORY_FILE_MAX_BYTES, MEMORY_MAX_BYTES, MEMORY_MAX_LINES, ensureWorkspace, isMemoryTopicName, workspaceDir, } from "./workspace.js";
25
+ export const MEMORY_INDEX = "MEMORY.md";
26
+ /** Carries an HTTP status so the route can answer without a lookup table.
27
+ * A conflict also carries what is on disk now, so an editor can show the
28
+ * bot's version next to the person's draft instead of guessing. */
29
+ export class MemoryStoreError extends Error {
30
+ status;
31
+ code;
32
+ currentHash;
33
+ current;
34
+ constructor(code, status, message, conflict) {
35
+ super(message);
36
+ this.name = "MemoryStoreError";
37
+ this.code = code;
38
+ this.status = status;
39
+ if (conflict) {
40
+ this.currentHash = conflict.currentHash;
41
+ this.current = conflict.current;
42
+ }
43
+ }
44
+ }
45
+ const BOT_ID = /^[\w-]{1,128}$/;
46
+ function pathError(path) {
47
+ return new MemoryStoreError("path", 400, `not a memory file: ${JSON.stringify(path)} — use MEMORY.md, memory/<topic>.md or memory/log/<day>.md`);
48
+ }
49
+ /** Only three shapes exist: the index, one topic file directly under
50
+ * memory/, or one daily log directly under memory/log/. The name gate is
51
+ * the same one listing uses (workspace.ts isMemoryTopicName), so what the
52
+ * API can reach and what the overview shows agree by construction. No
53
+ * separators inside a name means no traversal; no leading dot means no
54
+ * dotfiles and no bare "..". Backslashes and NUL never match. */
55
+ export function parseMemoryPath(path) {
56
+ if (typeof path !== "string" || !path)
57
+ throw pathError(path);
58
+ if (path === MEMORY_INDEX)
59
+ return { path, kind: "index", name: MEMORY_INDEX };
60
+ const parts = path.split("/");
61
+ if (parts.length === 2 && parts[0] === "memory" && isMemoryTopicName(parts[1])) {
62
+ return { path, kind: "topic", name: parts[1] };
63
+ }
64
+ if (parts.length === 3 && parts[0] === "memory" && parts[1] === "log" && isMemoryTopicName(parts[2])) {
65
+ return { path, kind: "log", name: parts[2] };
66
+ }
67
+ throw pathError(path);
68
+ }
69
+ function assertBotId(botId) {
70
+ if (!BOT_ID.test(botId))
71
+ throw new MemoryStoreError("path", 400, "invalid bot id");
72
+ }
73
+ function relativeSegments(ref) {
74
+ if (ref.kind === "index")
75
+ return [MEMORY_INDEX];
76
+ if (ref.kind === "topic")
77
+ return ["memory", ref.name];
78
+ return ["memory", "log", ref.name];
79
+ }
80
+ /** The absolute path a memory document lives at, after proving it stays
81
+ * inside the workspace. The grammar already forbids traversal; this guards
82
+ * the other escape — a symlink. A bot with file tools can turn memory/ or
83
+ * a topic file into a link to anywhere, and the editor would then read or
84
+ * overwrite that target. So the nearest existing ancestor is resolved
85
+ * through realpath and must sit under the real workspace, and the file
86
+ * itself, when present, must be a regular file rather than a link. */
87
+ export function resolveMemoryPath(botId, path) {
88
+ assertBotId(botId);
89
+ const ref = parseMemoryPath(path);
90
+ const root = workspaceDir(botId);
91
+ const absolute = join(root, ...relativeSegments(ref));
92
+ let realRoot;
93
+ try {
94
+ realRoot = realpathSync(root);
95
+ }
96
+ catch {
97
+ // no workspace yet — nothing to escape from, nothing to read either
98
+ return absolute;
99
+ }
100
+ let ancestor = dirname(absolute);
101
+ while (!existsSync(ancestor) && ancestor !== root)
102
+ ancestor = dirname(ancestor);
103
+ const realAncestor = realpathSync(ancestor);
104
+ if (realAncestor !== realRoot && !realAncestor.startsWith(realRoot + sep)) {
105
+ throw new MemoryStoreError("path", 400, `memory path escapes the workspace: ${JSON.stringify(path)}`);
106
+ }
107
+ try {
108
+ if (lstatSync(absolute).isSymbolicLink()) {
109
+ throw new MemoryStoreError("path", 400, `memory file is a link, not a file: ${JSON.stringify(path)}`);
110
+ }
111
+ }
112
+ catch (error) {
113
+ if (error instanceof MemoryStoreError)
114
+ throw error;
115
+ // missing is fine: a topic the person is about to create
116
+ }
117
+ return absolute;
118
+ }
119
+ export function hashMemoryText(text) {
120
+ return createHash("sha256").update(text, "utf8").digest("hex");
121
+ }
122
+ /** Mirrors loadMemory()'s cut exactly so the gauge never claims something
123
+ * loads that does not: first MEMORY_MAX_LINES lines, then MEMORY_MAX_BYTES
124
+ * bytes, whichever cuts first. */
125
+ export function memoryCapacity(raw) {
126
+ const lines = raw === "" ? 0 : raw.split("\n").length;
127
+ let loaded = raw;
128
+ let truncated = false;
129
+ if (lines > MEMORY_MAX_LINES) {
130
+ loaded = raw.split("\n").slice(0, MEMORY_MAX_LINES).join("\n");
131
+ truncated = true;
132
+ }
133
+ if (Buffer.byteLength(loaded, "utf8") > MEMORY_MAX_BYTES) {
134
+ loaded = Buffer.from(loaded, "utf8").subarray(0, MEMORY_MAX_BYTES).toString("utf8").replace(/�+$/, "");
135
+ truncated = true;
136
+ }
137
+ return {
138
+ lines,
139
+ bytes: Buffer.byteLength(raw, "utf8"),
140
+ maxLines: MEMORY_MAX_LINES,
141
+ maxBytes: MEMORY_MAX_BYTES,
142
+ loadedLines: loaded === "" ? 0 : loaded.split("\n").length,
143
+ loadedBytes: Buffer.byteLength(loaded, "utf8"),
144
+ truncated,
145
+ hash: hashMemoryText(raw),
146
+ };
147
+ }
148
+ function listMarkdown(dir, prefix) {
149
+ let names;
150
+ try {
151
+ names = readdirSync(dir);
152
+ }
153
+ catch {
154
+ return [];
155
+ }
156
+ const files = [];
157
+ for (const name of names) {
158
+ if (!isMemoryTopicName(name))
159
+ continue;
160
+ try {
161
+ // lstat, not stat: a symlinked topic is not a memory file (see
162
+ // resolveMemoryPath) and must not be listed as one
163
+ const info = lstatSync(join(dir, name));
164
+ if (!info.isFile())
165
+ continue;
166
+ files.push({ path: `${prefix}/${name}`, name, bytes: info.size, modifiedAt: Math.round(info.mtimeMs) });
167
+ }
168
+ catch {
169
+ // deleted between readdir and lstat: simply not listed
170
+ }
171
+ }
172
+ // newest first: the file the bot just touched is the one to look at
173
+ return files.sort((a, b) => b.modifiedAt - a.modifiedAt || a.name.localeCompare(b.name));
174
+ }
175
+ /** Reads never create the workspace: a bot that has not run yet simply
176
+ * has nothing to show, and an overview must not leave a folder behind. */
177
+ export function memoryOverview(botId) {
178
+ assertBotId(botId);
179
+ const root = workspaceDir(botId);
180
+ let raw = "";
181
+ try {
182
+ raw = readFileSync(resolveMemoryPath(botId, MEMORY_INDEX), "utf8");
183
+ }
184
+ catch (error) {
185
+ if (error instanceof MemoryStoreError)
186
+ throw error;
187
+ raw = "";
188
+ }
189
+ return {
190
+ botId,
191
+ workspacePath: root,
192
+ index: memoryCapacity(raw),
193
+ topics: listMarkdown(join(root, "memory"), "memory"),
194
+ logs: listMarkdown(join(root, "memory", "log"), "memory/log"),
195
+ };
196
+ }
197
+ /** A missing document reads as empty rather than as an error: the topic
198
+ * the person is about to create has no file yet, and its hash is the hash
199
+ * of "", which is what a save of a new file must present. */
200
+ export function readMemoryDoc(botId, path) {
201
+ const absolute = resolveMemoryPath(botId, path);
202
+ try {
203
+ const text = readFileSync(absolute, "utf8");
204
+ return { path, text, hash: hashMemoryText(text), exists: true };
205
+ }
206
+ catch {
207
+ return { path, text: "", hash: hashMemoryText(""), exists: false };
208
+ }
209
+ }
210
+ /** Atomic, 0600, redacted. With `expectedHash`, the write is refused (409)
211
+ * unless the file still hashes to what the caller last saw — the bot may
212
+ * have written between the person's read and their save, and a whole-file
213
+ * overwrite would erase that note without anyone noticing. */
214
+ export function writeMemoryDoc(botId, path, text, opts = {}) {
215
+ const ref = parseMemoryPath(path);
216
+ ensureWorkspace(botId);
217
+ if (ref.kind === "log")
218
+ mkdirSync(join(workspaceDir(botId), "memory", "log"), { recursive: true, mode: 0o700 });
219
+ const absolute = resolveMemoryPath(botId, path);
220
+ const after = redactSecretsInText(text);
221
+ const bytes = Buffer.byteLength(after, "utf8");
222
+ if (bytes > MEMORY_FILE_MAX_BYTES) {
223
+ throw new MemoryStoreError("too-large", 413, `memory files are capped at ${Math.round(MEMORY_FILE_MAX_BYTES / 1024)}KB — move longer notes into memory/<topic>.md files`);
224
+ }
225
+ const current = readMemoryDoc(botId, path);
226
+ if (opts.expectedHash !== undefined && opts.expectedHash !== current.hash) {
227
+ throw new MemoryStoreError("conflict", 409, "this file changed since you opened it — reload to see the new version, or save again to overwrite it", { currentHash: current.hash, current: current.text });
228
+ }
229
+ writeFileAtomic(absolute, after, { mode: 0o600 });
230
+ return { path, before: current.exists ? current.text : null, after, hash: hashMemoryText(after) };
231
+ }
232
+ /** Topic and log files only. MEMORY.md is loaded unconditionally at turn
233
+ * start and the next ensureWorkspace() would silently reseed it, which
234
+ * reads as "delete did nothing" — clearing it is a write of "". */
235
+ export function deleteMemoryDoc(botId, path) {
236
+ const ref = parseMemoryPath(path);
237
+ if (ref.kind === "index") {
238
+ throw new MemoryStoreError("forbidden", 400, "MEMORY.md cannot be deleted — clear its text instead");
239
+ }
240
+ const absolute = resolveMemoryPath(botId, path);
241
+ const current = readMemoryDoc(botId, path);
242
+ if (current.exists)
243
+ unlinkSync(absolute);
244
+ return { path, before: current.exists ? current.text : null };
245
+ }
246
+ export function obsidianUrlFor(workspacePath) {
247
+ return `obsidian://open?path=${encodeURIComponent(workspacePath)}`;
248
+ }
249
+ /** Runs the platform URL/folder opener and resolves its exit code; null
250
+ * when it could not be launched or did not exit in time. Same shape as
251
+ * cli.ts openDashboard: no shell, fixed argv, nothing from the request. */
252
+ const spawnOpener = (command, args) => new Promise((done) => {
253
+ let child;
254
+ try {
255
+ child = spawn(command, args, { stdio: "ignore", windowsHide: true, detached: true });
256
+ }
257
+ catch {
258
+ return done(null);
259
+ }
260
+ const timer = setTimeout(() => {
261
+ child.kill();
262
+ done(null);
263
+ }, 3000);
264
+ child.once("error", () => {
265
+ clearTimeout(timer);
266
+ done(null);
267
+ });
268
+ child.once("exit", (code) => {
269
+ clearTimeout(timer);
270
+ done(code);
271
+ });
272
+ child.unref();
273
+ });
274
+ /** Opens the bot's memory folder on the machine running the server — in
275
+ * Obsidian (obsidian://open?path=…) or in Finder/Explorer/the file manager.
276
+ * The folder is on the server's disk, so this only makes sense from the
277
+ * same machine; the route restricts it to loopback callers. The desktop
278
+ * shell's own openExternal is http(s)-only, which is why the URL opener
279
+ * lives here rather than going through the preload bridge. */
280
+ export async function openMemoryLocation(botId, target, platform = process.platform, run = spawnOpener) {
281
+ assertBotId(botId);
282
+ const workspacePath = ensureWorkspace(botId);
283
+ const location = target === "obsidian" ? obsidianUrlFor(workspacePath) : workspacePath;
284
+ let command;
285
+ let args;
286
+ if (platform === "darwin") {
287
+ command = "open";
288
+ args = [location];
289
+ }
290
+ else if (platform === "win32") {
291
+ command = target === "obsidian" ? "rundll32.exe" : "explorer.exe";
292
+ args = target === "obsidian" ? ["url.dll,FileProtocolHandler", location] : [location];
293
+ }
294
+ else {
295
+ command = "xdg-open";
296
+ args = [location];
297
+ }
298
+ const code = await run(command, args);
299
+ // explorer.exe reports 1 even when it opened the window; only "could not
300
+ // launch" is a failure there
301
+ const ok = code === 0 || (platform === "win32" && target === "folder" && code !== null);
302
+ if (ok)
303
+ return { ok: true, workspacePath };
304
+ const error = target === "obsidian"
305
+ ? `Obsidian did not open. Is it installed? The folder is ${workspacePath}`
306
+ : `The file manager did not open. The folder is ${workspacePath}`;
307
+ return { ok: false, error, workspacePath };
308
+ }
@@ -49,8 +49,40 @@ function open() {
49
49
  );
50
50
  `);
51
51
  ensureRecallIndex(db);
52
+ ensureMemoryIndex(db);
52
53
  return db;
53
54
  }
55
+ // The bot's memory files — MEMORY.md, memory/<topic>.md, memory/log/<day>.md
56
+ // — indexed for the same session_search. One row per file, with the size
57
+ // and mtime it was indexed at so a search can notice a file the bot's own
58
+ // file tools rewrote without any filesystem watcher. Kept in this database
59
+ // because FTS5 is already here; the files themselves stay the source of
60
+ // truth on disk and this table is rebuilt from them at any time.
61
+ function ensureMemoryIndex(db) {
62
+ db.exec(`
63
+ CREATE TABLE IF NOT EXISTS memory_files (
64
+ bot_id TEXT NOT NULL,
65
+ path TEXT NOT NULL,
66
+ text TEXT NOT NULL,
67
+ mtime_ms INTEGER NOT NULL,
68
+ bytes INTEGER NOT NULL,
69
+ PRIMARY KEY (bot_id, path)
70
+ );
71
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
72
+ text, content='memory_files', content_rowid='rowid', tokenize='unicode61'
73
+ );
74
+ CREATE TRIGGER IF NOT EXISTS memory_fts_ai AFTER INSERT ON memory_files BEGIN
75
+ INSERT INTO memory_fts(rowid, text) VALUES (new.rowid, new.text);
76
+ END;
77
+ CREATE TRIGGER IF NOT EXISTS memory_fts_ad AFTER DELETE ON memory_files BEGIN
78
+ INSERT INTO memory_fts(memory_fts, rowid, text) VALUES ('delete', old.rowid, old.text);
79
+ END;
80
+ CREATE TRIGGER IF NOT EXISTS memory_fts_au AFTER UPDATE ON memory_files BEGIN
81
+ INSERT INTO memory_fts(memory_fts, rowid, text) VALUES ('delete', old.rowid, old.text);
82
+ INSERT INTO memory_fts(rowid, text) VALUES (new.rowid, new.text);
83
+ END;
84
+ `);
85
+ }
54
86
  // Ranked recall over transcript text, for the bot's own session_search tool.
55
87
  // An external-content FTS5 table over messages.text: the index stores no
56
88
  // copy of the text, and three triggers keep it in step with every insert,
@@ -58,14 +90,33 @@ function open() {
58
90
  // is no more of a dependency than the table it indexes. The sidebar's LIKE
59
91
  // search below stays as it is — substring find over a single thread wants
60
92
  // every occurrence, not a relevance ranking.
93
+ /** FTS5 is in every runtime OpenMausBot supports — node:sqlite on Node ≥ 24
94
+ * (package.json engines) and the Node inside Electron 43 — so a SQLite
95
+ * without it is a mis-installed runtime, not a mode to run in. Say that,
96
+ * instead of surfacing SQLite's own "no such module: fts5" from deep inside
97
+ * open() with nothing about what to do. Anything else is rethrown as-is. */
98
+ export function describeMissingFts5(error) {
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ if (!/no such module:\s*fts5/i.test(message))
101
+ return null;
102
+ return new Error(`OpenMausBot needs SQLite with FTS5, which is built into Node 24 and newer (and into the app). ` +
103
+ `This Node (${process.version}) has none: install Node 24 or newer. (${message})`);
104
+ }
61
105
  function ensureRecallIndex(db) {
62
106
  const existed = db
63
107
  .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages_fts'")
64
108
  .get();
109
+ try {
110
+ db.exec(`
111
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
112
+ text, content='messages', content_rowid='rowid', tokenize='unicode61'
113
+ );
114
+ `);
115
+ }
116
+ catch (error) {
117
+ throw describeMissingFts5(error) ?? error;
118
+ }
65
119
  db.exec(`
66
- CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
67
- text, content='messages', content_rowid='rowid', tokenize='unicode61'
68
- );
69
120
  CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN
70
121
  INSERT INTO messages_fts(rowid, text) VALUES (new.rowid, new.text);
71
122
  END;
@@ -347,6 +398,42 @@ export function recallMessages(query, threadIds, limit = 12) {
347
398
  };
348
399
  });
349
400
  }
401
+ /** Index one memory file (upsert keeps the rowid, so the update trigger
402
+ * keeps the FTS rows in step — the same reasoning as UPSERT_MESSAGE). */
403
+ export function indexMemoryFile(botId, path, text, stat) {
404
+ db()
405
+ .prepare("INSERT INTO memory_files (bot_id, path, text, mtime_ms, bytes) VALUES (?, ?, ?, ?, ?) " +
406
+ "ON CONFLICT(bot_id, path) DO UPDATE SET text = excluded.text, mtime_ms = excluded.mtime_ms, bytes = excluded.bytes")
407
+ .run(botId, path, text, Math.trunc(stat.mtimeMs), stat.bytes);
408
+ }
409
+ export function removeMemoryFile(botId, path) {
410
+ db().prepare("DELETE FROM memory_files WHERE bot_id = ? AND path = ?").run(botId, path);
411
+ }
412
+ /** What is indexed for a bot, so the caller can compare against the disk. */
413
+ export function indexedMemoryFiles(botId) {
414
+ const rows = db()
415
+ .prepare("SELECT path, mtime_ms, bytes FROM memory_files WHERE bot_id = ?")
416
+ // SAFETY: the SELECT names exactly these three NOT NULL columns
417
+ .all(botId);
418
+ return rows.map((row) => ({ path: row.path, mtimeMs: row.mtime_ms, bytes: row.bytes }));
419
+ }
420
+ /** Relevance-ranked recall over ONE bot's memory files. Scoped by bot id
421
+ * in SQL, the same way recallMessages scopes by thread: another bot's
422
+ * memory is not a lower-ranked result, it is not a result. */
423
+ export function recallMemory(query, botId, limit = 12) {
424
+ const match = ftsQuery(query);
425
+ if (!match)
426
+ return [];
427
+ const rows = db()
428
+ .prepare("SELECT f.path, f.mtime_ms, " +
429
+ `snippet(memory_fts, 0, '[', ']', '…', ${SNIPPET_TOKENS}) AS snippet ` +
430
+ "FROM memory_fts JOIN memory_files f ON f.rowid = memory_fts.rowid " +
431
+ "WHERE memory_fts MATCH ? AND f.bot_id = ? " +
432
+ "ORDER BY bm25(memory_fts), f.mtime_ms DESC LIMIT ?")
433
+ // SAFETY: the SELECT names exactly these three columns; snippet() is never null
434
+ .all(match, botId, limit);
435
+ return rows.map((row) => ({ file: row.path, at: row.mtime_ms, snippet: row.snippet.replace(/\s+/g, " ").trim() }));
436
+ }
350
437
  /** Test/shutdown hook — closes the handle so a wiped DATA_DIR starts clean. */
351
438
  export function closeMessageDb() {
352
439
  try {
@@ -32,6 +32,8 @@ export function createBotPackageExport(input) {
32
32
  const playbooks = [];
33
33
  const playbookKeys = new Set();
34
34
  const agentPlaybooks = new Map();
35
+ const packageSkills = new Map();
36
+ const agentSkills = new Map();
35
37
  for (const bot of bots) {
36
38
  const agentKey = idToKey.get(bot.id);
37
39
  const assigned = [];
@@ -50,6 +52,20 @@ export function createBotPackageExport(input) {
50
52
  assigned.push(key);
51
53
  }
52
54
  agentPlaybooks.set(bot.id, assigned);
55
+ const assignedSkills = [];
56
+ for (const skill of input.skillsByBot?.get(bot.id) ?? []) {
57
+ const existing = packageSkills.get(skill.name);
58
+ if (existing && (existing.instructions !== skill.instructions || existing.description !== skill.description ||
59
+ existing.source !== skill.source || existing.license !== skill.license ||
60
+ existing.compatibility !== skill.compatibility)) {
61
+ throw new Error(`Skill "${skill.name}" has conflicting content across selected bots`);
62
+ }
63
+ if (!existing)
64
+ packageSkills.set(skill.name, { ...skill });
65
+ if (!assignedSkills.includes(skill.name))
66
+ assignedSkills.push(skill.name);
67
+ }
68
+ agentSkills.set(bot.id, assignedSkills);
53
69
  }
54
70
  const requirements = new Map();
55
71
  for (const bot of bots) {
@@ -132,6 +148,9 @@ export function createBotPackageExport(input) {
132
148
  const assigned = agentPlaybooks.get(bot.id);
133
149
  if (assigned?.length)
134
150
  agent.playbooks = assigned;
151
+ const assignedSkills = agentSkills.get(bot.id);
152
+ if (assignedSkills?.length)
153
+ agent.skills = assignedSkills;
135
154
  return agent;
136
155
  });
137
156
  const definition = {
@@ -157,6 +176,12 @@ export function createBotPackageExport(input) {
157
176
  definition.routines = routines;
158
177
  if (playbooks.length)
159
178
  definition.playbooks = playbooks;
179
+ if (packageSkills.size) {
180
+ definition.skills = {
181
+ version: 1,
182
+ entries: [...packageSkills.values()].map((skill) => ({ ...skill })),
183
+ };
184
+ }
160
185
  return parseBotPackage({
161
186
  format: "openmaus.package",
162
187
  version: 1,
@@ -156,7 +156,7 @@ export function resolvePeerComms(_bus, requestId, behavior) {
156
156
  /** Drop every approval waiting on a bot that no longer exists (or is being
157
157
  * deleted), denying it so the caller's turn doesn't wait out the timeout. */
158
158
  export function cancelPeerApprovalsFor(botId) {
159
- for (const [requestId, pending] of [...pendingComms]) {
159
+ for (const [requestId, pending] of Array.from(pendingComms)) {
160
160
  if (pending.fromBotId !== botId && pending.toBotId !== botId)
161
161
  continue;
162
162
  pendingComms.delete(requestId);
@@ -0,0 +1,16 @@
1
+ /** The most specific price for a turn: driver/model, then model, then default. */
2
+ export function priceFor(turn, prices) {
3
+ return prices[`${turn.driverKind}/${turn.model}`] ?? prices[turn.model] ?? prices.default ?? null;
4
+ }
5
+ const clean = (value) => (typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0);
6
+ /** Dollars billable for one turn, or null when the list has no price for it. */
7
+ export function billableFor(turn, prices) {
8
+ const price = priceFor(turn, prices);
9
+ if (!price)
10
+ return null;
11
+ const input = clean(turn.input);
12
+ const cached = Math.min(clean(turn.cachedInput), input);
13
+ const fresh = input - cached;
14
+ const cachedRate = price.cachedInputPerMillion ?? price.inputPerMillion;
15
+ return (fresh * price.inputPerMillion + cached * cachedRate + clean(turn.output) * price.outputPerMillion) / 1_000_000;
16
+ }
@@ -1,6 +1,9 @@
1
1
  const failure = (message, status) => Object.assign(new Error(message), { status });
2
2
  export class ProviderAuthSessions {
3
3
  flows = new Map();
4
+ get active() {
5
+ return [...this.flows.values()].some((flow) => flow.busy || (!flow.revoked && flow.expiresAt > Date.now()));
6
+ }
4
7
  async start(instance, owner) {
5
8
  if (!instance.startAuthentication)
6
9
  throw failure("Account setup is unavailable for this provider.", 404);
@@ -0,0 +1,66 @@
1
+ export const PROVIDER_KEY_KINDS = ["anthropic", "openaiCompat", "xai"];
2
+ const DEFAULT_URLS = {
3
+ anthropic: "https://api.anthropic.com",
4
+ openaiCompat: "https://openrouter.ai/api/v1",
5
+ xai: "https://api.x.ai/v1",
6
+ };
7
+ const MAX_MODELS = 5;
8
+ function isLoopback(hostname) {
9
+ const host = hostname.replace(/^\[|\]$/g, "");
10
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host.startsWith("127.");
11
+ }
12
+ /** The models endpoint for a provider, from its base URL or the default. */
13
+ export function providerModelsUrl(provider, base) {
14
+ const root = (base?.trim() || DEFAULT_URLS[provider]).replace(/\/+$/, "");
15
+ if (provider === "anthropic")
16
+ return root.endsWith("/v1") ? `${root}/models` : `${root}/v1/models`;
17
+ return `${root}/models`;
18
+ }
19
+ function modelIds(body) {
20
+ const record = body && typeof body === "object" ? body : null;
21
+ const list = Array.isArray(record?.data) ? record.data : Array.isArray(record?.models) ? record.models : [];
22
+ const ids = [];
23
+ for (const entry of list) {
24
+ const item = entry && typeof entry === "object" ? entry : null;
25
+ const id = typeof item?.id === "string" ? item.id : typeof item?.name === "string" ? item.name : null;
26
+ if (id && id.length <= 120)
27
+ ids.push(id);
28
+ if (ids.length === MAX_MODELS)
29
+ break;
30
+ }
31
+ return ids;
32
+ }
33
+ export async function checkProviderKey(input, fetchImpl = fetch, timeoutMs = 10_000) {
34
+ let url;
35
+ try {
36
+ url = new URL(providerModelsUrl(input.provider, input.url));
37
+ }
38
+ catch {
39
+ return { ok: false, reason: "unexpected" };
40
+ }
41
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) {
42
+ return { ok: false, reason: "unexpected" };
43
+ }
44
+ const headers = input.provider === "anthropic"
45
+ ? { "x-api-key": input.key, "anthropic-version": "2023-06-01" }
46
+ : { authorization: `Bearer ${input.key}` };
47
+ const controller = new AbortController();
48
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
49
+ try {
50
+ // A redirect is never followed: the key must not be replayed to
51
+ // whichever host the provider's front door points at today.
52
+ const response = await fetchImpl(url, { headers, signal: controller.signal, redirect: "manual" });
53
+ if (response.status === 401 || response.status === 403)
54
+ return { ok: false, reason: "rejected", status: response.status };
55
+ if (!response.ok)
56
+ return { ok: false, reason: "unexpected", status: response.status };
57
+ const body = await response.json().catch(() => null);
58
+ return { ok: true, models: modelIds(body) };
59
+ }
60
+ catch {
61
+ return { ok: false, reason: "unreachable" };
62
+ }
63
+ finally {
64
+ clearTimeout(timer);
65
+ }
66
+ }
@@ -40,6 +40,7 @@ export const SPAWNED_PROXIES = {
40
40
  agents: resolveProxy("drivers/agents-proxy"),
41
41
  dweb: resolveProxy("drivers/dweb-proxy"),
42
42
  connectors: resolveProxy("connector-proxy"),
43
+ mcpGate: resolveProxy("mcp-gate"),
43
44
  phone: resolveProxy("drivers/phone-proxy"),
44
45
  // Loaded by the external `pi` process via `-e`, not by this server — but
45
46
  // resolved through the same single source of truth so the packaged layout
@@ -35,6 +35,9 @@ const mask = (value) => (REDACTION_MARKER.test(value) ? value : `«redacted ${va
35
35
  // transcript, so only shapes that are unmistakably credentials match.
36
36
  const KEY_PREFIXES = [
37
37
  /\bsk-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g, // anthropic / openai / stripe
38
+ /\bxai-[A-Za-z0-9_-]{20,}/g, // xai (grok)
39
+ /\bgsk_[A-Za-z0-9]{40,}/g, // groq
40
+ /\bhf_[A-Za-z0-9]{30,}/g, // hugging face
38
41
  /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g, // github classic
39
42
  /\bgithub_pat_[A-Za-z0-9_]{20,}/g, // github fine-grained
40
43
  /\bxox[abposr]-[A-Za-z0-9-]{20,}/g, // slack
@@ -49,6 +52,15 @@ const PEM_BLOCK = /(-----BEGIN [A-Z ]*PRIVATE KEY-----)([\s\S]*?)(-----END [A-Z
49
52
  * The value must be a single token of some length; prose after a colon
50
53
  * ("password: leave blank…") has spaces and does not match. */
51
54
  const KEY_VALUE = /\b((?:[A-Za-z0-9_-]*_)?(?:api[_-]?key|apikey|secret|token|password|passwd|authorization|auth[_-]?token|access[_-]?key|private[_-]?key)s?)(["']?\s*[=:]\s*)(["']?)([A-Za-z0-9._~+/=-]{8,})\3/gi;
55
+ /** `X_KEY=value`, `xai-key=value`: an assignment to a name that ENDS in key
56
+ * is a credential whatever the value looks like, so no length floor. The
57
+ * separator before `key` is what keeps `hotkey=` and `keyboard=` out. */
58
+ const KEY_SUFFIX_ASSIGNMENT = /\b([A-Za-z][A-Za-z0-9_-]*[_-]key)s?(=)(["']?)([A-Za-z0-9._~+/=-]+)\3/gi;
59
+ /** `--token abc`, `--password=abc`: the flag names a secret; the value is
60
+ * whatever single token follows, never another flag. */
61
+ const SECRET_FLAG = /(--(?:token|password|passwd|api-key|apikey|secret|access-key|auth-token)(?:=|\s+))(["']?)(?!-)([A-Za-z0-9._~+/=-]+)\2/gi;
62
+ /** `scheme://user:secret@host` — the password in a URL's userinfo. */
63
+ const URL_USERINFO = /(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@'"]+:)([^\s/@'"«»]+)(@)/gi;
52
64
  export function redactSecretsInText(text) {
53
65
  if (!text || text.length < 8)
54
66
  return text;
@@ -58,6 +70,9 @@ export function redactSecretsInText(text) {
58
70
  out = out.replace(re, (m) => mask(m));
59
71
  out = out.replace(BEARER, (_m, lead, tok) => `${lead}${mask(tok)}`);
60
72
  out = out.replace(KEY_VALUE, (_m, key, sep, quote, value) => `${key}${sep}${quote}${mask(value)}${quote}`);
73
+ out = out.replace(KEY_SUFFIX_ASSIGNMENT, (_m, key, sep, quote, value) => `${key}${sep}${quote}${mask(value)}${quote}`);
74
+ out = out.replace(SECRET_FLAG, (_m, flag, quote, value) => `${flag}${quote}${mask(value)}${quote}`);
75
+ out = out.replace(URL_USERINFO, (_m, lead, secret, at) => `${lead}${mask(secret)}${at}`);
61
76
  return out;
62
77
  }
63
78
  /** Deep copy with credential VALUES replaced. Handles the two shapes that