paseo-bm-plugin 0.0.0-placeholder.0
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/LICENSE +21 -0
- package/README.md +53 -0
- package/client/agent-tree.ts +308 -0
- package/client/answer-state.ts +62 -0
- package/client/bead-chips.tsx +147 -0
- package/client/beads-header-button.ts +108 -0
- package/client/beads-model.ts +581 -0
- package/client/beads-screen.tsx +516 -0
- package/client/beads-tab.tsx +58 -0
- package/client/chat-card.tsx +636 -0
- package/client/chat-cards.ts +1038 -0
- package/client/dashboard-actions.tsx +255 -0
- package/client/dashboard-model.ts +947 -0
- package/client/dashboard-view.ts +215 -0
- package/client/dashboard.tsx +318 -0
- package/client/launch-manager.ts +323 -0
- package/client/launcher.tsx +516 -0
- package/client/markdown-view.tsx +112 -0
- package/client/markdown.ts +145 -0
- package/client/settings.tsx +104 -0
- package/client/setup-model.ts +552 -0
- package/client/setup-screen.tsx +913 -0
- package/client/slot.ts +47 -0
- package/client/tree.tsx +204 -0
- package/client/ui.tsx +262 -0
- package/client/waiting-pills-model.ts +156 -0
- package/client/waiting-pills.tsx +201 -0
- package/index.client.tsx +232 -0
- package/index.server.ts +168 -0
- package/package.json +35 -0
- package/paseo-plugin.json +6 -0
- package/roles/manager.md +181 -0
- package/roles/reviewer.md +160 -0
- package/roles/worker.md +407 -0
- package/server/agent-labels.ts +194 -0
- package/server/agent-role.ts +102 -0
- package/server/answer-marks.ts +120 -0
- package/server/bead-actions.ts +88 -0
- package/server/bead-work.ts +80 -0
- package/server/beads-store.ts +342 -0
- package/server/bm-report.ts +433 -0
- package/server/chat-peers.ts +65 -0
- package/server/chat-rpc.ts +122 -0
- package/server/chat-waiting.ts +182 -0
- package/server/collector.ts +629 -0
- package/server/config-writer.ts +222 -0
- package/server/cost.ts +88 -0
- package/server/dashboard-rpc.ts +662 -0
- package/server/fallback-detect.ts +183 -0
- package/server/fallback-handover.ts +365 -0
- package/server/fallback-manager.ts +170 -0
- package/server/fallback-reviewer.ts +198 -0
- package/server/fallback-rpc.ts +306 -0
- package/server/fallback-settings.ts +322 -0
- package/server/fallback-state.ts +518 -0
- package/server/fallback-switch.ts +191 -0
- package/server/fallback-wait.ts +188 -0
- package/server/format-check.ts +352 -0
- package/server/install-home.ts +187 -0
- package/server/live-timeline.ts +129 -0
- package/server/manager-instructions.ts +9 -0
- package/server/manager.ts +647 -0
- package/server/model-costs.ts +238 -0
- package/server/notice-queue.ts +315 -0
- package/server/notices.ts +81 -0
- package/server/paseo-cli.ts +115 -0
- package/server/provider-id.ts +12 -0
- package/server/review-budget.ts +208 -0
- package/server/reviewer-instructions.ts +9 -0
- package/server/role-choices.ts +161 -0
- package/server/role-extras.ts +270 -0
- package/server/role-hook.ts +347 -0
- package/server/role-mode.ts +397 -0
- package/server/role-settings-rpc.ts +325 -0
- package/server/roles.ts +96 -0
- package/server/settings-notices.ts +112 -0
- package/server/setup-rpc.ts +70 -0
- package/server/setup-skills.ts +121 -0
- package/server/setup-tools.ts +162 -0
- package/server/shell.ts +68 -0
- package/server/stop-propagation.ts +365 -0
- package/server/tools-check.ts +118 -0
- package/server/trace-store.ts +1137 -0
- package/server/traces.ts +1356 -0
- package/server/worker-instructions.ts +9 -0
- package/server/workflow-steps.ts +422 -0
- package/shared/bead-ids.ts +25 -0
- package/shared/bm-fallback.ts +91 -0
- package/shared/bm-format.ts +424 -0
- package/shared/bm-questions.ts +213 -0
- package/shared/bm-report.ts +433 -0
- package/shared/contracts.ts +1371 -0
- package/shared/fallback-patterns.ts +201 -0
- package/shared/fallback.ts +46 -0
- package/shared/new-request.ts +20 -0
- package/shared/order.ts +22 -0
- package/shared/prices.ts +65 -0
- package/shared/settings.ts +57 -0
- package/shared/sole-worker.ts +20 -0
- package/shared/version.ts +6 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat cards for messages between the Manager, Workers and Reviewers
|
|
3
|
+
* (delta 20260916-chat-cards).
|
|
4
|
+
*
|
|
5
|
+
* Paseo runs a timeline transformer on every chat item of every agent, and the
|
|
6
|
+
* transformer cannot tell whose chat it is. So a card is made only for what is
|
|
7
|
+
* unmistakably paseo-bm's: a message another agent sent that carries a
|
|
8
|
+
* `BM-REPORT`, a `BM-REVIEW` or a request id, an agent's own finished
|
|
9
|
+
* message that carries a report or review block, and the plugin's own
|
|
10
|
+
* `BM-FALLBACK` notice (delta 20260921 §4.4.6). Everything else is left to
|
|
11
|
+
* Paseo. Who sent what is decided later, by the renderer, from `chat.peers`.
|
|
12
|
+
*
|
|
13
|
+
* Pure: no React, no React Native, no `server/` import.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { looksLikeReport, parseReports, parseReviews, requestIdFromText } from "../shared/bm-report";
|
|
17
|
+
import { answersText, parseQuestions, type Pick, type Question } from "../shared/bm-questions";
|
|
18
|
+
import { checkBlocks, issueText } from "../shared/bm-format";
|
|
19
|
+
import { BM_FALLBACK_MARKER, parseFallbackNotice } from "../shared/bm-fallback";
|
|
20
|
+
import {
|
|
21
|
+
FALLBACK_MAX_WAIT_MS,
|
|
22
|
+
type BeadRow,
|
|
23
|
+
type ChatPeer,
|
|
24
|
+
type FallbackActInput,
|
|
25
|
+
type FallbackIncident,
|
|
26
|
+
type RoleModelOption,
|
|
27
|
+
type WaitingWorker,
|
|
28
|
+
} from "../shared/contracts";
|
|
29
|
+
import { MODEL_PRICES, type ModelPrice } from "../shared/prices";
|
|
30
|
+
import { soleWorkerOf } from "../shared/sole-worker";
|
|
31
|
+
import type { Badge, GraphNode, Tone } from "./dashboard-model";
|
|
32
|
+
import { errorCodeOf, errorMessageOf } from "./launch-manager";
|
|
33
|
+
import { providerLabel } from "./setup-model";
|
|
34
|
+
|
|
35
|
+
export const CHAT_CARD_KIND = "bm-message";
|
|
36
|
+
export const CHAT_CARD_VERSION = 1;
|
|
37
|
+
|
|
38
|
+
/** A question of a `BM-QUESTIONS` block (delta 20260918c-question-cards §4.4). */
|
|
39
|
+
export const questionSchema = z.object({
|
|
40
|
+
id: z.string(),
|
|
41
|
+
text: z.string(),
|
|
42
|
+
options: z.array(z.object({ key: z.string(), text: z.string(), recommended: z.boolean() })),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What a `BM-FALLBACK` card keeps of its notice (delta 20260921 §4.4.6): the
|
|
47
|
+
* incident it is about, and what to show until `fallback.incidents` answers.
|
|
48
|
+
* Never the incident's state — no status, candidate or reset time — so an old
|
|
49
|
+
* notice cannot show a button that no longer applies.
|
|
50
|
+
*/
|
|
51
|
+
export const fallbackNoticeCardSchema = z.object({
|
|
52
|
+
incident: z.string(),
|
|
53
|
+
role: z.enum(["manager", "worker", "reviewer"]).nullable(),
|
|
54
|
+
agent: z.string().nullable(),
|
|
55
|
+
class: z.enum(["L1", "L2", "L4", "L5"]).nullable(),
|
|
56
|
+
provider: z.string().nullable(),
|
|
57
|
+
message: z.string().nullable(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export type FallbackNoticeCard = z.infer<typeof fallbackNoticeCardSchema>;
|
|
61
|
+
|
|
62
|
+
export const chatCardSchema = z.object({
|
|
63
|
+
type: z.enum(["report", "review", "message", "fallback"]),
|
|
64
|
+
/** `received`: another agent sent it into this chat; `sent`: this chat's agent wrote it. */
|
|
65
|
+
direction: z.enum(["received", "sent"]),
|
|
66
|
+
requestId: z.string().nullable(),
|
|
67
|
+
batchId: z.string().nullable(),
|
|
68
|
+
phase: z.string().nullable(),
|
|
69
|
+
tier: z.string().nullable(),
|
|
70
|
+
verdict: z.string().nullable(),
|
|
71
|
+
blocking: z.number().nullable(),
|
|
72
|
+
blockers: z.string().nullable(),
|
|
73
|
+
beads: z.object({ created: z.number(), updated: z.number(), closed: z.number() }),
|
|
74
|
+
/** First meaningful line of a free-text message. */
|
|
75
|
+
gist: z.string(),
|
|
76
|
+
text: z.string(),
|
|
77
|
+
/** The report's `BM-QUESTIONS`; empty for every other card and for old-style reports. */
|
|
78
|
+
questions: z.array(questionSchema).default([]),
|
|
79
|
+
/**
|
|
80
|
+
* Where the message's BM-* blocks break their template (delta 20260918g
|
|
81
|
+
* §4.8), one `<kind> <field>: <issue>` line each; empty when they follow it.
|
|
82
|
+
*/
|
|
83
|
+
formatIssues: z.array(z.string()).default([]),
|
|
84
|
+
/** The notice of a `fallback` card; null for every other card. */
|
|
85
|
+
fallback: fallbackNoticeCardSchema.nullable().default(null),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export type ChatCard = z.infer<typeof chatCardSchema>;
|
|
89
|
+
|
|
90
|
+
const BARE_REQUEST_ID = /\breq-\d{8}T\d{6}Z\b/;
|
|
91
|
+
/** A `phase:` line listing the allowed values: the report format, not a report. */
|
|
92
|
+
const TEMPLATE_PHASE = /^\s*>?\s*phase\s*:[^\n]*\|/im;
|
|
93
|
+
const REVIEW_MARKER = /^\s*>?\s*(?:```\s*)?BM-REVIEW\b/m;
|
|
94
|
+
const BATCH_ID = /\bbatch(?:Id)?[`*\s]*[:=]?[`*\s]*([A-Za-z]?\d+[A-Za-z0-9._-]*)\b/i;
|
|
95
|
+
|
|
96
|
+
/** The item fields the transformer reads; Paseo's timeline items carry more. */
|
|
97
|
+
export interface ChatItem {
|
|
98
|
+
type: string;
|
|
99
|
+
text?: unknown;
|
|
100
|
+
clientMessageId?: unknown;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function gistOf(text: string): string {
|
|
104
|
+
for (const raw of text.split("\n")) {
|
|
105
|
+
const line = raw.replace(/^[#>*\s-]+/, "").replace(/[*`_]/g, "").trim();
|
|
106
|
+
if (line !== "") return line.length > 160 ? `${line.slice(0, 157)}…` : line;
|
|
107
|
+
}
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The card for a chat item, or undefined to leave the item to Paseo.
|
|
113
|
+
* Never throws: a transformer error only costs a console line, but the item
|
|
114
|
+
* would still be shown raw, so this simply declines instead.
|
|
115
|
+
*/
|
|
116
|
+
export function toChatCard(item: ChatItem, phase: "streaming" | "complete"): ChatCard | undefined {
|
|
117
|
+
if (typeof item.text !== "string" || item.text.trim() === "") return undefined;
|
|
118
|
+
const text = item.text;
|
|
119
|
+
// The plugin's own notice. Paseo stores a `clientMessageId` on it like on
|
|
120
|
+
// the user's words (`server/notices.ts`), so it is told apart by its first
|
|
121
|
+
// line, as the plugin writes it, before that test.
|
|
122
|
+
if (item.type === "user_message") {
|
|
123
|
+
const card = fallbackCardOf(text);
|
|
124
|
+
if (card !== undefined) return card;
|
|
125
|
+
}
|
|
126
|
+
let direction: ChatCard["direction"];
|
|
127
|
+
if (item.type === "user_message") {
|
|
128
|
+
// Typed by the user in Paseo's app: that is the user's own message.
|
|
129
|
+
if (typeof item.clientMessageId === "string") return undefined;
|
|
130
|
+
direction = "received";
|
|
131
|
+
} else if (item.type === "assistant_message") {
|
|
132
|
+
if (phase !== "complete") return undefined;
|
|
133
|
+
direction = "sent";
|
|
134
|
+
} else {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const context = { agentId: "", at: "" };
|
|
139
|
+
const isReport = looksLikeReport(text);
|
|
140
|
+
const isReview = REVIEW_MARKER.test(text);
|
|
141
|
+
const namesRequest = BARE_REQUEST_ID.test(text) || requestIdFromText(text) !== null;
|
|
142
|
+
if (!isReport && !isReview && (direction === "sent" || !namesRequest)) return undefined;
|
|
143
|
+
|
|
144
|
+
// A block that lists the allowed values ("phase: received | finished", "verdict:
|
|
145
|
+
// approved | changes-required") is the format being explained, not a report:
|
|
146
|
+
// Workers paste it into every review request.
|
|
147
|
+
const report = isReport
|
|
148
|
+
? parseReports(text, context)
|
|
149
|
+
.filter((block) =>
|
|
150
|
+
block.requestId !== null && BARE_REQUEST_ID.test(block.requestId)
|
|
151
|
+
? true
|
|
152
|
+
: block.phase !== null && !TEMPLATE_PHASE.test(text),
|
|
153
|
+
)
|
|
154
|
+
.at(-1)
|
|
155
|
+
: undefined;
|
|
156
|
+
const review = !isReport && isReview
|
|
157
|
+
? parseReviews(text, context)
|
|
158
|
+
.filter((block) => block.verdict === null || !block.verdict.includes("|"))
|
|
159
|
+
.at(-1)
|
|
160
|
+
: undefined;
|
|
161
|
+
if (report === undefined && review === undefined && (direction === "sent" || !namesRequest)) return undefined;
|
|
162
|
+
const requestId = report?.requestId ?? requestIdFromText(text) ?? BARE_REQUEST_ID.exec(text)?.[0] ?? null;
|
|
163
|
+
// A block naming another request is not this Worker's to be answered here.
|
|
164
|
+
const asked = report === undefined ? null : parseQuestions(text);
|
|
165
|
+
const questions = asked !== null && (asked.requestId === null || asked.requestId === requestId) ? asked.questions : [];
|
|
166
|
+
// A received message is another agent's block, checked against its
|
|
167
|
+
// template. Of this chat's own messages only a Reviewer's review is one: a
|
|
168
|
+
// Worker quoting its report in its own chat has sent nothing.
|
|
169
|
+
const formatIssues = checkBlocks(text)
|
|
170
|
+
.filter((block) => direction === "received" || block.kind === "BM-REVIEW")
|
|
171
|
+
.flatMap((block) => block.issues.map(issueText));
|
|
172
|
+
return {
|
|
173
|
+
type: report !== undefined ? "report" : review !== undefined ? "review" : "message",
|
|
174
|
+
direction,
|
|
175
|
+
requestId,
|
|
176
|
+
batchId: review?.batchId ?? BATCH_ID.exec(text)?.[1] ?? null,
|
|
177
|
+
phase: report?.phase ?? null,
|
|
178
|
+
tier: report?.tier ?? null,
|
|
179
|
+
verdict: review?.verdict ?? null,
|
|
180
|
+
blocking: review?.blockingCount ?? null,
|
|
181
|
+
blockers: report?.blockers ?? null,
|
|
182
|
+
beads: {
|
|
183
|
+
created: report?.beadsCreated.length ?? 0,
|
|
184
|
+
updated: report?.beadsUpdated.length ?? 0,
|
|
185
|
+
closed: report?.beadsClosed.length ?? 0,
|
|
186
|
+
},
|
|
187
|
+
gist: gistOf(text),
|
|
188
|
+
text,
|
|
189
|
+
questions,
|
|
190
|
+
formatIssues,
|
|
191
|
+
fallback: null,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Who sent it, who gets it.
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
export type ChatRole = "manager" | "worker" | "reviewer";
|
|
200
|
+
|
|
201
|
+
export interface Party {
|
|
202
|
+
role: ChatRole | null;
|
|
203
|
+
/** Null when the agent cannot be established. */
|
|
204
|
+
id: string | null;
|
|
205
|
+
title: string | null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const ROLE_NAME: Record<ChatRole, string> = { manager: "Manager", worker: "Worker", reviewer: "Reviewer" };
|
|
209
|
+
|
|
210
|
+
function party(peer: ChatPeer | undefined, fallback: ChatRole | null): Party {
|
|
211
|
+
if (peer === undefined) return { role: fallback, id: null, title: null };
|
|
212
|
+
const role = peer.role === "unknown" ? fallback : peer.role;
|
|
213
|
+
return { role, id: peer.id, title: peer.title };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Exactly one match, or nothing: a card never picks between candidates. */
|
|
217
|
+
function only<T>(items: readonly T[]): T | undefined {
|
|
218
|
+
return items.length === 1 ? items[0] : undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Sender and recipient of a card in the chat of `owner`. Returns roles even
|
|
223
|
+
* when the agents are unknown, and ids only when exactly one agent fits.
|
|
224
|
+
*/
|
|
225
|
+
export function partiesOf(card: ChatCard, owner: ChatPeer | null, peers: readonly ChatPeer[]): { from: Party; to: Party } {
|
|
226
|
+
const ownerRole = owner === null || owner.role === "unknown" ? null : owner.role;
|
|
227
|
+
const self = owner === null ? party(undefined, null) : party(owner, ownerRole);
|
|
228
|
+
const byId = (id: string | null) => (id === null ? undefined : peers.find((peer) => peer.id === id));
|
|
229
|
+
const ofRole = (role: ChatRole) => peers.filter((peer) => peer.role === role);
|
|
230
|
+
// The one live Worker of the request (the rule `chat.waiting` uses too); an
|
|
231
|
+
// old card of a Worker that has since been archived keeps its name
|
|
232
|
+
// (delta 20260918f F12).
|
|
233
|
+
const workerOf = (requestId: string | null) =>
|
|
234
|
+
soleWorkerOf(peers, requestId) ??
|
|
235
|
+
only(ofRole("worker").filter((peer) => requestId !== null && peer.requestId === requestId));
|
|
236
|
+
const manager = () => {
|
|
237
|
+
const parent = byId(owner?.parentId ?? null);
|
|
238
|
+
return parent?.role === "manager" ? parent : only(ofRole("manager"));
|
|
239
|
+
};
|
|
240
|
+
const parentWorker = () => {
|
|
241
|
+
const parent = byId(owner?.parentId ?? null);
|
|
242
|
+
return parent?.role === "worker" ? parent : undefined;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
if (card.direction === "sent") {
|
|
246
|
+
const role = ownerRole ?? (card.type === "review" ? "reviewer" : card.type === "report" ? "worker" : null);
|
|
247
|
+
const to =
|
|
248
|
+
card.type === "review"
|
|
249
|
+
? party(parentWorker() ?? workerOf(card.requestId), "worker")
|
|
250
|
+
: party(manager(), "manager");
|
|
251
|
+
return { from: { ...self, role }, to };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (card.type === "report") return { from: party(workerOf(card.requestId), "worker"), to: self };
|
|
255
|
+
if (card.type === "review") {
|
|
256
|
+
const reviewers = ofRole("reviewer").filter(
|
|
257
|
+
(peer) => (card.requestId === null || peer.requestId === card.requestId) && (card.batchId === null || peer.batchId === card.batchId),
|
|
258
|
+
);
|
|
259
|
+
return { from: party(only(reviewers), "reviewer"), to: self };
|
|
260
|
+
}
|
|
261
|
+
switch (ownerRole) {
|
|
262
|
+
case "worker":
|
|
263
|
+
return { from: party(manager(), "manager"), to: self };
|
|
264
|
+
case "reviewer":
|
|
265
|
+
return { from: party(parentWorker(), "worker"), to: self };
|
|
266
|
+
case "manager":
|
|
267
|
+
return { from: party(workerOf(card.requestId), "worker"), to: self };
|
|
268
|
+
default:
|
|
269
|
+
return { from: party(undefined, null), to: self };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function partyName(p: Party): string {
|
|
274
|
+
const role = p.role === null ? "Agent" : ROLE_NAME[p.role];
|
|
275
|
+
if (p.title !== null && p.title.trim() !== "") return `${role} · ${p.title.trim()}`;
|
|
276
|
+
return p.id === null ? `${role} (unknown)` : `${role} ${p.id.slice(0, 8)}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Whether the renderer should draw the card at all. A chat that is not a
|
|
281
|
+
* paseo-bm agent's, or a block the chat's agent only quoted (a Manager quoting
|
|
282
|
+
* a review), is shown as plain text instead of pretending to be a card.
|
|
283
|
+
*/
|
|
284
|
+
export function drawAsCard(card: ChatCard, owner: ChatPeer | null): boolean {
|
|
285
|
+
if (owner === null || owner.role === "unknown") return false;
|
|
286
|
+
if (card.direction === "received") return true;
|
|
287
|
+
return card.type === "review" ? owner.role === "reviewer" : owner.role === "worker";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* What a card says in the chat of an agent that carries no `bm.role` label —
|
|
292
|
+
* started outside Beads Manager and recognised by its provider (delta
|
|
293
|
+
* 20260918g §4.4, owner decision Q1 a) — or null for every other chat.
|
|
294
|
+
*/
|
|
295
|
+
export function ownerWarning(owner: ChatPeer | null): { chip: Badge; line: string } | null {
|
|
296
|
+
if (owner === null || owner.labelled !== false) return null;
|
|
297
|
+
return {
|
|
298
|
+
chip: { text: "Not started by paseo-bm", tone: "warning" },
|
|
299
|
+
line: "This agent has no bm.role label: it was started outside Beads Manager, and paseo-bm recognised it by its provider.",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The graph node kind whose icon and colour a role uses (`ROLE_MARK`). */
|
|
304
|
+
export function markOf(role: ChatRole | null): GraphNode["kind"] | null {
|
|
305
|
+
return role === "manager" ? "request" : role;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// What the card says.
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
export function statusChip(card: ChatCard): Badge | null {
|
|
313
|
+
if (card.type === "report" && card.phase !== null) {
|
|
314
|
+
const tone: Badge["tone"] = card.phase === "blocked" ? "warning" : card.phase === "finished" ? "success" : "info";
|
|
315
|
+
return { text: card.phase, tone };
|
|
316
|
+
}
|
|
317
|
+
if (card.type === "review" && card.verdict !== null) {
|
|
318
|
+
const verdict = card.verdict.toLowerCase();
|
|
319
|
+
const tone: Badge["tone"] = /pass|approved/.test(verdict) ? "success" : /stopped/.test(verdict) ? "muted" : "warning";
|
|
320
|
+
return { text: card.blocking ? `${card.verdict} · ${card.blocking} blocking` : card.verdict, tone };
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A `finished` report's card opens with its whole message showing, in every
|
|
327
|
+
* chat that draws it: the Manager does not repeat the result, so it must be
|
|
328
|
+
* readable without a tap (delta 20260918d §4.10, Q1 a of req-20260918T074311Z).
|
|
329
|
+
*/
|
|
330
|
+
export function startsOpen(card: ChatCard): boolean {
|
|
331
|
+
return card.type === "report" && card.phase === "finished";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The tone of the outline around the whole card, or null for the usual border:
|
|
336
|
+
* only a `finished` report stands out (delta 20260918d §4.10, Q2 a of
|
|
337
|
+
* req-20260918T074311Z — the one exception to "no coloured border", Q11).
|
|
338
|
+
*/
|
|
339
|
+
export function outlineTone(card: ChatCard): "success" | null {
|
|
340
|
+
return card.type === "report" && card.phase === "finished" ? "success" : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Longest `blockers` text on the summary line (delta 20260918c, Q50): the full
|
|
345
|
+
* text is one tap away in the opened message, and repeating a 1000-character
|
|
346
|
+
* paragraph above it made old-style reports hard to answer.
|
|
347
|
+
*/
|
|
348
|
+
export const SUMMARY_BLOCKERS_CHARS = 160;
|
|
349
|
+
|
|
350
|
+
function shortened(text: string, limit: number): string {
|
|
351
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
352
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1).trimEnd()}…`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** One line under the header. */
|
|
356
|
+
export function summaryOf(card: ChatCard): string {
|
|
357
|
+
if (card.type === "report") {
|
|
358
|
+
const beads = [
|
|
359
|
+
card.beads.created > 0 ? `${card.beads.created} created` : null,
|
|
360
|
+
card.beads.updated > 0 ? `${card.beads.updated} updated` : null,
|
|
361
|
+
card.beads.closed > 0 ? `${card.beads.closed} closed` : null,
|
|
362
|
+
].filter((part) => part !== null);
|
|
363
|
+
const count = card.questions.length;
|
|
364
|
+
const blockers =
|
|
365
|
+
count > 0
|
|
366
|
+
? `${count} ${count === 1 ? "question" : "questions"} waiting`
|
|
367
|
+
: card.blockers === null || /^none\b/i.test(card.blockers)
|
|
368
|
+
? null
|
|
369
|
+
: `waiting on: ${shortened(card.blockers, SUMMARY_BLOCKERS_CHARS)}`;
|
|
370
|
+
return [card.tier, beads.length === 0 ? null : `beads ${beads.join(", ")}`, blockers].filter((part) => part !== null).join(" · ") || "report";
|
|
371
|
+
}
|
|
372
|
+
if (card.type === "review") return [card.batchId === null ? null : `batch ${card.batchId}`, card.gist].filter((part) => part !== null).join(" · ");
|
|
373
|
+
return card.gist;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const BLOCK_START = /^\s*>?\s*(?:```\s*)?(BM-REPORT|BM-REVIEW|BM-QUESTIONS|BM-ANSWERS)\b/;
|
|
377
|
+
/** An option line of a `BM-QUESTIONS` block, nested under its question. */
|
|
378
|
+
const OPTION_LINE = /^>?\s*[-*]\s+(?:\(([a-z])\)|([a-z])\s*[:.)])\s*(.*)$/i;
|
|
379
|
+
const FENCE = /^\s*```\s*$/;
|
|
380
|
+
/** A top-level `key: value` line; indented lines belong to a list item above. */
|
|
381
|
+
const FIELD = /^>?\s?([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.*)$/;
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The message as Markdown. Report and review blocks are `key: value` lines,
|
|
385
|
+
* which Markdown would run together into one paragraph, so each becomes a list
|
|
386
|
+
* item with the key in bold. The fences around a block are dropped.
|
|
387
|
+
*/
|
|
388
|
+
export function markdownOf(text: string): string {
|
|
389
|
+
const lines = text.split("\n");
|
|
390
|
+
const out: string[] = [];
|
|
391
|
+
let inBlock = false;
|
|
392
|
+
lines.forEach((line, index) => {
|
|
393
|
+
if (!inBlock && FENCE.test(line) && BLOCK_START.test(lines[index + 1] ?? "")) return;
|
|
394
|
+
if (BLOCK_START.test(line)) {
|
|
395
|
+
inBlock = true;
|
|
396
|
+
out.push(`**${line.trim().replace(/^>?\s*(?:```\s*)?/, "")}**`);
|
|
397
|
+
out.push("");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (inBlock) {
|
|
401
|
+
if (FENCE.test(line)) {
|
|
402
|
+
inBlock = false;
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const field = FIELD.exec(line);
|
|
406
|
+
if (field !== null) {
|
|
407
|
+
out.push(`- **${field[1]}**: ${field[2]}`);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const option = OPTION_LINE.exec(line);
|
|
411
|
+
if (option !== null) {
|
|
412
|
+
out.push(` - **${option[1] ?? option[2]}**: ${option[3]}`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (line.trim() === "") inBlock = false;
|
|
416
|
+
}
|
|
417
|
+
out.push(line);
|
|
418
|
+
});
|
|
419
|
+
return out.join("\n");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The Markdown of a message shown as plain text instead of a card (delta
|
|
424
|
+
* 20260918g §4.8, owner decision Q3 a): laid out like the card's opened
|
|
425
|
+
* message, one field per line and questions apart from their options, never
|
|
426
|
+
* the raw text that Markdown runs together into one paragraph.
|
|
427
|
+
*/
|
|
428
|
+
export function fallbackMarkdown(card: { text: string }): string {
|
|
429
|
+
return markdownOf(card.text);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The reply as sent: it names the request, so the agent knows what it answers. */
|
|
433
|
+
export function replyText(card: ChatCard, answer: string): string {
|
|
434
|
+
const about = [card.requestId === null ? null : `\`${card.requestId}\``, card.batchId === null ? null : `batch ${card.batchId}`]
|
|
435
|
+
.filter((part) => part !== null)
|
|
436
|
+
.join(", ");
|
|
437
|
+
const head = about === "" ? "Reply from the user:" : `Reply from the user about ${about}:`;
|
|
438
|
+
return `${head}\n\n${answer.trim()}`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Prefilled answers for a report that waits on the user. Never sent on their own. */
|
|
442
|
+
export function quickReplies(card: ChatCard): string[] {
|
|
443
|
+
// "Continue as you proposed" is ambiguous next to questions with options.
|
|
444
|
+
if (card.type !== "report" || card.phase !== "blocked" || card.questions.length > 0) return [];
|
|
445
|
+
return ["Continue as you proposed.", "Stop here and send your finished report."];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function roleName(role: ChatRole | null): string {
|
|
449
|
+
return role === null ? "agent" : ROLE_NAME[role];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
// Answering a Worker's questions from its card (delta 20260918c-question-cards §4.4).
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
/** The user's answer so far, by question id. */
|
|
457
|
+
export type Picks = Record<string, Pick>;
|
|
458
|
+
|
|
459
|
+
/** Only the Manager's chat answers a Worker's questions, and only in the report it received. */
|
|
460
|
+
export function showsQuestions(card: ChatCard, owner: ChatPeer | null): boolean {
|
|
461
|
+
return card.type === "report" && card.direction === "received" && owner?.role === "manager" && card.questions.length > 0;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** A question with fewer than two options can only be answered in the user's own words. */
|
|
465
|
+
export function choosable(question: Question): boolean {
|
|
466
|
+
return question.options.length >= 2;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** The part of a question before its first ` — `: shown in bold. */
|
|
470
|
+
export function topicOf(question: Question): { topic: string | null; rest: string } {
|
|
471
|
+
const at = question.text.indexOf(" — ");
|
|
472
|
+
return at <= 0 ? { topic: null, rest: question.text } : { topic: question.text.slice(0, at), rest: question.text.slice(at + 3) };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* A question as the card draws it (delta 20260918d §4.4): a bold heading
|
|
477
|
+
* `Q6 · Storage`, then the question itself on its own line.
|
|
478
|
+
*/
|
|
479
|
+
export function questionHeading(question: Question): { heading: string; body: string } {
|
|
480
|
+
const { topic, rest } = topicOf(question);
|
|
481
|
+
return topic === null ? { heading: question.id, body: rest } : { heading: `${question.id} · ${topic}`, body: rest };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Fills the recommended option into every question still unanswered; never overwrites a pick. */
|
|
485
|
+
export function recommendedPicks(questions: readonly Question[], picks: Readonly<Picks>): Picks {
|
|
486
|
+
const next: Picks = { ...picks };
|
|
487
|
+
for (const question of questions) {
|
|
488
|
+
if (next[question.id] !== undefined || !choosable(question)) continue;
|
|
489
|
+
const recommended = question.options.find((option) => option.recommended);
|
|
490
|
+
if (recommended !== undefined) next[question.id] = { key: recommended.key };
|
|
491
|
+
}
|
|
492
|
+
return next;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* A pick that answers its question: an existing option of a question with at
|
|
497
|
+
* least two options, or the user's own non-blank words.
|
|
498
|
+
*/
|
|
499
|
+
export function isAnswered(question: Question, pick: Pick | undefined): boolean {
|
|
500
|
+
if (pick === undefined) return false;
|
|
501
|
+
if ("key" in pick) return choosable(question) && question.options.some((option) => option.key === pick.key);
|
|
502
|
+
return pick.other.trim() !== "";
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The `BM-ANSWERS` block for the answered questions only, in question order;
|
|
507
|
+
* "" when none is answered or the card names no request (delta 20260918d §4.1).
|
|
508
|
+
* An unanswered question gets no line and stays open: the Worker asks again.
|
|
509
|
+
*/
|
|
510
|
+
export function answersDraft(card: ChatCard, picks: Readonly<Picks>): string {
|
|
511
|
+
if (card.requestId === null) return "";
|
|
512
|
+
const answered = card.questions.filter((question) => isAnswered(question, picks[question.id]));
|
|
513
|
+
return answered.length === 0 ? "" : answersText(card.requestId, answered, picks);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const ANSWERS_HEAD = "BM-ANSWERS";
|
|
517
|
+
const ANSWERS_LINE = /^\s*(requestId|Q\d+)\s*:/;
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The Reply box `text` with its `BM-ANSWERS` block replaced by `block`, or
|
|
521
|
+
* removed when `block` is "". The block always ends up at the very start; the
|
|
522
|
+
* user's own words — above or below the old block — follow it, in order
|
|
523
|
+
* (delta 20260918d §4.1, owner decision Q4).
|
|
524
|
+
*/
|
|
525
|
+
export function withAnswersBlock(text: string, block: string): string {
|
|
526
|
+
const lines = text.split("\n");
|
|
527
|
+
const head = lines.findIndex((line) => line.trim() === ANSWERS_HEAD);
|
|
528
|
+
let rest = lines;
|
|
529
|
+
if (head !== -1) {
|
|
530
|
+
let end = head + 1;
|
|
531
|
+
while (end < lines.length && ANSWERS_LINE.test(lines[end]!)) end += 1;
|
|
532
|
+
while (end < lines.length && lines[end]!.trim() === "") end += 1;
|
|
533
|
+
rest = [...lines.slice(0, head), ...lines.slice(end)];
|
|
534
|
+
}
|
|
535
|
+
let first = 0;
|
|
536
|
+
while (first < rest.length && rest[first]!.trim() === "") first += 1;
|
|
537
|
+
let last = rest.length;
|
|
538
|
+
while (last > first && rest[last - 1]!.trim() === "") last -= 1;
|
|
539
|
+
const words = rest.slice(first, last).join("\n");
|
|
540
|
+
if (block === "") return words;
|
|
541
|
+
return words === "" ? block : `${block}\n\n${words}`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** `Q1 a, Q2 other`: the answered questions, as the card says it sent them. */
|
|
545
|
+
export function answerSummary(questions: readonly Question[], picks: Readonly<Picks>): string {
|
|
546
|
+
return questions
|
|
547
|
+
.filter((question) => isAnswered(question, picks[question.id]))
|
|
548
|
+
.map((question) => {
|
|
549
|
+
const pick = picks[question.id]!;
|
|
550
|
+
return `${question.id} ${"key" in pick ? pick.key : "other"}`;
|
|
551
|
+
})
|
|
552
|
+
.join(", ");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* What a sent Reply answered: the summary when the current answers block went
|
|
557
|
+
* out whole in `text`, else null (the user deleted it, or picked nothing).
|
|
558
|
+
*/
|
|
559
|
+
export function sentSummary(card: ChatCard, picks: Readonly<Picks>, text: string): string | null {
|
|
560
|
+
const draft = answersDraft(card, picks);
|
|
561
|
+
return draft !== "" && text.includes(draft) ? answerSummary(card.questions, picks) : null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
// Is a question card answered? (delta 20260918d §4.9, batch b6)
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
|
|
568
|
+
/** The report of `card` is one `chat.waiting` still lists for this Manager's chat. */
|
|
569
|
+
export function stillWaiting(card: ChatCard, chatAgentId: string, waiting: readonly WaitingWorker[]): boolean {
|
|
570
|
+
return waiting.some((entry) => entry.managerId === chatAgentId && entry.requestId === card.requestId && entry.text === card.text);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export type AnsweredHow = "sent" | "marked" | "moved-on";
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Why a question card counts as answered, or null: answers sent from a copy of
|
|
577
|
+
* it in this session; the user's saved mark; or `chat.waiting` no longer
|
|
578
|
+
* listing its report — the Worker is working, has reported since, or is gone
|
|
579
|
+
* (owner decision Q17). `waiting` is null while it is unknown, and then
|
|
580
|
+
* nothing is derived from it.
|
|
581
|
+
*/
|
|
582
|
+
export function answeredHow(input: {
|
|
583
|
+
sent: boolean;
|
|
584
|
+
marked: boolean;
|
|
585
|
+
waiting: readonly WaitingWorker[] | null;
|
|
586
|
+
stillWaitingNow: boolean;
|
|
587
|
+
}): AnsweredHow | null {
|
|
588
|
+
if (input.sent) return "sent";
|
|
589
|
+
if (input.marked) return "marked";
|
|
590
|
+
if (input.waiting !== null && !input.stillWaitingNow) return "moved-on";
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
// Related beads on a card (delta 20260918d §4.7, batch b4).
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
/** How many related-bead chips a card shows before folding the rest behind "…". */
|
|
599
|
+
export const BEAD_CHIPS_SHOWN = 2;
|
|
600
|
+
|
|
601
|
+
/** The items a card shows, and how many are folded behind the "…" chip. */
|
|
602
|
+
export function visibleBeads<T>(items: readonly T[], expanded: boolean): { shown: T[]; hidden: number } {
|
|
603
|
+
if (expanded || items.length <= BEAD_CHIPS_SHOWN) return { shown: [...items], hidden: 0 };
|
|
604
|
+
return { shown: items.slice(0, BEAD_CHIPS_SHOWN), hidden: items.length - BEAD_CHIPS_SHOWN };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The chips of a card's related beads: the first two beads the store REALLY
|
|
609
|
+
* has, and how many more it has (delta 20260918f F10). Folding must come after
|
|
610
|
+
* the lookup: the candidates are only a shape test, so `BM-REPORT`,
|
|
611
|
+
* `BM-QUESTIONS` or `local-first` come first and would take the two places.
|
|
612
|
+
*/
|
|
613
|
+
export function beadChipsView(found: readonly BeadRow[], expanded: boolean): { shown: BeadRow[]; hidden: number } {
|
|
614
|
+
return visibleBeads(found, expanded);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* What a card offers for replying (delta 20260918d §4.7, owner decision Q14):
|
|
619
|
+
* the Reply button, or — once a reply went out from this card — the small
|
|
620
|
+
* "Answered" chip, which reopens the Reply box.
|
|
621
|
+
*/
|
|
622
|
+
export function replyControls(canReply: boolean, replied: boolean): { replyButton: boolean; answeredChip: boolean } {
|
|
623
|
+
return { replyButton: canReply && !replied, answeredChip: canReply && replied };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
// Replying from any card (delta 20260918d-card-replies §4.2).
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
629
|
+
|
|
630
|
+
export type ReplyTarget = { peer: ChatPeer } | { reason: string };
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* The agent a card's Reply goes to, or why it cannot go now. The recipient is
|
|
634
|
+
* the one `partiesOf` finds — the sender of a received card, the addressee of
|
|
635
|
+
* a sent one — and must be in `peers`, because its status is read there.
|
|
636
|
+
* Only an `idle` or `error` agent is sent to: a message to a running one would
|
|
637
|
+
* replace the turn it is in and throw that work away.
|
|
638
|
+
*/
|
|
639
|
+
export function replyTarget(card: ChatCard, owner: ChatPeer | null, peers: readonly ChatPeer[]): ReplyTarget {
|
|
640
|
+
const { from, to } = partiesOf(card, owner, peers);
|
|
641
|
+
const counterpart = card.direction === "received" ? from : to;
|
|
642
|
+
const peer = counterpart.id === null ? undefined : peers.find((candidate) => candidate.id === counterpart.id);
|
|
643
|
+
if (peer === undefined) {
|
|
644
|
+
if (card.type === "report" && card.direction === "received") {
|
|
645
|
+
return { reason: `Cannot tell which Worker asked this: no single Worker has \`${card.requestId ?? "this request"}\`.` };
|
|
646
|
+
}
|
|
647
|
+
return { reason: `Cannot tell which ${roleName(counterpart.role)} to send this to.` };
|
|
648
|
+
}
|
|
649
|
+
if (owner !== null && peer.id === owner.id) return { reason: "This is your own message." };
|
|
650
|
+
const name = partyName({ role: counterpart.role, id: peer.id, title: peer.title });
|
|
651
|
+
// Paseo brings an archived agent back when it gets a message (ADR-005).
|
|
652
|
+
if (peer.archived) return { reason: `${name} is archived.` };
|
|
653
|
+
if (peer.status === "idle" || peer.status === "error") return { peer };
|
|
654
|
+
if (peer.status === "running" || peer.status === "initializing") {
|
|
655
|
+
return { reason: `${name} is working; a message now would replace its turn. Send when it stops.` };
|
|
656
|
+
}
|
|
657
|
+
return { reason: `${name} is ${peer.status}.` };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export interface SendReplyInput {
|
|
661
|
+
card: ChatCard;
|
|
662
|
+
/** What is in the Reply box. */
|
|
663
|
+
text: string;
|
|
664
|
+
/** Fresh `chat.peers` for the chat: the cached one can be half a minute old. */
|
|
665
|
+
refreshPeers: () => Promise<{ owner: ChatPeer | null; peers: ChatPeer[] }>;
|
|
666
|
+
send: (agentId: string, text: string) => Promise<void>;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export type SendReplyResult = { ok: true; to: ChatPeer; text: string } | { ok: false; reason: string };
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* The whole send path of a card's Reply box, kept here so it is tested without
|
|
673
|
+
* a renderer: re-read who is where and in what state, then send ONE message.
|
|
674
|
+
* `send` is called at most once.
|
|
675
|
+
*/
|
|
676
|
+
export async function sendReply(input: SendReplyInput): Promise<SendReplyResult> {
|
|
677
|
+
const { card, text } = input;
|
|
678
|
+
if (text.trim() === "") return { ok: false, reason: "Write a reply first." };
|
|
679
|
+
try {
|
|
680
|
+
const fresh = await input.refreshPeers();
|
|
681
|
+
const target = replyTarget(card, fresh.owner, fresh.peers);
|
|
682
|
+
if ("reason" in target) return { ok: false, reason: target.reason };
|
|
683
|
+
const message = replyText(card, text);
|
|
684
|
+
await input.send(target.peer.id, message);
|
|
685
|
+
return { ok: true, to: target.peer, text: message };
|
|
686
|
+
} catch (failure) {
|
|
687
|
+
return { ok: false, reason: errorMessageOf(failure) };
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** djb2: short and stable, enough to tell two reports of one request apart. */
|
|
692
|
+
function hashOf(text: string): string {
|
|
693
|
+
let hash = 5381;
|
|
694
|
+
for (let index = 0; index < text.length; index += 1) hash = ((hash << 5) + hash + text.charCodeAt(index)) | 0;
|
|
695
|
+
return (hash >>> 0).toString(36);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Key of the "already answered" memory: this chat, this request, these questions, this message. */
|
|
699
|
+
export function answeredKey(agentId: string, card: ChatCard): string {
|
|
700
|
+
return [agentId, card.requestId ?? "", card.questions.map((question) => question.id).join(","), hashOf(card.text)].join("|");
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ---------------------------------------------------------------------------
|
|
704
|
+
// The fallback card (delta 20260921 §4.4.6, REQ-065 c).
|
|
705
|
+
//
|
|
706
|
+
// Built from the plugin's `BM-FALLBACK` notice, but everything that decides
|
|
707
|
+
// what the user may do — status, candidate, reset time — comes from
|
|
708
|
+
// `fallback.incidents`. The notice is only the incident's id and what to show
|
|
709
|
+
// until that answers.
|
|
710
|
+
// ---------------------------------------------------------------------------
|
|
711
|
+
|
|
712
|
+
/** First line of a text, trimmed. */
|
|
713
|
+
function firstLineOf(text: string): string {
|
|
714
|
+
return (text.trimStart().split(/\r?\n/, 1)[0] ?? "").trim();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** What a fallback card leaves empty: it carries no report or review block. */
|
|
718
|
+
const NO_BLOCK = { batchId: null, phase: null, tier: null, verdict: null, blocking: null, blockers: null } as const;
|
|
719
|
+
|
|
720
|
+
function stoppedTitle(role: ChatRole | null): string {
|
|
721
|
+
return `${role === null ? "Agent" : ROLE_NAME[role]} stopped by its provider plan`;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* The card of a `BM-FALLBACK` notice, or undefined: the text must start with
|
|
726
|
+
* the marker line, as the plugin writes it, and name a usable incident id.
|
|
727
|
+
*/
|
|
728
|
+
export function fallbackCardOf(text: string): ChatCard | undefined {
|
|
729
|
+
if (firstLineOf(text) !== BM_FALLBACK_MARKER) return undefined;
|
|
730
|
+
const notice = parseFallbackNotice(text);
|
|
731
|
+
if (notice === null) return undefined;
|
|
732
|
+
return {
|
|
733
|
+
...NO_BLOCK,
|
|
734
|
+
type: "fallback",
|
|
735
|
+
direction: "received",
|
|
736
|
+
requestId: notice.requestId,
|
|
737
|
+
beads: { created: 0, updated: 0, closed: 0 },
|
|
738
|
+
questions: [],
|
|
739
|
+
formatIssues: [],
|
|
740
|
+
gist: stoppedTitle(notice.role),
|
|
741
|
+
text,
|
|
742
|
+
fallback: {
|
|
743
|
+
incident: notice.incident,
|
|
744
|
+
role: notice.role,
|
|
745
|
+
agent: notice.agent,
|
|
746
|
+
class: notice.class,
|
|
747
|
+
provider: notice.provider,
|
|
748
|
+
message: notice.message,
|
|
749
|
+
},
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** The failed agent's provider alias and model, as the card shows them. */
|
|
754
|
+
function failedProviderOf(incident: FallbackIncident): string {
|
|
755
|
+
const alias = incident.agentProvider.split("/", 1)[0] ?? incident.agentProvider;
|
|
756
|
+
return incident.agentModel === null ? alias : `${alias} · ${incident.agentModel}`;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* The card a waiting pill shows for an incident it counts: the pill reads
|
|
761
|
+
* `chat.waiting`, not the timeline, so there is no notice text to build from.
|
|
762
|
+
*/
|
|
763
|
+
export function fallbackCardOfIncident(incident: FallbackIncident): ChatCard {
|
|
764
|
+
return {
|
|
765
|
+
...NO_BLOCK,
|
|
766
|
+
type: "fallback",
|
|
767
|
+
direction: "received",
|
|
768
|
+
requestId: incident.requestId,
|
|
769
|
+
beads: { created: 0, updated: 0, closed: 0 },
|
|
770
|
+
questions: [],
|
|
771
|
+
formatIssues: [],
|
|
772
|
+
gist: stoppedTitle(incident.role),
|
|
773
|
+
text: "",
|
|
774
|
+
fallback: {
|
|
775
|
+
incident: incident.id,
|
|
776
|
+
role: incident.role,
|
|
777
|
+
agent: incident.agentId,
|
|
778
|
+
class: incident.class,
|
|
779
|
+
provider: failedProviderOf(incident),
|
|
780
|
+
message: incident.message,
|
|
781
|
+
},
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** What `fallback.incidents` said about a card's incident. */
|
|
786
|
+
export type FallbackLookup =
|
|
787
|
+
| { state: "loading" }
|
|
788
|
+
| { state: "failed"; error: unknown }
|
|
789
|
+
| { state: "missing" }
|
|
790
|
+
| { state: "found"; incident: FallbackIncident };
|
|
791
|
+
|
|
792
|
+
export type FallbackAction = FallbackActInput["action"];
|
|
793
|
+
|
|
794
|
+
export interface FallbackButton {
|
|
795
|
+
action: FallbackAction;
|
|
796
|
+
label: string;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
export interface FallbackCardView {
|
|
800
|
+
role: ChatRole | null;
|
|
801
|
+
title: string;
|
|
802
|
+
requestId: string | null;
|
|
803
|
+
/** The failure class, and the failed agent's provider and model. */
|
|
804
|
+
summary: string;
|
|
805
|
+
/** The provider's own words, verbatim. */
|
|
806
|
+
message: string | null;
|
|
807
|
+
/** The incident's status, once known. */
|
|
808
|
+
chip: Badge | null;
|
|
809
|
+
/** Only while the incident is `pending`. */
|
|
810
|
+
buttons: FallbackButton[];
|
|
811
|
+
/** Where there are no buttons: what became of the incident, or why nothing can be decided here. */
|
|
812
|
+
statusLine: { text: string; tone: Tone } | null;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const CLASS_LABELS: Readonly<Record<FallbackIncident["class"], string>> = {
|
|
816
|
+
L1: "Usage limit (L1)",
|
|
817
|
+
L2: "Billing (L2)",
|
|
818
|
+
L4: "Login (L4)",
|
|
819
|
+
L5: "Provider unavailable (L5)",
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
const STATUS_TONES: Readonly<Record<FallbackIncident["status"], Tone>> = {
|
|
823
|
+
pending: "warning",
|
|
824
|
+
switched: "success",
|
|
825
|
+
waiting: "info",
|
|
826
|
+
resumed: "success",
|
|
827
|
+
dismissed: "muted",
|
|
828
|
+
exhausted: "danger",
|
|
829
|
+
expired: "muted",
|
|
830
|
+
failed: "danger",
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
|
|
834
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
|
|
835
|
+
|
|
836
|
+
/** `15:40`, `tomorrow 15:40`, `yesterday 15:40` or `Thu 24 Sep 15:40`, in the device's time zone. */
|
|
837
|
+
export function localTimeText(at: Date, now: Date): string {
|
|
838
|
+
const two = (value: number) => String(value).padStart(2, "0");
|
|
839
|
+
const time = `${two(at.getHours())}:${two(at.getMinutes())}`;
|
|
840
|
+
const dayOf = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
841
|
+
// Rounded: a day with a clock change is 23 or 25 hours long.
|
|
842
|
+
const days = Math.round((dayOf(at) - dayOf(now)) / 86_400_000);
|
|
843
|
+
if (days === 0) return time;
|
|
844
|
+
if (days === 1) return `tomorrow ${time}`;
|
|
845
|
+
if (days === -1) return `yesterday ${time}`;
|
|
846
|
+
return `${WEEKDAYS[at.getDay()]} ${at.getDate()} ${MONTHS[at.getMonth()]} ${time}`;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* The reset time "Wait" may wait for: a readable `resetsAt` at most
|
|
851
|
+
* `FALLBACK_MAX_WAIT_MS` ahead — the rule `fallback.act` `wait` applies (§4.4.9).
|
|
852
|
+
* A reset already past still counts: waiting then resumes the agent at once.
|
|
853
|
+
*/
|
|
854
|
+
export function waitDeadline(resetsAt: string | null, now: Date): Date | null {
|
|
855
|
+
if (resetsAt === null) return null;
|
|
856
|
+
const at = new Date(resetsAt);
|
|
857
|
+
if (Number.isNaN(at.getTime())) return null;
|
|
858
|
+
return at.getTime() - now.getTime() <= FALLBACK_MAX_WAIT_MS ? at : null;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** `bm-worker-fallback-1 · Codex · gpt-5.6-sol`. */
|
|
862
|
+
export function candidateText(candidate: NonNullable<FallbackIncident["candidate"]>): string {
|
|
863
|
+
return `${candidate.alias} · ${providerLabel(candidate.baseProvider)} · ${candidate.model}`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** `~$5 / $25 per 1M tokens`, as Roles & models words a listed price. */
|
|
867
|
+
export function priceText(cost: ModelPrice): string {
|
|
868
|
+
return `~$${cost.inputUsdPerMTok} / $${cost.outputUsdPerMTok} per 1M tokens`;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* The candidate's price, in the order the Dashboard uses (§4.2.7): the bundled
|
|
873
|
+
* table, then the rate Paseo lists for the model (`roles.options` of its base
|
|
874
|
+
* provider, `listed`), else none.
|
|
875
|
+
*/
|
|
876
|
+
export function candidateCost(
|
|
877
|
+
candidate: FallbackIncident["candidate"],
|
|
878
|
+
listed: readonly RoleModelOption[] | null | undefined,
|
|
879
|
+
): ModelPrice | null {
|
|
880
|
+
if (candidate === null) return null;
|
|
881
|
+
return MODEL_PRICES[candidate.model] ?? listed?.find((model) => model.id === candidate.model)?.cost ?? null;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* The base provider whose `roles.options` the card reads for the Switch
|
|
886
|
+
* button's price, or null: only for a pending incident with a candidate the
|
|
887
|
+
* bundled table has no price for.
|
|
888
|
+
*/
|
|
889
|
+
export function listedCostProvider(lookup: FallbackLookup): string | null {
|
|
890
|
+
if (lookup.state !== "found" || lookup.incident.status !== "pending") return null;
|
|
891
|
+
const candidate = lookup.incident.candidate;
|
|
892
|
+
if (candidate === null || MODEL_PRICES[candidate.model] !== undefined) return null;
|
|
893
|
+
return candidate.baseProvider;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** The buttons of an incident: none unless it is `pending`, and "I'll handle it" always then. */
|
|
897
|
+
export function fallbackButtons(incident: FallbackIncident, now: Date, cost: ModelPrice | null): FallbackButton[] {
|
|
898
|
+
// A switched Reviewer is replaced by its Worker; if the new Reviewer never
|
|
899
|
+
// appeared (the notice queue was lost on a reload), the instructions can go again (§4.5.1).
|
|
900
|
+
if (incident.role === "reviewer" && incident.status === "switched" && incident.replacementId === null) {
|
|
901
|
+
return [{ action: "resend", label: "Resend to Worker" }];
|
|
902
|
+
}
|
|
903
|
+
if (incident.status !== "pending") return [];
|
|
904
|
+
const buttons: FallbackButton[] = [];
|
|
905
|
+
if (incident.candidate !== null) {
|
|
906
|
+
buttons.push({
|
|
907
|
+
action: "switch",
|
|
908
|
+
label: `Switch to ${candidateText(incident.candidate)}${cost === null ? "" : ` · ${priceText(cost)}`}`,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
const deadline = waitDeadline(incident.resetsAt, now);
|
|
912
|
+
if (deadline !== null) {
|
|
913
|
+
const at = localTimeText(deadline, now);
|
|
914
|
+
buttons.push({ action: "wait", label: deadline.getTime() > now.getTime() ? `Wait until ${at}` : `Resume now (the limit reset at ${at})` });
|
|
915
|
+
}
|
|
916
|
+
buttons.push({ action: "dismiss", label: "I'll handle it" });
|
|
917
|
+
return buttons;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function timeOf(iso: string | null, now: Date): string | null {
|
|
921
|
+
if (iso === null) return null;
|
|
922
|
+
const at = new Date(iso);
|
|
923
|
+
return Number.isNaN(at.getTime()) ? null : localTimeText(at, now);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** One line for an incident that is no longer `pending`; null while it is. */
|
|
927
|
+
export function fallbackStatusLine(incident: FallbackIncident, now: Date): { text: string; tone: Tone } | null {
|
|
928
|
+
const tone = STATUS_TONES[incident.status];
|
|
929
|
+
const role = roleName(incident.role);
|
|
930
|
+
switch (incident.status) {
|
|
931
|
+
case "pending":
|
|
932
|
+
return null;
|
|
933
|
+
case "switched": {
|
|
934
|
+
// A timeline card cannot open an agent (REQ-059 i): it points at the usual entries (§4.5.2).
|
|
935
|
+
if (incident.role === "manager") {
|
|
936
|
+
const on = incident.candidate === null ? "its fallback" : candidateText(incident.candidate);
|
|
937
|
+
return { text: `A new Beads Manager is running on ${on}. Open Beads Manager from the sidebar or Command Center to continue with it.`, tone };
|
|
938
|
+
}
|
|
939
|
+
const to = incident.candidate === null ? "a fallback agent" : candidateText(incident.candidate);
|
|
940
|
+
const agent = incident.replacementId === null ? "" : ` (agent ${incident.replacementId.slice(0, 8)})`;
|
|
941
|
+
return { text: `Switched to ${to}${agent}.`, tone };
|
|
942
|
+
}
|
|
943
|
+
case "waiting": {
|
|
944
|
+
const until = timeOf(incident.waitUntil ?? incident.resetsAt, now);
|
|
945
|
+
return { text: until === null ? `Waiting for the usage reset; then the ${role} carries on.` : `Waiting until ${until}; then the ${role} carries on.`, tone };
|
|
946
|
+
}
|
|
947
|
+
case "resumed":
|
|
948
|
+
return { text: `Resumed: the limit reset and the ${role} was asked to carry on.`, tone };
|
|
949
|
+
case "dismissed":
|
|
950
|
+
return { text: "Dismissed: you handle it.", tone };
|
|
951
|
+
case "exhausted":
|
|
952
|
+
return { text: "No fallback left to switch to.", tone };
|
|
953
|
+
case "expired":
|
|
954
|
+
return { text: `Expired: by the reset the ${role} was archived, running or already replaced.`, tone };
|
|
955
|
+
case "failed":
|
|
956
|
+
return { text: `Failed: ${incident.error ?? "unknown error"}`, tone };
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** An RPC error with its registry code once, in front: `(<code>): <message>`. */
|
|
961
|
+
function codedReason(error: unknown): string {
|
|
962
|
+
const code = errorCodeOf(error);
|
|
963
|
+
const message = errorMessageOf(error);
|
|
964
|
+
if (code === null) return `: ${message}`;
|
|
965
|
+
const rest = message.replace(/^\s*E_[A-Z0-9_]+\s*:?\s*/, "");
|
|
966
|
+
return rest === "" ? ` (${code})` : ` (${code}): ${rest}`;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Everything a fallback card draws, from its notice and the incident as
|
|
971
|
+
* `fallback.incidents` has it now. `cost` is the candidate's price, if known.
|
|
972
|
+
*/
|
|
973
|
+
export function fallbackCardView(card: ChatCard, lookup: FallbackLookup, now: Date, cost: ModelPrice | null): FallbackCardView {
|
|
974
|
+
const notice = card.fallback;
|
|
975
|
+
const incident = lookup.state === "found" ? lookup.incident : null;
|
|
976
|
+
const role = incident?.role ?? notice?.role ?? null;
|
|
977
|
+
const cls = incident?.class ?? notice?.class ?? null;
|
|
978
|
+
const provider = incident === null ? (notice?.provider ?? null) : failedProviderOf(incident);
|
|
979
|
+
const base = {
|
|
980
|
+
role,
|
|
981
|
+
title: stoppedTitle(role),
|
|
982
|
+
requestId: incident === null ? card.requestId : incident.requestId,
|
|
983
|
+
summary: [cls === null ? null : CLASS_LABELS[cls], provider].filter((part) => part !== null).join(" · "),
|
|
984
|
+
message: incident === null ? (notice?.message ?? null) : incident.message.trim() || null,
|
|
985
|
+
};
|
|
986
|
+
switch (lookup.state) {
|
|
987
|
+
case "loading":
|
|
988
|
+
return { ...base, chip: null, buttons: [], statusLine: { text: "Checking the incident…", tone: "muted" } };
|
|
989
|
+
case "failed":
|
|
990
|
+
return { ...base, chip: null, buttons: [], statusLine: { text: `Could not read the incident${codedReason(lookup.error)}`, tone: "danger" } };
|
|
991
|
+
case "missing":
|
|
992
|
+
return { ...base, chip: null, buttons: [], statusLine: { text: "This incident is no longer recorded; there is nothing to decide here.", tone: "muted" } };
|
|
993
|
+
case "found":
|
|
994
|
+
return {
|
|
995
|
+
...base,
|
|
996
|
+
chip: { text: lookup.incident.status, tone: STATUS_TONES[lookup.incident.status] },
|
|
997
|
+
buttons: fallbackButtons(lookup.incident, now, cost),
|
|
998
|
+
statusLine: fallbackStatusLine(lookup.incident, now),
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/** The incident a `fallback.incidents` answer has for this card: `missing` when it has none. */
|
|
1004
|
+
export function lookupOf(incidentId: string, incidents: readonly FallbackIncident[]): FallbackLookup {
|
|
1005
|
+
const incident = incidents.find((candidate) => candidate.id === incidentId);
|
|
1006
|
+
return incident === undefined ? { state: "missing" } : { state: "found", incident };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const ACTION_WORDS: Readonly<Record<FallbackAction, string>> = {
|
|
1010
|
+
switch: "switch to the fallback",
|
|
1011
|
+
wait: "wait for the reset",
|
|
1012
|
+
dismiss: "record that you handle it",
|
|
1013
|
+
resend: "resend the instructions to the Worker",
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
/** The error line of a failed button, with the registry code the server sent. */
|
|
1017
|
+
export function fallbackActError(action: FallbackAction, error: unknown): string {
|
|
1018
|
+
return `Could not ${ACTION_WORDS[action]}${codedReason(error)}`;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
export type FallbackActResult = { ok: true; incident: FallbackIncident } | { ok: false; reason: string };
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* One button press: ONE `fallback.act` call. The incident it answers with is
|
|
1025
|
+
* the card's new state; a failure becomes the card's error line.
|
|
1026
|
+
*/
|
|
1027
|
+
export async function runFallbackAction(input: {
|
|
1028
|
+
incidentId: string;
|
|
1029
|
+
action: FallbackAction;
|
|
1030
|
+
act: (input: FallbackActInput) => Promise<{ incident: FallbackIncident }>;
|
|
1031
|
+
}): Promise<FallbackActResult> {
|
|
1032
|
+
try {
|
|
1033
|
+
const { incident } = await input.act({ incidentId: input.incidentId, action: input.action });
|
|
1034
|
+
return { ok: true, incident };
|
|
1035
|
+
} catch (failure) {
|
|
1036
|
+
return { ok: false, reason: fallbackActError(input.action, failure) };
|
|
1037
|
+
}
|
|
1038
|
+
}
|