openmausbot 0.1.70 → 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 (56) hide show
  1. package/dist/assets/{index-D1RGhLUK.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 +4 -2
  6. package/dist-server/drivers/agents-proxy.js +202 -16
  7. package/dist-server/drivers/pi-mcp-extension.ts +1 -1
  8. package/dist-server/index.js +2258 -834
  9. package/dist-server/local-computer.js +2 -1
  10. package/dist-server/mcp-gate.js +543 -0
  11. package/dist-server/mcp-server.js +38 -0
  12. package/dist-server/openmausbot.js +416 -130
  13. package/dist-server/pair-cli.js +416 -130
  14. package/dist-server/proxy-paths.js +1 -0
  15. package/dist-server/server/agent-tool-policy.js +29 -0
  16. package/dist-server/server/bot-package.js +55 -2
  17. package/dist-server/server/browser-live.js +12 -7
  18. package/dist-server/server/config.js +40 -4
  19. package/dist-server/server/drivers/acp/core.js +2 -0
  20. package/dist-server/server/drivers/acp/cursor.js +1 -1
  21. package/dist-server/server/drivers/acp/grok.js +5 -1
  22. package/dist-server/server/drivers/agents-proxy.js +195 -16
  23. package/dist-server/server/drivers/boxagent.js +1 -1
  24. package/dist-server/server/drivers/claude.js +288 -17
  25. package/dist-server/server/drivers/codex.js +13 -4
  26. package/dist-server/server/drivers/pi.js +1 -1
  27. package/dist-server/server/harness/bus.js +1 -1
  28. package/dist-server/server/index.js +520 -193
  29. package/dist-server/server/local-computer.js +1 -1
  30. package/dist-server/server/mcp-gate-config.js +56 -0
  31. package/dist-server/server/mcp-gate.js +227 -0
  32. package/dist-server/server/mcp-trim.js +182 -0
  33. package/dist-server/server/memory-journal.js +440 -0
  34. package/dist-server/server/memory-store.js +308 -0
  35. package/dist-server/server/message-db.js +90 -3
  36. package/dist-server/server/package-export.js +25 -0
  37. package/dist-server/server/peer-approval.js +1 -1
  38. package/dist-server/server/proxy-paths.js +1 -0
  39. package/dist-server/server/redact.js +15 -0
  40. package/dist-server/server/resume-recovery.js +46 -0
  41. package/dist-server/server/screen-frame-gate.js +9 -6
  42. package/dist-server/server/skill-library.js +5 -5
  43. package/dist-server/server/store.js +7 -3
  44. package/dist-server/server/system-prompt.js +13 -1
  45. package/dist-server/server/team-backup.js +35 -0
  46. package/dist-server/server/tool-summary.js +39 -0
  47. package/dist-server/server/tts/speech-text.js +3 -1
  48. package/dist-server/server/turn-context.js +18 -0
  49. package/dist-server/server/workspace.js +321 -22
  50. package/dist-server/shared/approval-mode.js +1 -1
  51. package/dist-server/shared/team-backup.js +13 -2
  52. package/dist-server/vps-container-mcp.js +4 -2
  53. package/package.json +1 -1
  54. package/skills/create-verification-skill/SKILL.md +2 -3
  55. package/dist/assets/index-B2F__IRS.css +0 -1
  56. package/dist/assets/index-CLj3t5Ba.js +0 -280
@@ -33,7 +33,7 @@ import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
33
33
  import { peerAllowed, peerName, peerRosterSystemPrompt, reachablePeers, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
34
34
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
35
35
  import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
36
- import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, saveConfig, showToolCallsEnabled, skillRecorderEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
36
+ import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, saveConfig, showToolCallsEnabled, skillAuthoringEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
37
37
  import { ComputerControl } from "./computer-control.js";
38
38
  import { MAX_REMOTE_COMMAND_LENGTH } from "./remote-computer.js";
39
39
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -67,14 +67,16 @@ import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
67
67
  import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, } from "./store.js";
68
68
  import * as tts from "./tts/index.js";
69
69
  import { narrateTool, toUtterances } from "./tts/speech-text.js";
70
- import { buildTurnContext, engineIsFresh } from "./turn-context.js";
70
+ import { buildRecoveryText, buildTurnContext, engineIsFresh } from "./turn-context.js";
71
71
  import { extractTurnImages } from "./turn-images.js";
72
72
  import { TurnWatchdog } from "./turn-watchdog.js";
73
73
  import { TurnResources, workspaceResource } from "./turn-resources.js";
74
- import { ensureWorkspace, ensureTaskWorkspace, updateMemory, listMemoryTopics, isMemoryTopicName, memorySystemPrompt, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
75
- import { readMemoryFile, readMemoryTopic, writeMemoryFile, MEMORY_FILE_MAX_BYTES, } from "./workspace.js";
74
+ import { ensureWorkspace, ensureTaskWorkspace, updateMemory, appendMemoryLog, isMemoryTopicName, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
75
+ import { readMemoryTopic } from "./workspace.js";
76
+ import { MEMORY_INDEX, MemoryStoreError, memoryCapacity, memoryOverview, openMemoryLocation, readMemoryDoc, } from "./memory-store.js";
77
+ import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, flushMemoryJournal, journalMemoryDelete, journalMemoryWrite, readMemoryJournal, revertMemoryChange, } from "./memory-journal.js";
76
78
  import { readSectionContext, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
77
- import { applyStagedSkillWrite, getStagedSkillWrite, installSkill, listSkills, listStagedSkillWrites, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
79
+ import { applyStagedSkillWrite, getStagedSkillWrite, installSkill, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
78
80
  import { fetchSkillFromSource } from "./skill-fetch.js";
79
81
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
80
82
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
@@ -103,7 +105,7 @@ import { flushAllProfileHistory, flushProfileHistory, readHistory, recordProfile
103
105
  import { fetchBotDirectory, matchDirectoryBots } from "./bot-directory.js";
104
106
  import { scoutProject, suggestTeam } from "./project-scout.js";
105
107
  import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-library.js";
106
- import { isBotPackage, packageAgentAsMember, parseBotPackage, renderBotPackageMarkdown } from "./bot-package.js";
108
+ import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageAgentAsMember, parseBotPackage, renderBotPackageMarkdown } from "./bot-package.js";
107
109
  import { createTeamManifest, importedMemberProfile, parseTeamManifest } from "./team-manifest.js";
108
110
  import { readThreadEvents } from "./thread-events.js";
109
111
  import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
@@ -884,6 +886,54 @@ function checkedModelSelection(raw, current, requireAvailableModel = false) {
884
886
  }
885
887
  return { ok: true, selection };
886
888
  }
889
+ function checkedExportSkillNames(value, bots) {
890
+ // Sharing instructions is explicit: ordinary package exports include none.
891
+ if (value === undefined)
892
+ return { ok: true, names: [] };
893
+ if (!Array.isArray(value) || value.some((name) => typeof name !== "string" || !isSkillName(name))) {
894
+ return { ok: false, error: "skillIds must be a list of exact imported skill names" };
895
+ }
896
+ const names = [...value];
897
+ if (new Set(names).size !== names.length)
898
+ return { ok: false, error: "skillIds must not contain duplicates" };
899
+ if (names.length > BOT_PACKAGE_MAX_SKILLS)
900
+ return { ok: false, error: `skillIds must contain at most ${BOT_PACKAGE_MAX_SKILLS} names` };
901
+ const available = new Set(bots.flatMap((bot) => listSkills(bot.id).map((skill) => skill.name)));
902
+ const unknown = names.find((name) => !available.has(name));
903
+ if (unknown)
904
+ return { ok: false, error: `skillIds contains unknown imported skill "${unknown}"` };
905
+ return { ok: true, names };
906
+ }
907
+ function collectExportSkills(bots, names) {
908
+ const selected = new Set(names);
909
+ const byBot = new Map();
910
+ if (!selected.size)
911
+ return byBot;
912
+ for (const bot of bots) {
913
+ const assigned = [];
914
+ for (const listing of listSkills(bot.id)) {
915
+ if (!selected.has(listing.name))
916
+ continue;
917
+ const instructions = readSkillFile(bot.id, listing.name);
918
+ if (instructions === null) {
919
+ throw new Error(`Skill "${listing.name}" changed or is unavailable and cannot be exported safely`);
920
+ }
921
+ const skill = {
922
+ name: listing.name,
923
+ description: listing.description,
924
+ ...(listing.source ? { source: listing.source } : {}),
925
+ ...(listing.license ? { license: listing.license } : {}),
926
+ ...(listing.compatibility ? { compatibility: listing.compatibility } : {}),
927
+ instructions,
928
+ };
929
+ // The shared exporter validates duplicate bytes and metadata together.
930
+ assigned.push(skill);
931
+ }
932
+ if (assigned.length)
933
+ byBot.set(bot.id, assigned);
934
+ }
935
+ return byBot;
936
+ }
887
937
  function checkedGroupResponder(value, memberIds) {
888
938
  if (!value || typeof value !== "object" || Array.isArray(value))
889
939
  return null;
@@ -1010,7 +1060,7 @@ function previewSystemPrompt(bot) {
1010
1060
  id: "setup",
1011
1061
  label: "Setup",
1012
1062
  text: setupSystemPrompt(agentsMounted && setupModeActive({ soul: bot.soul, description: bot.description, text: "" }), {
1013
- skills: skillRecorderEnabled(cfg),
1063
+ skills: skillAuthoringEnabled(cfg),
1014
1064
  cwd: bot.cwd,
1015
1065
  }),
1016
1066
  },
@@ -1447,6 +1497,165 @@ function groupGoalCoordinatorTurnForEvent(event) {
1447
1497
  function groupIsWorking(group) {
1448
1498
  return Boolean(group.busyBotId) || Boolean(groupTurnOperations.get(group.id)?.size);
1449
1499
  }
1500
+ // The public and Chief room tools use the same synchronous validation and write.
1501
+ // Keep authorization at each ingress; no internal caller gains public admin scope.
1502
+ function createChannel(value) {
1503
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1504
+ throw Object.assign(new Error("channel must be a JSON object"), { status: 400 });
1505
+ }
1506
+ const body = value;
1507
+ const roster = checkedMemberIds(body.memberIds);
1508
+ if (!roster.ok)
1509
+ throw Object.assign(new Error(roster.error), { status: 400 });
1510
+ const { memberIds } = roster;
1511
+ if (body.name !== undefined && typeof body.name !== "string") {
1512
+ throw Object.assign(new Error("channel name must be a string"), { status: 400 });
1513
+ }
1514
+ const name = body.name?.trim() || `${store.bot(memberIds[0]).name} & co.`;
1515
+ if (name.length > 100)
1516
+ throw Object.assign(new Error("channel name must be at most 100 characters"), { status: 400 });
1517
+ let section;
1518
+ if (body.section !== undefined && body.section !== null) {
1519
+ if (typeof body.section !== "string")
1520
+ throw Object.assign(new Error("context must be a string"), { status: 400 });
1521
+ section = body.section.trim() || undefined;
1522
+ if (section && section.length > 60) {
1523
+ throw Object.assign(new Error("context must be at most 60 characters"), { status: 400 });
1524
+ }
1525
+ }
1526
+ let setup;
1527
+ if (body.setup !== undefined) {
1528
+ if (!body.setup || typeof body.setup !== "object" || Array.isArray(body.setup)) {
1529
+ throw Object.assign(new Error("setup must be an object"), { status: 400 });
1530
+ }
1531
+ const requested = body.setup;
1532
+ if (typeof requested.bulletin !== "string") {
1533
+ throw Object.assign(new Error("setup.bulletin must be a string"), { status: 400 });
1534
+ }
1535
+ if (requested.bulletin.length > 12_000) {
1536
+ throw Object.assign(new Error("setup.bulletin must be at most 12000 characters"), { status: 400 });
1537
+ }
1538
+ const responder = checkedGroupResponder(requested.defaultResponder, memberIds);
1539
+ if (!responder)
1540
+ throw Object.assign(new Error("invalid setup.defaultResponder"), { status: 400 });
1541
+ setup = { bulletin: requested.bulletin, defaultResponder: responder, completed: true };
1542
+ }
1543
+ return store.createGroup(name, memberIds, false, section, setup);
1544
+ }
1545
+ function updateChannel(groupId, value) {
1546
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1547
+ throw Object.assign(new Error("body must be a JSON object"), { status: 400 });
1548
+ }
1549
+ const body = value;
1550
+ const existing = store.group(groupId);
1551
+ if (!existing)
1552
+ throw Object.assign(new Error("no such room"), { status: 404 });
1553
+ if (body.memberIds !== undefined && phoneSecretSubmissions.hasGroup(existing.id)) {
1554
+ throw Object.assign(new Error("this channel is securely saving a credential — try again when it finishes"), { status: 409 });
1555
+ }
1556
+ if (channelTaskBlocked(existing) &&
1557
+ (body.memberIds !== undefined || body.defaultResponder !== undefined || body.bulletin !== undefined)) {
1558
+ throw Object.assign(new Error("this channel is working or waiting on you — finish that turn first"), { status: 409 });
1559
+ }
1560
+ const patch = {};
1561
+ if (body.name !== undefined) {
1562
+ if (typeof body.name !== "string")
1563
+ throw Object.assign(new Error("room name must be a string"), { status: 400 });
1564
+ const name = body.name.trim();
1565
+ if (!name)
1566
+ throw Object.assign(new Error("room name must not be empty"), { status: 400 });
1567
+ if (name.length > 100)
1568
+ throw Object.assign(new Error("room name must be at most 100 characters"), { status: 400 });
1569
+ patch.name = name;
1570
+ }
1571
+ if (body.bulletin !== undefined) {
1572
+ if (typeof body.bulletin !== "string")
1573
+ throw Object.assign(new Error("bulletin must be a string"), { status: 400 });
1574
+ if (body.bulletin.length > 12_000) {
1575
+ throw Object.assign(new Error("bulletin must be at most 12000 characters"), { status: 400 });
1576
+ }
1577
+ patch.bulletin = body.bulletin;
1578
+ }
1579
+ if (body.unread !== undefined) {
1580
+ if (typeof body.unread !== "boolean")
1581
+ throw Object.assign(new Error("unread must be true or false"), { status: 400 });
1582
+ patch.unread = body.unread;
1583
+ }
1584
+ if (body.memberIds !== undefined) {
1585
+ // A DM is the pair it was opened for; only real rooms have a roster.
1586
+ if (existing.dm)
1587
+ throw Object.assign(new Error("direct-message channels cannot change members"), { status: 400 });
1588
+ const roster = checkedMemberIds(body.memberIds);
1589
+ if (!roster.ok)
1590
+ throw Object.assign(new Error(roster.error.replace("channel", "room")), { status: 400 });
1591
+ const removedGoalLead = routines.listRoutines().some((routine) => routine.enabled &&
1592
+ routine.target === "room-goal" &&
1593
+ routine.groupId === existing.id &&
1594
+ !roster.memberIds.includes(routine.botId)) || routines.listRuns().some((run) => run.target === "room-goal" &&
1595
+ run.groupId === existing.id &&
1596
+ ["queued", "running", "waiting"].includes(run.status) &&
1597
+ !roster.memberIds.includes(run.botId));
1598
+ if (removedGoalLead) {
1599
+ throw Object.assign(new Error("pause or reassign this room's team-goal routine before removing its lead"), { status: 409 });
1600
+ }
1601
+ patch.memberIds = roster.memberIds;
1602
+ }
1603
+ if (body.defaultResponder !== undefined) {
1604
+ const memberIds = patch.memberIds ?? existing.memberIds;
1605
+ const responder = checkedGroupResponder(body.defaultResponder, memberIds);
1606
+ if (!responder)
1607
+ throw Object.assign(new Error("invalid default responder"), { status: 400 });
1608
+ patch.defaultResponder = responder;
1609
+ }
1610
+ if (body.cwd !== undefined) {
1611
+ if (existing.dm)
1612
+ throw Object.assign(new Error("direct-message channels cannot have a working folder"), { status: 400 });
1613
+ if (existing.pinnedCwd !== undefined) {
1614
+ throw Object.assign(new Error("the room's working folder is fixed after its first turn"), { status: 409 });
1615
+ }
1616
+ const checked = validateBotCwd(body.cwd);
1617
+ if (!checked.ok)
1618
+ throw Object.assign(new Error(checked.error), { status: 400 });
1619
+ patch.cwd = checked.cwd ?? undefined;
1620
+ }
1621
+ // one pinned message per room; null/"" clears. The id is not
1622
+ // validated against the transcript here — a pin whose message was
1623
+ // edited away or deleted simply resolves to nothing in the UI.
1624
+ if (body.pinnedMessageId !== undefined) {
1625
+ if (body.pinnedMessageId === null || body.pinnedMessageId === "")
1626
+ patch.pinnedMessageId = undefined;
1627
+ else if (typeof body.pinnedMessageId === "string" && /^[\w-]+$/.test(body.pinnedMessageId)) {
1628
+ patch.pinnedMessageId = body.pinnedMessageId;
1629
+ }
1630
+ else
1631
+ throw Object.assign(new Error("pinnedMessageId must be a message id"), { status: 400 });
1632
+ }
1633
+ // same contract as a bot's sidebar section: null/"" clears, 60 chars max
1634
+ if (body.section !== undefined) {
1635
+ if (body.section === null)
1636
+ patch.section = undefined;
1637
+ else if (typeof body.section !== "string")
1638
+ throw Object.assign(new Error("section must be a string"), { status: 400 });
1639
+ else {
1640
+ const trimmed = body.section.trim();
1641
+ if (!trimmed)
1642
+ patch.section = undefined;
1643
+ else if (trimmed.length > 60)
1644
+ throw Object.assign(new Error("section must be at most 60 characters"), { status: 400 });
1645
+ else
1646
+ patch.section = trimmed;
1647
+ }
1648
+ }
1649
+ const group = store.patchGroup(groupId, patch);
1650
+ if (!group)
1651
+ throw Object.assign(new Error("no such room"), { status: 404 });
1652
+ return group;
1653
+ }
1654
+ const channelTaskBlocked = (group) => groupIsWorking(group) ||
1655
+ store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
1656
+ message.card?.requestId &&
1657
+ !message.card.answered &&
1658
+ !message.card.dismissed));
1450
1659
  function publicGroupState(group) {
1451
1660
  return { ...group, working: groupIsWorking(group) };
1452
1661
  }
@@ -1899,7 +2108,7 @@ function broadcast(payload) {
1899
2108
  replayBuffer.push({ seq, kind, frame: kind === "screen" ? null : frame, clientFrame: kind === "screen" ? null : clientFrame });
1900
2109
  if (replayBuffer.length > REPLAY_MAX)
1901
2110
  replayBuffer.shift();
1902
- for (const client of [...sseClients]) {
2111
+ for (const client of Array.from(sseClients)) {
1903
2112
  if (!wants(client, kind))
1904
2113
  continue;
1905
2114
  // Screen frames are replaceable and durable events are not: see
@@ -2205,6 +2414,18 @@ bus.subscribe((event) => {
2205
2414
  else if (event.type !== "session.exited")
2206
2415
  watchdog.touch(event.threadId);
2207
2416
  });
2417
+ // Memory journal turn boundary (server/memory-journal.ts). A bot's own
2418
+ // file-tool writes to MEMORY.md and memory/ have no hook to tap, so the
2419
+ // diff against the baseline taken at dispatch is made when the turn
2420
+ // settles — session.exited too, because a turn that died may still have
2421
+ // written. Fire-and-forget by construction: endMemoryTurn swallows its own
2422
+ // failures and never reaches the fold below.
2423
+ bus.subscribe((event) => {
2424
+ if (shouldIgnoreProviderEvent(event))
2425
+ return;
2426
+ if (event.type === "turn.completed" || event.type === "session.exited")
2427
+ endMemoryTurn(event.threadId);
2428
+ });
2208
2429
  // Bots currently working with nobody at the keyboard — a webhook turn, or a
2209
2430
  // turn a webhook-driven bot handed to a teammate. Auto mode is a decision
2210
2431
  // someone made for turns they were present for, so these don't inherit it:
@@ -2616,7 +2837,7 @@ bus.subscribe((event) => {
2616
2837
  const existing = store.messagesFor(event.threadId).find((m) => m.id === messageId)?.tool;
2617
2838
  toolName = existing?.name ?? "tool";
2618
2839
  store.patchMessage(event.threadId, messageId, {
2619
- tool: { name: toolName, ok: event.ok, spoken: existing?.spoken },
2840
+ tool: { name: toolName, ok: event.ok, spoken: existing?.spoken, summary: existing?.summary },
2620
2841
  });
2621
2842
  toolMessageByItem.delete(itemKey);
2622
2843
  }
@@ -2648,7 +2869,7 @@ bus.subscribe((event) => {
2648
2869
  const message = pushMessage({
2649
2870
  role: "bot",
2650
2871
  kind: "activity",
2651
- tool: { name, spoken: narrateTool(name) ?? undefined },
2872
+ tool: { name, spoken: narrateTool(name) ?? undefined, summary: event.summary },
2652
2873
  });
2653
2874
  if (event.itemId)
2654
2875
  toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
@@ -3880,7 +4101,7 @@ async function startTurn(botId, text, opts) {
3880
4101
  // that never mounts agent tools (or a turn already at the comms-depth cap)
3881
4102
  // must not be steered into — or told about — tools it cannot call.
3882
4103
  const agentsMounted = commsDepth < MAX_COMMS_DEPTH && instance.adapter.capabilities.agentsMcp === true;
3883
- const skillAuthoring = skillRecorderEnabled(cfg) && agentsMounted;
4104
+ const skillAuthoring = skillAuthoringEnabled(cfg) && agentsMounted;
3884
4105
  // Setup mode's turn-text rewrite (parseSetupCommand/expandSetupTurnText)
3885
4106
  // must not run ahead of a system prompt that can't explain it: a driver
3886
4107
  // without agent tools sees the user's literal "/setup ..." message. The
@@ -3901,6 +4122,10 @@ async function startTurn(botId, text, opts) {
3901
4122
  // this already-built turn must either keep its old session or replay on the
3902
4123
  // following turn, never start a blank session with no transcript.
3903
4124
  const resumeCursor = resume ? task.resumeCursors[instanceId] : undefined;
4125
+ // A cursor the provider no longer honours must not brick the thread: the
4126
+ // driver may fall back to ONE fresh session, and this is what it sends
4127
+ // there, so the new session is not blank (server/resume-recovery.ts).
4128
+ const recoveryText = resumeCursor !== undefined ? buildRecoveryText({ text: turnText, transcript }) : undefined;
3904
4129
  const persona = [
3905
4130
  `You are ${bot.name}, a personal bot in OpenMausBot.`,
3906
4131
  bot.title && `Role: ${bot.title}.`,
@@ -3968,8 +4193,11 @@ async function startTurn(botId, text, opts) {
3968
4193
  // desk, not the whole house — and the workspace is where its
3969
4194
  // MEMORY.md lives. API/box engines have no local filesystem story.
3970
4195
  const worksInWorkspace = instance.driverKind !== "grok" && instance.driverKind !== "boxAgent";
3971
- if (worksInWorkspace)
4196
+ if (worksInWorkspace) {
3972
4197
  ensureWorkspace(bot.id);
4198
+ // baseline for the journal's turn-boundary diff (see the bus hook)
4199
+ beginMemoryTurn(bot.id, threadId);
4200
+ }
3973
4201
  const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
3974
4202
  const skillInstructions = renderSkillInstructions(selectedSkills, {
3975
4203
  includeRoot: worksInWorkspace && opts?.runOn !== "cloud",
@@ -4343,8 +4571,11 @@ async function startTurn(botId, text, opts) {
4343
4571
  // the active task's own session — another task's cursor would
4344
4572
  // resume the wrong conversation and defeat the context bubble
4345
4573
  resumeCursor,
4574
+ ...(recoveryText !== undefined ? { recoveryText } : {}),
4346
4575
  transcript,
4347
4576
  system: prompt.text,
4577
+ systemStable: prompt.stable,
4578
+ systemVolatile: prompt.volatile,
4348
4579
  integrations,
4349
4580
  cwd,
4350
4581
  }), () => !directTurnClaimExists(bot.id, dispatchClaimId, threadId), async () => {
@@ -5141,7 +5372,7 @@ setupRetry = 0) {
5141
5372
  };
5142
5373
  try {
5143
5374
  const integrations = {};
5144
- const skillAuthoring = skillRecorderEnabled(cfg) &&
5375
+ const skillAuthoring = skillAuthoringEnabled(cfg) &&
5145
5376
  hop === 0 &&
5146
5377
  !skillAuthoringClaim.claimed &&
5147
5378
  !cardContinuation &&
@@ -5382,6 +5613,9 @@ setupRetry = 0) {
5382
5613
  // conversation, not a different bot
5383
5614
  const worksInWorkspace = instance.driverKind !== "grok" && instance.driverKind !== "boxAgent";
5384
5615
  const workspace = worksInWorkspace ? ensureWorkspace(bot.id) : undefined;
5616
+ // a room member's memory writes are journaled the same as a 1:1 turn's
5617
+ if (workspace)
5618
+ beginMemoryTurn(bot.id, threadId);
5385
5619
  // The room's folder pins here — on the first turn that actually
5386
5620
  // dispatches, not at PATCH time — so a folder set on a never-used room
5387
5621
  // still takes effect, while a room that already worked somewhere never
@@ -5401,12 +5635,14 @@ setupRetry = 0) {
5401
5635
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
5402
5636
  // the room path has always put a newline before memory and trimmed
5403
5637
  // the block's leading space; keep that so existing prompts are
5404
- // byte-identical
5405
- { id: "memory", label: "Memory", text: workspace ? `\n${memorySystemPrompt(bot.id).trim()}` : "" },
5638
+ // byte-identical. The write guidance follows the tools actually
5639
+ // mounted, exactly as the 1:1 path decides it: memory_update is on the
5640
+ // agents server, so a room turn with it must be told to use it too.
5641
+ { id: "memory", label: "Memory", text: workspace ? `\n${memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents) }).trim()}` : "" },
5406
5642
  { id: "skills", label: "Skills index", text: workspace ? skillsSystemPrompt(bot.id) : "" },
5407
5643
  { id: "skill-instructions", label: "Skill instructions", text: renderSkillInstructions(selectedSkills, { includeRoot: Boolean(workspace) }) },
5408
5644
  { id: "playbooks", label: "Playbooks", text: installedPlaybookInstructions(text, bot.playbooks) },
5409
- ]).text;
5645
+ ]);
5410
5646
  // run the turn and wait for it to settle, folding the reply text so a
5411
5647
  // chained @mention can be routed afterwards
5412
5648
  // Claim only after setup succeeded. An unavailable, busy, unsupported, or
@@ -5509,7 +5745,9 @@ setupRetry = 0) {
5509
5745
  text,
5510
5746
  images: turnImages,
5511
5747
  approvalMode: approvalModeForTurn(readyBot, false, threadId),
5512
- system: roomSystem,
5748
+ system: roomSystem.text,
5749
+ systemStable: roomSystem.stable,
5750
+ systemVolatile: roomSystem.volatile,
5513
5751
  cwd,
5514
5752
  integrations,
5515
5753
  ...memberTurnSelection(readyBot.modelSelection),
@@ -7044,7 +7282,7 @@ function configStatus() {
7044
7282
  maxInstances: localVmMaxInstances(cfg),
7045
7283
  },
7046
7284
  features: {
7047
- skillRecorder: skillRecorderEnabled(cfg),
7285
+ skillAuthoring: skillAuthoringEnabled(cfg),
7048
7286
  showToolCalls: showToolCallsEnabled(cfg),
7049
7287
  browser: builtInBrowserEnabled(cfg),
7050
7288
  },
@@ -7235,6 +7473,26 @@ function json(res, status, body) {
7235
7473
  res.writeHead(status, { "content-type": "application/json" });
7236
7474
  res.end(data);
7237
7475
  }
7476
+ /** A store refusal is a client error with a status of its own (400 path,
7477
+ * 409 conflict, 413 too large); a 409 also carries what is on disk now so
7478
+ * the editor can show the bot's version instead of guessing. Anything else
7479
+ * is a real failure and goes to the handler's catch-all. */
7480
+ function replyMemoryError(res, error) {
7481
+ if (!(error instanceof MemoryStoreError))
7482
+ throw error;
7483
+ if (error.code === "conflict") {
7484
+ return json(res, error.status, { error: error.message, code: error.code, currentHash: error.currentHash, current: error.current });
7485
+ }
7486
+ return json(res, error.status, { error: error.message, code: error.code });
7487
+ }
7488
+ /** The journal row as the panel shows it: the full prior text stays on
7489
+ * the server (a revert needs it there, the list does not), and the thread
7490
+ * id becomes the chat title people recognise. */
7491
+ function journalEntryForClient(botId, entry) {
7492
+ const { before: _before, ...visible } = entry;
7493
+ const threadTitle = entry.threadId ? store.taskByThread(botId, entry.threadId)?.title : undefined;
7494
+ return threadTitle ? { ...visible, threadTitle } : visible;
7495
+ }
7238
7496
  function readBody(req, limit = 1_000_000) {
7239
7497
  return new Promise((resolve, reject) => {
7240
7498
  let data = "";
@@ -7602,10 +7860,23 @@ const handleRequest = async (req, res) => {
7602
7860
  throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
7603
7861
  }
7604
7862
  };
7863
+ // Where an entry came from, as the person will read it in MEMORY.md:
7864
+ // the room or the thread title, never a bare id unless nothing else
7865
+ // names the conversation.
7866
+ const memorySource = () => memorySourceLabel({
7867
+ room: store.groupByThread(internalCapability.threadId),
7868
+ task: store.taskByThread(internalSender.id, internalCapability.threadId),
7869
+ threadId: internalCapability.threadId,
7870
+ });
7605
7871
  if (method === "POST" && path === "/api/internal/memory") {
7606
7872
  const body = await readInternalBody();
7607
- const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText });
7608
- return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "too-large" ? 413 : 400, result);
7873
+ const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText }, { source: memorySource() });
7874
+ return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "over-budget" ? 413 : 400, result);
7875
+ }
7876
+ if (method === "POST" && path === "/api/internal/memory/log") {
7877
+ const body = await readInternalBody();
7878
+ const result = appendMemoryLog(internalSender.id, body.text, { source: memorySource() });
7879
+ return json(res, result.ok ? 200 : 400, result);
7609
7880
  }
7610
7881
  if (method === "POST" && path === "/api/internal/browser/mcp") {
7611
7882
  const body = await readInternalBody();
@@ -7910,6 +8181,16 @@ const handleRequest = async (req, res) => {
7910
8181
  return json(res, 400, { error: "q is required" });
7911
8182
  const rawLimit = Number(url.searchParams.get("limit"));
7912
8183
  const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.trunc(rawLimit), 25) : 12;
8184
+ // Memory files ride along by default: the bot's own notes are as
8185
+ // much its notebook as its transcripts, and scoped the same way —
8186
+ // the caller's own bot, never another's.
8187
+ const scope = url.searchParams.get("scope") ?? "all";
8188
+ if (scope !== "all" && scope !== "conversations" && scope !== "memory") {
8189
+ return json(res, 400, { error: "scope must be all, conversations, or memory" });
8190
+ }
8191
+ const memoryHits = scope === "conversations" ? [] : searchMemoryFiles(from.id, q, limit);
8192
+ if (scope === "memory")
8193
+ return json(res, 200, { hits: [], memoryHits });
7913
8194
  const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId)])];
7914
8195
  // A room is the only place a recall can be a disclosure: in a 1:1 the
7915
8196
  // user already owns every thread the bot can reach.
@@ -7923,7 +8204,7 @@ const handleRequest = async (req, res) => {
7923
8204
  if (inRoom) {
7924
8205
  discloseRecall(from, fromThreadId, hits.filter((hit) => hit.crossed).map((hit) => hit.threadId));
7925
8206
  }
7926
- return json(res, 200, { hits });
8207
+ return json(res, 200, { hits, memoryHits });
7927
8208
  }
7928
8209
  // session_read: the whole message behind a session_search hit. Same
7929
8210
  // own-bot scope — a message id from another bot's thread reads as
@@ -7957,8 +8238,8 @@ const handleRequest = async (req, res) => {
7957
8238
  });
7958
8239
  }
7959
8240
  if (method === "GET" && path === "/api/internal/skills") {
7960
- if (!skillRecorderEnabled(cfg))
7961
- return json(res, 403, { error: "learned skills are not enabled" });
8241
+ if (!skillAuthoringEnabled(cfg))
8242
+ return json(res, 403, { error: "skill authoring is not enabled in Settings" });
7962
8243
  if (!internalCapability.skillAuthoring) {
7963
8244
  return json(res, 403, { error: "skill authoring is not enabled for this turn" });
7964
8245
  }
@@ -7973,8 +8254,8 @@ const handleRequest = async (req, res) => {
7973
8254
  });
7974
8255
  }
