openmausbot 0.1.69 → 0.1.71

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 (66) hide show
  1. package/dist/assets/{index-BmHupgVJ.js → index-CJtyBonr.js} +1 -1
  2. package/dist/assets/index-CYLdYtYp.css +1 -0
  3. package/dist/assets/index-Di4ovwu5.js +289 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +6 -2
  6. package/dist-server/drivers/agents-proxy.js +305 -16
  7. package/dist-server/drivers/pi-mcp-extension.ts +1 -1
  8. package/dist-server/index.js +3141 -1124
  9. package/dist-server/local-computer-proxy.js +2 -0
  10. package/dist-server/local-computer.js +2 -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 +772 -260
  14. package/dist-server/pair-cli.js +772 -260
  15. package/dist-server/prepare-cloudflared.js +20 -3
  16. package/dist-server/proxy-paths.js +1 -0
  17. package/dist-server/server/agent-tool-policy.js +29 -0
  18. package/dist-server/server/bot-package.js +55 -2
  19. package/dist-server/server/browser-live.js +12 -7
  20. package/dist-server/server/config.js +40 -4
  21. package/dist-server/server/delegations.js +132 -74
  22. package/dist-server/server/drivers/acp/core.js +2 -0
  23. package/dist-server/server/drivers/acp/cursor.js +1 -1
  24. package/dist-server/server/drivers/acp/grok.js +5 -1
  25. package/dist-server/server/drivers/agents-proxy.js +324 -16
  26. package/dist-server/server/drivers/boxagent.js +1 -1
  27. package/dist-server/server/drivers/claude-login-auth.js +102 -9
  28. package/dist-server/server/drivers/claude.js +298 -21
  29. package/dist-server/server/drivers/codex.js +13 -4
  30. package/dist-server/server/drivers/pi.js +1 -1
  31. package/dist-server/server/engine-install.js +137 -0
  32. package/dist-server/server/env-path.js +12 -0
  33. package/dist-server/server/harness/bus.js +1 -1
  34. package/dist-server/server/harness/registry.js +31 -6
  35. package/dist-server/server/index.js +860 -215
  36. package/dist-server/server/local-computer.js +1 -1
  37. package/dist-server/server/mcp-gate-config.js +56 -0
  38. package/dist-server/server/mcp-gate.js +227 -0
  39. package/dist-server/server/mcp-trim.js +182 -0
  40. package/dist-server/server/memory-journal.js +440 -0
  41. package/dist-server/server/memory-store.js +308 -0
  42. package/dist-server/server/message-db.js +90 -3
  43. package/dist-server/server/package-export.js +25 -0
  44. package/dist-server/server/peer-approval.js +1 -1
  45. package/dist-server/server/peer-provenance.js +6 -2
  46. package/dist-server/server/proxy-paths.js +1 -0
  47. package/dist-server/server/redact.js +15 -0
  48. package/dist-server/server/resume-recovery.js +46 -0
  49. package/dist-server/server/routines.js +116 -55
  50. package/dist-server/server/screen-frame-gate.js +9 -6
  51. package/dist-server/server/skill-library.js +5 -5
  52. package/dist-server/server/steer-queue.js +20 -1
  53. package/dist-server/server/store.js +29 -7
  54. package/dist-server/server/system-prompt.js +14 -1
  55. package/dist-server/server/team-backup.js +53 -4
  56. package/dist-server/server/tool-summary.js +39 -0
  57. package/dist-server/server/tts/speech-text.js +3 -1
  58. package/dist-server/server/turn-context.js +18 -0
  59. package/dist-server/server/workspace.js +321 -22
  60. package/dist-server/shared/approval-mode.js +1 -1
  61. package/dist-server/shared/team-backup.js +14 -2
  62. package/dist-server/vps-container-mcp.js +6 -2
  63. package/package.json +1 -1
  64. package/skills/create-verification-skill/SKILL.md +2 -3
  65. package/dist/assets/index-COp5lMnv.js +0 -279
  66. package/dist/assets/index-Jk7TIeyW.css +0 -1
