@chibitek/chibi 0.1.158 → 0.1.161

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/assets/{abnfDiagram-O67JEVCF-B8-8zzMS.js → abnfDiagram-O67JEVCF-Bb-Unz3V.js} +1 -1
  2. package/dist/assets/{arc-CSHQ9Dx3.js → arc-HBMH7B4K.js} +1 -1
  3. package/dist/assets/{architectureDiagram-NJMV4G6O-BCHM9rXW.js → architectureDiagram-NJMV4G6O-CYwkb2PE.js} +1 -1
  4. package/dist/assets/{blockDiagram-BEXU5L5S-B-5uxPFN.js → blockDiagram-BEXU5L5S-DPYYNxBH.js} +1 -1
  5. package/dist/assets/{c4Diagram-YGBWAQC7-CuKNM_MR.js → c4Diagram-YGBWAQC7-DZ9hKDlA.js} +1 -1
  6. package/dist/assets/channel-Nx85fHZ4.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-BNF02GM4.js → chunk-2Q5K7J3B-OKrUlts6.js} +1 -1
  8. package/dist/assets/{chunk-3FUC2YCW-uEWgBd4M.js → chunk-3FUC2YCW-Tuur0lqz.js} +2 -2
  9. package/dist/assets/{chunk-5VM5RSS4-eSZuXctY.js → chunk-5VM5RSS4-BL8QOZeP.js} +1 -1
  10. package/dist/assets/{chunk-GTNCS2PH-CslS8nRS.js → chunk-GTNCS2PH-DMi8-ixt.js} +1 -1
  11. package/dist/assets/{chunk-GWA4HPMP-w71-MeKj.js → chunk-GWA4HPMP-Dd7yNJ0G.js} +1 -1
  12. package/dist/assets/{chunk-JWPE2WC7-Bjju7WGh.js → chunk-JWPE2WC7-g2dOoIMX.js} +1 -1
  13. package/dist/assets/{chunk-NETBCI7D-HxrroD4M.js → chunk-NETBCI7D-Cum7feOh.js} +1 -1
  14. package/dist/assets/{chunk-WEXAMYUT-D7Rz470x.js → chunk-WEXAMYUT-BzCC1jc4.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-BWKnAeyM.js → chunk-XXDRQBXY-Ck63jUMo.js} +1 -1
  16. package/dist/assets/{classDiagram-v2-NBCMYWYE-CeBDRHmP.js → classDiagram-v2-NBCMYWYE-DFSuRVMZ.js} +1 -1
  17. package/dist/assets/{cose-bilkent-JH36ORCC-BBbRKlvr.js → cose-bilkent-JH36ORCC-CegRj_kY.js} +1 -1
  18. package/dist/assets/{cynefin-OW5HDTMX-W02cnJ0B.js → cynefin-OW5HDTMX-cZS_kt1A.js} +1 -1
  19. package/dist/assets/{cynefinDiagram-VND7K2PF-Cs47eaxd.js → cynefinDiagram-VND7K2PF-DfQa9Rht.js} +1 -1
  20. package/dist/assets/{dagre-6A5THRUB-8mG18hzT.js → dagre-6A5THRUB-DJ9qftJu.js} +1 -1
  21. package/dist/assets/{diagram-22UHCM2B-DF4Qrw67.js → diagram-22UHCM2B-KXUGUahJ.js} +1 -1
  22. package/dist/assets/{diagram-3UASUU5V-3BAVoed1.js → diagram-3UASUU5V-BudCYrCp.js} +1 -1
  23. package/dist/assets/{diagram-ATOU4E4O-CKO3pECE.js → diagram-ATOU4E4O-Du2O03NS.js} +1 -1
  24. package/dist/assets/{diagram-CDSNMT55-CZhrGvur.js → diagram-CDSNMT55-DXybXGYf.js} +1 -1
  25. package/dist/assets/{diagram-MLGK6HIB-C07l9pdQ.js → diagram-MLGK6HIB-DfrknXGE.js} +1 -1
  26. package/dist/assets/{diagram-MPIPVDR6-D3or4lkZ.js → diagram-MPIPVDR6-C29FmaJm.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-ZINNZB2B-DV2epXp_.js → ebnfDiagram-ZINNZB2B-BF9wS0Nz.js} +1 -1
  28. package/dist/assets/{elk-276RUBZZ-DlvEn4-1.js → elk-276RUBZZ-B2Fah1VF.js} +1 -1
  29. package/dist/assets/{erDiagram-OPXOYQCR-CB6X7Wt5.js → erDiagram-OPXOYQCR-Di3q4bpF.js} +1 -1
  30. package/dist/assets/{flowDiagram-KWPJA3E3-C3bqz4lC.js → flowDiagram-KWPJA3E3-D1UiqwbF.js} +1 -1
  31. package/dist/assets/{ganttDiagram-FUAMR5RP-yo_Evhab.js → ganttDiagram-FUAMR5RP-BjZqMt7o.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-X574FWY7-Cq2oUlap.js → gitGraphDiagram-X574FWY7-DiZtdip7.js} +1 -1
  33. package/dist/assets/index-B2fJMh8N.css +1 -0
  34. package/dist/assets/{index-CMcr5k3Z.js → index-XFwW09vd.js} +246 -246
  35. package/dist/assets/{index-CG4AFPb4.js → index-dbQveHan.js} +1 -1
  36. package/dist/assets/{infoDiagram-VRGFBTTK-Y3ZrY7qL.js → infoDiagram-VRGFBTTK-BJUJCgio.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BpOtSfyi.js → ishikawaDiagram-OU5B5YK6-BX0XdHUx.js} +1 -1
  38. package/dist/assets/{journeyDiagram-ZHPQQLJL-UQM727le.js → journeyDiagram-ZHPQQLJL-Bg904IIx.js} +1 -1
  39. package/dist/assets/{kanban-definition-PNTS6WVX-C3s5oHpY.js → kanban-definition-PNTS6WVX-hkZMFMHZ.js} +1 -1
  40. package/dist/assets/{linear-hX6nFOQr.js → linear-Bh29RTbQ.js} +1 -1
  41. package/dist/assets/{mermaid.core-DZObGLN7.js → mermaid.core-aqB1jW_w.js} +5 -5
  42. package/dist/assets/{mindmap-definition-NLK3R4M7-Br-i2E3P.js → mindmap-definition-NLK3R4M7-D5GMV-Zd.js} +1 -1
  43. package/dist/assets/{pegDiagram-GJSIUBJH-D5SERB-N.js → pegDiagram-GJSIUBJH-7_wFtkFH.js} +1 -1
  44. package/dist/assets/{pieDiagram-5QR66LMP-BPNf4J3D.js → pieDiagram-5QR66LMP-DXtZBQ4f.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-O4NWA36T-Bm_Wh7Jr.js → quadrantDiagram-O4NWA36T-C1v3ydTF.js} +1 -1
  46. package/dist/assets/{railroadDiagram-XR7U4H2S-D6bo0abD.js → railroadDiagram-XR7U4H2S-CLmhiOXg.js} +1 -1
  47. package/dist/assets/{requirementDiagram-PLB6GJNP--1n5rkV7.js → requirementDiagram-PLB6GJNP-BKxI2rh_.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-IPEJSGJF-Ct4tP2kK.js → sankeyDiagram-IPEJSGJF-gKK-Qs7b.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-PO4LG4MO-CCG7O5fR.js → sequenceDiagram-PO4LG4MO-BT94Uowp.js} +1 -1
  50. package/dist/assets/{sizeCapture-INFHLROL-CJJJra9c.js → sizeCapture-INFHLROL-RDJL6sCM.js} +1 -1
  51. package/dist/assets/{stateDiagram-v2-GCMORJYK-BMdRvVEX.js → stateDiagram-v2-GCMORJYK-DqHMzv-J.js} +1 -1
  52. package/dist/assets/{swimlanes-2SLR337P-B1wnogip.js → swimlanes-2SLR337P-B-NN0ql6.js} +1 -1
  53. package/dist/assets/swimlanesDiagram-TC7HE7FX-BO2gR34z.js +8 -0
  54. package/dist/assets/{timeline-definition-EJHVYXUP-l-3yhXUP.js → timeline-definition-EJHVYXUP-C51S9tpN.js} +1 -1
  55. package/dist/assets/{usecaseDiagram-POWQR4AR-DtwtCOoJ.js → usecaseDiagram-POWQR4AR-DyfNMmhq.js} +1 -1
  56. package/dist/assets/{vennDiagram-UO4OBE2U-BZAEce1B.js → vennDiagram-UO4OBE2U-D924r33S.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-VNRHLVJA-Na_BCMMO.js → wardleyDiagram-VNRHLVJA-DXSfsjUr.js} +1 -1
  58. package/dist/assets/{xychartDiagram-PMCCYNJV-BW9dlUfE.js → xychartDiagram-PMCCYNJV-mYX9tnBU.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/dist-server/container-mcp.js +1 -1
  61. package/dist-server/index.js +3611 -3082
  62. package/dist-server/openmausbot.js +370 -162
  63. package/dist-server/pair-cli.js +370 -162
  64. package/dist-server/server/attachment-mimes.js +58 -0
  65. package/dist-server/server/attachments.js +28 -67
  66. package/dist-server/server/avatar-image.js +1 -0
  67. package/dist-server/server/avatar-subscription.js +24 -1
  68. package/dist-server/server/checkpoint-recovery.js +18 -0
  69. package/dist-server/server/drivers/acp/core.js +17 -17
  70. package/dist-server/server/drivers/claude-message.js +12 -0
  71. package/dist-server/server/drivers/claude.js +9 -12
  72. package/dist-server/server/drivers/codex-image.js +141 -0
  73. package/dist-server/server/drivers/codex.js +6 -9
  74. package/dist-server/server/drivers/openai-chat.js +13 -2
  75. package/dist-server/server/engine-failover.js +44 -4
  76. package/dist-server/server/engine-failure.js +2 -2
  77. package/dist-server/server/harness-import/history.js +5 -5
  78. package/dist-server/server/harness-import/test-fixtures.js +1 -1
  79. package/dist-server/server/index.js +40 -26
  80. package/dist-server/server/message-reactions.js +11 -0
  81. package/dist-server/server/model-evidence-context.js +18 -0
  82. package/dist-server/server/model-evidence.js +145 -0
  83. package/dist-server/shared/emoji-catalog.json +1 -0
  84. package/dist-server/shared/emoji.js +27 -0
  85. package/dist-server/shared/mascot-bodies.js +9 -1
  86. package/dist-server/shared/model-evidence.js +25 -0
  87. package/dist-server/vps-container-mcp.js +1 -1
  88. package/package.json +1 -1
  89. package/dist/assets/channel-bPN03jIt.js +0 -1
  90. package/dist/assets/index-CZjRcUdn.css +0 -1
  91. package/dist/assets/swimlanesDiagram-TC7HE7FX-DWyjdGGR.js +0 -8
  92. package/dist-server/connector-proxy.js +0 -205
  93. package/dist-server/server/composio.js +0 -1051
  94. package/dist-server/server/connector-proxy.js +0 -249
  95. package/dist-server/server/mcp-public-registry.js +0 -325
  96. package/dist-server/shared/connector-availability.js +0 -12
@@ -1,3 +1,37 @@
1
+ // Silent automatic failover (docs/chibitek/engine-tiers.md, step 2).
2
+ //
3
+ // A turn whose engine runs out of its usage allowance, turns out to be
4
+ // signed out, goes fully silent, or fails in any other way, is not failed:
5
+ // the engine rests and the same turn runs on the next engine of the bot's
6
+ // ladder at the same level, at most two tries per turn. No chat message is
7
+ // added; the client shows a small banner that fades (src/lib/engine-switch.ts).
8
+ //
9
+ // Modelled on OpenRouter's routing (docs/chibitek/engine-tiers.md): any
10
+ // error is a reason to try the next candidate, not only the ones we have
11
+ // learned to recognise. Recognised failures (a usage limit, a failed or
12
+ // dead sign-in, a stall, a rate limit, a reasonless 400) rest the engine
13
+ // for as long as that cause lasts; anything else is `provider_error`, which
14
+ // rests it briefly (a circuit breaker) so it is not hammered. Every error an
15
+ // engine reports while its turn is watched is held, so the person never sees
16
+ // a provider's raw words for a failure the next engine recovers from.
17
+ // This module is the part that watches a turn's events:
18
+ //
19
+ // - While a turn is tracked, the engine's own report of the limit (a
20
+ // runtime.error, or the text a CLI prints in place of a reply, which the
21
+ // drivers mark `synthetic`) is held back instead of delivered. So is a
22
+ // reply that starts the way an agent's billing error does (Hermes answers
23
+ // "Billing or credits exhausted: HTTP 402: ..." as ordinary text and ends
24
+ // the turn as a success): it waits until the reply is complete, and if it
25
+ // is that error, with no tool run, the turn counts as failed.
26
+ // - When the turn completes failed with a held (or recognisable) limit,
27
+ // `onFailure` decides: it either moves the turn to another engine, and
28
+ // returns the one record that says so (turn.engine_failover), or it stops
29
+ // the turn with one plain line naming the resting engines.
30
+ // - A turn that ends any other way gets everything it held, in order.
31
+ //
32
+ // The server wires `onFailure` to the ladder, the resting store and
33
+ // startTurn (server/index.ts); everything here is pure bookkeeping.
34
+ import { newEventId } from "./contracts.js";
1
35
  import { classifyEngineFailure, isCredentialDead, mayBeReplyFailure, replyEngineFailure, statedReset } from "./engine-failure.js";
2
36
  /** The two-try cap: at most this many engines take part in one turn, the
3
37
  * first and one switch. Every failed attempt counts toward it, whatever the
@@ -15,10 +49,10 @@ export const NO_ANSWER_FAILURE = "The engine ended the turn without an answer.";
15
49
  function unrecognisedFailure(turn) {
16
50
  return { kind: "provider_error", message: turn.lastError ?? NO_ANSWER_FAILURE };
17
51
  }
18
- /** Ways a turn ends failed that are not the engine's fault: the person (or
19
- * the harness on their behalf) stopped it. Every other failed end moves the
20
- * turn to the next engine. */
21
- const NOT_A_PROVIDER_FAILURE = new Set(["interrupted", "cancelled"]);
52
+ /** Stops that must not dispatch the request again: cancellation or a broken
53
+ * checkpoint whose accepted prompt may already have executed actions.
54
+ * The next explicit turn rebuilds that checkpoint from retained history. */
55
+ const NOT_A_PROVIDER_FAILURE = new Set(["interrupted", "cancelled", "invalid_checkpoint"]);
22
56
  export class FailoverGate {
23
57
  turns = new Map();
24
58
  onFailure;
@@ -224,3 +258,9 @@ export function restingLine(engines, formatTime, computer, options = {}) {
224
258
  return `${parts[0]}, and no other engine at the same level is free, so this turn stopped.${hint}`;
225
259
  return `Every engine this bot can use is resting, so this turn stopped: ${parts.join(", ")}.${hint}`;
226
260
  }
261
+ /** A new harness event about the same provider turn as `event`. */
262
+ export const eventBase = (event) => ({
263
+ eventId: newEventId(), provider: event.provider, threadId: event.threadId, createdAt: new Date().toISOString(),
264
+ ...(event.providerInstanceId ? { providerInstanceId: event.providerInstanceId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}),
265
+ });
266
+ export const restTime = (at) => new Date(at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
@@ -160,9 +160,9 @@ export function classifyEngineFailure(text, now = Date.now()) {
160
160
  return null;
161
161
  }
162
162
  // Hermes returns billing and expired-login refusals as successful reply text.
163
- const REPLY_FAILURE_HEADS = ["login expired", "error: nonretriableerror:", "billing or credits exhausted", "http 402:", "error: http 402:", "api error: http 402:"];
163
+ const REPLY_FAILURE_HEADS = ["login expired", "error: nonretriableerror:", "billing or credits exhausted", "http 402:", "error: http 402:", "api error: http 402:", "usage limit reached"];
164
164
  const REPLY_FAILURE = [
165
- /^billing or credits exhausted\b/i,
165
+ /^billing or credits exhausted\b|^usage limit reached\b[\s\S]*\b(?:quota|limit will reset|resets? (?:in|at|on))\b/i, // the second: a quota page as the reply (2026-10-26)
166
166
  // A bare "HTTP 402:" at the head counts only with the provider's own
167
167
  // billing words after it.
168
168
  /^(?:(?:api )?error:\s*)?http 402:[\s\S]*\b(?:positive balance|insufficient (?:balance|credits?|funds)|add (?:credits|balance|funds)|top-?up|credits? (?:exhausted|depleted))\b/i,
@@ -11,7 +11,7 @@ import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { DatabaseSync } from "node:sqlite";
13
13
  import { pathToFileURL } from "node:url";
14
- import { attachmentBytesUsed, ATTACHMENTS_DIR, ATTACHMENTS_MAX_BYTES, deleteAttachment, extensionForFileMime, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, saveFile, saveImage, } from "../attachments.js";
14
+ import { attachmentBytesAvailable, ATTACHMENTS_DIR, deleteAttachment, extensionForFileMime, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, saveFile, saveImage, } from "../attachments.js";
15
15
  import { redactSecretsInText } from "../redact.js";
16
16
  import { existingBots, importIdentity, mergedBotOf, sourceRefFor } from "./apply.js";
17
17
  import { GROKBOT_HISTORY_ALLOWLIST } from "./grokbot.js";
@@ -31,7 +31,7 @@ const MAX_STORE_BYTES = 2 * 1024 * 1024 * 1024;
31
31
  /** The real attachment directory, through the same save calls uploads use. */
32
32
  export function historyAttachmentStore() {
33
33
  return {
34
- usedBytes: attachmentBytesUsed,
34
+ availableBytes: attachmentBytesAvailable,
35
35
  has(uploadId, extension) {
36
36
  try {
37
37
  return statSync(join(ATTACHMENTS_DIR, `${uploadId}${extension}`)).isFile();
@@ -421,7 +421,7 @@ export function planBotHistory(sourceId, entries, ctx) {
421
421
  hidden: cleaner.hidden(),
422
422
  };
423
423
  }
424
- export const defaultHistoryBudget = (usedBytes) => Math.max(0, ATTACHMENTS_MAX_BYTES - usedBytes - HISTORY_QUOTA_MARGIN_BYTES);
424
+ export const defaultHistoryBudget = (availableBytes) => Math.max(0, availableBytes - HISTORY_QUOTA_MARGIN_BYTES);
425
425
  /** Which files to copy: images first, then documents, newest first, until
426
426
  * the budget is spent. A file already in Atomic costs nothing. */
427
427
  export function allocateFiles(input, budget, stored) {
@@ -608,7 +608,7 @@ const storedCheck = (store) => (file) => {
608
608
  export function previewHistory(root, result, deps, options) {
609
609
  const source = options.source ?? GROKBOT_HISTORY;
610
610
  const targets = historyTargets(result, deps.bots());
611
- const budget = options.budget ?? defaultHistoryBudget(deps.attachments.usedBytes());
611
+ const budget = options.budget ?? defaultHistoryBudget(deps.attachments.availableBytes());
612
612
  const empty = { threads: 0, messages: 0, files: { copied: 0, copiedBytes: 0, skipped: 0, skippedBytes: 0 } };
613
613
  if (!options.requested) {
614
614
  return { requested: false, available: targets.filter((target) => source.available(root, target.sourceId, options.fs)).length, bots: [], totals: empty, budgetBytes: budget };
@@ -698,7 +698,7 @@ export async function applyHistory(root, result, deps, options = {}) {
698
698
  out.hiddenMessages += plan.hidden;
699
699
  }
700
700
  }
701
- const budget = options.budget ?? defaultHistoryBudget(deps.attachments.usedBytes());
701
+ const budget = options.budget ?? defaultHistoryBudget(deps.attachments.availableBytes());
702
702
  const stored = storedCheck(deps.attachments);
703
703
  const allocation = allocateFiles(work.flatMap((item) => item.threads.flatMap(({ thread }) => thread.messages.flatMap((message) => message.files))), budget, stored);
704
704
  out.files.tooLarge = allocation.tooLarge.size;
@@ -276,7 +276,7 @@ export function fakeHistoryDeps(options = {}) {
276
276
  },
277
277
  deleteThread: (_botId, threadId) => state.threads.delete(threadId),
278
278
  attachments: {
279
- usedBytes: () => state.used,
279
+ availableBytes: () => 512 * 1024 * 1024 - state.used,
280
280
  has: (uploadId, extension) => state.files.has(`/attachments/${uploadId}${extension}`),
281
281
  saveImage: (bytes, mime, uploadId) => {
282
282
  const path = pathFor(uploadId, mime);
@@ -1,3 +1,6 @@
1
+ import { invalidateBrokenCheckpoint } from "./checkpoint-recovery.js";
2
+ import { createModelExpectation, ModelEvidenceLedger } from "./model-evidence-context.js";
3
+ import { reactToMessage } from "./message-reactions.js";
1
4
  import { hasUnboundGoalGuard, goalGuardForEvent, addGoalGuard, removeGoalGuard, runPersonalGoalTurn, updatePersonalGoalReceipt } from "./personal-goal-turn.js";
2
5
  import { PersonalGoalRuntime, receiveGoalCommand } from "./personal-goal-runtime.js";
3
6
  import { personalGoalInstructions } from "./personal-goals.js";
@@ -46,7 +49,8 @@ import { validateBotCwd } from "./bot-cwd.js";
46
49
  import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
47
50
  import { companyCheck, companyContext } from "./company-document-check.js";
48
51
  import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
49
- import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAvatarImage, avatarImageStatus, snapshotAvatarGenerationState, } from "./avatar-image.js";
52
+ import { avatarGenerationRequestSchema, avatarGenerationStateMatches, avatarImageStatus, snapshotAvatarGenerationState } from "./avatar-image.js";
53
+ import { generateRoutedAvatar, installAvatarSubscriptionRoute } from "./avatar-subscription.js";
50
54
  import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
51
55
  import { groupTurnCwd } from "./room-cwd.js";
52
56
  import { RepoRegistry } from "./repos.js";
@@ -304,7 +308,7 @@ import { createNexusMemoryRoutes } from "./routes/nexus-memory.js";
304
308
  import { createBoardRoutes } from "./routes/board.js";
305
309
  import { readBoardFromNexus } from "./board/nexus-board.js";
306
310
  import { LocalModelInventory, localModePlacement, machineMemory } from "./local-models-mode.js";
307
- import { FailoverGate, MAX_ENGINES_PER_TURN, continuationNote, restingLine } from "./engine-failover.js";
311
+ import { eventBase, restTime, FailoverGate, MAX_ENGINES_PER_TURN, continuationNote, restingLine } from "./engine-failover.js";
308
312
  import { enterSessionFallback, onSessionFallback, ownOrFallback, sessionFallbackSpentLine, withoutSubscription } from "./session-fallback.js";
309
313
  import { TURN_ERROR_CATEGORIES } from "../shared/runtime-events.js";
310
314
  import { EngineProbes, PROBE_PROMPT, isProbeThread, probeThreadId } from "./engine-probe.js";
@@ -487,6 +491,7 @@ function customDomainStatus() {
487
491
  };
488
492
  }
489
493
  const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
494
+ installAvatarSubscriptionRoute(() => registry.instances());
490
495
  // Engines installed from Settings live under the data directory and win over
491
496
  // any other copy on PATH.
492
497
  registerEnginesBinDir();
@@ -3564,6 +3569,9 @@ const roomFailoverTurns = new Map();
3564
3569
  * default (a resting engine's stand-in, a failover): the ledger books it. */
3565
3570
  const roomTurnSelections = new Map();
3566
3571
  const turnEngineOverrides = new Map();
3572
+ const modelEvidence = new ModelEvidenceLedger(DATA_DIR);
3573
+ const modelExpectation = createModelExpectation({ get store() { return store; }, get registry() { return registry; }, turnEngineOverrides, roomTurnSelections,
3574
+ speakerBot: threadId => groupSpeakers.get(threadId)?.botId, enginePlan, botClassChoices });
3567
3575
  const failoverGate = new FailoverGate((turn, completion) => engineFailover(turn, completion));
3568
3576
  /** An approval shows a shell command as the model meant it: the RTK add-on's
3569
3577
  * launcher, which engines that take RTK as instructions put in front of
@@ -3582,9 +3590,16 @@ bus.setGate((published) => {
3582
3590
  return [];
3583
3591
  }
3584
3592
  const event = withoutRtkInSummary(published);
3593
+ if (!shouldIgnoreProviderEvent(event))
3594
+ modelEvidence.observe(event, () => modelExpectation(event.threadId, event.providerInstanceId, event.provider));
3585
3595
  // [jev-verifier] note a failover turn that finished, before the gate forgets it.
3586
3596
  const jevCandidate = jevAnswerCheckCandidate(event);
3587
3597
  const delivered = failoverGate.filter(event);
3598
+ // Gate-generated switches never re-enter publish; preserve their evidence too.
3599
+ for (const out of delivered)
3600
+ if (out.type === "turn.engine_failover" && out !== event) {
3601
+ modelEvidence.observe(out, () => modelExpectation(out.threadId, out.providerInstanceId, out.provider));
3602
+ }
3588
3603
  if (jevCandidate)
3589
3604
  setImmediate(() => void jevAnswerCheck(jevCandidate));
3590
3605
  for (const out of delivered)
@@ -3603,12 +3618,6 @@ function noteEngineBack(event) {
3603
3618
  const model = selection?.instanceId === instanceId ? selection.model : undefined;
3604
3619
  engineRests.noteBack(instanceId, candidate ? engineAccount(candidate) : undefined, candidate ? restScope(candidate, model) : undefined);
3605
3620
  }
3606
- /** A new harness event about the same provider turn as `event`. */
3607
- const eventBase = (event) => ({
3608
- eventId: newEventId(), provider: event.provider, threadId: event.threadId, createdAt: new Date().toISOString(),
3609
- ...(event.providerInstanceId ? { providerInstanceId: event.providerInstanceId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}),
3610
- });
3611
- const restTime = (at) => new Date(at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
3612
3621
  /** Rest a refused engine and continue on an eligible engine, or show an actionable line. */
3613
3622
  function engineFailover(turn, completion) {
3614
3623
  const failure = turn.failure;
@@ -8162,22 +8171,20 @@ bus.subscribe((event) => {
8162
8171
  });
8163
8172
  break;
8164
8173
  case "runtime.error":
8174
+ if (bot)
8175
+ invalidateBrokenCheckpoint(store, bot.id, event.providerInstanceId ? registry.get(event.providerInstanceId)?.driverKind : undefined, event);
8165
8176
  if (event.message?.trim())
8166
8177
  lastTurnError.set(event.threadId, event.message.trim().slice(0, 300));
8167
8178
  pushMessage({
8168
8179
  role: "bot",
8169
8180
  kind: "activity",
8170
- // The category travels with the line so every surface words it the
8171
- // same way; an error line is never a lost connection either way.
8181
+ // Keep the category consistent across surfaces.
8172
8182
  // The harness's own stop line is kept whole: it ends with where to fix
8173
8183
  // the account, which a 160-character cut would drop.
8174
8184
  tool: { name: `error: ${event.message.slice(0, event.category === "account" || event.category === "failover_exhausted" ? 600 : 160)}`, ok: false, setup: event.setup, ...(event.terminal ? { terminal: true } : {}),
8175
8185
  ...(event.category ? { errorCategory: event.category } : {}) },
8176
8186
  });
8177
- // a setup error means the engine could not even start: the bot is
8178
- // dead until something changes, not merely idle. The next successful
8179
- // dispatch moves it to working; turn.completed (which follows a setup
8180
- // failure) is told to leave "dead" alone.
8187
+ // Setup failures remain dead until a later successful dispatch.
8181
8188
  if (event.setup && bot)
8182
8189
  store.setTaskActivity(bot.id, event.threadId, "dead");
8183
8190
  break;
@@ -9825,6 +9832,7 @@ async function startTurn(botId, text, opts) {
9825
9832
  // thread, so this is the one moment the ledger's "who asked" may change.
9826
9833
  if (opts?.trigger)
9827
9834
  turnTriggers.set(threadId, opts.trigger);
9835
+ const expectedModel = modelExpectation(threadId);
9828
9836
  // The engine the ladder chose becomes the thread's model, as a person's
9829
9837
  // switch would; the thread stays tier-managed for its next turn.
9830
9838
  if (tierSelection && JSON.stringify(tierSelection) !== JSON.stringify(task.modelSelection)) {
@@ -9849,6 +9857,8 @@ async function startTurn(botId, text, opts) {
9849
9857
  // busy flips immediately so the composer locks; the dispatch itself runs
9850
9858
  // in the background — box provisioning can take ~90s and must never
9851
9859
  // hang the HTTP request
9860
+ const evidenceAttemptId = expectedModel ? modelEvidence.begin({ ...expectedModel, requestedModel: model,
9861
+ instanceId: instance.instanceId, provider: instance.driverKind }, Boolean(failover)) : undefined;
9852
9862
  const dispatchClaimId = randomUUID();
9853
9863
  const resourceOwner = { threadId, generation: dispatchClaimId };
9854
9864
  turnResourceOwners.set(threadId, resourceOwner);
@@ -10889,6 +10899,8 @@ async function startTurn(botId, text, opts) {
10889
10899
  }
10890
10900
  }
10891
10901
  catch (e) {
10902
+ if (evidenceAttemptId)
10903
+ modelEvidence.finishDispatch(threadId, evidenceAttemptId);
10892
10904
  handoffs.abandon(threadId, dispatchClaimId);
10893
10905
  failoverGate.untrack(threadId, dispatchClaimId);
10894
10906
  if (failoverTurns.get(threadId)?.generation === dispatchClaimId)
@@ -20478,14 +20490,7 @@ const handleRequest = async (req, res) => {
20478
20490
  // emoji reactions — works on any thread (1:1 or room)
20479
20491
  m = path.match(/^\/api\/threads\/([\w-]+)\/messages\/([\w-]+)\/reactions$/);
20480
20492
  if (m && method === "POST") {
20481
- const body = await readBody(req);
20482
- const emoji = String(body.emoji ?? "").slice(0, 8);
20483
- if (!emoji)
20484
- return json(res, 400, { error: "emoji required" });
20485
- const patched = store.toggleReaction(m[1], m[2], emoji, typeof body.by === "string" ? body.by : "user");
20486
- if (!patched)
20487
- return json(res, 404, { error: "no such message" });
20488
- return json(res, 200, { message: patched });
20493
+ return reactToMessage(store, req, res, m[1], m[2]);
20489
20494
  }
20490
20495
  if (path === "/api/sidebar-sections" && method === "GET") {
20491
20496
  return json(res, 200, { sections: store.sections });
@@ -20598,13 +20603,13 @@ const handleRequest = async (req, res) => {
20598
20603
  const profile = parseBotProfilePatch(body.profile, true);
20599
20604
  if (!profile.ok)
20600
20605
  return json(res, 400, { error: profile.error });
20601
- const checked = avatarGenerationRequestSchema.safeParse({ prompt: body.prompt });
20606
+ const checked = avatarGenerationRequestSchema.safeParse({ prompt: body.prompt, source: body.source });
20602
20607
  if (!checked.success)
20603
20608
  return json(res, 400, { error: checked.error.message });
20604
20609
  const { prompt } = checked.data;
20605
- const generated = await generateAvatarImage(cfg, {
20610
+ const generated = await generateRoutedAvatar(cfg, registry.instances(), {
20606
20611
  name: profile.patch.name ?? "", title: profile.patch.title ?? "", description: profile.patch.description ?? "",
20607
- }, prompt);
20612
+ }, prompt, checked.data.source);
20608
20613
  // A preview belongs to the draft. Upload it through the existing image
20609
20614
  // endpoint only when the user commits the new bot or its defaults.
20610
20615
  return json(res, 200, { dataUrl: `data:${generated.mime};base64,${generated.bytes.toString("base64")}` });
@@ -20855,7 +20860,7 @@ const handleRequest = async (req, res) => {
20855
20860
  if (!parsed.success) {
20856
20861
  return json(res, 400, { error: `prompt must be at most 400 characters` });
20857
20862
  }
20858
- const generated = await generateAvatarImage(cfg, existing, parsed.data.prompt);
20863
+ const generated = await generateRoutedAvatar(cfg, registry.instances(), existing, parsed.data.prompt, parsed.data.source);
20859
20864
  const current = store.bot(existing.id);
20860
20865
  if (!current)
20861
20866
  return json(res, 404, { error: "no such bot" });
@@ -21836,6 +21841,15 @@ const handleRequest = async (req, res) => {
21836
21841
  return json(res, 404, { error: "no such bot" });
21837
21842
  return json(res, 200, previewSystemPrompt(bot, { privateOwner: privateOwnerFor(auth) }));
21838
21843
  }
21844
+ m = path.match(/^\/api\/bots\/([\w-]+)\/model-evidence$/);
21845
+ if (m && method === "GET") {
21846
+ const thread = requestedTaskBot(m[1], url.searchParams.get("threadId") ?? undefined);
21847
+ const range = parseUsageRange(url.searchParams.get("from"), url.searchParams.get("to"));
21848
+ if (!range)
21849
+ return json(res, 400, { error: "Provide a date range of at most one year" });
21850
+ res.setHeader("cache-control", "no-store");
21851
+ return json(res, 200, modelEvidence.query(m[1], thread.threadId, range.from, range.to));
21852
+ }
21839
21853
  m = path.match(/^\/api\/bots\/([\w-]+)\/model-classes$/);
21840
21854
  if (m && method === "GET") {
21841
21855
  const thread = requestedTaskBot(m[1], url.searchParams.get("threadId") ?? undefined);
@@ -0,0 +1,11 @@
1
+ import { isReactionEmoji } from "../shared/emoji.js";
2
+ import { json, readBody } from "./harness/http.js";
3
+ export async function reactToMessage(store, req, res, threadId, messageId) {
4
+ const body = await readBody(req);
5
+ if (!isReactionEmoji(body.emoji))
6
+ return json(res, 400, { error: "Choose one emoji" });
7
+ const message = store.toggleReaction(threadId, messageId, body.emoji, "user");
8
+ if (!message)
9
+ return json(res, 404, { error: "no such message" });
10
+ return json(res, 200, { message });
11
+ }
@@ -0,0 +1,18 @@
1
+ export { ModelEvidenceLedger } from "./model-evidence.js";
2
+ // Snapshot the same class choices the model picker displays before routing mutates the task.
3
+ import { threadEngineMode } from "../shared/engine-tiers.js";
4
+ export function createModelExpectation(context) {
5
+ return (threadId, instanceId, provider) => {
6
+ const profile = context.store.botByThread(threadId) ?? context.store.bot(context.speakerBot(threadId) ?? "");
7
+ if (!profile)
8
+ return null;
9
+ const task = context.store.taskByThread(profile.id, threadId);
10
+ const selection = context.turnEngineOverrides.get(threadId) ?? context.roomTurnSelections.get(threadId) ?? task?.modelSelection ?? profile.modelSelection;
11
+ const modelClass = context.enginePlan(profile, threadId).modelClass;
12
+ const mode = threadEngineMode(profile, task ?? {});
13
+ const preview = mode.kind === "pinned" ? undefined : context.botClassChoices(profile, threadId).options.find(option => option.modelClass === modelClass);
14
+ return { botId: profile.id, threadId, label: `${mode.kind === "tier" ? "Automatic: " : ""}${modelClass} · ${preview?.label ?? selection.model}`, modelClass,
15
+ model: preview?.selection?.model ?? selection.model, requestedModel: selection.model,
16
+ instanceId: instanceId ?? selection.instanceId, provider: provider ?? context.registry.get(selection.instanceId)?.driverKind ?? "unknown" };
17
+ };
18
+ }
@@ -0,0 +1,145 @@
1
+ // Name: model-evidence; author: Atomic Engineering; date: 2026-10-27.
2
+ // Append-only identity history beside the existing usage ledger, without prompts.
3
+ import { randomUUID } from "node:crypto";
4
+ import { mkdirSync, appendFileSync, readdirSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { modelEvidenceMetrics } from "../shared/model-evidence.js";
7
+ export { modelEvidenceMetrics };
8
+ const safe = (value) => typeof value === "string" && /^[\w .:/@+()[\]-]{1,200}$/u.test(value) ? value : undefined;
9
+ export class ModelEvidenceLedger {
10
+ active = new Map();
11
+ byTurn = new Map();
12
+ failures = 0;
13
+ dataDir;
14
+ constructor(dataDir) { this.dataDir = dataDir; }
15
+ begin(expected, continuation = false) {
16
+ const prior = this.active.get(expected.threadId);
17
+ const attempt = { expected: { ...expected, ...(continuation && prior ? { label: prior.expected.label, modelClass: prior.expected.modelClass, model: prior.expected.model } : {}) }, logicalTurnId: continuation && prior ? prior.logicalTurnId : randomUUID(), attemptId: randomUUID() };
18
+ this.active.set(expected.threadId, attempt);
19
+ this.write(attempt, { kind: "dispatch", level: expected.requestedModel || expected.model ? "requested-only" : "unknown" });
20
+ return attempt.attemptId;
21
+ }
22
+ finishDispatch(threadId, attemptId) {
23
+ const attempt = this.active.get(threadId);
24
+ if (attempt?.attemptId === attemptId)
25
+ this.write(attempt, { kind: "completed", level: "unknown", ok: false, reason: "dispatch ended before provider completion" });
26
+ }
27
+ write(attempt, fields) {
28
+ const row = { ...attempt.expected, version: 1, id: randomUUID(), at: new Date().toISOString(),
29
+ logicalTurnId: attempt.logicalTurnId, attemptId: attempt.attemptId, ...(attempt.turnId ? { turnId: attempt.turnId } : {}), ...fields };
30
+ try {
31
+ const directory = join(this.dataDir, "model-evidence");
32
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
33
+ appendFileSync(join(directory, `${row.at.slice(0, 7)}.jsonl`), JSON.stringify(row) + "\n", { mode: 0o600 });
34
+ }
35
+ catch {
36
+ this.failures++;
37
+ if (this.failures === 1)
38
+ console.error("[model-evidence] Cannot persist model history; queries will report incomplete evidence.");
39
+ }
40
+ }
41
+ observe(event, expectation) {
42
+ if (!["turn.started", "session.started", "model.evidence", "model.call", "turn.retrying", "turn.engine_failover", "session.model-fallback", "turn.completed", "session.exited"].includes(event.type))
43
+ return;
44
+ const turnKey = event.turnId ? JSON.stringify([event.threadId, event.providerInstanceId ?? event.provider, event.turnId]) : undefined;
45
+ let attempt = turnKey ? this.byTurn.get(turnKey) : undefined;
46
+ if (!attempt) {
47
+ attempt = this.active.get(event.threadId);
48
+ if (!attempt || (event.type === "turn.started" && attempt.turnId && attempt.turnId !== event.turnId)) {
49
+ const expected = expectation();
50
+ if (!expected)
51
+ return;
52
+ this.begin(expected);
53
+ attempt = this.active.get(event.threadId);
54
+ }
55
+ if (event.turnId) {
56
+ attempt.turnId = event.turnId;
57
+ this.byTurn.set(turnKey, attempt);
58
+ }
59
+ }
60
+ const base = { provider: event.provider, instanceId: event.providerInstanceId ?? attempt.expected.instanceId };
61
+ switch (event.type) {
62
+ case "turn.started":
63
+ this.write(attempt, { ...base, kind: "start", level: "requested-only" });
64
+ break;
65
+ case "model.call":
66
+ this.write(attempt, { ...base, kind: "call", level: "unknown", callId: event.callId });
67
+ break;
68
+ case "model.evidence":
69
+ this.write(attempt, { ...base, kind: "evidence", level: safe(event.model) ? event.level : "unknown",
70
+ ...(safe(event.model) ? { reportedModel: safe(event.model) } : {}), callId: event.callId, responseId: safe(event.responseId) });
71
+ break;
72
+ case "session.started":
73
+ this.write(attempt, { ...base, kind: "evidence", level: safe(event.model) ? event.modelEvidenceLevel ?? "requested-only" : "unknown", reportedModel: safe(event.model) });
74
+ break;
75
+ case "turn.retrying": {
76
+ this.write(attempt, { ...base, kind: "retry", level: "unknown", reason: safe(event.reason) });
77
+ // CLI retries reuse their native turn id, but are distinct attempts.
78
+ this.begin(attempt.expected, true);
79
+ const retry = this.active.get(event.threadId);
80
+ retry.turnId = event.turnId;
81
+ if (turnKey)
82
+ this.byTurn.set(turnKey, retry);
83
+ break;
84
+ }
85
+ case "turn.engine_failover":
86
+ case "session.model-fallback":
87
+ this.write(attempt, { ...base, kind: "fallback", level: "unknown", reason: safe(event.reason) ?? "provider fallback" });
88
+ break;
89
+ case "turn.completed":
90
+ this.write(attempt, { ...base, kind: "completed", level: "unknown", ok: event.ok });
91
+ break;
92
+ case "session.exited":
93
+ this.write(attempt, { ...base, kind: "exited", level: "unknown" });
94
+ break;
95
+ }
96
+ // ponytail: bounded correlation cache; durable history remains on disk.
97
+ if (this.byTurn.size > 10000)
98
+ this.byTurn.delete(this.byTurn.keys().next().value);
99
+ if (this.active.size > 10000)
100
+ this.active.delete(this.active.keys().next().value);
101
+ }
102
+ query(botId, threadId, from, to) {
103
+ const directory = join(this.dataDir, "model-evidence");
104
+ const rows = [];
105
+ let incomplete = this.failures > 0;
106
+ let files = [];
107
+ try {
108
+ files = readdirSync(directory).filter(file => /^\d{4}-\d{2}\.jsonl$/.test(file)).sort();
109
+ }
110
+ catch (error) {
111
+ if (error.code !== "ENOENT")
112
+ incomplete = true;
113
+ }
114
+ for (const file of files) {
115
+ if (file.slice(0, 7) < from.toISOString().slice(0, 7) || file.slice(0, 7) > to.toISOString().slice(0, 7))
116
+ continue;
117
+ try {
118
+ for (const line of readFileSync(join(directory, file), "utf8").split("\n").filter(Boolean)) {
119
+ try {
120
+ const row = JSON.parse(line);
121
+ if (row.version !== 1 || !row.logicalTurnId || !row.attemptId) {
122
+ incomplete = true;
123
+ continue;
124
+ }
125
+ if (row.botId === botId && row.threadId === threadId && row.at >= from.toISOString() && row.at <= to.toISOString())
126
+ rows.push(row);
127
+ }
128
+ catch {
129
+ incomplete = true;
130
+ }
131
+ }
132
+ }
133
+ catch {
134
+ incomplete = true;
135
+ }
136
+ }
137
+ const latestDispatch = rows.findLast(row => row.kind === "dispatch");
138
+ const latest = latestDispatch && rows.findLast(row => row.attemptId === latestDispatch.attemptId);
139
+ // Late exit/events from an older attempt must not settle a newer attempt.
140
+ const running = latest && !rows.some(row => row.attemptId === latest.attemptId && ["completed", "exited"].includes(row.kind));
141
+ return { rows, metrics: modelEvidenceMetrics(rows), incomplete, writeFailures: this.failures,
142
+ current: running && this.active.has(threadId) ? latest.logicalTurnId : null,
143
+ interrupted: Boolean(running && !this.active.has(threadId)), last: latest?.logicalTurnId ?? null };
144
+ }
145
+ }