openmausbot 0.1.74 → 0.1.76
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-BtT047dk.css +1 -0
- package/dist/assets/{index-CwoQdM0z.js → index-C6blGSq5.js} +1 -1
- package/dist/assets/index-fKXFpB_J.js +305 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/routes.js +6 -2
- package/dist-server/drivers/agents-proxy.js +133 -11
- package/dist-server/index.js +3379 -1431
- package/dist-server/mcp-gate.js +28 -18
- package/dist-server/openmausbot.js +557 -325
- package/dist-server/pair-cli.js +557 -325
- package/dist-server/permission-proxy.js +119 -14
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/atomic.js +37 -1
- package/dist-server/server/auto-approve.js +15 -0
- package/dist-server/server/bot-overview.js +5 -1
- package/dist-server/server/chief-of-staff.js +17 -12
- package/dist-server/server/delegations.js +177 -63
- package/dist-server/server/drivers/acp/core.js +2 -1
- package/dist-server/server/drivers/acp/grok.js +12 -1
- package/dist-server/server/drivers/acp/hermes.js +21 -3
- package/dist-server/server/drivers/agents-proxy.js +113 -10
- package/dist-server/server/drivers/claude.js +91 -17
- package/dist-server/server/drivers/codex.js +15 -4
- package/dist-server/server/drivers/openai-chat.js +90 -67
- package/dist-server/server/drivers/openai-compat.js +9 -1
- package/dist-server/server/drivers/pi.js +18 -3
- package/dist-server/server/drivers/retry.js +4 -0
- package/dist-server/server/env-path.js +18 -17
- package/dist-server/server/index.js +1172 -302
- package/dist-server/server/message-db.js +21 -0
- package/dist-server/server/peer-roster.js +48 -9
- package/dist-server/server/permission-proxy.js +90 -15
- package/dist-server/server/procs.js +19 -3
- package/dist-server/server/room-handoffs.js +237 -0
- package/dist-server/server/section-context.js +64 -14
- package/dist-server/server/setup-mode.js +6 -7
- package/dist-server/server/store.js +266 -8
- package/dist-server/server/surface.js +16 -5
- package/dist-server/server/team-backup.js +17 -1
- package/dist-server/server/team-setup-requests.js +252 -0
- package/dist-server/server/thread-events.js +10 -1
- package/dist-server/server/turn-dispatch-guard.js +31 -3
- package/dist-server/server/workspace.js +13 -0
- package/dist-server/shared/approval-mode.js +7 -0
- package/dist-server/shared/ask-question.js +172 -0
- package/dist-server/shared/provider-safety.js +6 -0
- package/dist-server/shared/team-backup.js +1 -0
- package/dist-server/shared/team-setup.js +1 -0
- package/package.json +1 -1
- package/dist/assets/index-BRLHsQ5g.js +0 -298
- package/dist/assets/index-ByLQ7a3F.css +0 -1
|
@@ -11,14 +11,13 @@ import { writeFileAtomic } from "./atomic.js";
|
|
|
11
11
|
import { DATA_DIR } from "./config.js";
|
|
12
12
|
export const SECTION_CONTEXT_MAX_BYTES = 24_000;
|
|
13
13
|
export const SECTION_CONTEXTS_FILE = join(DATA_DIR, "section-contexts.json");
|
|
14
|
+
const sectionContextRecordSchema = z.object({ text: z.string(), updatedAt: z.number().finite() });
|
|
14
15
|
const sectionContextFileSchema = z.object({
|
|
15
16
|
version: z.literal(1),
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
updatedAt: z.number().finite(),
|
|
19
|
-
})),
|
|
17
|
+
sections: z.array(z.string()).optional(),
|
|
18
|
+
contexts: z.record(z.string(), sectionContextRecordSchema),
|
|
20
19
|
});
|
|
21
|
-
const emptyFile = () => ({ version: 1, contexts:
|
|
20
|
+
const emptyFile = () => ({ version: 1, sections: [], contexts: Object.create(null) });
|
|
22
21
|
/** Section labels are identities everywhere else in the store: trim once,
|
|
23
22
|
* and use the empty key for the unsectioned General team. */
|
|
24
23
|
export function sectionContextKey(section) {
|
|
@@ -27,25 +26,75 @@ export function sectionContextKey(section) {
|
|
|
27
26
|
export function sectionContextLabel(section) {
|
|
28
27
|
return sectionContextKey(section) || "General";
|
|
29
28
|
}
|
|
30
|
-
function loadFile() {
|
|
29
|
+
function loadFile(forWrite = false) {
|
|
31
30
|
if (!existsSync(SECTION_CONTEXTS_FILE))
|
|
32
31
|
return emptyFile();
|
|
33
32
|
try {
|
|
34
|
-
const
|
|
33
|
+
const input = JSON.parse(readFileSync(SECTION_CONTEXTS_FILE, "utf8"));
|
|
34
|
+
const candidate = sectionContextFileSchema.safeParse(input);
|
|
35
35
|
if (!candidate.success)
|
|
36
|
-
|
|
37
|
-
const contexts =
|
|
38
|
-
|
|
36
|
+
throw new Error("Invalid team instructions file");
|
|
37
|
+
const contexts = Object.create(null);
|
|
38
|
+
// Read own entries after validating the envelope: record parsers can omit
|
|
39
|
+
// labels such as __proto__, which are ordinary team names here. Validate
|
|
40
|
+
// each value again before putting it into the prototype-free dictionary.
|
|
41
|
+
const entries = input.contexts;
|
|
42
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
43
|
+
const parsed = sectionContextRecordSchema.safeParse(value);
|
|
44
|
+
if (!parsed.success)
|
|
45
|
+
continue;
|
|
46
|
+
const record = parsed.data;
|
|
39
47
|
if (Buffer.byteLength(record.text, "utf8") > SECTION_CONTEXT_MAX_BYTES)
|
|
40
48
|
continue;
|
|
41
49
|
contexts[sectionContextKey(key)] = { text: record.text, updatedAt: record.updatedAt };
|
|
42
50
|
}
|
|
43
|
-
return { version: 1,
|
|
51
|
+
return { version: 1, sections: [...new Set([
|
|
52
|
+
...(candidate.data.sections ?? []), ...Object.keys(contexts),
|
|
53
|
+
].map(sectionContextKey).filter(Boolean))], contexts };
|
|
44
54
|
}
|
|
45
55
|
catch {
|
|
56
|
+
if (forWrite)
|
|
57
|
+
throw new Error("Saved teams and shared instructions could not be read; the existing file was left unchanged");
|
|
46
58
|
return emptyFile();
|
|
47
59
|
}
|
|
48
60
|
}
|
|
61
|
+
function saveFile(data) {
|
|
62
|
+
mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
|
|
63
|
+
writeFileAtomic(SECTION_CONTEXTS_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
64
|
+
}
|
|
65
|
+
export function readSections() {
|
|
66
|
+
return loadFile().sections;
|
|
67
|
+
}
|
|
68
|
+
/** Remember legacy labels and newly created teams without changing membership. */
|
|
69
|
+
export function ensureSections(names) {
|
|
70
|
+
const data = loadFile(true);
|
|
71
|
+
const sections = [...new Set([...data.sections, ...names.map(sectionContextKey).filter(Boolean)])];
|
|
72
|
+
if (sections.length === data.sections.length)
|
|
73
|
+
return false;
|
|
74
|
+
saveFile({ ...data, sections });
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
/** The caller checks that no bots or group chats still use this identity. */
|
|
78
|
+
export function changeEmptySection(name, nextName) {
|
|
79
|
+
const data = loadFile(true);
|
|
80
|
+
const index = data.sections.indexOf(name);
|
|
81
|
+
if (index < 0)
|
|
82
|
+
throw new Error("No such team");
|
|
83
|
+
if (nextName !== null && nextName !== name && data.sections.includes(nextName)) {
|
|
84
|
+
throw new Error("A team with that name already exists");
|
|
85
|
+
}
|
|
86
|
+
if (nextName === name)
|
|
87
|
+
return;
|
|
88
|
+
if (nextName === null)
|
|
89
|
+
data.sections.splice(index, 1);
|
|
90
|
+
else
|
|
91
|
+
data.sections[index] = nextName;
|
|
92
|
+
const context = data.contexts[name];
|
|
93
|
+
delete data.contexts[name];
|
|
94
|
+
if (nextName !== null && context)
|
|
95
|
+
data.contexts[nextName] = context;
|
|
96
|
+
saveFile(data);
|
|
97
|
+
}
|
|
49
98
|
export function readSectionContext(section) {
|
|
50
99
|
const record = loadFile().contexts[sectionContextKey(section)];
|
|
51
100
|
return record ? { ...record } : null;
|
|
@@ -56,16 +105,17 @@ export function writeSectionContext(section, text, now = Date.now()) {
|
|
|
56
105
|
if (Buffer.byteLength(text, "utf8") > SECTION_CONTEXT_MAX_BYTES) {
|
|
57
106
|
throw new Error(`section context is capped at ${SECTION_CONTEXT_MAX_BYTES} bytes`);
|
|
58
107
|
}
|
|
59
|
-
const data = loadFile();
|
|
108
|
+
const data = loadFile(true);
|
|
60
109
|
const key = sectionContextKey(section);
|
|
110
|
+
if (key && !data.sections.includes(key))
|
|
111
|
+
data.sections.push(key);
|
|
61
112
|
if (!text.trim()) {
|
|
62
113
|
delete data.contexts[key];
|
|
63
114
|
}
|
|
64
115
|
else {
|
|
65
116
|
data.contexts[key] = { text, updatedAt: now };
|
|
66
117
|
}
|
|
67
|
-
|
|
68
|
-
writeFileAtomic(SECTION_CONTEXTS_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
118
|
+
saveFile(data);
|
|
69
119
|
return data.contexts[key] ? { ...data.contexts[key] } : null;
|
|
70
120
|
}
|
|
71
121
|
/** A bounded, explicitly lower-priority reference block. It contains no file
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Setup mode:
|
|
2
|
-
//
|
|
1
|
+
// Setup mode: optional coaching when the user asks for it with /setup.
|
|
2
|
+
// An empty profile is not a request to interview the user. The bot says
|
|
3
3
|
// what it intends, and then configures itself only through proposal cards
|
|
4
4
|
// (propose_profile, propose_routine, skill_manage, request_credential) — so
|
|
5
5
|
// nothing changes without the user's approval. Mirrors skill-learn.ts:
|
|
@@ -29,11 +29,10 @@ export function expandSetupTurnText(userText) {
|
|
|
29
29
|
? `Set yourself up for this job: ${setup.request}`
|
|
30
30
|
: "Set yourself up. Ask me what you need to know, then propose your configuration.";
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
32
|
+
/** Only an explicit setup request enters coaching. Existing/blank bots must
|
|
33
|
+
* still do ordinary work, including delegated and scheduled requests. */
|
|
34
34
|
export function setupModeActive(input) {
|
|
35
|
-
|
|
36
|
-
return blank || parseSetupCommand(input.text) !== null;
|
|
35
|
+
return parseSetupCommand(input.text) !== null;
|
|
37
36
|
}
|
|
38
37
|
// skill_manage is only ever mounted alongside the other agent tools when
|
|
39
38
|
// skill authoring is turned on for this turn (OMB_SKILL_AUTHORING_ENABLED);
|
|
@@ -46,7 +45,7 @@ function folderClause(cwd) {
|
|
|
46
45
|
: "which folder on this computer it should work in (today it has none and works in a private workspace; offer to keep that, or ask for a path)";
|
|
47
46
|
}
|
|
48
47
|
function buildSetupPrompt(profileAside, cwd) {
|
|
49
|
-
return ("\n\
|
|
48
|
+
return ("\n\nThe user explicitly asked you to set yourself up. For this setup request, help configure the bot from what the user tells you." +
|
|
50
49
|
` First ask at most four questions that change what you would build: what the job is, when it should happen (on demand, on a schedule, or when something arrives), which apps or accounts it touches, and ${folderClause(cwd)}.` +
|
|
51
50
|
" Then, before any tool call, tell the user in plain language what you intend: who you will be, what you will do and when, where you will work, what you will need from them, and what you will not do. Wait for a yes." +
|
|
52
51
|
" When they say yes, first send one message that lists the cards you are about to raise, then make the tool calls — the cards must appear after that message, never before it. After the tool calls add at most one short line and do not repeat the list." +
|
|
@@ -6,6 +6,7 @@ import { createHash } from "node:crypto";
|
|
|
6
6
|
import { existsSync, readFileSync, mkdirSync, rmSync, unlinkSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { writeFileAtomic } from "./atomic.js";
|
|
9
|
+
import { ensureSections, readSections, changeEmptySection } from "./section-context.js";
|
|
9
10
|
import { removeBotFolder, soulFile, soulHash, writeSoulMirror } from "./bot-folder.js";
|
|
10
11
|
import { peerAllowKey } from "./peer-approval-key.js";
|
|
11
12
|
import { DATA_DIR, loadBrowserProfileIdAliases } from "./config.js";
|
|
@@ -72,6 +73,25 @@ function redactBotAuthored(message) {
|
|
|
72
73
|
card.summary = redactSecretsInText(card.summary);
|
|
73
74
|
if (typeof card.held === "string")
|
|
74
75
|
card.held = redactSecretsInText(card.held);
|
|
76
|
+
if (typeof card.answeredText === "string")
|
|
77
|
+
card.answeredText = redactSecretsInText(card.answeredText);
|
|
78
|
+
// Bot-authored question text sits behind the subtitle the same way a
|
|
79
|
+
// routine's instructions do, so it is scrubbed on the same boundary.
|
|
80
|
+
if (card.questionRequest) {
|
|
81
|
+
card.questionRequest = {
|
|
82
|
+
...card.questionRequest,
|
|
83
|
+
questions: card.questionRequest.questions.map((question) => ({
|
|
84
|
+
...question,
|
|
85
|
+
question: redactSecretsInText(question.question),
|
|
86
|
+
...(question.header ? { header: redactSecretsInText(question.header) } : {}),
|
|
87
|
+
options: question.options.map((option) => ({
|
|
88
|
+
...option,
|
|
89
|
+
label: redactSecretsInText(option.label),
|
|
90
|
+
...(option.description ? { description: redactSecretsInText(option.description) } : {}),
|
|
91
|
+
})),
|
|
92
|
+
})),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
75
95
|
// Routine definitions are executable bot-authored text stored behind the
|
|
76
96
|
// visible summary. Scrub the durable payload too so nesting it on a card
|
|
77
97
|
// cannot bypass the transcript's secret-redaction boundary.
|
|
@@ -275,6 +295,8 @@ export class Store {
|
|
|
275
295
|
threads = new Map();
|
|
276
296
|
defaultSelection;
|
|
277
297
|
listeners = new Set();
|
|
298
|
+
/** A broken team registry must not prevent loading independent chat data. */
|
|
299
|
+
registeringInitialSections = true;
|
|
278
300
|
/** Room turns and old callers have their own activity slot. Clearing
|
|
279
301
|
* that slot must not clear a concurrently running independent task. */
|
|
280
302
|
legacyActivities = new Map();
|
|
@@ -293,6 +315,7 @@ export class Store {
|
|
|
293
315
|
catch {
|
|
294
316
|
this.groups = [];
|
|
295
317
|
}
|
|
318
|
+
this.rememberSections([...this.bots, ...this.groups].map((record) => record.section));
|
|
296
319
|
// busy never survives a restart — no turn does either. Rooms saved
|
|
297
320
|
// before default responders existed adopt their first member as lead.
|
|
298
321
|
let botsMigrated = false;
|
|
@@ -340,6 +363,11 @@ export class Store {
|
|
|
340
363
|
delete b.autoStartVps;
|
|
341
364
|
botsMigrated = true;
|
|
342
365
|
}
|
|
366
|
+
if (b.managedSections !== undefined && (!b.chiefOfStaff || !Array.isArray(b.managedSections) ||
|
|
367
|
+
b.managedSections.length > 100 || b.managedSections.some(section => typeof section !== "string" || section.length > 60))) {
|
|
368
|
+
delete b.managedSections;
|
|
369
|
+
botsMigrated = true;
|
|
370
|
+
}
|
|
343
371
|
if (b.approvalMode !== undefined && !isApprovalMode(b.approvalMode)) {
|
|
344
372
|
delete b.approvalMode;
|
|
345
373
|
botsMigrated = true;
|
|
@@ -384,6 +412,7 @@ export class Store {
|
|
|
384
412
|
continue;
|
|
385
413
|
}
|
|
386
414
|
b.chiefOfStaff = false;
|
|
415
|
+
delete b.managedSections;
|
|
387
416
|
botsMigrated = true;
|
|
388
417
|
}
|
|
389
418
|
// Peer grants originally used mutable display names (ask_bot:@Helper).
|
|
@@ -528,16 +557,59 @@ export class Store {
|
|
|
528
557
|
if (existsSync(legacyFile))
|
|
529
558
|
mdb.readThread(threadId, legacyFile);
|
|
530
559
|
}
|
|
560
|
+
this.registeringInitialSections = false;
|
|
531
561
|
}
|
|
532
562
|
saveBots(bots = this.bots) {
|
|
563
|
+
this.rememberSections([...this.bots, ...bots].map((bot) => bot.section));
|
|
533
564
|
writeFileAtomic(BOTS_FILE, JSON.stringify(bots.map(({ busy: _busy, activity: _activity, ...bot }) => ({
|
|
534
565
|
...bot,
|
|
535
566
|
tasks: bot.tasks?.map(({ busy: _taskBusy, activity: _taskActivity, ...task }) => task),
|
|
536
567
|
})), null, 2));
|
|
537
568
|
}
|
|
538
569
|
saveGroups() {
|
|
570
|
+
this.rememberSections(this.groups.map((group) => group.section));
|
|
539
571
|
writeFileAtomic(GROUPS_FILE, JSON.stringify(this.groups.map(({ busyBotId: _busyBotId, ...g }) => g), null, 2));
|
|
540
572
|
}
|
|
573
|
+
get sections() { return readSections(); }
|
|
574
|
+
rememberSections(names) {
|
|
575
|
+
try {
|
|
576
|
+
if (ensureSections(names))
|
|
577
|
+
this.emit({ type: "sections" });
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
if (!this.registeringInitialSections)
|
|
581
|
+
throw error;
|
|
582
|
+
console.warn(`[teams] Startup could not register team names; saved teams and shared instructions were left unchanged: ${error.message}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/** Empty-only changes cannot merge teams or silently change anybody's access. */
|
|
586
|
+
changeEmptySection(name, nextName) {
|
|
587
|
+
if (!this.sections.includes(name))
|
|
588
|
+
return "No such team";
|
|
589
|
+
if ([...this.bots, ...this.groups].some((record) => sectionKey(record.section) === name)) {
|
|
590
|
+
return "Move all bots (including archived bots) and group chats out of this team first";
|
|
591
|
+
}
|
|
592
|
+
if (nextName !== null && nextName !== name && this.sections.includes(nextName)) {
|
|
593
|
+
return "A team with that name already exists";
|
|
594
|
+
}
|
|
595
|
+
if (nextName === name)
|
|
596
|
+
return undefined;
|
|
597
|
+
const revoked = this.bots.filter((bot) => bot.managedSections?.some((section) => sectionKey(section) === name));
|
|
598
|
+
if (revoked.length) {
|
|
599
|
+
const grants = new Map(revoked.map((bot) => [bot.id, bot.managedSections.filter((section) => sectionKey(section) !== name)]));
|
|
600
|
+
// Revoke durably before freeing the name. If the registry write then
|
|
601
|
+
// fails, authority stays narrowed; recreating a name can never revive
|
|
602
|
+
// its old grants. Update existing objects so in-flight checks see it.
|
|
603
|
+
this.saveBots(this.bots.map((bot) => grants.has(bot.id) ? { ...bot, managedSections: grants.get(bot.id) } : bot));
|
|
604
|
+
for (const bot of revoked)
|
|
605
|
+
bot.managedSections = grants.get(bot.id);
|
|
606
|
+
for (const bot of revoked)
|
|
607
|
+
this.emit({ type: "bot", botId: bot.id });
|
|
608
|
+
}
|
|
609
|
+
changeEmptySection(name, nextName);
|
|
610
|
+
this.emit({ type: "sections" });
|
|
611
|
+
return undefined;
|
|
612
|
+
}
|
|
541
613
|
// ── groups ────────────────────────────────────────────────────────────
|
|
542
614
|
/** Subscribe to every write. Listeners run after the write and after
|
|
543
615
|
* save; a throwing listener never breaks the write. */
|
|
@@ -562,6 +634,7 @@ export class Store {
|
|
|
562
634
|
return this.groups.find((group) => group.threadId === threadId || group.tasks?.some((task) => task.threadId === threadId));
|
|
563
635
|
}
|
|
564
636
|
createGroup(name, memberIds, dm = false, section, setup) {
|
|
637
|
+
this.rememberSections([section]);
|
|
565
638
|
const threadId = newId();
|
|
566
639
|
const createdAt = Date.now();
|
|
567
640
|
const group = {
|
|
@@ -597,6 +670,9 @@ export class Store {
|
|
|
597
670
|
const group = this.group(id);
|
|
598
671
|
if (!group)
|
|
599
672
|
return null;
|
|
673
|
+
if (Object.prototype.hasOwnProperty.call(patch, "section")) {
|
|
674
|
+
this.rememberSections([patch.section]);
|
|
675
|
+
}
|
|
600
676
|
Object.assign(group, patch);
|
|
601
677
|
if (!group.dm && Object.prototype.hasOwnProperty.call(patch, "pinnedMessageId")) {
|
|
602
678
|
const active = this.activeGroupTask(group.id);
|
|
@@ -763,12 +839,20 @@ export class Store {
|
|
|
763
839
|
return this.patchMessage(threadId, messageId, { reactions: next.length ? next : undefined });
|
|
764
840
|
}
|
|
765
841
|
thread(threadId) {
|
|
766
|
-
|
|
842
|
+
const t = this.threads.get(threadId);
|
|
767
843
|
if (t)
|
|
768
844
|
return t;
|
|
769
845
|
// SQLite is the source of truth; a thread with no rows imports its
|
|
770
846
|
// legacy messages-<threadId>.json once, inside readThread
|
|
771
|
-
|
|
847
|
+
return this.cacheThread(threadId, mdb.readThread(threadId, messagesFile(threadId)));
|
|
848
|
+
}
|
|
849
|
+
/** Finish hydrating a full set of thread rows into the cache: chain any
|
|
850
|
+
* legacy (pre-branching) rows' parentId in array order, default the
|
|
851
|
+
* active leaf to the newest message, and store it. Shared by a full load
|
|
852
|
+
* and by messagesTail() when its bounded read turns out to be the whole
|
|
853
|
+
* thread anyway. */
|
|
854
|
+
cacheThread(threadId, rows) {
|
|
855
|
+
const { messages, activeLeafId: storedLeaf } = rows;
|
|
772
856
|
let activeLeafId = storedLeaf;
|
|
773
857
|
// legacy rows carry no parentId — chain them in array order
|
|
774
858
|
let prev = null;
|
|
@@ -779,13 +863,44 @@ export class Store {
|
|
|
779
863
|
}
|
|
780
864
|
if (!activeLeafId)
|
|
781
865
|
activeLeafId = messages.at(-1)?.id ?? null;
|
|
782
|
-
t = { messages, activeLeafId };
|
|
866
|
+
const t = { messages, activeLeafId };
|
|
783
867
|
this.threads.set(threadId, t);
|
|
784
868
|
return t;
|
|
785
869
|
}
|
|
786
870
|
messagesFor(threadId) {
|
|
787
871
|
return this.thread(threadId).messages;
|
|
788
872
|
}
|
|
873
|
+
/** A bounded page of a thread's newest messages, for callers that only
|
|
874
|
+
* need a display page — the startup/reconnect hydrate and a fresh
|
|
875
|
+
* scrollback view. Reads just `limit` rows at the SQL boundary instead of
|
|
876
|
+
* the whole transcript, unless the thread is already cached from other
|
|
877
|
+
* work (then it's a plain in-memory slice, no extra SQL) or the bounded
|
|
878
|
+
* read comes back as the complete thread anyway (short thread, or a
|
|
879
|
+
* one-time legacy import) — that gets cached like any other full load so
|
|
880
|
+
* a later messagesFor() doesn't re-read it. Legacy rows that predate
|
|
881
|
+
* per-message parentId are only chained correctly on a full load, so a
|
|
882
|
+
* bounded page missing that context falls back to one rather than
|
|
883
|
+
* returning messages with a broken parent chain. */
|
|
884
|
+
messagesTail(threadId, limit) {
|
|
885
|
+
let state = this.threads.get(threadId);
|
|
886
|
+
if (!state) {
|
|
887
|
+
const tail = mdb.readThreadTail(threadId, messagesFile(threadId), limit);
|
|
888
|
+
const legacyRows = tail.hasMore !== undefined && tail.messages.some((m) => m.parentId === undefined);
|
|
889
|
+
if (tail.hasMore !== true || legacyRows) {
|
|
890
|
+
state = this.cacheThread(threadId, legacyRows ? mdb.readThread(threadId, messagesFile(threadId)) : tail);
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
return {
|
|
894
|
+
messages: tail.messages,
|
|
895
|
+
hasMore: tail.hasMore,
|
|
896
|
+
activeLeafId: tail.activeLeafId ?? tail.messages.at(-1)?.id ?? null,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
const { messages, activeLeafId } = state;
|
|
901
|
+
const start = Math.max(0, messages.length - limit);
|
|
902
|
+
return { messages: messages.slice(start), hasMore: start > 0, activeLeafId };
|
|
903
|
+
}
|
|
789
904
|
/** Used only with newly allocated import threads. No live actions are
|
|
790
905
|
* replayed: the importer supplies inert text and freshly remapped IDs. */
|
|
791
906
|
importTranscript(threadId, messages, activeLeafId) {
|
|
@@ -963,6 +1078,7 @@ export class Store {
|
|
|
963
1078
|
return this.bots.find((b) => b.threadId === threadId || b.tasks?.some((t) => t.threadId === threadId)) ?? null;
|
|
964
1079
|
}
|
|
965
1080
|
createBot(profile = {}, opts = {}) {
|
|
1081
|
+
this.rememberSections([profile.section]);
|
|
966
1082
|
const name = profile.name?.trim() || pickBotName(this.bots.map((b) => b.name));
|
|
967
1083
|
const section = sectionKey(profile.section);
|
|
968
1084
|
const bot = {
|
|
@@ -1018,11 +1134,103 @@ export class Store {
|
|
|
1018
1134
|
}
|
|
1019
1135
|
return bot;
|
|
1020
1136
|
}
|
|
1021
|
-
|
|
1137
|
+
/** All setup fields and the Chief's receipt commit before publishing any
|
|
1138
|
+
* mutation. Model defaults never rewrite saved thread selections. */
|
|
1139
|
+
applyTeamSetup(request) {
|
|
1140
|
+
const chief = this.bot(request.botId);
|
|
1141
|
+
if (!chief)
|
|
1142
|
+
throw new Error("The requesting Chief no longer exists");
|
|
1143
|
+
if (chief.lastTeamSetupReceipt?.requestId === request.requestId)
|
|
1144
|
+
return chief.lastTeamSetupReceipt.result;
|
|
1145
|
+
const managedSections = [...new Set([...(chief.managedSections ?? []), ...request.newTeams])];
|
|
1146
|
+
if (managedSections.length > 100 || managedSections.some((name) => name.trim() !== name || name.length > 60) ||
|
|
1147
|
+
request.newTeams.some((name) => !name) || (request.newTeams.length && !chief.chiefOfStaff))
|
|
1148
|
+
throw new Error("Invalid reviewed Chief team scope");
|
|
1149
|
+
const nextBots = [...this.bots];
|
|
1150
|
+
const changed = [];
|
|
1151
|
+
for (const operation of request.operations) {
|
|
1152
|
+
const at = nextBots.findIndex((bot) => bot.id === operation.botId);
|
|
1153
|
+
let next;
|
|
1154
|
+
if (operation.action === "create") {
|
|
1155
|
+
if (at >= 0 || !operation.threadId || !operation.fields.name || !operation.fields.modelSelection)
|
|
1156
|
+
throw new Error("Invalid new bot in team setup");
|
|
1157
|
+
const createdAt = Date.now();
|
|
1158
|
+
next = { id: operation.botId, threadId: operation.threadId, name: operation.fields.name,
|
|
1159
|
+
title: "", description: "", soul: "", notifications: true, color: COLORS[nextBots.length % COLORS.length], unread: false,
|
|
1160
|
+
modelSelection: operation.fields.modelSelection, resumeCursors: {}, createdAt, ...operation.fields,
|
|
1161
|
+
approvalMode: "ask", autoApprove: false, composio: false, approvePeerComms: false,
|
|
1162
|
+
tasks: [{ threadId: operation.threadId, title: UNTITLED_THREAD, createdAt, resumeCursors: {},
|
|
1163
|
+
modelSelection: structuredClone(operation.fields.modelSelection), approvalMode: "ask", autoApprove: false,
|
|
1164
|
+
unread: false, activity: "idle", busy: false }],
|
|
1165
|
+
};
|
|
1166
|
+
nextBots.unshift(next);
|
|
1167
|
+
}
|
|
1168
|
+
else {
|
|
1169
|
+
if (at < 0)
|
|
1170
|
+
throw new Error("A setup target no longer exists");
|
|
1171
|
+
const previous = nextBots[at];
|
|
1172
|
+
next = { ...previous, ...operation.fields };
|
|
1173
|
+
if (operation.fields.modelSelection)
|
|
1174
|
+
next.tasks = previous.tasks?.map((task) => ({
|
|
1175
|
+
...task,
|
|
1176
|
+
modelSelection: structuredClone(task.modelSelection ?? previous.modelSelection),
|
|
1177
|
+
approvalMode: approvalModeFor(this.projectBotForTask(previous.id, task.threadId)),
|
|
1178
|
+
autoApprove: task.autoApprove ?? previous.autoApprove,
|
|
1179
|
+
alwaysAllow: structuredClone(task.alwaysAllow ?? previous.alwaysAllow ?? []),
|
|
1180
|
+
}));
|
|
1181
|
+
nextBots[at] = next;
|
|
1182
|
+
}
|
|
1183
|
+
next.section = sectionKey(next.section) || undefined;
|
|
1184
|
+
if (operation.fields.soul !== undefined) {
|
|
1185
|
+
next.soulHash = soulHash(operation.fields.soul);
|
|
1186
|
+
next.soulDrift = false;
|
|
1187
|
+
}
|
|
1188
|
+
changed.push(next);
|
|
1189
|
+
}
|
|
1190
|
+
const result = { state: "applied", newTeams: request.newTeams, bots: changed.map((bot, index) => ({
|
|
1191
|
+
id: bot.id, name: bot.name, section: bot.section, modelSelection: structuredClone(bot.modelSelection),
|
|
1192
|
+
action: request.operations[index].action === "create" ? "created" : "updated",
|
|
1193
|
+
})) };
|
|
1194
|
+
const chiefAt = nextBots.findIndex((bot) => bot.id === chief.id);
|
|
1195
|
+
const nextChief = { ...nextBots[chiefAt], lastTeamSetupReceipt: { requestId: request.requestId, result } };
|
|
1196
|
+
// Only the newly-created teams explicitly named in the human review may
|
|
1197
|
+
// extend this Chief's reach. Existing teams require owner settings.
|
|
1198
|
+
if (request.newTeams.length) {
|
|
1199
|
+
nextChief.managedSections = managedSections;
|
|
1200
|
+
}
|
|
1201
|
+
nextBots[chiefAt] = nextChief;
|
|
1202
|
+
this.saveBots(nextBots);
|
|
1203
|
+
this.bots = nextBots;
|
|
1204
|
+
for (const bot of changed) {
|
|
1205
|
+
try {
|
|
1206
|
+
writeSoulMirror(bot.id, bot.soul ?? "");
|
|
1207
|
+
}
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
console.warn(`[bot-folder] could not refresh reviewed setup mirror for ${bot.id}: ${error.message}`);
|
|
1210
|
+
}
|
|
1211
|
+
this.emit({ type: "bot", botId: bot.id });
|
|
1212
|
+
}
|
|
1213
|
+
this.emit({ type: "bot", botId: chief.id });
|
|
1214
|
+
return result;
|
|
1215
|
+
}
|
|
1216
|
+
deleteBot(id, setupRequest) {
|
|
1022
1217
|
const bot = this.bot(id);
|
|
1023
1218
|
if (!bot)
|
|
1024
1219
|
return false;
|
|
1025
|
-
|
|
1220
|
+
let nextBots = this.bots.filter((b) => b.id !== id);
|
|
1221
|
+
if (setupRequest) {
|
|
1222
|
+
const chief = this.bot(setupRequest.botId);
|
|
1223
|
+
if (!chief || chief.id === id || setupRequest.deletion?.botId !== id)
|
|
1224
|
+
throw new Error("The reviewed deletion no longer has a valid owner");
|
|
1225
|
+
const lastTeamSetupReceipt = { requestId: setupRequest.requestId, result: { state: "applied", newTeams: [], bots: [
|
|
1226
|
+
{ id: bot.id, name: bot.name, action: "deleted" },
|
|
1227
|
+
] } };
|
|
1228
|
+
nextBots = nextBots.map((candidate) => candidate.id === chief.id ? { ...candidate, lastTeamSetupReceipt } : candidate);
|
|
1229
|
+
}
|
|
1230
|
+
// Persist removal and the review receipt before deleting conversation or
|
|
1231
|
+
// workspace data. A failed save must leave the bot recoverable in place.
|
|
1232
|
+
this.saveBots(nextBots);
|
|
1233
|
+
this.bots = nextBots;
|
|
1026
1234
|
this.legacyActivities.delete(id);
|
|
1027
1235
|
// every task's transcript goes with the bot, not just the open one
|
|
1028
1236
|
for (const threadId of new Set([bot.threadId, ...(bot.tasks ?? []).map((t) => t.threadId)])) {
|
|
@@ -1045,7 +1253,6 @@ export class Store {
|
|
|
1045
1253
|
catch { }
|
|
1046
1254
|
// The bot folder (SOUL.md mirror) is the bot's too.
|
|
1047
1255
|
removeBotFolder(id);
|
|
1048
|
-
this.saveBots();
|
|
1049
1256
|
this.emit({ type: "bot.deleted", botId: id });
|
|
1050
1257
|
return true;
|
|
1051
1258
|
}
|
|
@@ -1145,6 +1352,7 @@ export class Store {
|
|
|
1145
1352
|
for (const botId of changedIds)
|
|
1146
1353
|
this.emit({ type: "bot", botId });
|
|
1147
1354
|
}
|
|
1355
|
+
this.rememberSections([targetSection]);
|
|
1148
1356
|
return { ok: true, bots: ids.map((id) => this.bot(id)) };
|
|
1149
1357
|
}
|
|
1150
1358
|
/** Legacy bot/room activity occupies its own slot; direct conversations
|
|
@@ -1202,6 +1410,7 @@ export class Store {
|
|
|
1202
1410
|
}
|
|
1203
1411
|
else {
|
|
1204
1412
|
bot.chiefOfStaff = false;
|
|
1413
|
+
delete bot.managedSections;
|
|
1205
1414
|
}
|
|
1206
1415
|
changed.push(bot);
|
|
1207
1416
|
}
|
|
@@ -1441,6 +1650,37 @@ export class Store {
|
|
|
1441
1650
|
this.emit({ type: "bot", botId });
|
|
1442
1651
|
return task;
|
|
1443
1652
|
}
|
|
1653
|
+
/** Model/provider changes are one configuration transaction: never publish
|
|
1654
|
+
* a new provider before its confirmed approval downgrade, or change the
|
|
1655
|
+
* default while leaving the selected thread behind after a write failure. */
|
|
1656
|
+
switchTaskModel(botId, threadId, selection, updateBotDefault, resetApprovalToAsk, taskPatch = {}) {
|
|
1657
|
+
const bot = this.bot(botId);
|
|
1658
|
+
const task = this.taskByThread(botId, threadId);
|
|
1659
|
+
if (!bot || !task)
|
|
1660
|
+
return null;
|
|
1661
|
+
const patch = { modelSelection: structuredClone(selection),
|
|
1662
|
+
...(resetApprovalToAsk ? { approvalMode: "ask", autoApprove: false, alwaysAllow: [] } : {}) };
|
|
1663
|
+
const nextTask = { ...task, ...taskPatch, ...patch,
|
|
1664
|
+
...(typeof taskPatch.title === "string" ? { title: taskPatch.title.trim().slice(0, 80) || UNTITLED_THREAD } : {}) };
|
|
1665
|
+
// Older threads may still inherit settings. Freeze their effective
|
|
1666
|
+
// values before updating the default so "other threads unchanged" also
|
|
1667
|
+
// holds for workspaces created before per-thread approval settings.
|
|
1668
|
+
const nextTasks = bot.tasks.map((candidate) => candidate === task ? nextTask : !updateBotDefault ? candidate : {
|
|
1669
|
+
...candidate,
|
|
1670
|
+
modelSelection: structuredClone(candidate.modelSelection ?? bot.modelSelection),
|
|
1671
|
+
approvalMode: approvalModeFor(this.projectBotForTask(botId, candidate.threadId)),
|
|
1672
|
+
autoApprove: candidate.autoApprove ?? bot.autoApprove,
|
|
1673
|
+
alwaysAllow: structuredClone(candidate.alwaysAllow ?? bot.alwaysAllow ?? []),
|
|
1674
|
+
});
|
|
1675
|
+
const next = { ...bot, ...(updateBotDefault ? patch : {}),
|
|
1676
|
+
tasks: nextTasks };
|
|
1677
|
+
this.saveBots(this.bots.map((candidate) => candidate === bot ? next : candidate));
|
|
1678
|
+
bot.tasks.forEach((candidate, index) => Object.assign(candidate, nextTasks[index]));
|
|
1679
|
+
if (updateBotDefault)
|
|
1680
|
+
Object.assign(bot, patch);
|
|
1681
|
+
this.emit({ type: "bot", botId });
|
|
1682
|
+
return task;
|
|
1683
|
+
}
|
|
1444
1684
|
mirrorActiveTask(bot, task) {
|
|
1445
1685
|
bot.threadId = task.threadId;
|
|
1446
1686
|
bot.resumeCursors = structuredClone(task.resumeCursors);
|
|
@@ -1492,6 +1732,24 @@ export class Store {
|
|
|
1492
1732
|
this.emit({ type: "bot", botId });
|
|
1493
1733
|
return task;
|
|
1494
1734
|
}
|
|
1735
|
+
/** Stamp or clear the closer record. `null` reopens: the next turn in a
|
|
1736
|
+
* closed thread calls this so the row comes back to the sidebar. Never
|
|
1737
|
+
* reachable from the HTTP task PATCH: closedBy is not a TASK_PATCH_FIELD. */
|
|
1738
|
+
setTaskClosedBy(botId, threadId, closedBy) {
|
|
1739
|
+
const bot = this.bot(botId);
|
|
1740
|
+
const task = this.taskByThread(botId, threadId);
|
|
1741
|
+
if (!bot || !task)
|
|
1742
|
+
return null;
|
|
1743
|
+
if (closedBy)
|
|
1744
|
+
task.closedBy = structuredClone(closedBy);
|
|
1745
|
+
else if (!task.closedBy)
|
|
1746
|
+
return task;
|
|
1747
|
+
else
|
|
1748
|
+
delete task.closedBy;
|
|
1749
|
+
this.saveBots();
|
|
1750
|
+
this.emit({ type: "bot", botId });
|
|
1751
|
+
return task;
|
|
1752
|
+
}
|
|
1495
1753
|
switchTask(botId, threadId) {
|
|
1496
1754
|
const bot = this.bot(botId);
|
|
1497
1755
|
const task = bot?.tasks?.find((t) => t.threadId === threadId);
|
|
@@ -1515,10 +1773,10 @@ export class Store {
|
|
|
1515
1773
|
this.emit({ type: "bot", botId });
|
|
1516
1774
|
}
|
|
1517
1775
|
/** Delete a task and its transcript, retaining generated project files.
|
|
1518
|
-
*
|
|
1776
|
+
* When no visible tasks remain, replace it with a fresh conversation. */
|
|
1519
1777
|
deleteTask(botId, threadId) {
|
|
1520
1778
|
const bot = this.bot(botId);
|
|
1521
|
-
if (!bot
|
|
1779
|
+
if (!bot?.tasks)
|
|
1522
1780
|
return null;
|
|
1523
1781
|
if (!bot.tasks.some((t) => t.threadId === threadId))
|
|
1524
1782
|
return null;
|
|
@@ -19,14 +19,25 @@ export function surfaceOfComputerKind(kind) {
|
|
|
19
19
|
return kind;
|
|
20
20
|
}
|
|
21
21
|
const NO_BROWSER_NOTE = " This bot is set to work in the built-in browser, but the built-in browser is switched off in App Settings, so you have no browser and no computer this turn — say so instead of guessing.";
|
|
22
|
+
const OFF_NOTE = " This bot's \"Works on\" setting is Off, so no computer and no built-in browser are mounted this turn: you cannot open a page, click, or type on any screen. If the user asks for something that needs one, tell them Works on is Off in this bot's settings — never claim you are opening a browser you do not have.";
|
|
22
23
|
/** Decide what a turn mounts from the bot's destination, the task's pin
|
|
23
|
-
* and what is actually reachable. Explicit choices are strict:
|
|
24
|
-
* destination keeps whatever browser the bot
|
|
25
|
-
* model which to use), a browser destination
|
|
26
|
-
* follows the task's pin while the pinned
|
|
27
|
-
* re-resolves the way it always has. */
|
|
24
|
+
* and what is actually reachable. Explicit choices are strict: Off mounts
|
|
25
|
+
* neither surface, a computer destination keeps whatever browser the bot
|
|
26
|
+
* has (the prompt tells the model which to use), a browser destination
|
|
27
|
+
* mounts only the browser. Auto follows the task's pin while the pinned
|
|
28
|
+
* place still exists, and otherwise re-resolves the way it always has. */
|
|
28
29
|
export function resolveSurface(input) {
|
|
29
30
|
const { destination, browserOn } = input;
|
|
31
|
+
// Off is the whole answer: no computer and no browser. It used to withhold
|
|
32
|
+
// only the computer, which left a bot set to Off holding the built-in
|
|
33
|
+
// browser — the one surface the setting most obviously reads as forbidding,
|
|
34
|
+
// and the one people then watched it reach for. The overview ("Can't use a
|
|
35
|
+
// computer.") and the settings prompt preview already described Off this
|
|
36
|
+
// way; the dispatch was the odd one out. A bot that should keep the browser
|
|
37
|
+
// and nothing else has its own destination: Browser.
|
|
38
|
+
if (destination === "off") {
|
|
39
|
+
return { computer: "off", browser: false, pinned: null, clearPin: false, note: OFF_NOTE };
|
|
40
|
+
}
|
|
30
41
|
if (destination === "browser") {
|
|
31
42
|
return browserOn
|
|
32
43
|
? { computer: "off", browser: true, pinned: null, clearPin: false, note: "" }
|
|
@@ -66,6 +66,9 @@ export function createTeamBackup(store, routines, name) {
|
|
|
66
66
|
openedBy: "openedBy" in task && task.openedBy
|
|
67
67
|
? { botId: task.openedBy.botId, name: task.openedBy.name, at: task.openedBy.at }
|
|
68
68
|
: undefined,
|
|
69
|
+
closedBy: "closedBy" in task && task.closedBy
|
|
70
|
+
? { botId: task.closedBy.botId, name: task.closedBy.name, at: task.closedBy.at }
|
|
71
|
+
: undefined,
|
|
69
72
|
activeLeafId: store.activeLeaf(task.threadId),
|
|
70
73
|
messages: store.messagesFor(task.threadId).map((message) => ({
|
|
71
74
|
id: message.id, role: message.role, text: messageText(message), at: message.at,
|
|
@@ -130,7 +133,10 @@ export function importTeamBackup(store, routines, input, selection) {
|
|
|
130
133
|
const groupIds = new Map();
|
|
131
134
|
const takenNames = new Set(store.bots.map((bot) => bot.name.trim().toLowerCase()));
|
|
132
135
|
const takenGroups = new Set(store.groups.map((group) => group.name.trim().toLowerCase()));
|
|
133
|
-
const takenSections = new Set([
|
|
136
|
+
const takenSections = new Set([
|
|
137
|
+
...store.sections.map((section) => section.toLowerCase()),
|
|
138
|
+
...[...store.bots, ...store.groups].map((record) => record.section?.trim().toLowerCase() ?? ""),
|
|
139
|
+
]);
|
|
134
140
|
const sections = new Map();
|
|
135
141
|
const sectionFor = (section) => {
|
|
136
142
|
const key = section?.trim() ?? "";
|
|
@@ -182,6 +188,12 @@ export function importTeamBackup(store, routines, input, selection) {
|
|
|
182
188
|
const opener = task.openedBy && botIds.get(task.openedBy.botId);
|
|
183
189
|
if (task.openedBy && opener)
|
|
184
190
|
record.openedBy = { botId: opener, name: task.openedBy.name, at: task.openedBy.at };
|
|
191
|
+
// A closed thread stays closed after import — the pile the person
|
|
192
|
+
// tidied does not come back as a pile — with the closer remapped
|
|
193
|
+
// the same way, or absent when it was a stranger.
|
|
194
|
+
const closer = task.closedBy && botIds.get(task.closedBy.botId);
|
|
195
|
+
if (task.closedBy && closer)
|
|
196
|
+
record.closedBy = { botId: closer, name: task.closedBy.name, at: task.closedBy.at };
|
|
185
197
|
return record;
|
|
186
198
|
});
|
|
187
199
|
// Own the task IDs before writing their transcripts, so rollback also
|
|
@@ -228,6 +240,10 @@ export function importTeamBackup(store, routines, input, selection) {
|
|
|
228
240
|
for (const bot of store.bots)
|
|
229
241
|
if (!existingBotIds.has(bot.id))
|
|
230
242
|
store.deleteBot(bot.id);
|
|
243
|
+
for (const section of sections.values()) {
|
|
244
|
+
if (store.sections.includes(section))
|
|
245
|
+
store.changeEmptySection(section, null);
|
|
246
|
+
}
|
|
231
247
|
throw error;
|
|
232
248
|
}
|
|
233
249
|
}
|