7975
8256
  if (method === "POST" && path === "/api/internal/skills/stage") {
7976
- if (!skillRecorderEnabled(cfg))
7977
- return json(res, 403, { error: "learned skills are not enabled" });
8257
+ if (!skillAuthoringEnabled(cfg))
8258
+ return json(res, 403, { error: "skill authoring is not enabled in Settings" });
7978
8259
  if (!internalCapability.skillAuthoring) {
7979
8260
  return json(res, 403, { error: "skill authoring is not enabled for this turn" });
7980
8261
  }
@@ -8611,6 +8892,86 @@ const handleRequest = async (req, res) => {
8611
8892
  model: safeBot.modelSelection.model,
8612
8893
  });
8613
8894
  }
8895
+ if (method === "POST" && (path === "/api/internal/create-room" || path === "/api/internal/manage-room")) {
8896
+ const body = await readInternalBody();
8897
+ const chief = store.bot(internalCapability.botId);
8898
+ if (chief.hidden || !chief.chiefOfStaff || !connectorThread(chief.id, internalCapability.threadId)) {
8899
+ return json(res, 403, { error: "only an active section Chief of Staff can manage rooms" });
8900
+ }
8901
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
8902
+ return json(res, 400, { error: "room request must be a JSON object" });
8903
+ }
8904
+ // ponytail: no new room-administration approval flow. Fail closed for
8905
+ // chiefs whose peer changes need review; add a proposal card if needed.
8906
+ if (chief.approvePeerComms) {
8907
+ return json(res, 403, { error: "peer approval is required; ask the user to make this room change" });
8908
+ }
8909
+ // Section labels are a permission boundary, not bot-owned organization.
8910
+ // A Chief may not recruit excluded peers or acquire a foreign transcript.
8911
+ const allowedIds = new Set([chief.id, ...reachablePeers(store.bots, chief).map((bot) => bot.id)]);
8912
+ const allowedRoster = (ids) => ids.includes(chief.id) && ids.every((id) => allowedIds.has(id));
8913
+ if (body.section !== undefined && (typeof body.section !== "string" || sectionKey(body.section) !== sectionKey(chief.section))) {
8914
+ return json(res, 403, { error: "rooms must stay in your own section; ask the user to move them" });
8915
+ }
8916
+ if (path === "/api/internal/create-room") {
8917
+ if ((internalCapability.createdRooms ?? 0) >= 4) {
8918
+ return json(res, 429, { error: "you can create at most 4 rooms in one turn" });
8919
+ }
8920
+ const parsed = z.object({
8921
+ fromBotId: z.string().optional(), fromThreadId: z.string().optional(),
8922
+ name: z.string().trim().min(1).max(100), memberIds: z.array(z.string()).min(1).max(100),
8923
+ section: z.string().optional(), bulletin: z.string().max(12_000).optional(),
8924
+ }).strict().safeParse(body);
8925
+ if (!parsed.success)
8926
+ return json(res, 400, { error: "provide a room name, memberIds, and optional bulletin (at most 12000 characters)" });
8927
+ const memberIds = [...new Set([chief.id, ...parsed.data.memberIds])];
8928
+ if (!allowedRoster(memberIds)) {
8929
+ return json(res, 403, { error: "include yourself and only active, allowed peers from your section" });
8930
+ }
8931
+ const group = createChannel({
8932
+ name: redactSecretsInText(parsed.data.name), memberIds, section: chief.section,
8933
+ setup: { bulletin: redactSecretsInText(parsed.data.bulletin ?? ""), defaultResponder: { kind: "member", botId: chief.id } },
8934
+ });
8935
+ internalCapability.createdRooms = (internalCapability.createdRooms ?? 0) + 1;
8936
+ return json(res, 201, { id: group.id, name: group.name, section: group.section || "General", memberIds: group.memberIds, memberCount: group.memberIds.length });
8937
+ }
8938
+ const parsed = z.object({
8939
+ fromBotId: z.string().optional(), fromThreadId: z.string().optional(),
8940
+ roomId: z.string(), action: z.enum(["add_members", "remove_members", "set_members", "rename", "set_bulletin"]),
8941
+ memberIds: z.array(z.string()).min(1).max(100).optional(),
8942
+ name: z.string().trim().min(1).max(100).optional(), bulletin: z.string().max(12_000).optional(),
8943
+ }).strict().safeParse(body);
8944
+ if (!parsed.success)
8945
+ return json(res, 400, { error: "provide roomId and a supported room action; section moves are user-only" });
8946
+ const room = store.group(parsed.data.roomId);
8947
+ if (!room || room.dm || sectionKey(room.section) !== sectionKey(chief.section) || !allowedRoster(room.memberIds)) {
8948
+ return json(res, 403, { error: "you can only manage rooms you belong to with allowed peers in your own section" });
8949
+ }
8950
+ const { action, memberIds, name, bulletin } = parsed.data;
8951
+ const patch = {};
8952
+ if (action === "rename") {
8953
+ if (name === undefined)
8954
+ return json(res, 400, { error: "rename requires a name" });
8955
+ patch.name = redactSecretsInText(name);
8956
+ }
8957
+ else if (action === "set_bulletin") {
8958
+ if (bulletin === undefined)
8959
+ return json(res, 400, { error: "set_bulletin requires bulletin text; use an empty string to clear it" });
8960
+ patch.bulletin = redactSecretsInText(bulletin);
8961
+ }
8962
+ else {
8963
+ if (!memberIds || memberIds.some((id) => !allowedIds.has(id))) {
8964
+ return json(res, 403, { error: "memberIds must name only yourself or active, allowed peers in your section" });
8965
+ }
8966
+ const next = action === "add_members" ? [...new Set([...room.memberIds, ...memberIds])]
8967
+ : action === "remove_members" ? room.memberIds.filter((id) => !memberIds.includes(id)) : memberIds;
8968
+ if (!allowedRoster(next))
8969
+ return json(res, 403, { error: "keep yourself in the room; only the user can remove its managing Chief" });
8970
+ patch.memberIds = next;
8971
+ }
8972
+ const updated = updateChannel(room.id, patch);
8973
+ return json(res, 200, { ok: true, memberIds: updated.memberIds, memberCount: updated.memberIds.length, message: `Updated room “${updated.name}”.` });
8974
+ }
8614
8975
  if (method === "POST" && path === "/api/internal/request-credential") {
8615
8976
  const body = await readInternalBody();
8616
8977
  const from = internalSender;
@@ -9447,47 +9808,7 @@ const handleRequest = async (req, res) => {
9447
9808
  }
9448
9809
  // ── channels (persisted internally as groups) ───────────────────────
9449
9810
  if (method === "POST" && path === "/api/groups") {
9450
- const body = await readBody(req);
9451
- if (!body || typeof body !== "object" || Array.isArray(body)) {
9452
- return json(res, 400, { error: "channel must be a JSON object" });
9453
- }
9454
- const roster = checkedMemberIds(body.memberIds);
9455
- if (!roster.ok)
9456
- return json(res, 400, { error: roster.error });
9457
- const { memberIds } = roster;
9458
- if (body.name !== undefined && typeof body.name !== "string") {
9459
- return json(res, 400, { error: "channel name must be a string" });
9460
- }
9461
- const name = body.name?.trim() || `${store.bot(memberIds[0]).name} & co.`;
9462
- if (name.length > 100)
9463
- return json(res, 400, { error: "channel name must be at most 100 characters" });
9464
- let section;
9465
- if (body.section !== undefined && body.section !== null) {
9466
- if (typeof body.section !== "string")
9467
- return json(res, 400, { error: "context must be a string" });
9468
- section = body.section.trim() || undefined;
9469
- if (section && section.length > 60) {
9470
- return json(res, 400, { error: "context must be at most 60 characters" });
9471
- }
9472
- }
9473
- let setup;
9474
- if (body.setup !== undefined) {
9475
- if (!body.setup || typeof body.setup !== "object" || Array.isArray(body.setup)) {
9476
- return json(res, 400, { error: "setup must be an object" });
9477
- }
9478
- const requested = body.setup;
9479
- if (typeof requested.bulletin !== "string") {
9480
- return json(res, 400, { error: "setup.bulletin must be a string" });
9481
- }
9482
- if (requested.bulletin.length > 12_000) {
9483
- return json(res, 400, { error: "setup.bulletin must be at most 12000 characters" });
9484
- }
9485
- const responder = checkedGroupResponder(requested.defaultResponder, memberIds);
9486
- if (!responder)
9487
- return json(res, 400, { error: "invalid setup.defaultResponder" });
9488
- setup = { bulletin: requested.bulletin, defaultResponder: responder, completed: true };
9489
- }
9490
- const group = store.createGroup(name, memberIds, false, section, setup);
9811
+ const group = createChannel(await readBody(req));
9491
9812
  return json(res, 201, { group: { ...publicGroupState(group), messages: [] } });
9492
9813
  }
9493
9814
  if (method === "POST" && path === "/api/teams/export") {
@@ -9506,12 +9827,17 @@ const handleRequest = async (req, res) => {
9506
9827
  return json(res, 200, createTeamBackup(store, routines.listRoutines(), name));
9507
9828
  }
9508
9829
  if (body.format === "package") {
9830
+ const selectedBots = store.bots.filter((bot) => !bot.hidden);
9831
+ const skillNames = checkedExportSkillNames(body.skillIds, selectedBots);
9832
+ if (!skillNames.ok)
9833
+ return json(res, 400, { error: skillNames.error });
9509
9834
  const document = createBotPackageExport({
9510
9835
  name,
9511
9836
  authorName: profileName,
9512
- bots: store.bots,
9837
+ bots: selectedBots,
9513
9838
  groups: store.groups,
9514
9839
  routines: routines.listRoutines(),
9840
+ skillsByBot: collectExportSkills(selectedBots, skillNames.names),
9515
9841
  });
9516
9842
  return json(res, 200, {
9517
9843
  name: document.package.name,
@@ -9656,8 +9982,8 @@ const handleRequest = async (req, res) => {
9656
9982
  const pkg = packageDocument?.package;
9657
9983
  const importName = pkg?.name ?? manifest.team.name;
9658
9984
  const sourceMembers = pkg
9659
- ? pkg.agents.map((agent) => ({ member: packageAgentAsMember(agent), playbookKeys: agent.playbooks ?? [] }))
9660
- : manifest.team.members.map((member) => ({ member, playbookKeys: [] }));
9985
+ ? pkg.agents.map((agent) => ({ member: packageAgentAsMember(agent), playbookKeys: agent.playbooks ?? [], skillNames: agent.skills ?? [] }))
9986
+ : manifest.team.members.map((member) => ({ member, playbookKeys: [], skillNames: [] }));
9661
9987
  const importedBots = [];
9662
9988
  const createdGroups = [];
9663
9989
  const createdRoutineIds = [];
@@ -9680,6 +10006,7 @@ const handleRequest = async (req, res) => {
9680
10006
  }
9681
10007
  }
9682
10008
  const playbookByKey = new Map((pkg?.playbooks ?? []).map((playbook) => [playbook.key, playbook]));
10009
+ const packageSkillByName = new Map((pkg?.skills?.entries ?? []).map((skill) => [skill.name, skill]));
9683
10010
  for (const source of sourceMembers) {
9684
10011
  const member = source.member;
9685
10012
  // importedMemberProfile is the authority boundary: persona fields
@@ -9713,6 +10040,17 @@ const handleRequest = async (req, res) => {
9713
10040
  }
9714
10041
  : {}),
9715
10042
  });
10043
+ for (const skillName of source.skillNames) {
10044
+ const skill = packageSkillByName.get(skillName);
10045
+ if (!skill)
10046
+ throw new Error(`Package skill "${skillName}" is unavailable`);
10047
+ const installed = installSkill(created.id, skill.source ?? `package:${pkg.id}`, [
10048
+ { path: "SKILL.md", content: skill.instructions },
10049
+ ]);
10050
+ if ("error" in installed) {
10051
+ throw new Error(`Package skill "${skillName}" could not be imported: ${installed.error}`);
10052
+ }
10053
+ }
9716
10054
  memberIds.set(member.key, created.id);
9717
10055
  }
9718
10056
  // A package is an explicit structure import: its rooms are created
@@ -9835,11 +10173,6 @@ const handleRequest = async (req, res) => {
9835
10173
  return json(res, 200, { group: publicGroupState(updated) });
9836
10174
  }
9837
10175
  // ── channel tasks: separate conversations for the same team ────────
9838
- const channelTaskBlocked = (group) => groupIsWorking(group) ||
9839
- store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
9840
- message.card?.requestId &&
9841
- !message.card.answered &&
9842
- !message.card.dismissed));
9843
10176
  // A scheduled goal starts in a detached task. Let the user open the