@@ -0,0 +1,440 @@
1
+ // What changed about what the bot believes — and a way back.
2
+ //
3
+ // Every change to a memory file that goes through the server becomes one
4
+ // row here: the person's edits (routes), the bot's (memory_update or its
5
+ // own file tools), imports, reverts. Bot writes have no hook to tap — a
6
+ // file tool is a process writing to disk — so the turn boundary is the
7
+ // hook: the last text this module saw of every memory file is kept as a
8
+ // baseline, and at turn end whatever differs from it is the bot's work.
9
+ //
10
+ // The journal lives OUTSIDE the workspace on purpose, at
11
+ // DATA_DIR/memory-journal/<botId>.ndjson. The workspace is the bot's desk —
12
+ // it has file tools pointed at it and acceptEdits on. An audit trail the
13
+ // audited party can rewrite is not an audit trail.
14
+ //
15
+ // Same discipline as profile-versions.ts: 0600, through redactSecrets,
16
+ // serialized per bot, fire-and-forget — a journal write must never take
17
+ // down the change it records, and a turn must never fail because an audit
18
+ // line did.
19
+ import { randomUUID } from "node:crypto";
20
+ import { closeSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
21
+ import { appendFile } from "node:fs/promises";
22
+ import { join } from "node:path";
23
+ import { DATA_DIR } from "./config.js";
24
+ import { MEMORY_INDEX, MemoryStoreError, deleteMemoryDoc, hashMemoryText, memoryOverview, readMemoryDoc, writeMemoryDoc, } from "./memory-store.js";
25
+ import { redactSecretsInText } from "./redact.js";
26
+ export const JOURNAL_DIR = join(DATA_DIR, "memory-journal");
27
+ /** A diff is for reading in a list, not for replay; past this it is noise. */
28
+ export const DIFF_CAP = 8_000;
29
+ /** Past this the prior text is not kept, so the row cannot be reverted. Far
30
+ * above what the store lets anyone write (256KB); only a bot's own file
31
+ * tools can make a memory file this large. */
32
+ export const BEFORE_CAP = 512 * 1024;
33
+ const MAX_JOURNAL_READ_BYTES = 8 * 1024 * 1024;
34
+ const MAX_ROWS = 500;
35
+ /** Beyond this many line pairs the LCS table is not worth building; the
36
+ * diff degrades to "everything removed, everything added". */
37
+ const LCS_CELL_CAP = 4_000_000;
38
+ const CONTEXT = 2;
39
+ export function journalFile(botId) {
40
+ return join(JOURNAL_DIR, `${botId}.ndjson`);
41
+ }
42
+ function editScript(a, b) {
43
+ if (a.length * b.length > LCS_CELL_CAP) {
44
+ return [...a.map((line) => ({ tag: "-", line })), ...b.map((line) => ({ tag: "+", line }))];
45
+ }
46
+ // classic LCS lengths table, then walk back from the corner
47
+ const rows = a.length + 1;
48
+ const cols = b.length + 1;
49
+ const table = new Uint32Array(rows * cols);
50
+ for (let i = a.length - 1; i >= 0; i--) {
51
+ for (let j = b.length - 1; j >= 0; j--) {
52
+ table[i * cols + j] = a[i] === b[j]
53
+ ? table[(i + 1) * cols + j + 1] + 1
54
+ : Math.max(table[(i + 1) * cols + j], table[i * cols + j + 1]);
55
+ }
56
+ }
57
+ const ops = [];
58
+ let i = 0;
59
+ let j = 0;
60
+ while (i < a.length && j < b.length) {
61
+ if (a[i] === b[j]) {
62
+ ops.push({ tag: " ", line: a[i] });
63
+ i++;
64
+ j++;
65
+ }
66
+ else if (table[(i + 1) * cols + j] >= table[i * cols + j + 1]) {
67
+ ops.push({ tag: "-", line: a[i++] });
68
+ }
69
+ else {
70
+ ops.push({ tag: "+", line: b[j++] });
71
+ }
72
+ }
73
+ while (i < a.length)
74
+ ops.push({ tag: "-", line: a[i++] });
75
+ while (j < b.length)
76
+ ops.push({ tag: "+", line: b[j++] });
77
+ return ops;
78
+ }
79
+ function splitLines(text) {
80
+ if (text === "")
81
+ return [];
82
+ const lines = text.split("\n");
83
+ // a trailing newline is not an extra empty line for diff purposes
84
+ if (lines[lines.length - 1] === "")
85
+ lines.pop();
86
+ return lines;
87
+ }
88
+ /** A plain unified diff with two lines of context, the shape every
89
+ * developer tool renders. Exported so the row can be checked in tests. */
90
+ export function unifiedDiff(before, after, path, cap = DIFF_CAP) {
91
+ if (before === after)
92
+ return { text: "", added: 0, removed: 0, truncated: false };
93
+ const ops = editScript(splitLines(before), splitLines(after));
94
+ let added = 0;
95
+ let removed = 0;
96
+ for (const op of ops) {
97
+ if (op.tag === "+")
98
+ added++;
99
+ else if (op.tag === "-")
100
+ removed++;
101
+ }
102
+ // hunks: runs of changes with CONTEXT equal lines on either side; two
103
+ // runs whose context would touch or overlap share one hunk
104
+ const changes = [];
105
+ ops.forEach((op, k) => {
106
+ if (op.tag !== " ")
107
+ changes.push(k);
108
+ });
109
+ const hunks = [];
110
+ let index = 0;
111
+ while (index < changes.length) {
112
+ let lastChange = changes[index];
113
+ let next = index + 1;
114
+ while (next < changes.length && changes[next] - lastChange <= CONTEXT * 2 + 1)
115
+ lastChange = changes[next++];
116
+ hunks.push({ start: Math.max(0, changes[index] - CONTEXT), end: Math.min(ops.length - 1, lastChange + CONTEXT) });
117
+ index = next;
118
+ }
119
+ const lines = [`--- a/${path}`, `+++ b/${path}`];
120
+ let oldLine = 1;
121
+ let newLine = 1;
122
+ let cursor = 0;
123
+ for (const hunk of hunks) {
124
+ for (; cursor < hunk.start; cursor++) {
125
+ if (ops[cursor].tag !== "+")
126
+ oldLine++;
127
+ if (ops[cursor].tag !== "-")
128
+ newLine++;
129
+ }
130
+ const slice = ops.slice(hunk.start, hunk.end + 1);
131
+ const oldCount = slice.filter((op) => op.tag !== "+").length;
132
+ const newCount = slice.filter((op) => op.tag !== "-").length;
133
+ // an empty side is conventionally anchored on the line before it
134
+ lines.push(`@@ -${oldCount ? oldLine : oldLine - 1},${oldCount} +${newCount ? newLine : newLine - 1},${newCount} @@`);
135
+ for (const op of slice)
136
+ lines.push(`${op.tag}${op.line}`);
137
+ for (; cursor <= hunk.end; cursor++) {
138
+ if (ops[cursor].tag !== "+")
139
+ oldLine++;
140
+ if (ops[cursor].tag !== "-")
141
+ newLine++;
142
+ }
143
+ }
144
+ let text = lines.join("\n");
145
+ let truncated = false;
146
+ if (text.length > cap) {
147
+ text = `${text.slice(0, cap)}\n… (diff cut here)`;
148
+ truncated = true;
149
+ }
150
+ return { text, added, removed, truncated };
151
+ }
152
+ // ── the append-only file ──────────────────────────────────────────────
153
+ const writeQueues = new Map();
154
+ function enqueue(botId, entry) {
155
+ const previous = writeQueues.get(botId) ?? Promise.resolve();
156
+ const queued = previous
157
+ .then(async () => {
158
+ mkdirSync(JOURNAL_DIR, { recursive: true, mode: 0o700 });
159
+ await appendFile(journalFile(botId), `${JSON.stringify(entry)}\n`, { mode: 0o600 });
160
+ })
161
+ .catch((error) => {
162
+ // fire-and-forget: the change this row records already happened
163
+ console.error(`memory-journal: could not append for ${botId}:`, error instanceof Error ? error.message : error);
164
+ });
165
+ writeQueues.set(botId, queued);
166
+ void queued.finally(() => {
167
+ if (writeQueues.get(botId) === queued)
168
+ writeQueues.delete(botId);
169
+ });
170
+ }
171
+ /** Test/route seam: a GET right after a write must see the row. */
172
+ export async function flushMemoryJournal(botId) {
173
+ await writeQueues.get(botId);
174
+ }
175
+ /** Shutdown seam. Each queued promise swallows its own error, so this
176
+ * never rejects either. */
177
+ export async function flushAllMemoryJournals() {
178
+ await Promise.allSettled(writeQueues.values());
179
+ }
180
+ function isEntry(value) {
181
+ if (typeof value !== "object" || value === null)
182
+ return false;
183
+ // SAFETY: shape probe on a parsed JSON line; the fields the panel keys on are checked below
184
+ const row = value;
185
+ return typeof row.id === "string" && typeof row.at === "number" && typeof row.path === "string" && typeof row.kind === "string";
186
+ }
187
+ /** Newest first, bounded to MAX_ROWS from the newest 8 MiB of the file, so
188
+ * an old bot with a long history does not make the panel load all of it. */
189
+ export function readMemoryJournal(botId, limit = 50) {
190
+ let text;
191
+ let offset;
192
+ try {
193
+ const fd = openSync(journalFile(botId), "r");
194
+ try {
195
+ const size = fstatSync(fd).size;
196
+ offset = Math.max(0, size - MAX_JOURNAL_READ_BYTES);
197
+ const buffer = Buffer.alloc(size - offset);
198
+ let length = 0;
199
+ while (length < buffer.length) {
200
+ const count = readSync(fd, buffer, length, buffer.length - length, offset + length);
201
+ if (!count)
202
+ break;
203
+ length += count;
204
+ }
205
+ text = buffer.subarray(0, length).toString("utf8");
206
+ }
207
+ finally {
208
+ closeSync(fd);
209
+ }
210
+ }
211
+ catch {
212
+ return [];
213
+ }
214
+ const rows = [];
215
+ const lines = text.split("\n");
216
+ const maxRows = Math.min(MAX_ROWS, Math.max(1, limit));
217
+ // when the window starts mid-file its first line is a fragment: skip it
218
+ for (let index = lines.length - 1; index >= (offset > 0 ? 1 : 0) && rows.length < maxRows; index--) {
219
+ const line = lines[index];
220
+ if (!line)
221
+ continue;
222
+ try {
223
+ const value = JSON.parse(line);
224
+ if (isEntry(value))
225
+ rows.push(value);
226
+ }
227
+ catch {
228
+ // a torn final line from a crash mid-append is skipped, not fatal
229
+ }
230
+ }
231
+ return rows;
232
+ }
233
+ /** The last text this module saw for each memory file of each bot. What
234
+ * the turn-boundary diff compares against, and what a route write
235
+ * advances — so a person's save between two turns is never re-journaled
236
+ * as the bot's work when the turn ends. */
237
+ const baselines = new Map();
238
+ function baselineFor(botId) {
239
+ let baseline = baselines.get(botId);
240
+ if (!baseline) {
241
+ baseline = new Map();
242
+ baselines.set(botId, baseline);
243
+ }
244
+ return baseline;
245
+ }
246
+ function advanceBaseline(botId, path, after) {
247
+ const baseline = baselineFor(botId);
248
+ if (after === null)
249
+ baseline.delete(path);
250
+ else
251
+ baseline.set(path, after);
252
+ }
253
+ /** Builds and queues one row. Never throws: hashing and diffing are pure,
254
+ * and the append is queued behind a catch. Returns the row so a route can
255
+ * answer with it. */
256
+ export function recordMemoryChange(botId, change) {
257
+ const { before, after } = change;
258
+ if (before === after)
259
+ return null;
260
+ advanceBaseline(botId, change.path, after);
261
+ const kind = before === null ? "created" : after === null ? "deleted" : "edited";
262
+ const diff = unifiedDiff(before ?? "", after ?? "", change.path);
263
+ let keptBefore = null;
264
+ let canRevert = true;
265
+ let revertUnavailableReason;
266
+ if (before !== null) {
267
+ if (Buffer.byteLength(before, "utf8") > BEFORE_CAP) {
268
+ canRevert = false;
269
+ revertUnavailableReason = "The earlier version was too large to keep, so this change cannot be reverted.";
270
+ }
271
+ else {
272
+ keptBefore = redactSecretsInText(before);
273
+ if (keptBefore !== before) {
274
+ // a redacted body is not the body that was there; restoring it
275
+ // would write our marker into the bot's memory as if it were text
276
+ canRevert = false;
277
+ revertUnavailableReason = "The earlier version contained sensitive text that was hidden, so it cannot be restored exactly.";
278
+ }
279
+ }
280
+ }
281
+ const entry = {
282
+ id: randomUUID(),
283
+ at: Date.now(),
284
+ botId,
285
+ path: change.path,
286
+ actor: change.actor,
287
+ via: change.via,
288
+ kind,
289
+ beforeHash: before === null ? null : hashMemoryText(before),
290
+ afterHash: after === null ? null : hashMemoryText(after),
291
+ before: keptBefore,
292
+ diff: redactSecretsInText(diff.text),
293
+ added: diff.added,
294
+ removed: diff.removed,
295
+ canRevert,
296
+ };
297
+ if (change.threadId)
298
+ entry.threadId = change.threadId;
299
+ if (revertUnavailableReason)
300
+ entry.revertUnavailableReason = revertUnavailableReason;
301
+ enqueue(botId, entry);
302
+ return entry;
303
+ }
304
+ /** The write every route uses: store first (it may refuse — 409, 413,
305
+ * containment), journal second. */
306
+ export function journalMemoryWrite(botId, path, text, opts) {
307
+ const result = writeMemoryDoc(botId, path, text, { expectedHash: opts.expectedHash });
308
+ const entry = recordMemoryChange(botId, {
309
+ path,
310
+ actor: opts.actor,
311
+ via: opts.via,
312
+ threadId: opts.threadId,
313
+ before: result.before,
314
+ after: result.after,
315
+ });
316
+ return { doc: { path, text: result.after, hash: result.hash, exists: true }, entry };
317
+ }
318
+ export function journalMemoryDelete(botId, path, opts) {
319
+ const result = deleteMemoryDoc(botId, path);
320
+ return recordMemoryChange(botId, { path, actor: opts.actor, via: opts.via, threadId: opts.threadId, before: result.before, after: null });
321
+ }
322
+ /** Puts the file back to the text the row recorded and journals that as
323
+ * its own change, so a revert is itself revertible. Unconditional on
324
+ * purpose: reverting an older row over newer ones is the person's call,
325
+ * and the row this writes carries the newer text should they change their
326
+ * mind. */
327
+ export function revertMemoryChange(botId, entryId, opts = {}) {
328
+ const entry = readMemoryJournal(botId, MAX_ROWS).find((row) => row.id === entryId);
329
+ if (!entry)
330
+ return { ok: false, status: 404, error: "no such memory change" };
331
+ if (!entry.canRevert)
332
+ return { ok: false, status: 400, error: entry.revertUnavailableReason ?? "this change cannot be reverted" };
333
+ try {
334
+ if (entry.kind === "created") {
335
+ // MEMORY.md is never deleted (the store refuses); emptying it is the
336
+ // same end state for the bot
337
+ if (entry.path === MEMORY_INDEX) {
338
+ const written = journalMemoryWrite(botId, entry.path, "", { actor: "person", via: "revert", threadId: opts.threadId });
339
+ return { ok: true, entry: written.entry, doc: written.doc };
340
+ }
341
+ const removed = journalMemoryDelete(botId, entry.path, { actor: "person", via: "revert", threadId: opts.threadId });
342
+ return { ok: true, entry: removed, doc: { path: entry.path, text: "", hash: hashMemoryText(""), exists: false } };
343
+ }
344
+ const written = journalMemoryWrite(botId, entry.path, entry.before ?? "", { actor: "person", via: "revert", threadId: opts.threadId });
345
+ return { ok: true, entry: written.entry, doc: written.doc };
346
+ }
347
+ catch (error) {
348
+ if (error instanceof MemoryStoreError)
349
+ return { ok: false, status: error.status, error: error.message };
350
+ throw error;
351
+ }
352
+ }
353
+ // ── the turn boundary ─────────────────────────────────────────────────
354
+ /** Bots with a turn in flight on each thread. A set, not one id: a room
355
+ * thread can have more than one member speaking on it. */
356
+ const turnBots = new Map();
357
+ function currentDocs(botId) {
358
+ const docs = new Map();
359
+ const overview = memoryOverview(botId);
360
+ for (const path of [MEMORY_INDEX, ...overview.topics.map((t) => t.path), ...overview.logs.map((l) => l.path)]) {
361
+ const doc = readMemoryDoc(botId, path);
362
+ if (doc.exists)
363
+ docs.set(path, doc.text);
364
+ }
365
+ return docs;
366
+ }
367
+ /** Everything on disk that differs from the baseline becomes a row under
368
+ * `actor`/`via`, and the baseline moves up to match. A path the baseline
369
+ * has never seen is a first sighting at turn start (the server may just
370
+ * have booted; what is already there is nobody's change now) and a
371
+ * creation at turn end (begin seeded everything, so anything new since
372
+ * is the turn's) — `unknownIsNew` says which. */
373
+ function reconcile(botId, actor, via, threadId, unknownIsNew) {
374
+ const baseline = baselineFor(botId);
375
+ const docs = currentDocs(botId);
376
+ const rows = [];
377
+ for (const [path, text] of docs) {
378
+ const known = baseline.get(path);
379
+ if (known === text)
380
+ continue;
381
+ if (known === undefined && !unknownIsNew) {
382
+ baseline.set(path, text);
383
+ continue;
384
+ }
385
+ const row = recordMemoryChange(botId, { path, actor, via, threadId, before: known ?? null, after: text });
386
+ if (row)
387
+ rows.push(row);
388
+ }
389
+ // deleting the current key mid-iteration is defined for a Map
390
+ for (const path of baseline.keys()) {
391
+ if (docs.has(path))
392
+ continue;
393
+ const row = recordMemoryChange(botId, { path, actor, via, threadId, before: baseline.get(path) ?? null, after: null });
394
+ if (row)
395
+ rows.push(row);
396
+ }
397
+ return rows;
398
+ }
399
+ /** Call at dispatch, after ensureWorkspace. A file seen for the first
400
+ * time seeds the baseline silently; a file that changed since the last
401
+ * look was edited outside the app between turns — an editor, Obsidian, a
402
+ * sync client — and is journaled as the person's, via "disk". */
403
+ export function beginMemoryTurn(botId, threadId) {
404
+ try {
405
+ let bots = turnBots.get(threadId);
406
+ if (!bots) {
407
+ bots = new Set();
408
+ turnBots.set(threadId, bots);
409
+ }
410
+ bots.add(botId);
411
+ reconcile(botId, "person", "disk", undefined, false);
412
+ }
413
+ catch (error) {
414
+ // a hashing or read failure must not stop the turn from dispatching
415
+ console.error(`memory-journal: could not snapshot ${botId}:`, error instanceof Error ? error.message : error);
416
+ }
417
+ }
418
+ /** Call at turn.completed (and session.exited — a turn that died may still
419
+ * have written). Whatever moved since the baseline is the bot's work. */
420
+ export function endMemoryTurn(threadId) {
421
+ const bots = turnBots.get(threadId);
422
+ if (!bots)
423
+ return [];
424
+ turnBots.delete(threadId);
425
+ const rows = [];
426
+ for (const botId of bots) {
427
+ try {
428
+ rows.push(...reconcile(botId, "bot", "turn", threadId, true));
429
+ }
430
+ catch (error) {
431
+ console.error(`memory-journal: could not diff ${botId}:`, error instanceof Error ? error.message : error);
432
+ }
433
+ }
434
+ return rows;
435
+ }
436
+ /** Test seam: forget every baseline and in-flight turn. */
437
+ export function resetMemoryJournalState() {
438
+ baselines.clear();
439
+ turnBots.clear();
440
+ }