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.
- package/dist/assets/{index-BmHupgVJ.js → index-CJtyBonr.js} +1 -1
- package/dist/assets/index-CYLdYtYp.css +1 -0
- package/dist/assets/index-Di4ovwu5.js +289 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +6 -2
- package/dist-server/drivers/agents-proxy.js +305 -16
- package/dist-server/drivers/pi-mcp-extension.ts +1 -1
- package/dist-server/index.js +3141 -1124
- package/dist-server/local-computer-proxy.js +2 -0
- package/dist-server/local-computer.js +2 -1
- package/dist-server/mcp-gate.js +543 -0
- package/dist-server/mcp-server.js +38 -0
- package/dist-server/openmausbot.js +772 -260
- package/dist-server/pair-cli.js +772 -260
- package/dist-server/prepare-cloudflared.js +20 -3
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/agent-tool-policy.js +29 -0
- package/dist-server/server/bot-package.js +55 -2
- package/dist-server/server/browser-live.js +12 -7
- package/dist-server/server/config.js +40 -4
- package/dist-server/server/delegations.js +132 -74
- 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 +324 -16
- package/dist-server/server/drivers/boxagent.js +1 -1
- package/dist-server/server/drivers/claude-login-auth.js +102 -9
- package/dist-server/server/drivers/claude.js +298 -21
- package/dist-server/server/drivers/codex.js +13 -4
- package/dist-server/server/drivers/pi.js +1 -1
- package/dist-server/server/engine-install.js +137 -0
- package/dist-server/server/env-path.js +12 -0
- package/dist-server/server/harness/bus.js +1 -1
- package/dist-server/server/harness/registry.js +31 -6
- package/dist-server/server/index.js +860 -215
- package/dist-server/server/local-computer.js +1 -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-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/peer-provenance.js +6 -2
- 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 +116 -55
- package/dist-server/server/screen-frame-gate.js +9 -6
- package/dist-server/server/skill-library.js +5 -5
- package/dist-server/server/steer-queue.js +20 -1
- package/dist-server/server/store.js +29 -7
- package/dist-server/server/system-prompt.js +14 -1
- package/dist-server/server/team-backup.js +53 -4
- 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/workspace.js +321 -22
- package/dist-server/shared/approval-mode.js +1 -1
- package/dist-server/shared/team-backup.js +14 -2
- package/dist-server/vps-container-mcp.js +6 -2
- package/package.json +1 -1
- package/skills/create-verification-skill/SKILL.md +2 -3
- package/dist/assets/index-COp5lMnv.js +0 -279
- package/dist/assets/index-Jk7TIeyW.css +0 -1
|
@@ -33,10 +33,11 @@ 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,
|
|
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";
|
|
40
|
+
import { registerEnginesBinDir } from "./engine-install.js";
|
|
40
41
|
import { describeSpawnFailure, execCli } from "./procs.js";
|
|
41
42
|
import { blockedTarget, buildNotification } from "./notify.js";
|
|
42
43
|
import { isEffortLevel, newId, } from "./contracts.js";
|
|
@@ -54,7 +55,7 @@ import { claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.j
|
|
|
54
55
|
const SESSION_READ_MAX_CHARS = 8_000;
|
|
55
56
|
import { promptWithReply, transcriptText } from "./replies.js";
|
|
56
57
|
import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DelegationWakeBudget, discardDelegations, drainDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
|
|
57
|
-
import { cancelSteeredMessage, drainSteeredMessages, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queueSteeredMessage, } from "./steer-queue.js";
|
|
58
|
+
import { cancelSteeredMessage, drainSteeredMessages, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, } from "./steer-queue.js";
|
|
58
59
|
import { cancelChannelMessage, drainChannelMessages, queuedChannelMessage, queueChannelMessage, } from "./channel-queue.js";
|
|
59
60
|
import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
|
|
60
61
|
import { EventBus } from "./harness/bus.js";
|
|
@@ -66,19 +67,21 @@ import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
|
|
|
66
67
|
import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, } from "./store.js";
|
|
67
68
|
import * as tts from "./tts/index.js";
|
|
68
69
|
import { narrateTool, toUtterances } from "./tts/speech-text.js";
|
|
69
|
-
import { buildTurnContext, engineIsFresh } from "./turn-context.js";
|
|
70
|
+
import { buildRecoveryText, buildTurnContext, engineIsFresh } from "./turn-context.js";
|
|
70
71
|
import { extractTurnImages } from "./turn-images.js";
|
|
71
72
|
import { TurnWatchdog } from "./turn-watchdog.js";
|
|
72
73
|
import { TurnResources, workspaceResource } from "./turn-resources.js";
|
|
73
|
-
import { ensureWorkspace, ensureTaskWorkspace, updateMemory,
|
|
74
|
-
import {
|
|
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";
|
|
75
78
|
import { readSectionContext, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
|
|
76
|
-
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";
|
|
77
80
|
import { fetchSkillFromSource } from "./skill-fetch.js";
|
|
78
81
|
import { expandLearnTurnText, learnSource } from "./skill-learn.js";
|
|
79
82
|
import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
|
|
80
83
|
import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
|
|
81
|
-
import { buildSystemPrompt, computerPrompt, mentionPrompt, COMPOSIO_PROMPT, CREDENTIAL_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
|
|
84
|
+
import { buildSystemPrompt, computerPrompt, mentionPrompt, COMPOSIO_PROMPT, CREDENTIAL_PROMPT, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
|
|
82
85
|
import { readCuaConnection, gatedLocalComputer } from "./local-computer.js";
|
|
83
86
|
import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
|
|
84
87
|
import { LocalVmIdleTimer } from "./local-vm-idle.js";
|
|
@@ -102,7 +105,7 @@ import { flushAllProfileHistory, flushProfileHistory, readHistory, recordProfile
|
|
|
102
105
|
import { fetchBotDirectory, matchDirectoryBots } from "./bot-directory.js";
|
|
103
106
|
import { scoutProject, suggestTeam } from "./project-scout.js";
|
|
104
107
|
import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-library.js";
|
|
105
|
-
import { isBotPackage, packageAgentAsMember, parseBotPackage, renderBotPackageMarkdown } from "./bot-package.js";
|
|
108
|
+
import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageAgentAsMember, parseBotPackage, renderBotPackageMarkdown } from "./bot-package.js";
|
|
106
109
|
import { createTeamManifest, importedMemberProfile, parseTeamManifest } from "./team-manifest.js";
|
|
107
110
|
import { readThreadEvents } from "./thread-events.js";
|
|
108
111
|
import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
|
|
@@ -208,6 +211,9 @@ function customDomainStatus() {
|
|
|
208
211
|
};
|
|
209
212
|
}
|
|
210
213
|
const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
|
|
214
|
+
// Engines installed from Settings live under the data directory and win over
|
|
215
|
+
// any other copy on PATH.
|
|
216
|
+
registerEnginesBinDir();
|
|
211
217
|
const providerAuthSessions = new ProviderAuthSessions();
|
|
212
218
|
await registry.load(instanceConfigs(cfg));
|
|
213
219
|
const bundledSkills = loadBundledSkills();
|
|
@@ -426,6 +432,7 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation) {
|
|
|
426
432
|
kind: "agents",
|
|
427
433
|
skillAuthoring,
|
|
428
434
|
createdBots: 0,
|
|
435
|
+
openedThreads: 0,
|
|
429
436
|
});
|
|
430
437
|
return {
|
|
431
438
|
command: process.execPath,
|
|
@@ -672,7 +679,7 @@ async function browserIntegration(botId, profile, turn) {
|
|
|
672
679
|
if (!turn)
|
|
673
680
|
return { profile: partitionId, session, spec, integration: spec };
|
|
674
681
|
const token = mintInternalCapability({ botId, ...turn, browserSession: session,
|
|
675
|
-
kind: "browser", depth: 0, skillAuthoring: false, createdBots: 0 });
|
|
682
|
+
kind: "browser", depth: 0, skillAuthoring: false, createdBots: 0, openedThreads: 0 });
|
|
676
683
|
return { profile: partitionId, session, spec, integration: {
|
|
677
684
|
command: process.execPath, args: [SPAWNED_PROXIES.browser], env: {
|
|
678
685
|
...AGENTS_NODE_FLAG, OMB_BROWSER_TOKEN: token, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
|
|
@@ -708,6 +715,7 @@ function connectedAppsIntegration(botId, threadId, generation) {
|
|
|
708
715
|
kind: "connectors",
|
|
709
716
|
skillAuthoring: false,
|
|
710
717
|
createdBots: 0,
|
|
718
|
+
openedThreads: 0,
|
|
711
719
|
});
|
|
712
720
|
return composio.mcpIntegration(cfg, {
|
|
713
721
|
harnessUrl: `http://127.0.0.1:${PORT}`,
|
|
@@ -760,6 +768,7 @@ function controlIntegration(botId, threadId, generation, localVmTarget) {
|
|
|
760
768
|
...(localVmTarget ? { localVmTarget } : {}),
|
|
761
769
|
skillAuthoring: false,
|
|
762
770
|
createdBots: 0,
|
|
771
|
+
openedThreads: 0,
|
|
763
772
|
}),
|
|
764
773
|
};
|
|
765
774
|
}
|
|
@@ -877,6 +886,54 @@ function checkedModelSelection(raw, current, requireAvailableModel = false) {
|
|
|
877
886
|
}
|
|
878
887
|
return { ok: true, selection };
|
|
879
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
|
+
}
|
|
880
937
|
function checkedGroupResponder(value, memberIds) {
|
|
881
938
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
882
939
|
return null;
|
|
@@ -1003,7 +1060,7 @@ function previewSystemPrompt(bot) {
|
|
|
1003
1060
|
id: "setup",
|
|
1004
1061
|
label: "Setup",
|
|
1005
1062
|
text: setupSystemPrompt(agentsMounted && setupModeActive({ soul: bot.soul, description: bot.description, text: "" }), {
|
|
1006
|
-
skills:
|
|
1063
|
+
skills: skillAuthoringEnabled(cfg),
|
|
1007
1064
|
cwd: bot.cwd,
|
|
1008
1065
|
}),
|
|
1009
1066
|
},
|
|
@@ -1440,6 +1497,165 @@ function groupGoalCoordinatorTurnForEvent(event) {
|
|
|
1440
1497
|
function groupIsWorking(group) {
|
|
1441
1498
|
return Boolean(group.busyBotId) || Boolean(groupTurnOperations.get(group.id)?.size);
|
|
1442
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));
|
|
1443
1659
|
function publicGroupState(group) {
|
|
1444
1660
|
return { ...group, working: groupIsWorking(group) };
|
|
1445
1661
|
}
|
|
@@ -1751,6 +1967,13 @@ store.onChange((change) => {
|
|
|
1751
1967
|
break;
|
|
1752
1968
|
case "thread.deleted":
|
|
1753
1969
|
routines?.forgetRoutineRequestReceiptsForThread(change.threadId);
|
|
1970
|
+
// A deleted destination must not strand an approval in an internal
|
|
1971
|
+
// task. Keep each run's snapshot and expose its execution as fallback.
|
|
1972
|
+
for (const run of routines?.listRuns() ?? []) {
|
|
1973
|
+
if (run.resultsThreadId === change.threadId || run.sourceThreadId === change.threadId) {
|
|
1974
|
+
syncRoutineRunToSource(run);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1754
1977
|
broadcast({ kind: "bot.queued", queues: publicBotQueuedMessages() });
|
|
1755
1978
|
break;
|
|
1756
1979
|
case "bot": {
|
|
@@ -1885,7 +2108,7 @@ function broadcast(payload) {
|
|
|
1885
2108
|
replayBuffer.push({ seq, kind, frame: kind === "screen" ? null : frame, clientFrame: kind === "screen" ? null : clientFrame });
|
|
1886
2109
|
if (replayBuffer.length > REPLAY_MAX)
|
|
1887
2110
|
replayBuffer.shift();
|
|
1888
|
-
for (const client of
|
|
2111
|
+
for (const client of Array.from(sseClients)) {
|
|
1889
2112
|
if (!wants(client, kind))
|
|
1890
2113
|
continue;
|
|
1891
2114
|
// Screen frames are replaceable and durable events are not: see
|
|
@@ -2191,6 +2414,18 @@ bus.subscribe((event) => {
|
|
|
2191
2414
|
else if (event.type !== "session.exited")
|
|
2192
2415
|
watchdog.touch(event.threadId);
|
|
2193
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
|
+
});
|
|
2194
2429
|
// Bots currently working with nobody at the keyboard — a webhook turn, or a
|
|
2195
2430
|
// turn a webhook-driven bot handed to a teammate. Auto mode is a decision
|
|
2196
2431
|
// someone made for turns they were present for, so these don't inherit it:
|
|
@@ -2602,7 +2837,7 @@ bus.subscribe((event) => {
|
|
|
2602
2837
|
const existing = store.messagesFor(event.threadId).find((m) => m.id === messageId)?.tool;
|
|
2603
2838
|
toolName = existing?.name ?? "tool";
|
|
2604
2839
|
store.patchMessage(event.threadId, messageId, {
|
|
2605
|
-
tool: { name: toolName, ok: event.ok, spoken: existing?.spoken },
|
|
2840
|
+
tool: { name: toolName, ok: event.ok, spoken: existing?.spoken, summary: existing?.summary },
|
|
2606
2841
|
});
|
|
2607
2842
|
toolMessageByItem.delete(itemKey);
|
|
2608
2843
|
}
|
|
@@ -2634,7 +2869,7 @@ bus.subscribe((event) => {
|
|
|
2634
2869
|
const message = pushMessage({
|
|
2635
2870
|
role: "bot",
|
|
2636
2871
|
kind: "activity",
|
|
2637
|
-
tool: { name, spoken: narrateTool(name) ?? undefined },
|
|
2872
|
+
tool: { name, spoken: narrateTool(name) ?? undefined, summary: event.summary },
|
|
2638
2873
|
});
|
|
2639
2874
|
if (event.itemId)
|
|
2640
2875
|
toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
|
|
@@ -3002,13 +3237,12 @@ bus.subscribe((event) => {
|
|
|
3002
3237
|
costUsd: event.cost ?? null,
|
|
3003
3238
|
});
|
|
3004
3239
|
const routineReportThread = routineRun ? routineSourceThread(routineRun) : null;
|
|
3005
|
-
|
|
3006
|
-
//
|
|
3007
|
-
// hidden execution task should not light up the bot's 1:1 sidebar too.
|
|
3240
|
+
// A routine's result belongs to its reporting thread's unread state.
|
|
3241
|
+
// Its internal execution should not light up the sidebar as well.
|
|
3008
3242
|
// Neither should a peer's hop: the exchange is already recorded in the
|
|
3009
3243
|
// pair channel and chipped into both threads, which is the whole of
|
|
3010
3244
|
// what the person needs to be able to find it.
|
|
3011
|
-
if (!
|
|
3245
|
+
if (!routineReportThread && !internal)
|
|
3012
3246
|
store.patchTask(bot.id, event.threadId, { unread: true });
|
|
3013
3247
|
// A failed peer turn stays a chip too. The bot that delegated is woken
|
|
3014
3248
|
// with the failure and answers the person in its own thread — buzzing
|
|
@@ -3357,13 +3591,27 @@ bus.subscribe((event) => {
|
|
|
3357
3591
|
/** How a drained delegation becomes a real turn on the target. Shared by
|
|
3358
3592
|
* the settle-time drain and the boot-time drain of what a previous process
|
|
3359
3593
|
* left queued. */
|
|
3360
|
-
const runDelegatedTurn = (toBotId,
|
|
3594
|
+
const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId) => {
|
|
3361
3595
|
// startTurn REJECTS on an ordinary condition — busy target, deleted bot,
|
|
3362
3596
|
// unavailable provider. Unhandled, that rejection is fatal to the
|
|
3363
3597
|
// harness (Node's default), which in the packaged app kills the server
|
|
3364
3598
|
// child. Every delegation failure has to land as a chip instead.
|
|
3365
|
-
|
|
3599
|
+
// A fresh-thread handoff runs in the thread the opener created — the
|
|
3600
|
+
// drain already dropped it if that thread is gone — never in whatever
|
|
3601
|
+
// the person is looking at.
|
|
3602
|
+
const targetThreadId = openedThreadId ?? store.bot(toBotId)?.threadId;
|
|
3366
3603
|
const target = store.bot(toBotId);
|
|
3604
|
+
const opener = store.bot(sourceBotId);
|
|
3605
|
+
const unattended = isUnattended(sourceBotId, sourceThreadId);
|
|
3606
|
+
// The first line of an opened thread is another bot's words: it carries
|
|
3607
|
+
// the same provenance note every relayed peer line does, structurally
|
|
3608
|
+
// (peerAsk) as well as in the text.
|
|
3609
|
+
const peerAsk = openedThreadId && opener
|
|
3610
|
+
? { botId: opener.id, name: opener.name, unattended: unattended || undefined }
|
|
3611
|
+
: undefined;
|
|
3612
|
+
const text = openedThreadId && opener
|
|
3613
|
+
? withPeerProvenance(rawText, { botName: opener.name, delivery: "start_thread", unattended })
|
|
3614
|
+
: rawText;
|
|
3367
3615
|
if (targetThreadId) {
|
|
3368
3616
|
delegationWatch.set(targetThreadId, {
|
|
3369
3617
|
channelId: channel?.id,
|
|
@@ -3400,7 +3648,8 @@ const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, ta
|
|
|
3400
3648
|
return startTurn(toBotId, text, {
|
|
3401
3649
|
threadId: targetThreadId,
|
|
3402
3650
|
commsDepth,
|
|
3403
|
-
unattended
|
|
3651
|
+
unattended,
|
|
3652
|
+
peerAsk,
|
|
3404
3653
|
// startTurn schedules provider/integration setup after marking the bot
|
|
3405
3654
|
// busy. Those asynchronous setup failures do not emit turn.completed,
|
|
3406
3655
|
// so clear the watch and report them through this callback too.
|
|
@@ -3428,9 +3677,16 @@ function retryDelegationsWaitingOn(botId) {
|
|
|
3428
3677
|
// Explicit idle releases (room/setup/reload/watchdog fallbacks) may not
|
|
3429
3678
|
// publish turn.completed. They free a waiting source continuation too.
|
|
3430
3679
|
drainDelegationWakes();
|
|
3431
|
-
|
|
3680
|
+
// A bot still busy in one thread has nevertheless freed a slot: the
|
|
3681
|
+
// fresh-thread handoffs waiting on it can move, while the active-thread
|
|
3682
|
+
// ones keep waiting for it to go idle, as they always have.
|
|
3683
|
+
const stillBusy = store.bot(botId)?.busy === true;
|
|
3684
|
+
if (stillBusy && botAtThreadCapacity(botId))
|
|
3432
3685
|
return;
|
|
3433
|
-
|
|
3686
|
+
const released = stillBusy
|
|
3687
|
+
? releaseDelegationsWaitingOn(botId, (item) => item.targetThreadId !== undefined)
|
|
3688
|
+
: releaseDelegationsWaitingOn(botId);
|
|
3689
|
+
for (const waitingThread of released) {
|
|
3434
3690
|
drainThreadDelegations(waitingThread);
|
|
3435
3691
|
}
|
|
3436
3692
|
});
|
|
@@ -3474,13 +3730,15 @@ bus.subscribe((event) => {
|
|
|
3474
3730
|
drainDelegationWakes();
|
|
3475
3731
|
});
|
|
3476
3732
|
function drainQueuedSends() {
|
|
3477
|
-
drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds) =>
|
|
3478
|
-
// A plain attended turn — no automationSource, no
|
|
3479
|
-
//
|
|
3733
|
+
drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds, unattended) =>
|
|
3734
|
+
// A plain attended turn — no automationSource, no comms depth: exactly
|
|
3735
|
+
// what typing the same words into an idle bot would run. The one
|
|
3736
|
+
// exception is `unattended`: a thread a bot opened on itself while
|
|
3737
|
+
// nobody was watching stays unwatched through the wait for a slot.
|
|
3480
3738
|
// Drain just appended the held lines; userMessage keeps startTurn
|
|
3481
3739
|
// from duplicating the last one, and excludeIds drops every drained
|
|
3482
3740
|
// line from the transcript-replay so they are not also in `prompt`.
|
|
3483
|
-
startTurn(botId, prompt, { threadId, userMessage, excludeMessageIds: excludeIds }).then(() => undefined).catch((err) => {
|
|
3741
|
+
startTurn(botId, prompt, { threadId, userMessage, excludeMessageIds: excludeIds, unattended }).then(() => undefined).catch((err) => {
|
|
3484
3742
|
store.appendMessage(threadId, {
|
|
3485
3743
|
role: "bot",
|
|
3486
3744
|
kind: "activity",
|
|
@@ -3511,6 +3769,36 @@ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId)
|
|
|
3511
3769
|
const message = await startTurn(botId, text, { threadId, replyTo, sendId });
|
|
3512
3770
|
return { ok: true, threadId, message };
|
|
3513
3771
|
}
|
|
3772
|
+
/** How many start_thread calls one turn may make. Same spirit as the
|
|
3773
|
+
* create-bot ceiling above: a handful is a plan, more is a fan-out. */
|
|
3774
|
+
const MAX_THREADS_OPENED_PER_TURN = 5;
|
|
3775
|
+
/** A thread a bot opened on itself gets its first turn exactly the way a
|
|
3776
|
+
* person's message would: it runs now if the bot has a free slot, and
|
|
3777
|
+
* otherwise waits in the same composer queue, in line behind whatever the
|
|
3778
|
+
* bot already has waiting. The only inheritance is `unattended` — a bot
|
|
3779
|
+
* nobody is watching does not become watched by opening a thread. */
|
|
3780
|
+
async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
|
|
3781
|
+
// A room turn holds the bot too (startTurn refuses a direct turn during
|
|
3782
|
+
// one); the drain's own block check already waits for it, so the words
|
|
3783
|
+
// queue here rather than bounce.
|
|
3784
|
+
if (botAtThreadCapacity(botId) || activeGroupTurnForBot(botId)) {
|
|
3785
|
+
queueSteeredMessage(botId, threadId, text, { reason: "capacity", unattended });
|
|
3786
|
+
return { state: "queued", position: queuedThreadPosition(botId, threadId) ?? 1 };
|
|
3787
|
+
}
|
|
3788
|
+
try {
|
|
3789
|
+
await startTurn(botId, text, { threadId, unattended });
|
|
3790
|
+
return { state: "running" };
|
|
3791
|
+
}
|
|
3792
|
+
catch (error) {
|
|
3793
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
3794
|
+
store.appendMessage(threadId, {
|
|
3795
|
+
role: "bot",
|
|
3796
|
+
kind: "activity",
|
|
3797
|
+
tool: { name: `error: this thread could not start — ${why.slice(0, 120)}`, ok: false },
|
|
3798
|
+
});
|
|
3799
|
+
return { state: "failed", error: why };
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3514
3802
|
const screenPollers = new Map();
|
|
3515
3803
|
/** The preview shares the box's single command endpoint with the agent's
|
|
3516
3804
|
* own actions, so every frame we take is latency stolen from the work the
|
|
@@ -3766,6 +4054,10 @@ async function startTurn(botId, text, opts) {
|
|
|
3766
4054
|
if (!opts?.cardContinuation) {
|
|
3767
4055
|
if (commsDepth === 0 && opts?.automationSource === undefined && !opts?.unattended) {
|
|
3768
4056
|
personAskAt.set(threadId, userMessage.at);
|
|
4057
|
+
// Continuing an old run as a normal conversation makes that task
|
|
4058
|
+
// visible again; these new replies are not routine report updates.
|
|
4059
|
+
if (task.routineRunId)
|
|
4060
|
+
store.patchTask(bot.id, threadId, { routineRunId: undefined });
|
|
3769
4061
|
}
|
|
3770
4062
|
else {
|
|
3771
4063
|
personAskAt.delete(threadId);
|
|
@@ -3809,7 +4101,7 @@ async function startTurn(botId, text, opts) {
|
|
|
3809
4101
|
// that never mounts agent tools (or a turn already at the comms-depth cap)
|
|
3810
4102
|
// must not be steered into — or told about — tools it cannot call.
|
|
3811
4103
|
const agentsMounted = commsDepth < MAX_COMMS_DEPTH && instance.adapter.capabilities.agentsMcp === true;
|
|
3812
|
-
const skillAuthoring =
|
|
4104
|
+
const skillAuthoring = skillAuthoringEnabled(cfg) && agentsMounted;
|
|
3813
4105
|
// Setup mode's turn-text rewrite (parseSetupCommand/expandSetupTurnText)
|
|
3814
4106
|
// must not run ahead of a system prompt that can't explain it: a driver
|
|
3815
4107
|
// without agent tools sees the user's literal "/setup ..." message. The
|
|
@@ -3830,6 +4122,10 @@ async function startTurn(botId, text, opts) {
|
|
|
3830
4122
|
// this already-built turn must either keep its old session or replay on the
|
|
3831
4123
|
// following turn, never start a blank session with no transcript.
|
|
3832
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;
|
|
3833
4129
|
const persona = [
|
|
3834
4130
|
`You are ${bot.name}, a personal bot in OpenMausBot.`,
|
|
3835
4131
|
bot.title && `Role: ${bot.title}.`,
|
|
@@ -3897,8 +4193,11 @@ async function startTurn(botId, text, opts) {
|
|
|
3897
4193
|
// desk, not the whole house — and the workspace is where its
|
|
3898
4194
|
// MEMORY.md lives. API/box engines have no local filesystem story.
|
|
3899
4195
|
const worksInWorkspace = instance.driverKind !== "grok" && instance.driverKind !== "boxAgent";
|
|
3900
|
-
if (worksInWorkspace)
|
|
4196
|
+
if (worksInWorkspace) {
|
|
3901
4197
|
ensureWorkspace(bot.id);
|
|
4198
|
+
// baseline for the journal's turn-boundary diff (see the bus hook)
|
|
4199
|
+
beginMemoryTurn(bot.id, threadId);
|
|
4200
|
+
}
|
|
3902
4201
|
const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
|
|
3903
4202
|
const skillInstructions = renderSkillInstructions(selectedSkills, {
|
|
3904
4203
|
includeRoot: worksInWorkspace && opts?.runOn !== "cloud",
|
|
@@ -4167,7 +4466,7 @@ async function startTurn(botId, text, opts) {
|
|
|
4167
4466
|
// model mostly did not think to make.
|
|
4168
4467
|
? peerRosterSystemPrompt(sectionPeers)
|
|
4169
4468
|
: "";
|
|
4170
|
-
const credentialPrompt = integrations.agents ? CREDENTIAL_PROMPT : "";
|
|
4469
|
+
const credentialPrompt = integrations.agents ? CREDENTIAL_PROMPT + THREADS_PROMPT : "";
|
|
4171
4470
|
const routinePrompt = integrations.agents ? ROUTINE_PROMPT : "";
|
|
4172
4471
|
const profilePrompt = integrations.agents ? PROFILE_PROMPT : "";
|
|
4173
4472
|
const recallPrompt = integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "";
|
|
@@ -4272,8 +4571,11 @@ async function startTurn(botId, text, opts) {
|
|
|
4272
4571
|
// the active task's own session — another task's cursor would
|
|
4273
4572
|
// resume the wrong conversation and defeat the context bubble
|
|
4274
4573
|
resumeCursor,
|
|
4574
|
+
...(recoveryText !== undefined ? { recoveryText } : {}),
|
|
4275
4575
|
transcript,
|
|
4276
4576
|
system: prompt.text,
|
|
4577
|
+
systemStable: prompt.stable,
|
|
4578
|
+
systemVolatile: prompt.volatile,
|
|
4277
4579
|
integrations,
|
|
4278
4580
|
cwd,
|
|
4279
4581
|
}), () => !directTurnClaimExists(bot.id, dispatchClaimId, threadId), async () => {
|
|
@@ -4383,6 +4685,13 @@ async function startTurn(botId, text, opts) {
|
|
|
4383
4685
|
// The scheduler owns timing and receipts; the existing harness remains the
|
|
4384
4686
|
// only owner of provider sessions, approvals, tools, computers and messages.
|
|
4385
4687
|
function routineSourceOwner(run) {
|
|
4688
|
+
if (run.resultsThreadId) {
|
|
4689
|
+
const bot = store.bot(run.botId);
|
|
4690
|
+
const task = bot && store.taskByThread(bot.id, run.resultsThreadId);
|
|
4691
|
+
return bot && !bot.hidden && task && !task.routineRunId
|
|
4692
|
+
? { bot, group: undefined, threadId: task.threadId }
|
|
4693
|
+
: null;
|
|
4694
|
+
}
|
|
4386
4695
|
const threadId = run.sourceThreadId?.trim();
|
|
4387
4696
|
if (!threadId)
|
|
4388
4697
|
return null;
|
|
@@ -4396,7 +4705,8 @@ function routineSourceOwner(run) {
|
|
|
4396
4705
|
// whose conversation held the card. Recheck their section before sharing
|
|
4397
4706
|
// a result, since either bot may have moved since confirmation.
|
|
4398
4707
|
const sourceBot = store.botByThread(threadId);
|
|
4399
|
-
if (sourceBot &&
|
|
4708
|
+
if (sourceBot && !sourceBot.hidden && !store.taskByThread(sourceBot.id, threadId)?.routineRunId &&
|
|
4709
|
+
sectionKey(sourceBot.section) === sectionKey(bot.section)) {
|
|
4400
4710
|
return { bot: sourceBot, group: undefined, threadId };
|
|
4401
4711
|
}
|
|
4402
4712
|
const group = store.groupByThread(threadId);
|
|
@@ -4413,6 +4723,7 @@ function routineRunCard(run) {
|
|
|
4413
4723
|
runId: run.id,
|
|
4414
4724
|
routineId: run.routineId,
|
|
4415
4725
|
routineName: redactSecretsInText(run.routineName),
|
|
4726
|
+
scheduledFor: run.scheduledFor,
|
|
4416
4727
|
status: run.status,
|
|
4417
4728
|
};
|
|
4418
4729
|
if (run.goalStatus)
|
|
@@ -4455,8 +4766,16 @@ function routineRunFallbackText(card) {
|
|
|
4455
4766
|
* another chat message. */
|
|
4456
4767
|
function syncRoutineRunToSource(run) {
|
|
4457
4768
|
const source = routineSourceOwner(run);
|
|
4458
|
-
if (!source)
|
|
4769
|
+
if (!source) {
|
|
4770
|
+
const execution = run.threadId ? store.taskByThread(run.botId, run.threadId) : null;
|
|
4771
|
+
if (execution?.routineRunId === run.id) {
|
|
4772
|
+
store.patchTask(run.botId, execution.threadId, {
|
|
4773
|
+
routineRunId: undefined,
|
|
4774
|
+
...(["waiting", "completed", "failed", "missed"].includes(run.status) ? { unread: true } : {}),
|
|
4775
|
+
});
|
|
4776
|
+
}
|
|
4459
4777
|
return null;
|
|
4778
|
+
}
|
|
4460
4779
|
const sourceThreadId = source.threadId;
|
|
4461
4780
|
const card = routineRunCard(run);
|
|
4462
4781
|
const text = routineRunFallbackText(card);
|
|
@@ -4477,6 +4796,18 @@ function syncRoutineRunToSource(run) {
|
|
|
4477
4796
|
}
|
|
4478
4797
|
store.appendMessage(sourceThreadId, message);
|
|
4479
4798
|
}
|
|
4799
|
+
// Mark only the fresh execution, after its first running card persisted
|
|
4800
|
+
// and before dispatch adds any transcript. A user can later promote it by
|
|
4801
|
+
// sending a normal follow-up; replaying/marking the old receipt seen must
|
|
4802
|
+
// never hide that conversation again.
|
|
4803
|
+
if (run.target === "bot" && run.triggerSource !== "webhook" && run.threadId && run.threadId !== sourceThreadId) {
|
|
4804
|
+
const execution = store.taskByThread(run.botId, run.threadId);
|
|
4805
|
+
const freshExecution = execution && run.status === "running" && !execution.routineRunId &&
|
|
4806
|
+
store.messagesFor(run.threadId).length === 0;
|
|
4807
|
+
if (execution && (freshExecution || (execution.routineRunId === run.id && execution.unread))) {
|
|
4808
|
+
store.patchTask(run.botId, run.threadId, { routineRunId: run.id, unread: false });
|
|
4809
|
+
}
|
|
4810
|
+
}
|
|
4480
4811
|
// Merely queueing/running is ambient progress. Attention and terminal
|
|
4481
4812
|
// states become unread in the conversation where the user asked for them.
|
|
4482
4813
|
if (statusChanged && ["waiting", "completed", "failed", "missed"].includes(run.status)) {
|
|
@@ -4541,7 +4872,7 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
|
|
|
4541
4872
|
// Load queued handoffs before scheduler recovery can fail an interrupted
|
|
4542
4873
|
// run. Its failure callback can then durably drop that work immediately;
|
|
4543
4874
|
// nothing dispatches until the listener is ready below.
|
|
4544
|
-
const commsBus = { store, broadcast };
|
|
4875
|
+
const commsBus = { store, broadcast, threadSlotFree: (botId) => !botAtThreadCapacity(botId) };
|
|
4545
4876
|
_loadPending();
|
|
4546
4877
|
routines = new RoutineManager({
|
|
4547
4878
|
emit: broadcast,
|
|
@@ -4573,6 +4904,25 @@ routines = new RoutineManager({
|
|
|
4573
4904
|
return task;
|
|
4574
4905
|
},
|
|
4575
4906
|
createGoalTask: (groupId, title) => store.createGroupTask(groupId, title, false),
|
|
4907
|
+
isResultsThread: (botId, threadId) => {
|
|
4908
|
+
const bot = store.bot(botId);
|
|
4909
|
+
const task = store.taskByThread(botId, threadId);
|
|
4910
|
+
return Boolean(bot && !bot.hidden && task && !task.routineRunId);
|
|
4911
|
+
},
|
|
4912
|
+
resolveResultsThread: (routine, forceNew) => {
|
|
4913
|
+
// A trusted chat source is already snapshotted as sourceThreadId on each
|
|
4914
|
+
// run. Keep it distinct: it can belong to a teammate or room, whereas an
|
|
4915
|
+
// explicit resultsThreadId must be a visible task owned by the running bot.
|
|
4916
|
+
if (!forceNew && routineSourceOwner(routine))
|
|
4917
|
+
return routine.resultsThreadId;
|
|
4918
|
+
return store.createTask(routine.botId, `${routine.name} · Results`, false)?.threadId;
|
|
4919
|
+
},
|
|
4920
|
+
discardResultsThread: (botId, threadId) => {
|
|
4921
|
+
const task = store.taskByThread(botId, threadId);
|
|
4922
|
+
if (task && !task.busy && !task.routineRunId && store.messagesFor(threadId).length === 0) {
|
|
4923
|
+
store.deleteTask(botId, threadId);
|
|
4924
|
+
}
|
|
4925
|
+
},
|
|
4576
4926
|
startTurn: (botId, threadId, prompt, runOn, triggerSource, onDispatchError) => startTurn(botId, prompt, { threadId, runOn, automationSource: triggerSource, onDispatchError })
|
|
4577
4927
|
.then(() => undefined),
|
|
4578
4928
|
startGoal: async (groupId, threadId, prompt, coordinatorBotId, runId, _onDispatchError) => {
|
|
@@ -5022,7 +5372,7 @@ setupRetry = 0) {
|
|
|
5022
5372
|
};
|
|
5023
5373
|
try {
|
|
5024
5374
|
const integrations = {};
|
|
5025
|
-
const skillAuthoring =
|
|
5375
|
+
const skillAuthoring = skillAuthoringEnabled(cfg) &&
|
|
5026
5376
|
hop === 0 &&
|
|
5027
5377
|
!skillAuthoringClaim.claimed &&
|
|
5028
5378
|
!cardContinuation &&
|
|
@@ -5247,7 +5597,7 @@ setupRetry = 0) {
|
|
|
5247
5597
|
readyGroup.bulletin.trim() && `Room bulletin (shared instructions for everyone):\n${readyGroup.bulletin.trim()}`,
|
|
5248
5598
|
`Reply as yourself, briefly and conversationally. To bring a teammate in, mention them like @Name — they'll see the conversation and respond.`,
|
|
5249
5599
|
outsideRoom.length > 0 && roomPeerRosterSystemPrompt(outsideRoom),
|
|
5250
|
-
integrations.agents && CREDENTIAL_PROMPT.trim(),
|
|
5600
|
+
integrations.agents && (CREDENTIAL_PROMPT + THREADS_PROMPT).trim(),
|
|
5251
5601
|
integrations.agents && ROUTINE_PROMPT.trim(),
|
|
5252
5602
|
integrations.agents && PROFILE_PROMPT.trim(),
|
|
5253
5603
|
skillAuthoring && LEARN_PROMPT.trim(),
|
|
@@ -5263,6 +5613,9 @@ setupRetry = 0) {
|
|
|
5263
5613
|
// conversation, not a different bot
|
|
5264
5614
|
const worksInWorkspace = instance.driverKind !== "grok" && instance.driverKind !== "boxAgent";
|
|
5265
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);
|
|
5266
5619
|
// The room's folder pins here — on the first turn that actually
|
|
5267
5620
|
// dispatches, not at PATCH time — so a folder set on a never-used room
|
|
5268
5621
|
// still takes effect, while a room that already worked somewhere never
|
|
@@ -5282,12 +5635,14 @@ setupRetry = 0) {
|
|
|
5282
5635
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
5283
5636
|
// the room path has always put a newline before memory and trimmed
|
|
5284
5637
|
// the block's leading space; keep that so existing prompts are
|
|
5285
|
-
// byte-identical
|
|
5286
|
-
|
|
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()}` : "" },
|
|
5287
5642
|
{ id: "skills", label: "Skills index", text: workspace ? skillsSystemPrompt(bot.id) : "" },
|
|
5288
5643
|
{ id: "skill-instructions", label: "Skill instructions", text: renderSkillInstructions(selectedSkills, { includeRoot: Boolean(workspace) }) },
|
|
5289
5644
|
{ id: "playbooks", label: "Playbooks", text: installedPlaybookInstructions(text, bot.playbooks) },
|
|
5290
|
-
])
|
|
5645
|
+
]);
|
|
5291
5646
|
// run the turn and wait for it to settle, folding the reply text so a
|
|
5292
5647
|
// chained @mention can be routed afterwards
|
|
5293
5648
|
// Claim only after setup succeeded. An unavailable, busy, unsupported, or
|
|
@@ -5390,7 +5745,9 @@ setupRetry = 0) {
|
|
|
5390
5745
|
text,
|
|
5391
5746
|
images: turnImages,
|
|
5392
5747
|
approvalMode: approvalModeForTurn(readyBot, false, threadId),
|
|
5393
|
-
system: roomSystem,
|
|
5748
|
+
system: roomSystem.text,
|
|
5749
|
+
systemStable: roomSystem.stable,
|
|
5750
|
+
systemVolatile: roomSystem.volatile,
|
|
5394
5751
|
cwd,
|
|
5395
5752
|
integrations,
|
|
5396
5753
|
...memberTurnSelection(readyBot.modelSelection),
|
|
@@ -6925,7 +7282,7 @@ function configStatus() {
|
|
|
6925
7282
|
maxInstances: localVmMaxInstances(cfg),
|
|
6926
7283
|
},
|
|
6927
7284
|
features: {
|
|
6928
|
-
|
|
7285
|
+
skillAuthoring: skillAuthoringEnabled(cfg),
|
|
6929
7286
|
showToolCalls: showToolCallsEnabled(cfg),
|
|
6930
7287
|
browser: builtInBrowserEnabled(cfg),
|
|
6931
7288
|
},
|
|
@@ -7116,6 +7473,26 @@ function json(res, status, body) {
|
|
|
7116
7473
|
res.writeHead(status, { "content-type": "application/json" });
|
|
7117
7474
|
res.end(data);
|
|
7118
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
|
+
}
|
|
7119
7496
|
function readBody(req, limit = 1_000_000) {
|
|
7120
7497
|
return new Promise((resolve, reject) => {
|
|
7121
7498
|
let data = "";
|
|
@@ -7417,6 +7794,7 @@ const handleRequest = async (req, res) => {
|
|
|
7417
7794
|
...parsed.data,
|
|
7418
7795
|
generation,
|
|
7419
7796
|
createdBots: 0,
|
|
7797
|
+
openedThreads: 0,
|
|
7420
7798
|
});
|
|
7421
7799
|
return json(res, 201, { token });
|
|
7422
7800
|
}
|
|
@@ -7482,10 +7860,23 @@ const handleRequest = async (req, res) => {
|
|
|
7482
7860
|
throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
|
|
7483
7861
|
}
|
|
7484
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
|
+
});
|
|
7485
7871
|
if (method === "POST" && path === "/api/internal/memory") {
|
|
7486
7872
|
const body = await readInternalBody();
|
|
7487
|
-
const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText });
|
|
7488
|
-
return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "
|
|
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);
|
|
7489
7880
|
}
|
|
7490
7881
|
if (method === "POST" && path === "/api/internal/browser/mcp") {
|
|
7491
7882
|
const body = await readInternalBody();
|
|
@@ -7538,6 +7929,82 @@ const handleRequest = async (req, res) => {
|
|
|
7538
7929
|
// to keep trying — but it is still NAMED, with the refusal it would
|
|
7539
7930
|
// have met. Without that the bot can only say it is in no room at
|
|
7540
7931
|
// all, while the person is looking at it in that very room.
|
|
7932
|
+
// list_threads: the caller's own threads, plus — on every peer it can
|
|
7933
|
+
// reach — only the threads the caller itself opened. A peer's other
|
|
7934
|
+
// threads are its own business (and the person's), so the scope is
|
|
7935
|
+
// "what you started", never "what that bot is doing". State is read
|
|
7936
|
+
// the way the sidebar reads it, so the bot and the person agree.
|
|
7937
|
+
if (method === "GET" && path === "/api/internal/threads") {
|
|
7938
|
+
const from = internalSender;
|
|
7939
|
+
const fromThreadId = internalCapability.threadId;
|
|
7940
|
+
if (!connectorThread(from.id, fromThreadId)) {
|
|
7941
|
+
return json(res, 403, { error: "source conversation does not belong to sender" });
|
|
7942
|
+
}
|
|
7943
|
+
const rows = [];
|
|
7944
|
+
const stateOf = (bot, task) => {
|
|
7945
|
+
if (task.activity === "waiting-on-you")
|
|
7946
|
+
return "waiting-on-you";
|
|
7947
|
+
if (threadBusy(bot.id, task.threadId))
|
|
7948
|
+
return "running";
|
|
7949
|
+
if (queuedThreadPosition(bot.id, task.threadId) !== null)
|
|
7950
|
+
return "queued";
|
|
7951
|
+
return "idle";
|
|
7952
|
+
};
|
|
7953
|
+
for (const task of store.tasks(from.id)) {
|
|
7954
|
+
rows.push({
|
|
7955
|
+
threadId: task.threadId, botId: from.id, botName: from.name, title: task.title,
|
|
7956
|
+
state: stateOf(from, task), unread: task.unread === true, openedAt: task.openedBy?.at ?? task.createdAt,
|
|
7957
|
+
delegationId: task.openedBy?.delegationId, own: true,
|
|
7958
|
+
});
|
|
7959
|
+
}
|
|
7960
|
+
for (const peer of reachablePeers(store.bots, from)) {
|
|
7961
|
+
for (const task of store.tasks(peer.id)) {
|
|
7962
|
+
if (task.openedBy?.botId !== from.id)
|
|
7963
|
+
continue;
|
|
7964
|
+
rows.push({
|
|
7965
|
+
threadId: task.threadId, botId: peer.id, botName: peer.name, title: task.title,
|
|
7966
|
+
state: stateOf(peer, task), unread: task.unread === true, openedAt: task.openedBy.at,
|
|
7967
|
+
delegationId: task.openedBy.delegationId, own: false,
|
|
7968
|
+
});
|
|
7969
|
+
}
|
|
7970
|
+
}
|
|
7971
|
+
rows.sort((a, b) => b.openedAt - a.openedAt);
|
|
7972
|
+
return json(res, 200, { threads: rows.slice(0, 100) });
|
|
7973
|
+
}
|
|
7974
|
+
// close_thread: a bot tidies a thread it opened (or one of its own)
|
|
7975
|
+
// once its result has been read. Closing is the sidebar's idle state
|
|
7976
|
+
// plus a chip saying who closed it — never a deletion, which stays a
|
|
7977
|
+
// person's confirmed action, and never while the thread is running.
|
|
7978
|
+
const closeMatch = method === "POST" ? path.match(/^\/api\/internal\/threads\/([\w-]+)\/close$/) : null;
|
|
7979
|
+
if (closeMatch) {
|
|
7980
|
+
const from = internalSender;
|
|
7981
|
+
const fromThreadId = internalCapability.threadId;
|
|
7982
|
+
if (!connectorThread(from.id, fromThreadId)) {
|
|
7983
|
+
return json(res, 403, { error: "source conversation does not belong to sender" });
|
|
7984
|
+
}
|
|
7985
|
+
const threadId = closeMatch[1];
|
|
7986
|
+
const owner = [from, ...reachablePeers(store.bots, from)].find((bot) => store.taskByThread(bot.id, threadId));
|
|
7987
|
+
const task = owner ? store.taskByThread(owner.id, threadId) : undefined;
|
|
7988
|
+
if (!owner || !task)
|
|
7989
|
+
return json(res, 404, { error: "no such thread — call list_threads for the ones you can see" });
|
|
7990
|
+
if (owner.id !== from.id && task.openedBy?.botId !== from.id) {
|
|
7991
|
+
return json(res, 403, { error: "that thread is not yours to close — only the bot that opened it, or its own bot, can" });
|
|
7992
|
+
}
|
|
7993
|
+
if (threadId === fromThreadId)
|
|
7994
|
+
return json(res, 400, { error: "you cannot close the thread you are speaking in — finish your turn instead" });
|
|
7995
|
+
if (threadBusy(owner.id, threadId)) {
|
|
7996
|
+
return json(res, 409, { error: `#${task.title} is still running — wait for it to finish (list_threads), or the person can stop it from the app` });
|
|
7997
|
+
}
|
|
7998
|
+
store.appendMessage(threadId, {
|
|
7999
|
+
role: "bot",
|
|
8000
|
+
kind: "activity",
|
|
8001
|
+
from: { botId: from.id, name: from.name, color: from.color },
|
|
8002
|
+
tool: { name: `Closed by @${from.name}`, ok: true },
|
|
8003
|
+
});
|
|
8004
|
+
if (task.unread)
|
|
8005
|
+
store.patchTask(owner.id, threadId, { unread: false });
|
|
8006
|
+
return json(res, 200, { closed: true, threadId, title: task.title, botName: owner.name });
|
|
8007
|
+
}
|
|
7541
8008
|
if (method === "GET" && path === "/api/internal/rooms") {
|
|
7542
8009
|
const from = internalSender;
|
|
7543
8010
|
const fromThreadId = internalCapability.threadId;
|
|
@@ -7714,6 +8181,16 @@ const handleRequest = async (req, res) => {
|
|
|
7714
8181
|
return json(res, 400, { error: "q is required" });
|
|
7715
8182
|
const rawLimit = Number(url.searchParams.get("limit"));
|
|
7716
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 });
|
|
7717
8194
|
const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId)])];
|
|
7718
8195
|
// A room is the only place a recall can be a disclosure: in a 1:1 the
|
|
7719
8196
|
// user already owns every thread the bot can reach.
|
|
@@ -7727,7 +8204,7 @@ const handleRequest = async (req, res) => {
|
|
|
7727
8204
|
if (inRoom) {
|
|
7728
8205
|
discloseRecall(from, fromThreadId, hits.filter((hit) => hit.crossed).map((hit) => hit.threadId));
|
|
7729
8206
|
}
|
|
7730
|
-
return json(res, 200, { hits });
|
|
8207
|
+
return json(res, 200, { hits, memoryHits });
|
|
7731
8208
|
}
|
|
7732
8209
|
// session_read: the whole message behind a session_search hit. Same
|
|
7733
8210
|
// own-bot scope — a message id from another bot's thread reads as
|
|
@@ -7761,8 +8238,8 @@ const handleRequest = async (req, res) => {
|
|
|
7761
8238
|
});
|
|
7762
8239
|
}
|
|
7763
8240
|
if (method === "GET" && path === "/api/internal/skills") {
|
|
7764
|
-
if (!
|
|
7765
|
-
return json(res, 403, { error: "
|
|
8241
|
+
if (!skillAuthoringEnabled(cfg))
|
|
8242
|
+
return json(res, 403, { error: "skill authoring is not enabled in Settings" });
|
|
7766
8243
|
if (!internalCapability.skillAuthoring) {
|
|
7767
8244
|
return json(res, 403, { error: "skill authoring is not enabled for this turn" });
|
|
7768
8245
|
}
|
|
@@ -7777,8 +8254,8 @@ const handleRequest = async (req, res) => {
|
|
|
7777
8254
|
});
|
|
7778
8255
|
}
|
|
7779
8256
|
if (method === "POST" && path === "/api/internal/skills/stage") {
|
|
7780
|
-
if (!
|
|
7781
|
-
return json(res, 403, { error: "
|
|
8257
|
+
if (!skillAuthoringEnabled(cfg))
|
|
8258
|
+
return json(res, 403, { error: "skill authoring is not enabled in Settings" });
|
|
7782
8259
|
if (!internalCapability.skillAuthoring) {
|
|
7783
8260
|
return json(res, 403, { error: "skill authoring is not enabled for this turn" });
|
|
7784
8261
|
}
|
|
@@ -8231,6 +8708,128 @@ const handleRequest = async (req, res) => {
|
|
|
8231
8708
|
store.appendMessage(fromThreadId, chip);
|
|
8232
8709
|
return json(res, 201, { ok: true, messageId: posted.id, roomName: room.name });
|
|
8233
8710
|
}
|
|
8711
|
+
// start_thread: a bot opens a real thread — on itself for separate
|
|
8712
|
+
// work, or on a teammate as a handoff that should run on its own.
|
|
8713
|
+
// Never activates: a bot must not move what the person is looking at.
|
|
8714
|
+
if (method === "POST" && path === "/api/internal/threads") {
|
|
8715
|
+
const body = await readInternalBody();
|
|
8716
|
+
const from = internalSender;
|
|
8717
|
+
const fromThreadId = internalCapability.threadId;
|
|
8718
|
+
const owner = connectorThread(from.id, fromThreadId);
|
|
8719
|
+
if (!owner)
|
|
8720
|
+
return json(res, 403, { error: "source conversation does not belong to sender" });
|
|
8721
|
+
if (body.depth !== undefined &&
|
|
8722
|
+
(!Number.isInteger(body.depth) || body.depth < 0 || body.depth !== internalCapability.depth)) {
|
|
8723
|
+
return json(res, 403, { error: "the recursion depth does not match this turn" });
|
|
8724
|
+
}
|
|
8725
|
+
const title = String(body.title ?? "").trim();
|
|
8726
|
+
const message = String(body.message ?? "").trim();
|
|
8727
|
+
if (!title || !message)
|
|
8728
|
+
return json(res, 400, { error: "title and message are required" });
|
|
8729
|
+
if (title.length > 80)
|
|
8730
|
+
return json(res, 400, { error: "title must be at most 80 characters" });
|
|
8731
|
+
// the title is quoted into chips and the sidebar, one line each
|
|
8732
|
+
if (!fitsOnOneLine(title))
|
|
8733
|
+
return json(res, 400, { error: "title must fit on one line" });
|
|
8734
|
+
if (internalCapability.openedThreads >= MAX_THREADS_OPENED_PER_TURN) {
|
|
8735
|
+
return json(res, 429, { error: `you can open at most ${MAX_THREADS_OPENED_PER_TURN} threads in one turn` });
|
|
8736
|
+
}
|
|
8737
|
+
const toBotId = typeof body.toBotId === "string" && body.toBotId.trim() ? body.toBotId.trim() : from.id;
|
|
8738
|
+
const target = store.bot(toBotId);
|
|
8739
|
+
if (!target)
|
|
8740
|
+
return json(res, 404, { error: "no such bot" });
|
|
8741
|
+
// A folder is the target's own organisation; a name is what the
|
|
8742
|
+
// model has, an id is what the sidebar has, so accept either.
|
|
8743
|
+
const folder = typeof body.folder === "string" ? body.folder.trim() : "";
|
|
8744
|
+
let projectId;
|
|
8745
|
+
if (folder) {
|
|
8746
|
+
const project = (target.projects ?? []).find((candidate) => candidate.id === folder || candidate.name.trim().toLowerCase() === folder.toLowerCase());
|
|
8747
|
+
if (!project) {
|
|
8748
|
+
const names = (target.projects ?? []).map((candidate) => candidate.name).join(", ");
|
|
8749
|
+
return json(res, 400, { error: `@${target.name} has no folder named "${folder}"${names ? ` — the folders are: ${names}` : " — it has no folders; leave folder out"}` });
|
|
8750
|
+
}
|
|
8751
|
+
projectId = project.id;
|
|
8752
|
+
}
|
|
8753
|
+
const sourceTitle = owner.group ? owner.group.name : (store.taskByThread(from.id, fromThreadId)?.title ?? "");
|
|
8754
|
+
if (target.id === from.id) {
|
|
8755
|
+
const task = store.createTask(from.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
|
|
8756
|
+
if (!task)
|
|
8757
|
+
return json(res, 500, { error: "couldn't create that thread" });
|
|
8758
|
+
internalCapability.openedThreads += 1;
|
|
8759
|
+
const chip = {
|
|
8760
|
+
role: "bot",
|
|
8761
|
+
kind: "activity",
|
|
8762
|
+
tool: { name: `Opened thread #${task.title}`, ok: true },
|
|
8763
|
+
threadRef: { botId: from.id, threadId: task.threadId, title: task.title },
|
|
8764
|
+
};
|
|
8765
|
+
if (owner.group)
|
|
8766
|
+
chip.from = { botId: from.id, name: from.name, color: from.color };
|
|
8767
|
+
store.appendMessage(fromThreadId, chip);
|
|
8768
|
+
// The first line is the bot's own words. Say so where the model
|
|
8769
|
+
// reads it, so a later turn in that thread never mistakes the
|
|
8770
|
+
// request for the person's.
|
|
8771
|
+
const opening = `[Thread you opened yourself${sourceTitle ? ` from #${sourceTitle}` : ""}. The request below is your own words, not the person's: do the work here and end with a clear result they can read.]\n\n${message}`;
|
|
8772
|
+
const outcome = await startOrQueueOpenedThread(from.id, task.threadId, opening, isUnattended(from.id, fromThreadId));
|
|
8773
|
+
return json(res, 201, {
|
|
8774
|
+
threadId: task.threadId,
|
|
8775
|
+
title: task.title,
|
|
8776
|
+
botId: from.id,
|
|
8777
|
+
botName: from.name,
|
|
8778
|
+
self: true,
|
|
8779
|
+
limit: maxConcurrentBotThreads(cfg),
|
|
8780
|
+
...outcome,
|
|
8781
|
+
});
|
|
8782
|
+
}
|
|
8783
|
+
// A peer thread is a handoff into a fresh thread: every gate the
|
|
8784
|
+
// classic handoff has applies unchanged, here at queue time and
|
|
8785
|
+
// again in the drain at dispatch time.
|
|
8786
|
+
const depth = internalCapability.depth;
|
|
8787
|
+
if (depth >= MAX_COMMS_DEPTH) {
|
|
8788
|
+
return json(res, 200, { error: "thread chains are limited to one hop — open the thread on yourself, or do this one here" });
|
|
8789
|
+
}
|
|
8790
|
+
if (sectionKey(from.section) !== sectionKey(target.section)) {
|
|
8791
|
+
return json(res, 403, { error: "that bot belongs to a different section" });
|
|
8792
|
+
}
|
|
8793
|
+
if (!peerAllowed(from, target.id)) {
|
|
8794
|
+
return json(res, 403, { error: "that bot is not on this bot's allowed peers — call list_bots for the ones you can reach" });
|
|
8795
|
+
}
|
|
8796
|
+
const task = store.createTask(target.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
|
|
8797
|
+
if (!task)
|
|
8798
|
+
return json(res, 500, { error: "couldn't create that thread" });
|
|
8799
|
+
const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
|
|
8800
|
+
if (queued.result !== "ok" || !queued.id) {
|
|
8801
|
+
// nothing will ever run there: take the row back before the person
|
|
8802
|
+
// sees a thread that goes nowhere
|
|
8803
|
+
store.deleteTask(target.id, task.threadId);
|
|
8804
|
+
const said = {
|
|
8805
|
+
self: "a bot cannot hand a thread to itself this way — leave bot_id out",
|
|
8806
|
+
too_deep: "thread chains are limited to one hop — open the thread on yourself, or do this one here",
|
|
8807
|
+
no_target: "no such bot",
|
|
8808
|
+
too_many: "too many handoffs queued on this turn — finish your turn and open the rest next time",
|
|
8809
|
+
};
|
|
8810
|
+
return json(res, 200, { error: said[queued.result === "ok" ? "no_target" : queued.result] });
|
|
8811
|
+
}
|
|
8812
|
+
store.setTaskOpenedBy(target.id, task.threadId, { botId: from.id, name: from.name, delegationId: queued.id, at: task.openedBy?.at ?? Date.now() });
|
|
8813
|
+
internalCapability.openedThreads += 1;
|
|
8814
|
+
// An honest forecast, not a promise: the handoff starts when this
|
|
8815
|
+
// turn ends, and by then the target's slots are taken by whatever is
|
|
8816
|
+
// running there plus the threads this turn already opened ahead.
|
|
8817
|
+
const limit = maxConcurrentBotThreads(cfg);
|
|
8818
|
+
const running = store.tasks(target.id).filter((candidate) => threadBusy(target.id, candidate.threadId)).length;
|
|
8819
|
+
const ahead = pendingDelegationSnapshot().filter((pending) => pending.toBotId === target.id && pending.targetThreadId !== undefined && pending.targetThreadId !== task.threadId).length;
|
|
8820
|
+
const position = running + ahead + 1;
|
|
8821
|
+
return json(res, 201, {
|
|
8822
|
+
threadId: task.threadId,
|
|
8823
|
+
title: task.title,
|
|
8824
|
+
botId: target.id,
|
|
8825
|
+
botName: target.name,
|
|
8826
|
+
self: false,
|
|
8827
|
+
delegationId: queued.id,
|
|
8828
|
+
approvalRequired: Boolean(from.approvePeerComms),
|
|
8829
|
+
limit,
|
|
8830
|
+
...(position > limit ? { state: "queued", position: position - limit } : { state: "pending" }),
|
|
8831
|
+
});
|
|
8832
|
+
}
|
|
8234
8833
|
if (method === "POST" && path === "/api/internal/create-bot") {
|
|
8235
8834
|
const body = await readInternalBody();
|
|
8236
8835
|
const chief = internalSender;
|
|
@@ -8293,6 +8892,86 @@ const handleRequest = async (req, res) => {
|
|
|
8293
8892
|
model: safeBot.modelSelection.model,
|
|
8294
8893
|
});
|
|
8295
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
|
+
}
|
|
8296
8975
|
if (method === "POST" && path === "/api/internal/request-credential") {
|
|
8297
8976
|
const body = await readInternalBody();
|
|
8298
8977
|
const from = internalSender;
|
|
@@ -9129,47 +9808,7 @@ const handleRequest = async (req, res) => {
|
|
|
9129
9808
|
}
|
|
9130
9809
|
// ── channels (persisted internally as groups) ───────────────────────
|
|
9131
9810
|
if (method === "POST" && path === "/api/groups") {
|
|
9132
|
-
const
|
|
9133
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
9134
|
-
return json(res, 400, { error: "channel must be a JSON object" });
|
|
9135
|
-
}
|
|
9136
|
-
const roster = checkedMemberIds(body.memberIds);
|
|
9137
|
-
if (!roster.ok)
|
|
9138
|
-
return json(res, 400, { error: roster.error });
|
|
9139
|
-
const { memberIds } = roster;
|
|
9140
|
-
if (body.name !== undefined && typeof body.name !== "string") {
|
|
9141
|
-
return json(res, 400, { error: "channel name must be a string" });
|
|
9142
|
-
}
|
|
9143
|
-
const name = body.name?.trim() || `${store.bot(memberIds[0]).name} & co.`;
|
|
9144
|
-
if (name.length > 100)
|
|
9145
|
-
return json(res, 400, { error: "channel name must be at most 100 characters" });
|
|
9146
|
-
let section;
|
|
9147
|
-
if (body.section !== undefined && body.section !== null) {
|
|
9148
|
-
if (typeof body.section !== "string")
|
|
9149
|
-
return json(res, 400, { error: "context must be a string" });
|
|
9150
|
-
section = body.section.trim() || undefined;
|
|
9151
|
-
if (section && section.length > 60) {
|
|
9152
|
-
return json(res, 400, { error: "context must be at most 60 characters" });
|
|
9153
|
-
}
|
|
9154
|
-
}
|
|
9155
|
-
let setup;
|
|
9156
|
-
if (body.setup !== undefined) {
|
|
9157
|
-
if (!body.setup || typeof body.setup !== "object" || Array.isArray(body.setup)) {
|
|
9158
|
-
return json(res, 400, { error: "setup must be an object" });
|
|
9159
|
-
}
|
|
9160
|
-
const requested = body.setup;
|
|
9161
|
-
if (typeof requested.bulletin !== "string") {
|
|
9162
|
-
return json(res, 400, { error: "setup.bulletin must be a string" });
|
|
9163
|
-
}
|
|
9164
|
-
if (requested.bulletin.length > 12_000) {
|
|
9165
|
-
return json(res, 400, { error: "setup.bulletin must be at most 12000 characters" });
|
|
9166
|
-
}
|
|
9167
|
-
const responder = checkedGroupResponder(requested.defaultResponder, memberIds);
|
|
9168
|
-
if (!responder)
|
|
9169
|
-
return json(res, 400, { error: "invalid setup.defaultResponder" });
|
|
9170
|
-
setup = { bulletin: requested.bulletin, defaultResponder: responder, completed: true };
|
|
9171
|
-
}
|
|
9172
|
-
const group = store.createGroup(name, memberIds, false, section, setup);
|
|
9811
|
+
const group = createChannel(await readBody(req));
|
|
9173
9812
|
return json(res, 201, { group: { ...publicGroupState(group), messages: [] } });
|
|
9174
9813
|
}
|
|
9175
9814
|
if (method === "POST" && path === "/api/teams/export") {
|
|
@@ -9188,12 +9827,17 @@ const handleRequest = async (req, res) => {
|
|
|
9188
9827
|
return json(res, 200, createTeamBackup(store, routines.listRoutines(), name));
|
|
9189
9828
|
}
|
|
9190
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 });
|
|
9191
9834
|
const document = createBotPackageExport({
|
|
9192
9835
|
name,
|
|
9193
9836
|
authorName: profileName,
|
|
9194
|
-
bots:
|
|
9837
|
+
bots: selectedBots,
|
|
9195
9838
|
groups: store.groups,
|
|
9196
9839
|
routines: routines.listRoutines(),
|
|
9840
|
+
skillsByBot: collectExportSkills(selectedBots, skillNames.names),
|
|
9197
9841
|
});
|
|
9198
9842
|
return json(res, 200, {
|
|
9199
9843
|
name: document.package.name,
|
|
@@ -9338,8 +9982,8 @@ const handleRequest = async (req, res) => {
|
|
|
9338
9982
|
const pkg = packageDocument?.package;
|
|
9339
9983
|
const importName = pkg?.name ?? manifest.team.name;
|
|
9340
9984
|
const sourceMembers = pkg
|
|
9341
|
-
? pkg.agents.map((agent) => ({ member: packageAgentAsMember(agent), playbookKeys: agent.playbooks ?? [] }))
|
|
9342
|
-
: 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: [] }));
|
|
9343
9987
|
const importedBots = [];
|
|
9344
9988
|
const createdGroups = [];
|
|
9345
9989
|
const createdRoutineIds = [];
|
|
@@ -9362,6 +10006,7 @@ const handleRequest = async (req, res) => {
|
|
|
9362
10006
|
}
|
|
9363
10007
|
}
|
|
9364
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]));
|
|
9365
10010
|
for (const source of sourceMembers) {
|
|
9366
10011
|
const member = source.member;
|
|
9367
10012
|
// importedMemberProfile is the authority boundary: persona fields
|
|
@@ -9395,6 +10040,17 @@ const handleRequest = async (req, res) => {
|
|
|
9395
10040
|
}
|
|
9396
10041
|
: {}),
|
|
9397
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
|
+
}
|
|
9398
10054
|
memberIds.set(member.key, created.id);
|
|
9399
10055
|
}
|
|
9400
10056
|
// A package is an explicit structure import: its rooms are created
|
|
@@ -9517,11 +10173,6 @@ const handleRequest = async (req, res) => {
|
|
|
9517
10173
|
return json(res, 200, { group: publicGroupState(updated) });
|
|
9518
10174
|
}
|
|
9519
10175
|
// ── channel tasks: separate conversations for the same team ────────
|
|
9520
|
-
const channelTaskBlocked = (group) => groupIsWorking(group) ||
|
|
9521
|
-
store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
|
|
9522
|
-
message.card?.requestId &&
|
|
9523
|
-
!message.card.answered &&
|
|
9524
|
-
!message.card.dismissed));
|
|
9525
10176
|
// A scheduled goal starts in a detached task. Let the user open the
|
|
9526
10177
|
// exact task that owns the live operation (or a durable approval card)
|
|
9527
10178
|
// so they can observe or unblock it; switching to an unrelated task is
|
|
@@ -9637,113 +10288,7 @@ const handleRequest = async (req, res) => {
|
|
|
9637
10288
|
if (field)
|
|
9638
10289
|
return json(res, 403, { error: `forbidden: this session may rename or mark a room, not change "${field}" (needs the admin scope)` });
|
|
9639
10290
|
}
|
|
9640
|
-
|
|
9641
|
-
return json(res, 400, { error: "body must be a JSON object" });
|
|
9642
|
-
}
|
|
9643
|
-
const existing = store.group(m[1]);
|
|
9644
|
-
if (!existing)
|
|
9645
|
-
return json(res, 404, { error: "no such room" });
|
|
9646
|
-
if (body.memberIds !== undefined && phoneSecretSubmissions.hasGroup(existing.id)) {
|
|
9647
|
-
return json(res, 409, { error: "this channel is securely saving a credential — try again when it finishes" });
|
|
9648
|
-
}
|
|
9649
|
-
if (channelTaskBlocked(existing) &&
|
|
9650
|
-
(body.memberIds !== undefined || body.defaultResponder !== undefined || body.bulletin !== undefined)) {
|
|
9651
|
-
return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" });
|
|
9652
|
-
}
|
|
9653
|
-
const patch = {};
|
|
9654
|
-
if (body.name !== undefined) {
|
|
9655
|
-
if (typeof body.name !== "string")
|
|
9656
|
-
return json(res, 400, { error: "room name must be a string" });
|
|
9657
|
-
const name = body.name.trim();
|
|
9658
|
-
if (!name)
|
|
9659
|
-
return json(res, 400, { error: "room name must not be empty" });
|
|
9660
|
-
if (name.length > 100)
|
|
9661
|
-
return json(res, 400, { error: "room name must be at most 100 characters" });
|
|
9662
|
-
patch.name = name;
|
|
9663
|
-
}
|
|
9664
|
-
if (body.bulletin !== undefined) {
|
|
9665
|
-
if (typeof body.bulletin !== "string")
|
|
9666
|
-
return json(res, 400, { error: "bulletin must be a string" });
|
|
9667
|
-
if (body.bulletin.length > 12_000) {
|
|
9668
|
-
return json(res, 400, { error: "bulletin must be at most 12000 characters" });
|
|
9669
|
-
}
|
|
9670
|
-
patch.bulletin = body.bulletin;
|
|
9671
|
-
}
|
|
9672
|
-
if (body.unread !== undefined) {
|
|
9673
|
-
if (typeof body.unread !== "boolean")
|
|
9674
|
-
return json(res, 400, { error: "unread must be true or false" });
|
|
9675
|
-
patch.unread = body.unread;
|
|
9676
|
-
}
|
|
9677
|
-
if (body.memberIds !== undefined) {
|
|
9678
|
-
// A DM is the pair it was opened for; only real rooms have a roster.
|
|
9679
|
-
if (existing.dm)
|
|
9680
|
-
return json(res, 400, { error: "direct-message channels cannot change members" });
|
|
9681
|
-
const roster = checkedMemberIds(body.memberIds);
|
|
9682
|
-
if (!roster.ok)
|
|
9683
|
-
return json(res, 400, { error: roster.error.replace("channel", "room") });
|
|
9684
|
-
const removedGoalLead = routines.listRoutines().some((routine) => routine.enabled &&
|
|
9685
|
-
routine.target === "room-goal" &&
|
|
9686
|
-
routine.groupId === existing.id &&
|
|
9687
|
-
!roster.memberIds.includes(routine.botId)) || routines.listRuns().some((run) => run.target === "room-goal" &&
|
|
9688
|
-
run.groupId === existing.id &&
|
|
9689
|
-
["queued", "running", "waiting"].includes(run.status) &&
|
|
9690
|
-
!roster.memberIds.includes(run.botId));
|
|
9691
|
-
if (removedGoalLead) {
|
|
9692
|
-
return json(res, 409, {
|
|
9693
|
-
error: "pause or reassign this room's team-goal routine before removing its lead",
|
|
9694
|
-
});
|
|
9695
|
-
}
|
|
9696
|
-
patch.memberIds = roster.memberIds;
|
|
9697
|
-
}
|
|
9698
|
-
if (body.defaultResponder !== undefined) {
|
|
9699
|
-
const memberIds = patch.memberIds ?? existing.memberIds;
|
|
9700
|
-
const responder = checkedGroupResponder(body.defaultResponder, memberIds);
|
|
9701
|
-
if (!responder)
|
|
9702
|
-
return json(res, 400, { error: "invalid default responder" });
|
|
9703
|
-
patch.defaultResponder = responder;
|
|
9704
|
-
}
|
|
9705
|
-
if (body.cwd !== undefined) {
|
|
9706
|
-
if (existing.dm)
|
|
9707
|
-
return json(res, 400, { error: "direct-message channels cannot have a working folder" });
|
|
9708
|
-
if (existing.pinnedCwd !== undefined) {
|
|
9709
|
-
return json(res, 409, { error: "the room's working folder is fixed after its first turn" });
|
|
9710
|
-
}
|
|
9711
|
-
const checked = validateBotCwd(body.cwd);
|
|
9712
|
-
if (!checked.ok)
|
|
9713
|
-
return json(res, 400, { error: checked.error });
|
|
9714
|
-
patch.cwd = checked.cwd ?? undefined;
|
|
9715
|
-
}
|
|
9716
|
-
// one pinned message per room; null/"" clears. The id is not
|
|
9717
|
-
// validated against the transcript here — a pin whose message was
|
|
9718
|
-
// edited away or deleted simply resolves to nothing in the UI.
|
|
9719
|
-
if (body.pinnedMessageId !== undefined) {
|
|
9720
|
-
if (body.pinnedMessageId === null || body.pinnedMessageId === "")
|
|
9721
|
-
patch.pinnedMessageId = undefined;
|
|
9722
|
-
else if (typeof body.pinnedMessageId === "string" && /^[\w-]+$/.test(body.pinnedMessageId)) {
|
|
9723
|
-
patch.pinnedMessageId = body.pinnedMessageId;
|
|
9724
|
-
}
|
|
9725
|
-
else
|
|
9726
|
-
return json(res, 400, { error: "pinnedMessageId must be a message id" });
|
|
9727
|
-
}
|
|
9728
|
-
// same contract as a bot's sidebar section: null/"" clears, 60 chars max
|
|
9729
|
-
if (body.section !== undefined) {
|
|
9730
|
-
if (body.section === null)
|
|
9731
|
-
patch.section = undefined;
|
|
9732
|
-
else if (typeof body.section !== "string")
|
|
9733
|
-
return json(res, 400, { error: "section must be a string" });
|
|
9734
|
-
else {
|
|
9735
|
-
const trimmed = body.section.trim();
|
|
9736
|
-
if (!trimmed)
|
|
9737
|
-
patch.section = undefined;
|
|
9738
|
-
else if (trimmed.length > 60)
|
|
9739
|
-
return json(res, 400, { error: "section must be at most 60 characters" });
|
|
9740
|
-
else
|
|
9741
|
-
patch.section = trimmed;
|
|
9742
|
-
}
|
|
9743
|
-
}
|
|
9744
|
-
const group = store.patchGroup(m[1], patch);
|
|
9745
|
-
if (!group)
|
|
9746
|
-
return json(res, 404, { error: "no such room" });
|
|
10291
|
+
const group = updateChannel(m[1], body);
|
|
9747
10292
|
return json(res, 200, { group: publicGroupState(group) });
|
|
9748
10293
|
}
|
|
9749
10294
|
m = path.match(/^\/api\/groups\/([\w-]+)\/read$/);
|
|
@@ -11021,31 +11566,130 @@ const handleRequest = async (req, res) => {
|
|
|
11021
11566
|
broadcast({ kind: "bot", bot: visible });
|
|
11022
11567
|
return json(res, 200, { bot: visible });
|
|
11023
11568
|
}
|
|
11024
|
-
// ── bot memory: MEMORY.md + memory/ topic files
|
|
11025
|
-
// The files already belong to the
|
|
11026
|
-
// workspace)
|
|
11027
|
-
//
|
|
11028
|
-
//
|
|
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.
|
|
11029
11577
|
m = path.match(/^\/api\/bots\/([\w-]+)\/memory$/);
|
|
11030
11578
|
if (m && method === "GET") {
|
|
11031
11579
|
if (!store.bot(m[1]))
|
|
11032
11580
|
return json(res, 404, { error: "no such bot" });
|
|
11033
|
-
|
|
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
|
+
}
|
|
11034
11590
|
}
|
|
11035
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.
|
|
11036
11595
|
if (!store.bot(m[1]))
|
|
11037
11596
|
return json(res, 404, { error: "no such bot" });
|
|
11038
11597
|
const parsed = z.object({ text: z.string() }).safeParse(await readBody(req));
|
|
11039
11598
|
if (!parsed.success)
|
|
11040
11599
|
return json(res, 400, { error: "text must be a string" });
|
|
11041
|
-
|
|
11042
|
-
|
|
11043
|
-
|
|
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,
|
|
11044
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]) });
|
|
11650
|
+
}
|
|
11651
|
+
catch (error) {
|
|
11652
|
+
return replyMemoryError(res, error);
|
|
11045
11653
|
}
|
|
11046
|
-
|
|
11047
|
-
|
|
11048
|
-
|
|
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);
|
|
11049
11693
|
}
|
|
11050
11694
|
m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/topics\/([^/]+)$/);
|
|
11051
11695
|
if (m && method === "GET") {
|
|
@@ -12035,7 +12679,7 @@ const handleRequest = async (req, res) => {
|
|
|
12035
12679
|
}
|
|
12036
12680
|
if (action === "install") {
|
|
12037
12681
|
if (!(await registry.installRuntime(instanceId)))
|
|
12038
|
-
return json(res, 404, { error: "
|
|
12682
|
+
return json(res, 404, { error: "Installing this engine from Settings is not available on this server. Use the install command on the machine running OpenMausBot." });
|
|
12039
12683
|
return json(res, 200, { instances: await describeInstances() });
|
|
12040
12684
|
}
|
|
12041
12685
|
if (action === "auth/start") {
|
|
@@ -13242,6 +13886,7 @@ const gracefulShutdown = createGracefulShutdown({
|
|
|
13242
13886
|
await browserRuntime.closeAll();
|
|
13243
13887
|
},
|
|
13244
13888
|
() => flushAllProfileHistory(),
|
|
13889
|
+
() => flushAllMemoryJournals(),
|
|
13245
13890
|
],
|
|
13246
13891
|
// Cleanup jobs run concurrently. Release only after they settle (or reach
|
|
13247
13892
|
// the shutdown deadline), immediately before the process exits, so no new
|