9844
10177
  // exact task that owns the live operation (or a durable approval card)
9845
10178
  // so they can observe or unblock it; switching to an unrelated task is
@@ -9955,113 +10288,7 @@ const handleRequest = async (req, res) => {
9955
10288
  if (field)
9956
10289
  return json(res, 403, { error: `forbidden: this session may rename or mark a room, not change "${field}" (needs the admin scope)` });
9957
10290
  }
9958
- if (!body || typeof body !== "object" || Array.isArray(body)) {
9959
- return json(res, 400, { error: "body must be a JSON object" });
9960
- }
9961
- const existing = store.group(m[1]);
9962
- if (!existing)
9963
- return json(res, 404, { error: "no such room" });
9964
- if (body.memberIds !== undefined && phoneSecretSubmissions.hasGroup(existing.id)) {
9965
- return json(res, 409, { error: "this channel is securely saving a credential — try again when it finishes" });
9966
- }
9967
- if (channelTaskBlocked(existing) &&
9968
- (body.memberIds !== undefined || body.defaultResponder !== undefined || body.bulletin !== undefined)) {
9969
- return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" });
9970
- }
9971
- const patch = {};
9972
- if (body.name !== undefined) {
9973
- if (typeof body.name !== "string")
9974
- return json(res, 400, { error: "room name must be a string" });
9975
- const name = body.name.trim();
9976
- if (!name)
9977
- return json(res, 400, { error: "room name must not be empty" });
9978
- if (name.length > 100)
9979
- return json(res, 400, { error: "room name must be at most 100 characters" });
9980
- patch.name = name;
9981
- }
9982
- if (body.bulletin !== undefined) {
9983
- if (typeof body.bulletin !== "string")
9984
- return json(res, 400, { error: "bulletin must be a string" });
9985
- if (body.bulletin.length > 12_000) {
9986
- return json(res, 400, { error: "bulletin must be at most 12000 characters" });
9987
- }
9988
- patch.bulletin = body.bulletin;
9989
- }
9990
- if (body.unread !== undefined) {
9991
- if (typeof body.unread !== "boolean")
9992
- return json(res, 400, { error: "unread must be true or false" });
9993
- patch.unread = body.unread;
9994
- }
9995
- if (body.memberIds !== undefined) {
9996
- // A DM is the pair it was opened for; only real rooms have a roster.
9997
- if (existing.dm)
9998
- return json(res, 400, { error: "direct-message channels cannot change members" });
9999
- const roster = checkedMemberIds(body.memberIds);
10000
- if (!roster.ok)
10001
- return json(res, 400, { error: roster.error.replace("channel", "room") });
10002
- const removedGoalLead = routines.listRoutines().some((routine) => routine.enabled &&
10003
- routine.target === "room-goal" &&
10004
- routine.groupId === existing.id &&
10005
- !roster.memberIds.includes(routine.botId)) || routines.listRuns().some((run) => run.target === "room-goal" &&
10006
- run.groupId === existing.id &&
10007
- ["queued", "running", "waiting"].includes(run.status) &&
10008
- !roster.memberIds.includes(run.botId));
10009
- if (removedGoalLead) {
10010
- return json(res, 409, {
10011
- error: "pause or reassign this room's team-goal routine before removing its lead",
10012
- });
10013
- }
10014
- patch.memberIds = roster.memberIds;
10015
- }
10016
- if (body.defaultResponder !== undefined) {
10017
- const memberIds = patch.memberIds ?? existing.memberIds;
10018
- const responder = checkedGroupResponder(body.defaultResponder, memberIds);
10019
- if (!responder)
10020
- return json(res, 400, { error: "invalid default responder" });
10021
- patch.defaultResponder = responder;
10022
- }
10023
- if (body.cwd !== undefined) {
10024
- if (existing.dm)
10025
- return json(res, 400, { error: "direct-message channels cannot have a working folder" });
10026
- if (existing.pinnedCwd !== undefined) {
10027
- return json(res, 409, { error: "the room's working folder is fixed after its first turn" });
10028
- }
10029
- const checked = validateBotCwd(body.cwd);
10030
- if (!checked.ok)
10031
- return json(res, 400, { error: checked.error });
10032
- patch.cwd = checked.cwd ?? undefined;
10033
- }
10034
- // one pinned message per room; null/"" clears. The id is not
10035
- // validated against the transcript here — a pin whose message was
10036
- // edited away or deleted simply resolves to nothing in the UI.
10037
- if (body.pinnedMessageId !== undefined) {
10038
- if (body.pinnedMessageId === null || body.pinnedMessageId === "")
10039
- patch.pinnedMessageId = undefined;
10040
- else if (typeof body.pinnedMessageId === "string" && /^[\w-]+$/.test(body.pinnedMessageId)) {
10041
- patch.pinnedMessageId = body.pinnedMessageId;
10042
- }
10043
- else
10044
- return json(res, 400, { error: "pinnedMessageId must be a message id" });
10045
- }
10046
- // same contract as a bot's sidebar section: null/"" clears, 60 chars max
10047
- if (body.section !== undefined) {
10048
- if (body.section === null)
10049
- patch.section = undefined;
10050
- else if (typeof body.section !== "string")
10051
- return json(res, 400, { error: "section must be a string" });
10052
- else {
10053
- const trimmed = body.section.trim();
10054
- if (!trimmed)
10055
- patch.section = undefined;
10056
- else if (trimmed.length > 60)
10057
- return json(res, 400, { error: "section must be at most 60 characters" });
10058
- else
10059
- patch.section = trimmed;
10060
- }
10061
- }
10062
- const group = store.patchGroup(m[1], patch);
10063
- if (!group)
10064
- return json(res, 404, { error: "no such room" });
10291
+ const group = updateChannel(m[1], body);
10065
10292
  return json(res, 200, { group: publicGroupState(group) });
