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.
- package/dist/assets/index-BFPYw6P-.js +296 -0
- package/dist/assets/{index-D1RGhLUK.js → index-Bpai86ua.js} +1 -1
- package/dist/assets/index-D1odUdTi.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/listener.js +12 -2
- package/dist-server/container-mcp.js +28 -2
- package/dist-server/drivers/agents-proxy.js +202 -16
- package/dist-server/drivers/pi-mcp-extension.ts +1 -1
- package/dist-server/index.js +9443 -3023
- package/dist-server/local-computer.js +6 -1
- package/dist-server/mcp-gate.js +543 -0
- package/dist-server/mcp-server.js +38 -0
- package/dist-server/openmausbot.js +1893 -583
- package/dist-server/pair-cli.js +1893 -583
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/agent-tool-policy.js +29 -0
- package/dist-server/server/bot-overview.js +28 -0
- package/dist-server/server/bot-package.js +55 -2
- package/dist-server/server/browser-engine.js +2 -1
- package/dist-server/server/browser-live.js +12 -7
- package/dist-server/server/calendar-calls.js +1 -0
- package/dist-server/server/cli.js +102 -1
- package/dist-server/server/config.js +94 -6
- package/dist-server/server/drivers/acp/core.js +2 -0
- package/dist-server/server/drivers/acp/cursor.js +1 -1
- package/dist-server/server/drivers/acp/grok.js +5 -1
- package/dist-server/server/drivers/agents-proxy.js +195 -16
- package/dist-server/server/drivers/boxagent.js +1 -1
- package/dist-server/server/drivers/claude.js +301 -23
- package/dist-server/server/drivers/codex.js +13 -4
- package/dist-server/server/drivers/pi.js +1 -1
- package/dist-server/server/fleet-agent.js +161 -0
- package/dist-server/server/fleet-cli.js +225 -0
- package/dist-server/server/fleet-client.js +45 -0
- package/dist-server/server/fleet.js +410 -0
- package/dist-server/server/harness/bus.js +1 -1
- package/dist-server/server/index.js +836 -201
- package/dist-server/server/local-computer.js +5 -1
- package/dist-server/server/mcp-gate-config.js +56 -0
- package/dist-server/server/mcp-gate.js +227 -0
- package/dist-server/server/mcp-registry.js +71 -3
- package/dist-server/server/mcp-trim.js +182 -0
- package/dist-server/server/memory-journal.js +440 -0
- package/dist-server/server/memory-store.js +308 -0
- package/dist-server/server/message-db.js +90 -3
- package/dist-server/server/package-export.js +25 -0
- package/dist-server/server/peer-approval.js +1 -1
- package/dist-server/server/prices.js +16 -0
- package/dist-server/server/provider-auth-sessions.js +3 -0
- package/dist-server/server/provider-key-check.js +66 -0
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/redact.js +15 -0
- package/dist-server/server/resume-recovery.js +46 -0
- package/dist-server/server/routines.js +1 -0
- package/dist-server/server/screen-frame-gate.js +9 -6
- package/dist-server/server/skill-learn.js +15 -1
- package/dist-server/server/skill-library.js +5 -5
- package/dist-server/server/spend.js +61 -0
- package/dist-server/server/store.js +7 -3
- package/dist-server/server/system-prompt.js +28 -6
- package/dist-server/server/tailscale.js +2 -2
- package/dist-server/server/team-backup.js +35 -0
- package/dist-server/server/tool-summary.js +39 -0
- package/dist-server/server/tts/speech-text.js +3 -1
- package/dist-server/server/turn-context.js +18 -0
- package/dist-server/server/usage-ledger.js +239 -0
- package/dist-server/server/webhook-ingress.js +7 -2
- package/dist-server/server/workspace-backup-http.js +264 -0
- package/dist-server/server/workspace-backup-maintenance.js +40 -0
- package/dist-server/server/workspace-backup-policy.js +28 -0
- package/dist-server/server/workspace-backup.js +1022 -0
- package/dist-server/server/workspace.js +321 -22
- package/dist-server/shared/approval-mode.js +1 -1
- package/dist-server/shared/learn-request.js +6 -0
- package/dist-server/shared/team-backup.js +13 -2
- package/dist-server/shared/workspace-backup-client.js +15 -0
- package/dist-server/shared/workspace-backup.js +1 -0
- package/dist-server/vps-container-mcp.js +28 -2
- package/package.json +1 -1
- package/skills/create-verification-skill/SKILL.md +2 -3
- package/dist/assets/index-B2F__IRS.css +0 -1
- 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
|
|
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
|