openmausbot 0.1.85 → 0.1.86
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/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/abnfDiagram-O67JEVCF-DpUZNnFD.js +1 -0
- package/dist/assets/arc-DW7mrI4p.js +1 -0
- package/dist/assets/architectureDiagram-NJMV4G6O-CyeZ5WLZ.js +36 -0
- package/dist/assets/blockDiagram-BEXU5L5S-CBVaNjO7.js +140 -0
- package/dist/assets/c4Diagram-YGBWAQC7-sf8d41uT.js +38 -0
- package/dist/assets/channel-BXFkgus9.js +1 -0
- package/dist/assets/chunk-2Q5K7J3B-BOGvy7Y_.js +1 -0
- package/dist/assets/chunk-3FUC2YCW-BiF9zyiz.js +2 -0
- package/dist/assets/chunk-5VM5RSS4-EFuZ1SBZ.js +15 -0
- package/dist/assets/chunk-GTNCS2PH-pJLNSAT7.js +88 -0
- package/dist/assets/chunk-GWA4HPMP-Cb-3_3IT.js +1 -0
- package/dist/assets/chunk-JWPE2WC7-DAf5aogn.js +1 -0
- package/dist/assets/chunk-NETBCI7D-DR3kiwZz.js +1 -0
- package/dist/assets/chunk-WEXAMYUT-BmU8PeyM.js +1 -0
- package/dist/assets/chunk-XXDRQBXY-Buh8g8OT.js +1 -0
- package/dist/assets/classDiagram-v2-NBCMYWYE-BurfmMWM.js +217 -0
- package/dist/assets/cose-bilkent-JH36ORCC-KkGZUEkU.js +1 -0
- package/dist/assets/cynefin-OW5HDTMX-C3PpV96d.js +166 -0
- package/dist/assets/cynefinDiagram-VND7K2PF-Dt8JwJaI.js +62 -0
- package/dist/assets/cytoscape.esm-BB4DxJjf.js +321 -0
- package/dist/assets/dagre-6A5THRUB-DkjN8266.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-22UHCM2B-DUAJS0JV.js +200 -0
- package/dist/assets/diagram-3UASUU5V-Dw4KhF1a.js +24 -0
- package/dist/assets/diagram-ATOU4E4O-Dd1gmQqf.js +3 -0
- package/dist/assets/diagram-CDSNMT55-CjwGs0Yw.js +30 -0
- package/dist/assets/diagram-MLGK6HIB-BL-69Zrq.js +24 -0
- package/dist/assets/diagram-MPIPVDR6-5Zi2N7wo.js +41 -0
- package/dist/assets/ebnfDiagram-ZINNZB2B-D9NekWUX.js +1 -0
- package/dist/assets/elk-276RUBZZ-CkaSQjxw.js +27 -0
- package/dist/assets/erDiagram-OPXOYQCR-CdAVBV8-.js +99 -0
- package/dist/assets/flowDiagram-KWPJA3E3-Bm_lWWH7.js +213 -0
- package/dist/assets/ganttDiagram-FUAMR5RP-FZ8ejYWJ.js +292 -0
- package/dist/assets/gitGraphDiagram-X574FWY7-Dlv9rizZ.js +106 -0
- package/dist/assets/graph-D8UmR1vK.js +1 -0
- package/dist/assets/index-BGgqlWPJ.css +1 -0
- package/dist/assets/index-BtHo8ZKl.js +577 -0
- package/dist/assets/{index-CTNNoqSJ.js → index-h9FFNwTu.js} +1 -1
- package/dist/assets/infoDiagram-VRGFBTTK-BdQ_gW5B.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/ishikawaDiagram-OU5B5YK6-EwssEp2X.js +70 -0
- package/dist/assets/journeyDiagram-ZHPQQLJL-BX1G1vOd.js +139 -0
- package/dist/assets/kanban-definition-PNTS6WVX-BB4kAsyC.js +89 -0
- package/dist/assets/linear-3su2TG93.js +1 -0
- package/dist/assets/mermaid.core-CDD1E0s9.js +311 -0
- package/dist/assets/mindmap-definition-NLK3R4M7-Ba50MsNK.js +96 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pegDiagram-GJSIUBJH-DZLkkZlM.js +1 -0
- package/dist/assets/pieDiagram-5QR66LMP-DaTz77Uq.js +39 -0
- package/dist/assets/quadrantDiagram-O4NWA36T-CpaiidpR.js +7 -0
- package/dist/assets/railroadDiagram-XR7U4H2S-mN4JrMeI.js +1 -0
- package/dist/assets/requirementDiagram-PLB6GJNP-BSNDRpg7.js +84 -0
- package/dist/assets/sankeyDiagram-IPEJSGJF-FngzGNug.js +40 -0
- package/dist/assets/sequenceDiagram-PO4LG4MO-CM4pCC3L.js +169 -0
- package/dist/assets/sizeCapture-INFHLROL-CRICHXkw.js +1 -0
- package/dist/assets/stateDiagram-v2-GCMORJYK-oyTWpxH2.js +291 -0
- package/dist/assets/swimlanes-2SLR337P-D9h9mPBq.js +1 -0
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +8 -0
- package/dist/assets/timeline-definition-EJHVYXUP-BJUlN9bW.js +120 -0
- package/dist/assets/usecaseDiagram-POWQR4AR-3uBNkbAb.js +370 -0
- package/dist/assets/vennDiagram-UO4OBE2U-D2odalzT.js +34 -0
- package/dist/assets/wardleyDiagram-VNRHLVJA-DHgFaB9S.js +78 -0
- package/dist/assets/xychartDiagram-PMCCYNJV-w5GYM2h0.js +7 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +19 -10
- package/dist-server/drivers/agents-proxy.js +16159 -1596
- package/dist-server/enterprise/LICENSE +32 -0
- package/dist-server/enterprise/server/index.js +5 -5
- package/dist-server/index.js +4779 -1667
- package/dist-server/mcp-server.js +3 -1
- package/dist-server/openmausbot.js +4679 -4266
- package/dist-server/pair-cli.js +4679 -4266
- package/dist-server/server/admin-activity.js +410 -0
- package/dist-server/server/bot-visibility.js +462 -0
- package/dist-server/server/box.js +2 -1
- package/dist-server/server/browser-runtime.js +1 -1
- package/dist-server/server/channel-queue.js +2 -0
- package/dist-server/server/cli-prompts.js +24 -2
- package/dist-server/server/cli.js +79 -10
- package/dist-server/server/composio.js +49 -11
- package/dist-server/server/config.js +79 -13
- package/dist-server/server/decision-log.js +209 -48
- package/dist-server/server/default-model-selection.js +20 -3
- package/dist-server/server/delegations.js +11 -9
- package/dist-server/server/drivers/acp/core.js +110 -22
- package/dist-server/server/drivers/acp/grok.js +4 -3
- package/dist-server/server/drivers/agents-call.js +1123 -0
- package/dist-server/server/drivers/agents-catalog.js +659 -0
- package/dist-server/server/drivers/agents-client.js +28 -0
- package/dist-server/server/drivers/agents-proxy.js +18 -1722
- package/dist-server/server/drivers/chat-mcp-tools.js +17 -2
- package/dist-server/server/drivers/claude.js +21 -6
- package/dist-server/server/drivers/codex-mcp-names.js +44 -0
- package/dist-server/server/drivers/codex.js +26 -4
- package/dist-server/server/drivers/grok.js +2 -1
- package/dist-server/server/drivers/openai-compat.js +15 -8
- package/dist-server/server/enterprise.js +97 -16
- package/dist-server/server/external-runtime.js +83 -0
- package/dist-server/server/guarded-requests.js +60 -0
- package/dist-server/server/harness/http.js +61 -0
- package/dist-server/server/hosted-models.js +151 -0
- package/dist-server/server/hosted-slack.js +11 -0
- package/dist-server/server/index.js +1718 -348
- package/dist-server/server/managed-desktop.js +79 -14
- package/dist-server/server/managed-policy.js +153 -0
- package/dist-server/server/message-db.js +28 -0
- package/dist-server/server/model-context-window.js +7 -0
- package/dist-server/server/model-prices.js +121 -0
- package/dist-server/server/notify.js +10 -1
- package/dist-server/server/peer-roster.js +13 -2
- package/dist-server/server/recent-work.js +15 -0
- package/dist-server/server/request-auth.js +96 -0
- package/dist-server/server/room-handoffs.js +25 -11
- package/dist-server/server/routes/hosted-slack.js +20 -0
- package/dist-server/server/routes/table.js +14 -0
- package/dist-server/server/routines.js +22 -0
- package/dist-server/server/spend.js +111 -16
- package/dist-server/server/steer-queue.js +6 -1
- package/dist-server/server/store.js +292 -21
- package/dist-server/server/team-backup.js +10 -1
- package/dist-server/server/thread-starters.js +53 -0
- package/dist-server/server/usage-ledger.js +31 -8
- package/dist-server/server/workspace-backup-policy.js +9 -2
- package/dist-server/server/workspace-backup.js +7 -4
- package/dist-server/shared/mention-boundary.js +15 -0
- package/dist-server/shared/routines.js +7 -1
- package/dist-server/shared/team-backup.js +2 -0
- package/dist-server/src/lib/composer-attachments.js +16 -0
- package/dist-server/vps-container-mcp.js +19 -10
- package/enterprise/README.md +33 -12
- package/enterprise/server/index.js +5 -5
- package/package.json +1 -1
- package/dist/assets/index-Ba9G44HI.js +0 -310
- package/dist/assets/index-CkRNp7wX.css +0 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ProviderSessionDirectory, recipe step 6: persist the binding from day
|
|
4
4
|
// one). messages-<threadId>.json holds the folded transcript.
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
-
import { existsSync, readFileSync, mkdirSync, rmSync, unlinkSync } from "node:fs";
|
|
6
|
+
import { chmodSync, existsSync, readFileSync, mkdirSync, rmSync, statSync, unlinkSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { writeFileAtomic } from "./atomic.js";
|
|
9
9
|
import { ensureSections, readSections, changeEmptySection } from "./section-context.js";
|
|
@@ -18,6 +18,7 @@ import { pickBotName } from "./names.js";
|
|
|
18
18
|
import { redactSecretsInText } from "./redact.js";
|
|
19
19
|
import { botAvatarProfile } from "../shared/bot-avatar.js";
|
|
20
20
|
import { approvalModeFor, isApprovalMode } from "../shared/approval-mode.js";
|
|
21
|
+
import { isMentionBoundary, isMentionNameContinuation } from "../shared/mention-boundary.js";
|
|
21
22
|
export const groupWireProjectionIsExact = true;
|
|
22
23
|
// Unicode's complete emoji sequences include flags, skin tones and ZWJ
|
|
23
24
|
// combinations. Also allow unqualified single symbols (e.g. ♥), but not
|
|
@@ -35,9 +36,18 @@ export function toWireTask(task) {
|
|
|
35
36
|
}
|
|
36
37
|
const TASK_PATCH_FIELDS = [
|
|
37
38
|
"title", "projectId", "modelSelection", "approvalMode", "autoApprove", "alwaysAllow",
|
|
38
|
-
"unread", "rewound", "archivedAt", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
|
|
39
|
+
"unread", "rewound", "archivedAt", "pinned", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
|
|
39
40
|
"routineRunId", "surface", "appliedCompactionId", "contextFloor", "lastContextModel",
|
|
40
41
|
];
|
|
42
|
+
/** Only `true` is a pin. false/undefined must not land in bots.json or groups.json. */
|
|
43
|
+
function persistedPin(task) {
|
|
44
|
+
if (task.pinned === true)
|
|
45
|
+
return task;
|
|
46
|
+
if (!("pinned" in task))
|
|
47
|
+
return task;
|
|
48
|
+
const { pinned: _pinned, ...rest } = task;
|
|
49
|
+
return rest;
|
|
50
|
+
}
|
|
41
51
|
/** Everything the BOT authored is scrubbed of content-shaped secrets before
|
|
42
52
|
* it is stored: its reply text, a tool title (an ACP engine's title can be
|
|
43
53
|
* the whole command line) and the command beside it, a permission card's
|
|
@@ -254,6 +264,22 @@ export function titleFromLlm(raw) {
|
|
|
254
264
|
export const botWireProjectionIsExact = true;
|
|
255
265
|
const BOTS_FILE = join(DATA_DIR, "bots.json");
|
|
256
266
|
const GROUPS_FILE = join(DATA_DIR, "groups.json");
|
|
267
|
+
/** The registries carry souls, project paths and per-bot settings, so they are
|
|
268
|
+
* owner-only like the other data-dir stores. A file written by an older
|
|
269
|
+
* release (or loosened by hand) is tightened on load. Best effort: failing to
|
|
270
|
+
* tighten must never stop the fleet from loading, and the next save replaces
|
|
271
|
+
* the file with a 0600 one anyway. Windows has no POSIX mode bits. */
|
|
272
|
+
function tightenRegistryFile(file) {
|
|
273
|
+
if (process.platform === "win32")
|
|
274
|
+
return;
|
|
275
|
+
try {
|
|
276
|
+
if ((statSync(file).mode & 0o077) !== 0)
|
|
277
|
+
chmodSync(file, 0o600);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
/* absent, or not ours to change */
|
|
281
|
+
}
|
|
282
|
+
}
|
|
257
283
|
const messagesFile = (threadId) => join(DATA_DIR, `messages-${threadId}.json`);
|
|
258
284
|
const COLORS = [
|
|
259
285
|
"green",
|
|
@@ -283,15 +309,15 @@ export function mentionedBots(text, peers) {
|
|
|
283
309
|
const found = [];
|
|
284
310
|
let at = -1;
|
|
285
311
|
while ((at = lower.indexOf("@", at + 1)) !== -1) {
|
|
286
|
-
if (
|
|
312
|
+
if (!isMentionBoundary(text, at))
|
|
287
313
|
continue; // user@host, not a tag
|
|
288
314
|
const rest = lower.slice(at + 1);
|
|
289
315
|
const hit = candidates.find((p) => {
|
|
290
316
|
const name = p.name.toLowerCase();
|
|
291
317
|
if (!rest.startsWith(name))
|
|
292
318
|
return false;
|
|
293
|
-
const after = rest
|
|
294
|
-
return
|
|
319
|
+
const after = rest.slice(name.length); // must not run into a longer word
|
|
320
|
+
return !isMentionNameContinuation(after);
|
|
295
321
|
});
|
|
296
322
|
if (hit && !found.includes(hit))
|
|
297
323
|
found.push(hit);
|
|
@@ -324,8 +350,15 @@ export function normalizeGroupDefaultResponder(value, memberIds, dm = false) {
|
|
|
324
350
|
* otherwise the room policy chooses one member, everyone, or nobody. */
|
|
325
351
|
export function roomResponders(text, members, defaultResponder) {
|
|
326
352
|
const available = members.filter((member) => !member.hidden);
|
|
327
|
-
|
|
328
|
-
|
|
353
|
+
const everyone = "everyone";
|
|
354
|
+
for (let at = text.indexOf("@"); at !== -1; at = text.indexOf("@", at + 1)) {
|
|
355
|
+
const candidate = text.slice(at + 1, at + 1 + everyone.length);
|
|
356
|
+
if (isMentionBoundary(text, at) &&
|
|
357
|
+
candidate.toLocaleLowerCase() === everyone &&
|
|
358
|
+
!isMentionNameContinuation(text.slice(at + 1 + everyone.length))) {
|
|
359
|
+
return available;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
329
362
|
const mentioned = mentionedBots(text, available);
|
|
330
363
|
if (mentioned.length)
|
|
331
364
|
return mentioned;
|
|
@@ -351,6 +384,8 @@ export class Store {
|
|
|
351
384
|
constructor(defaultSelection) {
|
|
352
385
|
this.defaultSelection = defaultSelection;
|
|
353
386
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
387
|
+
for (const file of [BOTS_FILE, GROUPS_FILE])
|
|
388
|
+
tightenRegistryFile(file);
|
|
354
389
|
try {
|
|
355
390
|
this.bots = JSON.parse(readFileSync(BOTS_FILE, "utf8"));
|
|
356
391
|
}
|
|
@@ -517,6 +552,7 @@ export class Store {
|
|
|
517
552
|
threadId: g.threadId,
|
|
518
553
|
title: this.firstUserLine(g.threadId) ?? UNTITLED_TASK,
|
|
519
554
|
createdAt: g.createdAt,
|
|
555
|
+
updatedAt: g.createdAt,
|
|
520
556
|
};
|
|
521
557
|
if (g.pinnedCwd !== undefined)
|
|
522
558
|
initialTask.pinnedCwd = g.pinnedCwd;
|
|
@@ -552,6 +588,7 @@ export class Store {
|
|
|
552
588
|
threadId: b.threadId,
|
|
553
589
|
title: this.firstUserLine(b.threadId) ?? UNTITLED_TASK,
|
|
554
590
|
createdAt: b.createdAt,
|
|
591
|
+
updatedAt: b.createdAt,
|
|
555
592
|
resumeCursors: b.resumeCursors ?? {},
|
|
556
593
|
}];
|
|
557
594
|
botsMigrated = true;
|
|
@@ -564,6 +601,7 @@ export class Store {
|
|
|
564
601
|
threadId: b.threadId,
|
|
565
602
|
title: this.firstUserLine(b.threadId) ?? UNTITLED_TASK,
|
|
566
603
|
createdAt: b.createdAt,
|
|
604
|
+
updatedAt: b.createdAt,
|
|
567
605
|
resumeCursors: b.resumeCursors ?? {},
|
|
568
606
|
};
|
|
569
607
|
b.tasks.unshift(active);
|
|
@@ -619,18 +657,124 @@ export class Store {
|
|
|
619
657
|
if (existsSync(legacyFile))
|
|
620
658
|
mdb.readThread(threadId, legacyFile);
|
|
621
659
|
}
|
|
660
|
+
// After legacy transcripts are in SQLite, so the first boot sees their
|
|
661
|
+
// newest message instead of stamping createdAt and jumping next launch.
|
|
662
|
+
this.repairThreadUpdatedAts();
|
|
663
|
+
// After the roster is loaded, so identity resolution sees which opener
|
|
664
|
+
// ids are still live.
|
|
665
|
+
this.repairDuplicatePairConversations();
|
|
622
666
|
this.registeringInitialSections = false;
|
|
623
667
|
}
|
|
668
|
+
/** Advance a task's update stamp in memory only. Message writes must not
|
|
669
|
+
* rewrite bots.json; the next snapshot and the startup repair read this. */
|
|
670
|
+
noteThreadActivity(threadId, at) {
|
|
671
|
+
if (!Number.isFinite(at))
|
|
672
|
+
return;
|
|
673
|
+
const advance = (current) => Math.max(current ?? 0, at);
|
|
674
|
+
for (const bot of this.bots) {
|
|
675
|
+
const task = bot.tasks?.find((candidate) => candidate.threadId === threadId);
|
|
676
|
+
if (!task)
|
|
677
|
+
continue;
|
|
678
|
+
const next = advance(task.updatedAt);
|
|
679
|
+
if (task.updatedAt !== next)
|
|
680
|
+
task.updatedAt = next;
|
|
681
|
+
}
|
|
682
|
+
for (const group of this.groups) {
|
|
683
|
+
const task = group.tasks?.find((candidate) => candidate.threadId === threadId);
|
|
684
|
+
if (!task)
|
|
685
|
+
continue;
|
|
686
|
+
const next = advance(task.updatedAt);
|
|
687
|
+
if (task.updatedAt !== next)
|
|
688
|
+
task.updatedAt = next;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/** Fill missing stamps, and raise a stamp that is older than the newest
|
|
692
|
+
* stored message. Never moves a stamp backwards: there is no per-message
|
|
693
|
+
* delete, and a clock skew must not reshuffle the list on every boot. */
|
|
694
|
+
repairThreadUpdatedAts() {
|
|
695
|
+
const botTasks = this.bots.flatMap((bot) => bot.tasks ?? []);
|
|
696
|
+
const groupTasks = this.groups.flatMap((group) => group.tasks ?? []);
|
|
697
|
+
const latest = mdb.latestMessageAts([...botTasks, ...groupTasks].map((task) => task.threadId));
|
|
698
|
+
let botsDirty = false;
|
|
699
|
+
let groupsDirty = false;
|
|
700
|
+
const repair = (task) => {
|
|
701
|
+
const fromMessages = latest.get(task.threadId);
|
|
702
|
+
const next = fromMessages !== undefined && (task.updatedAt === undefined || fromMessages > task.updatedAt)
|
|
703
|
+
? fromMessages
|
|
704
|
+
: task.updatedAt ?? task.createdAt;
|
|
705
|
+
if (task.updatedAt === next)
|
|
706
|
+
return false;
|
|
707
|
+
task.updatedAt = next;
|
|
708
|
+
return true;
|
|
709
|
+
};
|
|
710
|
+
for (const task of botTasks)
|
|
711
|
+
if (repair(task))
|
|
712
|
+
botsDirty = true;
|
|
713
|
+
for (const task of groupTasks)
|
|
714
|
+
if (repair(task))
|
|
715
|
+
groupsDirty = true;
|
|
716
|
+
if (botsDirty)
|
|
717
|
+
this.saveBots();
|
|
718
|
+
if (groupsDirty)
|
|
719
|
+
this.saveGroups();
|
|
720
|
+
}
|
|
721
|
+
/** At most one live pair row per (recipient, sender identity). Servers
|
|
722
|
+
* before identity-stable matching minted a second live pair row for a
|
|
723
|
+
* deleted-and-recreated sender while the predecessor's row dangled live
|
|
724
|
+
* forever; collapse those on load by keeping the row the resolver favors
|
|
725
|
+
* (first in the task list — the newest, actively used one) and demoting
|
|
726
|
+
* the rest to closed plain threads. History is kept, never deleted, and
|
|
727
|
+
* a demoted row is never re-adopted: adoption skips closed rows. Runs on
|
|
728
|
+
* every load and touches nothing in a store that already holds the
|
|
729
|
+
* invariant. */
|
|
730
|
+
repairDuplicatePairConversations() {
|
|
731
|
+
let botsDirty = false;
|
|
732
|
+
const sweep = (tasks, repaired) => {
|
|
733
|
+
const pairs = new Map();
|
|
734
|
+
for (const task of tasks ?? []) {
|
|
735
|
+
const by = task.openedBy;
|
|
736
|
+
if (by?.kind !== "pair" || task.closedBy)
|
|
737
|
+
continue;
|
|
738
|
+
const identity = this.openerIdentity(by);
|
|
739
|
+
pairs.set(identity, [...(pairs.get(identity) ?? []), task]);
|
|
740
|
+
}
|
|
741
|
+
for (const rows of pairs.values()) {
|
|
742
|
+
// rows are in task-list order, the same order the resolver's
|
|
743
|
+
// find() favors: the first is the one it keeps using.
|
|
744
|
+
for (const duplicate of rows.slice(1)) {
|
|
745
|
+
const by = duplicate.openedBy;
|
|
746
|
+
const identity = this.openerIdentity(by);
|
|
747
|
+
const { kind: _kind, ...opened } = by;
|
|
748
|
+
duplicate.openedBy = opened;
|
|
749
|
+
duplicate.closedBy = {
|
|
750
|
+
botId: identity,
|
|
751
|
+
name: this.bot(identity)?.name ?? by.name,
|
|
752
|
+
at: Date.now(),
|
|
753
|
+
};
|
|
754
|
+
repaired();
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
// Pair rows only ever live on bots: the resolver requires a bot
|
|
759
|
+
// recipient, and group tasks carry no peer stamps.
|
|
760
|
+
for (const bot of this.bots)
|
|
761
|
+
sweep(bot.tasks, () => { botsDirty = true; });
|
|
762
|
+
if (botsDirty)
|
|
763
|
+
this.saveBots();
|
|
764
|
+
}
|
|
624
765
|
saveBots(bots = this.bots) {
|
|
625
766
|
this.rememberSections([...this.bots, ...bots].map((bot) => bot.section));
|
|
626
767
|
writeFileAtomic(BOTS_FILE, JSON.stringify(bots.map(({ busy: _busy, activity: _activity, ...bot }) => ({
|
|
627
768
|
...bot,
|
|
628
|
-
tasks: bot.tasks?.map(({ busy: _taskBusy, activity: _taskActivity, turnStartedAt: _taskTurnStarted, ...task }) => task),
|
|
629
|
-
})), null, 2));
|
|
769
|
+
tasks: bot.tasks?.map(({ busy: _taskBusy, activity: _taskActivity, turnStartedAt: _taskTurnStarted, ...task }) => persistedPin(task)),
|
|
770
|
+
})), null, 2), { mode: 0o600 });
|
|
630
771
|
}
|
|
631
772
|
saveGroups() {
|
|
632
773
|
this.rememberSections(this.groups.map((group) => group.section));
|
|
633
|
-
writeFileAtomic(GROUPS_FILE, JSON.stringify(this.groups.map(({ busyBotId: _busyBotId, turnStartedAt: _turnStartedAt, ...g }) =>
|
|
774
|
+
writeFileAtomic(GROUPS_FILE, JSON.stringify(this.groups.map(({ busyBotId: _busyBotId, turnStartedAt: _turnStartedAt, ...g }) => ({
|
|
775
|
+
...g,
|
|
776
|
+
...(g.tasks ? { tasks: g.tasks.map((task) => persistedPin(task)) } : {}),
|
|
777
|
+
})), null, 2), { mode: 0o600 });
|
|
634
778
|
}
|
|
635
779
|
get sections() { return readSections(); }
|
|
636
780
|
rememberSections(names) {
|
|
@@ -715,7 +859,7 @@ export class Store {
|
|
|
715
859
|
section,
|
|
716
860
|
};
|
|
717
861
|
if (!dm) {
|
|
718
|
-
group.tasks = [{ threadId, title: UNTITLED_TASK, createdAt }];
|
|
862
|
+
group.tasks = [{ threadId, title: UNTITLED_TASK, createdAt, updatedAt: createdAt }];
|
|
719
863
|
group.setupCompletedAt = setup?.completed ? createdAt : null;
|
|
720
864
|
group.setupSkippedAt = null;
|
|
721
865
|
}
|
|
@@ -847,10 +991,12 @@ export class Store {
|
|
|
847
991
|
const group = this.group(groupId);
|
|
848
992
|
if (!group || group.dm)
|
|
849
993
|
return null;
|
|
994
|
+
const createdAt = Date.now();
|
|
850
995
|
const task = {
|
|
851
996
|
threadId: newId(),
|
|
852
997
|
title: title?.trim().slice(0, 80) || UNTITLED_TASK,
|
|
853
|
-
createdAt
|
|
998
|
+
createdAt,
|
|
999
|
+
updatedAt: createdAt,
|
|
854
1000
|
};
|
|
855
1001
|
group.tasks = [task, ...(group.tasks ?? [])];
|
|
856
1002
|
if (activate) {
|
|
@@ -874,6 +1020,18 @@ export class Store {
|
|
|
874
1020
|
this.emit({ type: "group", groupId });
|
|
875
1021
|
return group;
|
|
876
1022
|
}
|
|
1023
|
+
setGroupTaskPinned(groupId, threadId, pinned) {
|
|
1024
|
+
const task = this.groupTaskByThread(groupId, threadId);
|
|
1025
|
+
if (!task)
|
|
1026
|
+
return null;
|
|
1027
|
+
if (pinned)
|
|
1028
|
+
task.pinned = true;
|
|
1029
|
+
else
|
|
1030
|
+
delete task.pinned;
|
|
1031
|
+
this.saveGroups();
|
|
1032
|
+
this.emit({ type: "group", groupId });
|
|
1033
|
+
return task;
|
|
1034
|
+
}
|
|
877
1035
|
renameGroupTask(groupId, threadId, title) {
|
|
878
1036
|
const task = this.groupTaskByThread(groupId, threadId);
|
|
879
1037
|
if (!task)
|
|
@@ -1003,6 +1161,9 @@ export class Store {
|
|
|
1003
1161
|
throw new Error("Cannot import over an existing conversation");
|
|
1004
1162
|
mdb.importThread(threadId, messages, activeLeafId);
|
|
1005
1163
|
this.threads.delete(threadId);
|
|
1164
|
+
const newest = messages.reduce((max, message) => Math.max(max, message.at), Number.NEGATIVE_INFINITY);
|
|
1165
|
+
if (Number.isFinite(newest))
|
|
1166
|
+
this.noteThreadActivity(threadId, newest);
|
|
1006
1167
|
}
|
|
1007
1168
|
activeLeaf(threadId) {
|
|
1008
1169
|
return this.thread(threadId).activeLeafId;
|
|
@@ -1051,6 +1212,7 @@ export class Store {
|
|
|
1051
1212
|
this.emit({ type: "message.patch", threadId, message: pruned });
|
|
1052
1213
|
}
|
|
1053
1214
|
}
|
|
1215
|
+
this.noteThreadActivity(threadId, full.at);
|
|
1054
1216
|
this.emit({ type: "message", threadId, message: full });
|
|
1055
1217
|
// The first-run quiz is not a live ask. Talking past it hides it so the
|
|
1056
1218
|
// transcript is just the greeting plus what they said. Cards with a
|
|
@@ -1081,6 +1243,7 @@ export class Store {
|
|
|
1081
1243
|
this.emit({ type: "message.patch", threadId, message: pruned });
|
|
1082
1244
|
}
|
|
1083
1245
|
}
|
|
1246
|
+
this.noteThreadActivity(threadId, full.at);
|
|
1084
1247
|
this.emit({ type: "message", threadId, message: full });
|
|
1085
1248
|
// announced after the insert so no client ever sees two siblings
|
|
1086
1249
|
// claiming the same parent
|
|
@@ -1136,6 +1299,7 @@ export class Store {
|
|
|
1136
1299
|
t.messages.push(full);
|
|
1137
1300
|
t.activeLeafId = full.id;
|
|
1138
1301
|
mdb.appendMessage(threadId, full);
|
|
1302
|
+
this.noteThreadActivity(threadId, full.at);
|
|
1139
1303
|
this.emit({ type: "message", threadId, message: full });
|
|
1140
1304
|
// The message frame alone leaves every client on the OLD branch: a
|
|
1141
1305
|
// client adopts a new message as its leaf only when it chains onto the
|
|
@@ -1204,6 +1368,8 @@ export class Store {
|
|
|
1204
1368
|
color: profile.color ?? COLORS[this.bots.length % COLORS.length],
|
|
1205
1369
|
...(profile.mascotExpression ? { mascotExpression: profile.mascotExpression } : {}),
|
|
1206
1370
|
...(profile.mascotBody ? { mascotBody: profile.mascotBody } : {}),
|
|
1371
|
+
// Restricted from its first frame: no one else is ever told it exists.
|
|
1372
|
+
...(profile.visibility && profile.visibility !== "everyone" ? { visibility: structuredClone(profile.visibility) } : {}),
|
|
1207
1373
|
unread: false,
|
|
1208
1374
|
modelSelection: profile.modelSelection ?? this.defaultSelection(),
|
|
1209
1375
|
resumeCursors: {},
|
|
@@ -1215,6 +1381,7 @@ export class Store {
|
|
|
1215
1381
|
threadId: bot.threadId,
|
|
1216
1382
|
title: UNTITLED_THREAD,
|
|
1217
1383
|
createdAt: bot.createdAt,
|
|
1384
|
+
updatedAt: bot.createdAt,
|
|
1218
1385
|
resumeCursors: {},
|
|
1219
1386
|
modelSelection: structuredClone(bot.modelSelection),
|
|
1220
1387
|
unread: false,
|
|
@@ -1270,7 +1437,10 @@ export class Store {
|
|
|
1270
1437
|
title: "", description: "", soul: "", notifications: true, color: COLORS[nextBots.length % COLORS.length], unread: false,
|
|
1271
1438
|
modelSelection: operation.fields.modelSelection, resumeCursors: {}, createdAt, ...operation.fields,
|
|
1272
1439
|
approvalMode: "ask", autoApprove: false, composio: false, approvePeerComms: false,
|
|
1273
|
-
|
|
1440
|
+
// A Chief's new teammate is seen by exactly the Chief's audience:
|
|
1441
|
+
// a restricted Chief never creates a bot everyone sees.
|
|
1442
|
+
...(chief.visibility && chief.visibility !== "everyone" ? { visibility: structuredClone(chief.visibility) } : {}),
|
|
1443
|
+
tasks: [{ threadId: operation.threadId, title: UNTITLED_THREAD, createdAt, updatedAt: createdAt, resumeCursors: {},
|
|
1274
1444
|
modelSelection: structuredClone(operation.fields.modelSelection), approvalMode: "ask", autoApprove: false,
|
|
1275
1445
|
unread: false, activity: "idle", busy: false }],
|
|
1276
1446
|
};
|
|
@@ -1551,6 +1721,64 @@ export class Store {
|
|
|
1551
1721
|
this.emit({ type: "bot", botId: bot.id });
|
|
1552
1722
|
return changed;
|
|
1553
1723
|
}
|
|
1724
|
+
/** Company instance ids became stable across re-enrolment. Moves every
|
|
1725
|
+
* saved reference to an old id onto its replacement in one save: model
|
|
1726
|
+
* choices, native resume cursors and handed-message records. An entry that
|
|
1727
|
+
* already exists under the new id wins over the old one. */
|
|
1728
|
+
renameInstances(ids) {
|
|
1729
|
+
const touches = (record) => Boolean(record && Object.keys(record).some(key => ids.has(key)));
|
|
1730
|
+
const rename = (record) => {
|
|
1731
|
+
const next = {};
|
|
1732
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1733
|
+
const target = ids.get(key);
|
|
1734
|
+
if (!target)
|
|
1735
|
+
next[key] = value;
|
|
1736
|
+
else if (!Object.prototype.hasOwnProperty.call(record, target))
|
|
1737
|
+
next[target] = value;
|
|
1738
|
+
}
|
|
1739
|
+
return next;
|
|
1740
|
+
};
|
|
1741
|
+
const changed = [];
|
|
1742
|
+
for (const bot of this.bots) {
|
|
1743
|
+
let dirty = false;
|
|
1744
|
+
const selected = ids.get(bot.modelSelection.instanceId);
|
|
1745
|
+
if (selected) {
|
|
1746
|
+
bot.modelSelection = { ...bot.modelSelection, instanceId: selected };
|
|
1747
|
+
dirty = true;
|
|
1748
|
+
}
|
|
1749
|
+
if (touches(bot.resumeCursors)) {
|
|
1750
|
+
bot.resumeCursors = rename(bot.resumeCursors);
|
|
1751
|
+
dirty = true;
|
|
1752
|
+
}
|
|
1753
|
+
for (const task of bot.tasks ?? []) {
|
|
1754
|
+
const taskSelected = task.modelSelection && ids.get(task.modelSelection.instanceId);
|
|
1755
|
+
if (task.modelSelection && taskSelected) {
|
|
1756
|
+
task.modelSelection = { ...task.modelSelection, instanceId: taskSelected };
|
|
1757
|
+
dirty = true;
|
|
1758
|
+
}
|
|
1759
|
+
if (touches(task.resumeCursors)) {
|
|
1760
|
+
task.resumeCursors = rename(task.resumeCursors);
|
|
1761
|
+
dirty = true;
|
|
1762
|
+
}
|
|
1763
|
+
if (task.handedMessages && touches(task.handedMessages)) {
|
|
1764
|
+
task.handedMessages = rename(task.handedMessages);
|
|
1765
|
+
dirty = true;
|
|
1766
|
+
}
|
|
1767
|
+
const last = task.lastInstanceId && ids.get(task.lastInstanceId);
|
|
1768
|
+
if (last) {
|
|
1769
|
+
task.lastInstanceId = last;
|
|
1770
|
+
dirty = true;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
if (dirty)
|
|
1774
|
+
changed.push(bot);
|
|
1775
|
+
}
|
|
1776
|
+
if (changed.length)
|
|
1777
|
+
this.saveBots();
|
|
1778
|
+
for (const bot of changed)
|
|
1779
|
+
this.emit({ type: "bot", botId: bot.id });
|
|
1780
|
+
return changed.length;
|
|
1781
|
+
}
|
|
1554
1782
|
setResumeCursor(botId, instanceId, cursor, threadId) {
|
|
1555
1783
|
const bot = this.bot(botId);
|
|
1556
1784
|
if (!bot)
|
|
@@ -1797,6 +2025,8 @@ export class Store {
|
|
|
1797
2025
|
}
|
|
1798
2026
|
if (typeof patch.title === "string")
|
|
1799
2027
|
task.title = patch.title.trim().slice(0, 80) || UNTITLED_THREAD;
|
|
2028
|
+
if (Object.prototype.hasOwnProperty.call(patch, "pinned") && task.pinned !== true)
|
|
2029
|
+
delete task.pinned;
|
|
1800
2030
|
if (bot.threadId === threadId)
|
|
1801
2031
|
this.mirrorActiveTask(bot, task);
|
|
1802
2032
|
bot.unread = bot.tasks.some((candidate) => candidate.unread);
|
|
@@ -1814,8 +2044,8 @@ export class Store {
|
|
|
1814
2044
|
return null;
|
|
1815
2045
|
const patch = { modelSelection: structuredClone(selection),
|
|
1816
2046
|
...(resetApprovalToAsk ? { approvalMode: "ask", autoApprove: false, alwaysAllow: [] } : {}) };
|
|
1817
|
-
const nextTask = { ...task, ...taskPatch, ...patch,
|
|
1818
|
-
...(typeof taskPatch.title === "string" ? { title: taskPatch.title.trim().slice(0, 80) || UNTITLED_THREAD } : {}) };
|
|
2047
|
+
const nextTask = persistedPin({ ...task, ...taskPatch, ...patch,
|
|
2048
|
+
...(typeof taskPatch.title === "string" ? { title: taskPatch.title.trim().slice(0, 80) || UNTITLED_THREAD } : {}) });
|
|
1819
2049
|
// Older threads may still inherit settings. Freeze their effective
|
|
1820
2050
|
// values before updating the default so "other threads unchanged" also
|
|
1821
2051
|
// holds for workspaces created before per-thread approval settings.
|
|
@@ -1835,6 +2065,20 @@ export class Store {
|
|
|
1835
2065
|
this.emit({ type: "bot", botId });
|
|
1836
2066
|
return task;
|
|
1837
2067
|
}
|
|
2068
|
+
/** One durable write: never leave only part of a bot's threads updated. */
|
|
2069
|
+
setAllThreadApprovalMode(botId, mode) {
|
|
2070
|
+
const bot = this.bot(botId);
|
|
2071
|
+
if (!bot)
|
|
2072
|
+
return null;
|
|
2073
|
+
const patch = { approvalMode: mode, autoApprove: false, alwaysAllow: [] };
|
|
2074
|
+
const tasks = (bot.tasks ?? []).map(task => ({ ...task, ...patch }));
|
|
2075
|
+
const next = { ...bot, ...patch, approvalGrant: undefined, tasks };
|
|
2076
|
+
this.saveBots(this.bots.map(candidate => candidate === bot ? next : candidate));
|
|
2077
|
+
bot.tasks?.forEach((task, index) => Object.assign(task, tasks[index]));
|
|
2078
|
+
Object.assign(bot, patch, { approvalGrant: undefined });
|
|
2079
|
+
this.emit({ type: "bot", botId });
|
|
2080
|
+
return bot;
|
|
2081
|
+
}
|
|
1838
2082
|
mirrorActiveTask(bot, task) {
|
|
1839
2083
|
bot.threadId = task.threadId;
|
|
1840
2084
|
bot.resumeCursors = structuredClone(task.resumeCursors);
|
|
@@ -1843,23 +2087,25 @@ export class Store {
|
|
|
1843
2087
|
}
|
|
1844
2088
|
/** A fresh context on the same bot: new thread, new session, same
|
|
1845
2089
|
* persona/tools/computer. Becomes the active task. */
|
|
1846
|
-
createTask(botId, title, activate = true, projectId, openedBy) {
|
|
2090
|
+
createTask(botId, title, activate = true, projectId, openedBy, approvalMode) {
|
|
1847
2091
|
const bot = this.bot(botId);
|
|
1848
2092
|
if (!bot)
|
|
1849
2093
|
return null;
|
|
1850
2094
|
if (projectId !== undefined && !this.project(botId, projectId))
|
|
1851
2095
|
return null;
|
|
2096
|
+
const createdAt = Date.now();
|
|
1852
2097
|
const task = {
|
|
1853
2098
|
threadId: newId(),
|
|
1854
2099
|
title: threadTitleFrom(title),
|
|
1855
|
-
createdAt
|
|
2100
|
+
createdAt,
|
|
2101
|
+
updatedAt: createdAt,
|
|
1856
2102
|
...(projectId ? { projectId } : {}),
|
|
1857
2103
|
...(openedBy ? { openedBy: structuredClone(openedBy) } : {}),
|
|
1858
2104
|
resumeCursors: {},
|
|
1859
2105
|
modelSelection: structuredClone(bot.modelSelection),
|
|
1860
|
-
approvalMode: approvalModeFor(bot),
|
|
1861
|
-
autoApprove: Boolean(bot.autoApprove),
|
|
1862
|
-
alwaysAllow: [...(bot.alwaysAllow ?? [])],
|
|
2106
|
+
approvalMode: approvalMode ?? approvalModeFor(bot),
|
|
2107
|
+
autoApprove: approvalMode ? false : Boolean(bot.autoApprove),
|
|
2108
|
+
alwaysAllow: approvalMode ? [] : [...(bot.alwaysAllow ?? [])],
|
|
1863
2109
|
unread: false,
|
|
1864
2110
|
activity: "idle",
|
|
1865
2111
|
busy: false,
|
|
@@ -1932,12 +2178,29 @@ export class Store {
|
|
|
1932
2178
|
* interleave in one transcript. `label` names that thread; the caller
|
|
1933
2179
|
* closes it once its result has been reported. A pair conversation
|
|
1934
2180
|
* never auto-closes. */
|
|
2181
|
+
/** The identity a peer-opened row belongs to: its opener's id while that
|
|
2182
|
+
* bot lives, else the one live bot the stamp's name still points at —
|
|
2183
|
+
* what a deleted-and-recreated same-name bot inherits — else the dead id
|
|
2184
|
+
* itself. A name two live bots share resolves to nobody's twin:
|
|
2185
|
+
* ambiguous means unmatched, never a wrong merge. */
|
|
2186
|
+
openerIdentity(openedBy) {
|
|
2187
|
+
if (this.bot(openedBy.botId))
|
|
2188
|
+
return openedBy.botId;
|
|
2189
|
+
const named = this.bots.filter((bot) => bot.name === openedBy.name);
|
|
2190
|
+
return named.length === 1 ? named[0].id : openedBy.botId;
|
|
2191
|
+
}
|
|
1935
2192
|
resolvePairConversation(sender, recipientId, options) {
|
|
1936
2193
|
if (!this.bot(recipientId))
|
|
1937
2194
|
return null;
|
|
1938
2195
|
const title = `@${sender.name}`;
|
|
1939
2196
|
const opener = (kind, at = Date.now()) => ({ botId: sender.id, name: sender.name, kind, at });
|
|
1940
|
-
|
|
2197
|
+
// Identity-stable: the sender's own rows, plus ones a deleted
|
|
2198
|
+
// predecessor opened when the stamp's name still points at exactly
|
|
2199
|
+
// this bot — a same-name recreation inherits the conversation instead
|
|
2200
|
+
// of minting a twin while the old row dangles live. A name two live
|
|
2201
|
+
// bots share matches nobody's inheritance: refusing the fallback can
|
|
2202
|
+
// cost a new row, never merge two bots' histories.
|
|
2203
|
+
const fromSender = this.tasks(recipientId).filter((task) => task.openedBy && this.openerIdentity(task.openedBy) === sender.id);
|
|
1941
2204
|
let pair = fromSender.find((task) => task.openedBy?.kind === "pair");
|
|
1942
2205
|
if (!pair) {
|
|
1943
2206
|
const lastActivity = (task) => this.messagesTail(task.threadId, 1).messages.at(-1)?.at ?? task.openedBy?.at ?? task.createdAt;
|
|
@@ -1966,6 +2229,14 @@ export class Store {
|
|
|
1966
2229
|
// back up, never replaced: closing is only the sidebar's idle state.
|
|
1967
2230
|
if (pair.closedBy)
|
|
1968
2231
|
this.setTaskClosedBy(recipientId, pair.threadId, null);
|
|
2232
|
+
// An inherited row carries the predecessor's id; rebind it to the
|
|
2233
|
+
// live bot so the name fallback is needed only once — a namesake
|
|
2234
|
+
// appearing later cannot claim the row. The hour it was opened
|
|
2235
|
+
// stays: inheritance is not a new conversation.
|
|
2236
|
+
const inherited = pair.openedBy;
|
|
2237
|
+
if (inherited && inherited.botId !== sender.id) {
|
|
2238
|
+
this.setTaskOpenedBy(recipientId, pair.threadId, { ...inherited, botId: sender.id, name: sender.name });
|
|
2239
|
+
}
|
|
1969
2240
|
return { task: pair, created: false };
|
|
1970
2241
|
}
|
|
1971
2242
|
// The brief is never a title. An 80-character slice of an assignment
|
|
@@ -77,6 +77,7 @@ export function createTeamBackup(store, routines, name) {
|
|
|
77
77
|
closedBy: "closedBy" in task && task.closedBy
|
|
78
78
|
? { botId: task.closedBy.botId, name: task.closedBy.name, at: task.closedBy.at }
|
|
79
79
|
: undefined,
|
|
80
|
+
pinned: task.pinned === true ? true : undefined,
|
|
80
81
|
activeLeafId: store.activeLeaf(task.threadId),
|
|
81
82
|
messages: store.messagesFor(task.threadId).map((message) => ({
|
|
82
83
|
id: message.id, role: message.role, text: messageText(message), at: message.at,
|
|
@@ -130,7 +131,7 @@ export function createTeamBackup(store, routines, name) {
|
|
|
130
131
|
}
|
|
131
132
|
/** Import is always additive, including sections and Chiefs. Rollback owns
|
|
132
133
|
* only the fresh records below and cannot touch any pre-existing bot/chat. */
|
|
133
|
-
export function importTeamBackup(store, routines, input, selection) {
|
|
134
|
+
export function importTeamBackup(store, routines, input, selection, options = {}) {
|
|
134
135
|
const backup = parseTeamBackup(input);
|
|
135
136
|
const bots = [];
|
|
136
137
|
const groups = [];
|
|
@@ -175,6 +176,8 @@ export function importTeamBackup(store, routines, input, selection) {
|
|
|
175
176
|
color: source.color, mascotExpression: source.mascotExpression,
|
|
176
177
|
mascotBody: botMascotBody(source.mascotBody),
|
|
177
178
|
modelSelection: selection, section: sectionFor(source.section),
|
|
179
|
+
// who may see the imported team is the importing admin's choice
|
|
180
|
+
...(options.visibility ? { visibility: options.visibility } : {}),
|
|
178
181
|
}, { seedMessages: false });
|
|
179
182
|
bots.push(bot);
|
|
180
183
|
botIds.set(source.key, bot.id);
|
|
@@ -203,6 +206,10 @@ export function importTeamBackup(store, routines, input, selection) {
|
|
|
203
206
|
const closer = task.closedBy && botIds.get(task.closedBy.botId);
|
|
204
207
|
if (task.closedBy && closer)
|
|
205
208
|
record.closedBy = { botId: closer, name: task.closedBy.name, at: task.closedBy.at };
|
|
209
|
+
if (task.pinned === true)
|
|
210
|
+
record.pinned = true;
|
|
211
|
+
const newest = task.messages.reduce((max, message) => Math.max(max, message.at), task.createdAt);
|
|
212
|
+
record.updatedAt = newest;
|
|
206
213
|
return record;
|
|
207
214
|
});
|
|
208
215
|
// Own the task IDs before writing their transcripts, so rollback also
|
|
@@ -229,6 +236,8 @@ export function importTeamBackup(store, routines, input, selection) {
|
|
|
229
236
|
if (!source.dm) {
|
|
230
237
|
group.tasks = source.tasks.map((task, i) => ({
|
|
231
238
|
threadId: threads[i], title: task.title, createdAt: task.createdAt,
|
|
239
|
+
updatedAt: task.messages.reduce((max, message) => Math.max(max, message.at), task.createdAt),
|
|
240
|
+
...(task.pinned === true ? { pinned: true } : {}),
|
|
232
241
|
...(task.titleFromFirstMessage ? { titleFromFirstMessage: true } : {}),
|
|
233
242
|
}));
|
|
234
243
|
store.switchGroupTask(group.id, threads[source.tasks.findIndex((task) => task.key === source.activeTask)]);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Who each thread is for, when a signed-in person can be named: the person
|
|
2
|
+
// who opened it from a session, or — for a thread a bot opened while working
|
|
3
|
+
// on someone's request — the person that request came from. One small
|
|
4
|
+
// server-private file (<data>/thread-starters.json, 0600), never sent to a
|
|
5
|
+
// client and never part of a bot or room record, so no wire projection can
|
|
6
|
+
// leak it. It decides one thing only: on a workspace several people share,
|
|
7
|
+
// whose session may answer the cards raised in that thread
|
|
8
|
+
// (server/index.ts cardAnswerRefusal). A thread with no entry names nobody,
|
|
9
|
+
// and then anyone who may chat may answer, as before.
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
12
|
+
const KEY = /^p_[\w-]{22}$/;
|
|
13
|
+
const THREAD = /^[\w-]{1,128}$/;
|
|
14
|
+
/** Oldest entries go first past this; a thread that old has long settled. */
|
|
15
|
+
const MAX_ENTRIES = 50_000;
|
|
16
|
+
export class ThreadStarters {
|
|
17
|
+
starters = new Map();
|
|
18
|
+
file;
|
|
19
|
+
constructor(file) {
|
|
20
|
+
this.file = file;
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return; // absent or unreadable: nobody is named, which is today's behaviour
|
|
27
|
+
}
|
|
28
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
29
|
+
return;
|
|
30
|
+
for (const [threadId, person] of Object.entries(raw)) {
|
|
31
|
+
if (THREAD.test(threadId) && typeof person === "string" && KEY.test(person))
|
|
32
|
+
this.starters.set(threadId, person);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
get(threadId) {
|
|
36
|
+
return this.starters.get(threadId);
|
|
37
|
+
}
|
|
38
|
+
/** Record once; a thread keeps the person it was first opened for. */
|
|
39
|
+
set(threadId, person) {
|
|
40
|
+
if (!person || !KEY.test(person) || !THREAD.test(threadId) || this.starters.has(threadId))
|
|
41
|
+
return;
|
|
42
|
+
this.starters.set(threadId, person);
|
|
43
|
+
while (this.starters.size > MAX_ENTRIES)
|
|
44
|
+
this.starters.delete(this.starters.keys().next().value);
|
|
45
|
+
try {
|
|
46
|
+
writeFileAtomic(this.file, JSON.stringify(Object.fromEntries(this.starters)) + "\n", { mode: 0o600 });
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
// Kept in memory for this run; after a restart the thread names nobody.
|
|
50
|
+
console.warn(`thread starters: could not save ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|