10066
10293
  }
10067
10294
  m = path.match(/^\/api\/groups\/([\w-]+)\/read$/);
@@ -11339,31 +11566,130 @@ const handleRequest = async (req, res) => {
11339
11566
  broadcast({ kind: "bot", bot: visible });
11340
11567
  return json(res, 200, { bot: visible });
11341
11568
  }
11342
- // ── bot memory: MEMORY.md + memory/ topic files ─────────────────────
11343
- // The files already belong to the user (plain markdown in the bot's
11344
- // workspace); these routes only make them visible without a trip to
11345
- // the filesystem. Reads never create the workspace — a bot that has
11346
- // not run yet simply has nothing to show.
11569
+ // ── bot memory: MEMORY.md + memory/ topic and log files ──────────────
11570
+ // The files already belong to the person (plain markdown in the bot's
11571
+ // workspace). server/memory-store.ts decides which paths can be reached
11572
+ // and refuses a save whose expectedHash no longer matches the file;
11573
+ // server/memory-journal.ts records every change made here so it can be
11574
+ // read back and reverted. Reads never create the workspace — a bot that
11575
+ // has not run yet simply has nothing to show. Admin scope by default
11576
+ // (request-auth.ts), like the profile routes above.
11347
11577
  m = path.match(/^\/api\/bots\/([\w-]+)\/memory$/);
11348
11578
  if (m && method === "GET") {
11349
11579
  if (!store.bot(m[1]))
11350
11580
  return json(res, 404, { error: "no such bot" });
11351
- return json(res, 200, { ...readMemoryFile(m[1]), topics: listMemoryTopics(m[1]) });
11581
+ try {
11582
+ const overview = memoryOverview(m[1]);
11583
+ // `text` and `truncated` ride along one release for clients of the
11584
+ // old whole-file shape; the panel reads the file through /memory/file
11585
+ return json(res, 200, { ...overview, text: readMemoryDoc(m[1], MEMORY_INDEX).text, truncated: overview.index.truncated });
11586
+ }
11587
+ catch (error) {
11588
+ return replyMemoryError(res, error);
11589
+ }
11352
11590
  }
11353
11591
  if (m && method === "PUT") {
11592
+ // The pre-panel whole-file write, kept one release: no hash check, so
11593
+ // it can still overwrite a note the bot just wrote — journaled as the
11594
+ // person's so at least the journal can undo it.
11354
11595
  if (!store.bot(m[1]))
11355
11596
  return json(res, 404, { error: "no such bot" });
11356
11597
  const parsed = z.object({ text: z.string() }).safeParse(await readBody(req));
11357
11598
  if (!parsed.success)
11358
11599
  return json(res, 400, { error: "text must be a string" });
11359
- if (Buffer.byteLength(parsed.data.text, "utf8") > MEMORY_FILE_MAX_BYTES) {
11360
- return json(res, 400, {
11361
- error: `memory is capped at ${MEMORY_FILE_MAX_BYTES / 1024}KB — move longer notes into memory/<topic>.md files`,
11600
+ try {
11601
+ const { doc } = journalMemoryWrite(m[1], MEMORY_INDEX, parsed.data.text, { actor: "person", via: "api" });
11602
+ return json(res, 200, { ok: true, hash: doc.hash, truncated: memoryCapacity(doc.text).truncated });
11603
+ }
11604
+ catch (error) {
11605
+ // the old route answered 400 for an oversized body; keep that for
11606
+ // its callers while the new route says 413
11607
+ if (error instanceof MemoryStoreError && error.code === "too-large")
11608
+ return json(res, 400, { error: error.message });
11609
+ return replyMemoryError(res, error);
11610
+ }
11611
+ }
11612
+ m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/file$/);
11613
+ if (m && method === "GET") {
11614
+ if (!store.bot(m[1]))
11615
+ return json(res, 404, { error: "no such bot" });
11616
+ try {
11617
+ return json(res, 200, readMemoryDoc(m[1], url.searchParams.get("path") ?? MEMORY_INDEX));
11618
+ }
11619
+ catch (error) {
11620
+ return replyMemoryError(res, error);
11621
+ }
11622
+ }
11623
+ if (m && method === "PUT") {
11624
+ if (!store.bot(m[1]))
11625
+ return json(res, 404, { error: "no such bot" });
11626
+ const parsed = z
11627
+ .object({ path: z.string().default(MEMORY_INDEX), text: z.string(), expectedHash: z.string().optional() })
11628
+ .safeParse(await readBody(req));
11629
+ if (!parsed.success)
11630
+ return json(res, 400, { error: "text must be a string; path and expectedHash are optional strings" });
11631
+ try {
11632
+ const { doc, entry } = journalMemoryWrite(m[1], parsed.data.path, parsed.data.text, {
11633
+ actor: "person",
11634
+ via: "ui",
11635
+ expectedHash: parsed.data.expectedHash,
11362
11636
  });
11637
+ return json(res, 200, { ok: true, ...doc, entry: entry ? journalEntryForClient(m[1], entry) : null, overview: memoryOverview(m[1]) });
11638
+ }
11639
+ catch (error) {
11640
+ return replyMemoryError(res, error);
11641
+ }
11642
+ }
11643
+ if (m && method === "DELETE") {
11644
+ if (!store.bot(m[1]))
11645
+ return json(res, 404, { error: "no such bot" });
11646
+ const file = url.searchParams.get("path") ?? "";
11647
+ try {
11648
+ const entry = journalMemoryDelete(m[1], file, { actor: "person", via: "ui" });
11649
+ return json(res, 200, { ok: true, path: file, entry: entry ? journalEntryForClient(m[1], entry) : null, overview: memoryOverview(m[1]) });
11363
11650
  }
11364
- writeMemoryFile(m[1], parsed.data.text);
11365
- // truncated echoes back so the editor can warn about the load budget
11366
- return json(res, 200, { ok: true, truncated: readMemoryFile(m[1]).truncated });
11651
+ catch (error) {
11652
+ return replyMemoryError(res, error);
11653
+ }
11654
+ }
11655
+ m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/journal$/);
11656
+ if (m && method === "GET") {
11657
+ if (!store.bot(m[1]))
11658
+ return json(res, 404, { error: "no such bot" });
11659
+ // the row a save queued a moment ago may not have reached disk yet
11660
+ await flushMemoryJournal(m[1]);
11661
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
11662
+ const botId = m[1];
11663
+ return json(res, 200, { entries: readMemoryJournal(botId, limit).map((entry) => journalEntryForClient(botId, entry)) });
11664
+ }
11665
+ m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/journal\/([\w-]+)\/revert$/);
11666
+ if (m && method === "POST") {
11667
+ if (!store.bot(m[1]))
11668
+ return json(res, 404, { error: "no such bot" });
11669
+ await flushMemoryJournal(m[1]);
11670
+ const result = revertMemoryChange(m[1], m[2]);
11671
+ if (!result.ok)
11672
+ return json(res, result.status, { error: result.error });
11673
+ return json(res, 200, { ok: true, ...result.doc, entry: result.entry ? journalEntryForClient(m[1], result.entry) : null, overview: memoryOverview(m[1]) });
11674
+ }
11675
+ m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/open$/);
11676
+ if (m && method === "POST") {
11677
+ if (!store.bot(m[1]))
11678
+ return json(res, 404, { error: "no such bot" });
11679
+ const parsed = z.object({ target: z.enum(["obsidian", "folder"]) }).safeParse(await readBody(req));
11680
+ if (!parsed.success)
11681
+ return json(res, 400, { error: "target must be obsidian or folder" });
11682
+ // The folder is on this machine's disk; opening it only makes sense
11683
+ // from this machine. A paired phone or a remote browser gets the path
11684
+ // to open by hand instead.
11685
+ const workspacePath = memoryOverview(m[1]).workspacePath;
11686
+ if (auth.kind !== "loopback") {
11687
+ return json(res, 403, { error: `This only works on the computer running OpenMausBot. The memory folder there is ${workspacePath}`, workspacePath });
11688
+ }
11689
+ const opened = await openMemoryLocation(m[1], parsed.data.target);
11690
+ if (!opened.ok)
11691
+ return json(res, 500, { error: opened.error, workspacePath: opened.workspacePath });
11692
+ return json(res, 200, opened);
11367
11693
  }
11368
11694
  m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/topics\/([^/]+)$/);
11369
11695
  if (m && method === "GET") {
@@ -13560,6 +13886,7 @@ const gracefulShutdown = createGracefulShutdown({
13560
13886
  await browserRuntime.closeAll();
13561
13887
  },
13562
13888
  () => flushAllProfileHistory(),
13889
+ () => flushAllMemoryJournals(),
13563
13890
  ],
13564
13891
  // Cleanup jobs run concurrently. Release only after they settle (or reach
13565
13892
  // the shutdown deadline), immediately before the process exits, so no new