toolroll 0.9.95 → 0.9.97

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 (75) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/browser/workspace.css +2 -2
  3. package/dist/browser/workspace.js +32 -32
  4. package/dist/browser-workspace.d.ts +2 -0
  5. package/dist/card-render.d.ts +18 -1
  6. package/dist/card-render.js +40 -3
  7. package/dist/chat-delivery.d.ts +5 -1
  8. package/dist/chat-delivery.js +74 -25
  9. package/dist/chat-follow.d.ts +6 -3
  10. package/dist/chat-follow.js +23 -7
  11. package/dist/chat-live-schema.d.ts +17 -0
  12. package/dist/chat-live-schema.js +32 -0
  13. package/dist/chat-live.d.ts +161 -0
  14. package/dist/chat-live.js +491 -0
  15. package/dist/contracts/console-api.d.ts +1 -0
  16. package/dist/contracts/console-api.js +1 -0
  17. package/dist/desktop-update.d.ts +1 -1
  18. package/dist/discord-api.d.ts +3 -1
  19. package/dist/discord-api.js +9 -3
  20. package/dist/discord-chat.d.ts +13 -3
  21. package/dist/discord-chat.js +77 -21
  22. package/dist/discord.js +8 -1
  23. package/dist/lead-answer-eval.d.ts +86 -0
  24. package/dist/lead-answer-eval.js +255 -0
  25. package/dist/lead-cli-live.d.ts +56 -0
  26. package/dist/lead-cli-live.js +200 -0
  27. package/dist/lead-cli.d.ts +3 -0
  28. package/dist/lead-cli.js +34 -4
  29. package/dist/lead-latency-schema.d.ts +24 -0
  30. package/dist/lead-latency-schema.js +36 -0
  31. package/dist/lead-progress.d.ts +10 -1
  32. package/dist/lead-progress.js +1 -1
  33. package/dist/lead-queue-runtime.d.ts +1 -1
  34. package/dist/lead-queue-runtime.js +1 -1
  35. package/dist/lead-route.d.ts +56 -0
  36. package/dist/lead-route.js +104 -0
  37. package/dist/lead.d.ts +4 -2
  38. package/dist/lead.js +79 -26
  39. package/dist/live-reply.d.ts +83 -0
  40. package/dist/live-reply.js +302 -0
  41. package/dist/operate.js +39 -1
  42. package/dist/project-knowledge.d.ts +3 -3
  43. package/dist/reply-shape.d.ts +9 -0
  44. package/dist/reply-shape.js +13 -0
  45. package/dist/serve.js +11 -4
  46. package/dist/server/chat.d.ts +2 -1
  47. package/dist/server/chat.js +18 -6
  48. package/dist/server/live.js +18 -21
  49. package/dist/server/render-chat.d.ts +5 -0
  50. package/dist/server/render-chat.js +4 -2
  51. package/dist/server/render-settings.d.ts +8 -0
  52. package/dist/server/render-settings.js +13 -2
  53. package/dist/server/route-table.js +2 -0
  54. package/dist/server/settings.js +14 -1
  55. package/dist/slack-api.js +11 -0
  56. package/dist/slack-chat.d.ts +11 -1
  57. package/dist/slack-chat.js +50 -5
  58. package/dist/slack-settings.js +8 -1
  59. package/dist/slack.js +10 -3
  60. package/dist/store.d.ts +31 -2
  61. package/dist/store.js +59 -6
  62. package/dist/subscription-chat.d.ts +2 -0
  63. package/dist/subscription-chat.js +3 -1
  64. package/dist/teams-chat.d.ts +9 -2
  65. package/dist/teams-chat.js +50 -2
  66. package/dist/teams.js +5 -2
  67. package/dist/teardown-git.js +33 -3
  68. package/dist/telegram-lead.d.ts +3 -0
  69. package/dist/telegram-lead.js +63 -9
  70. package/dist/telegram-live.d.ts +116 -0
  71. package/dist/telegram-live.js +224 -0
  72. package/dist/telegram.d.ts +2 -0
  73. package/dist/telegram.js +50 -2
  74. package/dist/toolroll-update.js +4 -1
  75. package/package.json +1 -1
@@ -183,6 +183,7 @@ export const CONSOLE_FORMS = {
183
183
  leadIdentity: formContract("POST /settings/lead/identity", ["name", "persona"]),
184
184
  leadAbout: formContract("POST /settings/lead/about", ["about"]),
185
185
  leadPromiseCancel: formContract("POST /settings/lead/promise/cancel", ["promise"]),
186
+ leadQuick: formContract("POST /settings/lead/quick", ["enabled"]),
186
187
  leadCheck: formContract("POST /lead/check", ["return"]),
187
188
  leadOn: formContract("POST /settings/lead/on", ["agent", "return"]),
188
189
  welcomeSample: formContract("POST /welcome/sample", []),
@@ -97,6 +97,7 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
97
97
  export declare function lockHeld(file: string): boolean;
98
98
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
99
99
  export declare function desktopUpdateStatus(stateDir: string): {
100
+ version?: never;
100
101
  active: boolean;
101
102
  phase: string;
102
103
  detail: string;
@@ -110,7 +111,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
110
111
  wasRunning?: never;
111
112
  backupPath?: never;
112
113
  updateId?: never;
113
- version?: never;
114
114
  buildId?: never;
115
115
  workDir?: never;
116
116
  } | {
@@ -5,7 +5,7 @@ export declare const discordId: (v: unknown) => v is string;
5
5
  export type DiscordCredentials = ChatIdentity & {
6
6
  botToken: string;
7
7
  };
8
- export type DiscordApi = (method: "GET" | "POST" | "PATCH" | "PUT", path: string, body?: Record<string, unknown>, file?: {
8
+ export type DiscordApi = (method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE", path: string, body?: Record<string, unknown>, file?: {
9
9
  bytes: Uint8Array;
10
10
  name: string;
11
11
  }) => Promise<Record<string, unknown>>;
@@ -13,6 +13,8 @@ export declare class DiscordError extends ChatDeliveryError {
13
13
  }
14
14
  /** Discord's 403: the bot may not do this here (for a file, no Attach Files permission). */
15
15
  export declare const DISCORD_REFUSED = "Discord refused access. Check the app and direct-message permissions.";
16
+ /** Discord's 404: the message (or the conversation) is gone. */
17
+ export declare const DISCORD_GONE = "The Discord message or conversation is unavailable. Open the saved chat.";
16
18
  export declare function discordApi(token: string, fetcher?: typeof fetch): DiscordApi;
17
19
  export declare const discordCredentialFile: (dir: string) => string;
18
20
  export declare function loadDiscordCredentials(dir: string): DiscordCredentials | null;
@@ -10,10 +10,16 @@ export class DiscordError extends ChatDeliveryError {
10
10
  }
11
11
  /** Discord's 403: the bot may not do this here (for a file, no Attach Files permission). */
12
12
  export const DISCORD_REFUSED = "Discord refused access. Check the app and direct-message permissions.";
13
+ /** Discord's 404: the message (or the conversation) is gone. */
14
+ export const DISCORD_GONE = "The Discord message or conversation is unavailable. Open the saved chat.";
13
15
  export function discordApi(token, fetcher = fetch) {
14
16
  return async (method, path, body, file) => {
15
- // Only relative API routes; never send the bot credential to supplied URLs.
16
- if (!/^\/(?:users\/@me|users\/[0-9]+|oauth2\/applications\/@me|channels\/[0-9]+(?:\/typing|\/messages\/[0-9]+\/reactions\/%F0%9F%91%8D\/@me|\/messages(?:\/[0-9]+)?(?:\?limit=100)?)?|interactions\/[0-9]+\/[A-Za-z0-9._-]+\/callback)$/.test(path))
17
+ // Only relative API routes; never send the bot credential to supplied URLs. Beyond messages: typing, and the bot's
18
+ // own 👀 on a message (put on, taken off).
19
+ if (!/^\/(?:users\/@me|users\/[0-9]+|oauth2\/applications\/@me|channels\/[0-9]+(?:\/typing|\/messages\/[0-9]+\/reactions\/%F0%9F%91%80\/@me|\/messages(?:\/[0-9]+)?(?:\?limit=100)?)?|interactions\/[0-9]+\/[A-Za-z0-9._-]+\/callback)$/.test(path))
20
+ throw new DiscordError("Invalid Discord request");
21
+ // DELETE takes off the bot's own 👀 and nothing else (never a message).
22
+ if (method === "DELETE" && !/^\/channels\/[0-9]+\/messages\/[0-9]+\/reactions\/%F0%9F%91%80\/@me$/.test(path))
17
23
  throw new DiscordError("Invalid Discord request");
18
24
  let response;
19
25
  const headers = { Authorization: `Bot ${token}` };
@@ -60,7 +66,7 @@ export function discordApi(token, fetcher = fetch) {
60
66
  : response.status === 403
61
67
  ? DISCORD_REFUSED
62
68
  : response.status === 404
63
- ? "The Discord message or conversation is unavailable. Open the saved chat."
69
+ ? DISCORD_GONE
64
70
  : response.status === 413
65
71
  ? "Discord refused this file size. Open the saved result to view the evidence."
66
72
  : "Discord could not complete the request";
@@ -1,9 +1,11 @@
1
- import { ChatState, type ChatIdentity } from "./chat-delivery-state.js";
1
+ import { ChatState, type ChatIdentity, type ChatEvent } from "./chat-delivery-state.js";
2
2
  import { type ChatDeliveryOptions } from "./chat-delivery.js";
3
3
  import { discordPlain } from "./reply-shape.js";
4
+ import { type ChatAckHooks, type ChatLiveTransport, type LiveReplies } from "./chat-live.js";
4
5
  import { type DiscordApi } from "./discord-api.js";
5
- export type DiscordChatOptions = Omit<ChatDeliveryOptions, "state" | "label" | "member" | "partSize" | "maxProposal"> & {
6
- api: DiscordApi;
6
+ export type DiscordChatOptions = Omit<ChatDeliveryOptions, "state" | "label" | "member" | "partSize" | "maxProposal" | "live"> & {
7
+ api: DiscordApi; /** The live "Working on it…" message (chat-live.ts): on in the worker. */
8
+ liveReplies?: LiveReplies;
7
9
  };
8
10
  export declare const discordDelivery: (options: DiscordChatOptions) => ChatDeliveryOptions;
9
11
  export declare const processDiscordEvent: (options: DiscordChatOptions) => Promise<boolean>;
@@ -15,3 +17,11 @@ export declare function receiveDiscord(state: ChatState, identity: ChatIdentity,
15
17
  export { discordPlain };
16
18
  export declare function discordCard(text: string, buttons?: Record<string, unknown>[], voice?: boolean): Record<string, unknown>;
17
19
  export declare function deliverDiscordPart(options: DiscordChatOptions): Promise<boolean>;
20
+ /** The lead's words as Discord carries them, already rendered: one embed description (cut to its 4 096), empty
21
+ * content, its buttons (none on a live message) and never a ping. Every field is sent on every edit: Discord keeps
22
+ * what an edit leaves out, and inherits no allowed_mentions. */
23
+ export declare function discordVoicePayload(rendered: string, buttons?: Record<string, unknown>[]): Record<string, unknown>;
24
+ /** The live message for one owner message: a reply to it, created under a nonce so a repeat returns the same one. */
25
+ export declare function discordLive(options: Pick<DiscordChatOptions, "api" | "identity">, event: ChatEvent, spacingMs?: number): ChatLiveTransport;
26
+ /** The 👀 on the owner's message, and taking it off. A missing permission is asked about at most once a minute, quietly. */
27
+ export declare function discordAckHooks(options: Pick<DiscordChatOptions, "api">): ChatAckHooks;
@@ -14,23 +14,27 @@ import { processChatEvent, planChatNotifications, channelAccess, chatObject as o
14
14
  import { chatResultHref } from "./chat-controls.js";
15
15
  import { LEAD_MESSAGE_MAX_CHARS } from "./lead.js";
16
16
  import { discordPlain, renderReply } from "./reply-shape.js";
17
- import { WARM_EMOJI } from "./chat-warmth.js";
17
+ import { isLiveMessage, liveSpacingOf, underCooldown } from "./chat-live.js";
18
18
  import { proposalPreview, proposalLink, proposalOutcomeText, armedCardText, armedYesLabel, } from "./chat-channel.js";
19
19
  import { resultImageFileName, safeResultImageCaption, verifyResultImage, } from "./chat-evidence.js";
20
- import { discordId, discordMember, DiscordError, DISCORD_REFUSED, } from "./discord-api.js";
21
- export const discordDelivery = (options) => ({
22
- ...options,
23
- state: new ChatState(options.store, "discord"),
24
- label: "Discord",
25
- member: (member, channel) => discordMember(options.api, member, channel),
26
- // A 👍 on the owner's message and "typing…" while the lead works; a missing permission is skipped silently.
27
- warm: (event) => ({
28
- react: () => options.api("PUT", `/channels/${event.channel}/messages/${event.ts}/reactions/${encodeURIComponent(WARM_EMOJI)}/@me`),
29
- typing: () => options.api("POST", `/channels/${event.channel}/typing`),
30
- }),
31
- partSize: 1800,
32
- maxProposal: 3400,
33
- });
20
+ import { discordId, discordMember, DiscordError, DISCORD_REFUSED, DISCORD_GONE, } from "./discord-api.js";
21
+ export const discordDelivery = (options) => {
22
+ const state = new ChatState(options.store, "discord");
23
+ return {
24
+ ...options,
25
+ state,
26
+ label: "Discord",
27
+ member: (member, channel) => discordMember(options.api, member, channel),
28
+ // "typing…" while the lead works (the 👀 goes at arrival: discordAckHooks), under the shared cooldown; a missing
29
+ // permission is skipped silently.
30
+ warm: (event) => ({
31
+ typing: underCooldown(state, options.identity.installation, error => error instanceof DiscordError && error.code === "ratelimited" ? error.retryMs : null, () => options.clock?.() ?? new Date(), () => options.api("POST", `/channels/${event.channel}/typing`)),
32
+ }),
33
+ ...(options.liveReplies ? { live: (event) => discordLive(options, event, liveSpacingOf(options.liveReplies)) } : {}),
34
+ partSize: 1800,
35
+ maxProposal: 3400,
36
+ };
37
+ };
34
38
  export const processDiscordEvent = (options) => processChatEvent(discordDelivery(options));
35
39
  export const planDiscordRooms = (options) => planRoomMessages(discordDelivery(options));
36
40
  export const planDiscordNotifications = (options) => planChatNotifications(discordDelivery(options));
@@ -151,12 +155,7 @@ export { discordPlain };
151
155
  export function discordCard(text, buttons = [], voice = false) {
152
156
  // The lead's own reply: one description in Discord's Markdown (bold anchors, labelled links), no title line promoted to a header.
153
157
  if (voice)
154
- return {
155
- content: "",
156
- embeds: [{ description: renderReply(text, "discord").slice(0, 4096), color: 0x297b70 }],
157
- components: Array.from({ length: Math.min(5, Math.ceil(buttons.length / 5)) }, (_, row) => ({ type: 1, components: buttons.slice(row * 5, row * 5 + 5) })),
158
- allowed_mentions: { parse: [], replied_user: false },
159
- };
158
+ return discordVoicePayload(renderReply(text, "discord"), buttons);
160
159
  const lines = text.split("\n"), first = lines[0] ?? "Toolroll", title = discordPlain(first).length <= 256 &&
161
160
  first.length <= 150 &&
162
161
  lines.length > 1
@@ -401,6 +400,12 @@ export async function deliverDiscordPart(options) {
401
400
  }
402
401
  // No permission to attach files here: one plain line instead of the result's screenshots.
403
402
  const saved = readChatPart(row.payload), content = saved.ok ? saved.value : null;
403
+ // The live message the reply was to replace is gone: the reply goes out as a new message.
404
+ if (content?.edit !== undefined && error instanceof DiscordError && error.code === DISCORD_GONE && isLiveMessage(state, row.event, content.edit)) {
405
+ const { edit: _edit, ...asNew } = content;
406
+ state.repaint(row.id, asNew);
407
+ return true;
408
+ }
404
409
  // Discord refused the card's embed: the part's own plain text goes instead, so the message is never lost.
405
410
  if (content?.rich && error instanceof DiscordError && error.code === "Discord could not complete the request" && error.retryMs === 60_000) {
406
411
  state.repaint(row.id, withoutCard(content));
@@ -422,3 +427,54 @@ export async function deliverDiscordPart(options) {
422
427
  return true;
423
428
  }
424
429
  }
430
+ /** The lead's words as Discord carries them, already rendered: one embed description (cut to its 4 096), empty
431
+ * content, its buttons (none on a live message) and never a ping. Every field is sent on every edit: Discord keeps
432
+ * what an edit leaves out, and inherits no allowed_mentions. */
433
+ export function discordVoicePayload(rendered, buttons = []) {
434
+ return {
435
+ content: "",
436
+ embeds: [{ description: rendered.slice(0, 4096), color: 0x297b70 }],
437
+ components: Array.from({ length: Math.min(5, Math.ceil(buttons.length / 5)) }, (_, row) => ({ type: 1, components: buttons.slice(row * 5, row * 5 + 5) })),
438
+ allowed_mentions: { parse: [], replied_user: false },
439
+ };
440
+ }
441
+ /** The live message for one owner message: a reply to it, created under a nonce so a repeat returns the same one. */
442
+ export function discordLive(options, event, spacingMs) {
443
+ const nonce = chatHash(`discord:live:${event.id}`).slice(0, 24);
444
+ const confirmed = (answer, id) => {
445
+ if (!discordId(answer.id) || answer.channel_id !== event.channel || object(answer.author).id !== options.identity.bot || (id !== undefined && answer.id !== id))
446
+ throw new DiscordError("Discord did not confirm the message identity", 15_000, true);
447
+ return answer.id;
448
+ };
449
+ return {
450
+ platform: "discord",
451
+ ...(spacingMs === undefined ? {} : { spacingMs }),
452
+ limit: 4_000,
453
+ create: async (text) => confirmed(await options.api("POST", `/channels/${event.channel}/messages`, {
454
+ ...discordVoicePayload(text), nonce, enforce_nonce: true,
455
+ ...(discordId(event.thread) ? { message_reference: { message_id: event.thread, channel_id: event.channel, fail_if_not_exists: false } } : {}),
456
+ })),
457
+ // Discord checks an enforced nonce against "the past few minutes": a repeat within one minute is the same message.
458
+ nonceMs: 60_000,
459
+ recover: async () => {
460
+ const recent = await options.api("GET", `/channels/${event.channel}/messages?limit=100`);
461
+ const found = (Array.isArray(recent.items) ? recent.items : []).map(object)
462
+ .find(one => one.nonce === nonce && object(one.author).id === options.identity.bot && one.channel_id === event.channel && discordId(one.id));
463
+ return found === undefined ? null : String(found.id);
464
+ },
465
+ edit: async (message, text) => { confirmed(await options.api("PATCH", `/channels/${event.channel}/messages/${message}`, discordVoicePayload(text)), message); },
466
+ rateLimited: error => error instanceof DiscordError && error.code === "ratelimited" ? error.retryMs : null,
467
+ uncertain: error => !(error instanceof ChatDeliveryError) || error.uncertain,
468
+ };
469
+ }
470
+ /** The 👀 on the owner's message, and taking it off. A missing permission is asked about at most once a minute, quietly. */
471
+ export function discordAckHooks(options) {
472
+ const path = (event) => `/channels/${event.channel}/messages/${event.ts}/reactions/${encodeURIComponent("👀")}/@me`;
473
+ return {
474
+ react: (event) => options.api("PUT", path(event)),
475
+ unreact: (event) => options.api("DELETE", path(event)),
476
+ refused: error => error instanceof DiscordError && error.code === DISCORD_REFUSED,
477
+ settled: error => error instanceof DiscordError && error.code === DISCORD_GONE,
478
+ rateLimited: error => error instanceof DiscordError && error.code === "ratelimited" ? error.retryMs : null,
479
+ };
480
+ }
package/dist/discord.js CHANGED
@@ -3,7 +3,8 @@ import { randomBytes } from "node:crypto";
3
3
  import { abortablePause, workChatQueue } from "./chat-follow.js";
4
4
  import { loadDiscordCredentials, discordApi, object, discordId, } from "./discord-api.js";
5
5
  import { ChatState, chatHash } from "./chat-delivery-state.js";
6
- import { receiveDiscord, processDiscordEvent, deliverDiscordPart, planDiscordNotifications, planDiscordRooms, } from "./discord-chat.js";
6
+ import { runChatAcks } from "./chat-live.js";
7
+ import { receiveDiscord, processDiscordEvent, deliverDiscordPart, planDiscordNotifications, planDiscordRooms, discordAckHooks, } from "./discord-chat.js";
7
8
  export function discordReadyMatches(raw, identity) {
8
9
  const packet = object(raw);
9
10
  return (object(packet.application).id === identity.app &&
@@ -69,6 +70,9 @@ export async function followDiscord(options) {
69
70
  });
70
71
  client.on("error", () => problem("Discord connection failed. Check the bot token and reconnect."));
71
72
  client.on("shardError", () => problem("Discord connection failed. Saved replies will retry."));
73
+ // The 👀 on each owner message as it arrives, beside the queue (a turn there can take a minute).
74
+ const acks = runChatAcks({ state, installation: credentials.installation, owner, current: () => same() && connected,
75
+ hooks: discordAckHooks({ api }), same, signal: options.signal });
72
76
  // Never subscribe a logger to raw payloads, SDK debug output or interaction tokens.
73
77
  client.on("raw", (packet) => {
74
78
  if (!same())
@@ -88,6 +92,8 @@ export async function followDiscord(options) {
88
92
  const body = object(packet.d);
89
93
  try {
90
94
  const saved = receiveDiscord(state, credentials, packet.t, body, new Date());
95
+ if (saved && packet.t === "MESSAGE_CREATE")
96
+ acks.wake();
91
97
  if (packet.t === "INTERACTION_CREATE" &&
92
98
  body.type === 3 &&
93
99
  body.application_id === credentials.app &&
@@ -119,6 +125,7 @@ export async function followDiscord(options) {
119
125
  api,
120
126
  current: same,
121
127
  canNotify: options.notifications,
128
+ liveReplies: true,
122
129
  };
123
130
  try {
124
131
  await client.login(credentials.botToken);
@@ -0,0 +1,86 @@
1
+ import { type ChatConfig, type Store } from "./store.js";
2
+ import { type VerifiedApprover } from "./principal.js";
3
+ import type { SubscriptionLeadRunner } from "./subscription-chat.js";
4
+ export declare const LEAD_ANSWER_K = 3;
5
+ type Seeded = {
6
+ id: string;
7
+ title: string;
8
+ state: "queued" | "running" | "done" | "failed" | "cancelled";
9
+ };
10
+ export declare const LEAD_ANSWER_TASKS: readonly Seeded[];
11
+ /** A fact an answer must state: any one of its spellings (lower case). */
12
+ type Fact = readonly string[];
13
+ export type LeadAnswerQuestion = {
14
+ id: string;
15
+ text: string;
16
+ kind: "read" | "act";
17
+ mustName: readonly Fact[];
18
+ mustNot: readonly string[];
19
+ topic: string;
20
+ };
21
+ /** The 40 questions, each read question's oracle computed from LEAD_ANSWER_TASKS (the world's records). */
22
+ export declare function leadAnswerQuestions(): LeadAnswerQuestion[];
23
+ /** The seeded world: its store, its owner, the conversation config. */
24
+ export declare function seedLeadAnswerWorld(now?: Date): {
25
+ store: Store;
26
+ who: VerifiedApprover;
27
+ config: ChatConfig;
28
+ };
29
+ /** How an answer did against its question's oracle. */
30
+ export declare function gradeAnswer(question: LeadAnswerQuestion, answer: string): {
31
+ named: boolean;
32
+ falseClaims: number;
33
+ };
34
+ /**
35
+ * The scripted stand-in model (CI): reads with list_tasks and list_decisions, then answers from those results alone; a
36
+ * request without proposal tools (a quick step) asked to change something calls find_tools, as the contract tells a
37
+ * model to search before saying something can't be done. Each step advances `clock` by the model's simulated latency.
38
+ */
39
+ export declare function scriptedLeadAnswerRunner(questions: readonly LeadAnswerQuestion[], clock: {
40
+ advance(ms: number): void;
41
+ }, quickModel: string): SubscriptionLeadRunner;
42
+ type Run = {
43
+ model: "main" | "quick";
44
+ route: string | null;
45
+ answer: string;
46
+ ms: number;
47
+ named: boolean;
48
+ falseClaims: number;
49
+ failed: string | null;
50
+ };
51
+ export type LeadAnswerReport = {
52
+ mode: "scripted" | "real";
53
+ k: number;
54
+ quickModel: string;
55
+ read: {
56
+ main: {
57
+ passK: number;
58
+ falseClaims: number;
59
+ medianMs: number;
60
+ };
61
+ quick: {
62
+ passK: number;
63
+ falseClaims: number;
64
+ medianMs: number;
65
+ };
66
+ questions: number;
67
+ };
68
+ act: {
69
+ kept: number;
70
+ total: number;
71
+ failures: string[];
72
+ };
73
+ speedup: number;
74
+ pass: boolean;
75
+ reasons: string[];
76
+ runs: ({
77
+ question: string;
78
+ } & Run)[];
79
+ };
80
+ /** Run the eval. `runner` absent: this computer's signed-in Claude (real mode). */
81
+ export declare function runLeadAnswerEval(options: {
82
+ mode: "scripted" | "real";
83
+ k?: number;
84
+ onRun?: (line: string) => void;
85
+ }): Promise<LeadAnswerReport>;
86
+ export {};
@@ -0,0 +1,255 @@
1
+ /**
2
+ * The `lead-answer` eval: the gate on quick answers (docs/plans/responsive-chat.md §8). Quick answers ship OFF and are
3
+ * turned on by default only once this eval's real mode passes.
4
+ *
5
+ * A seeded world (tasks in every state, one decision waiting on the owner) and 40 questions:
6
+ * - 30 read questions, each with an oracle computed from the world's records: the task titles, states and counts its
7
+ * answer must name, and the titles it must not claim;
8
+ * - 10 act-intent questions, which must stay on the main model or be handed to it (escalated): 8 carry an act word, and
9
+ * 2 ask for a change without one, so only the quick step's own escalation can catch them.
10
+ * Each read question runs k = 3 times on the main model (quick answers off) and k = 3 on the quick route (on); a question
11
+ * passes on a model only when all three answers do (pass^k, the eval harness's rule). An act question that the router
12
+ * already keeps on the main model needs no model call; one it routes quick runs k = 3 and must escalate every time.
13
+ *
14
+ * Pass: quick pass^k ≥ main pass^k (no regression), quick false claims ≤ main's, 10/10 act questions on the main model
15
+ * or escalated, and the quick route's median turn time at least 30% below the main model's.
16
+ *
17
+ * Modes: `scripted` (CI: a deterministic stand-in model with simulated per-model latency proves the harness, the
18
+ * router, escalation and grading end to end, with no provider call) and `real` (the owner or the release runs it:
19
+ * every turn goes to this computer's signed-in Claude). Real mode spends subscription usage: about 30×3×2 + 2×3 turns.
20
+ *
21
+ * npm run build && node scripts/lead-answer-eval.mjs # scripted
22
+ * npm run build && node scripts/lead-answer-eval.mjs --real # real model calls
23
+ *
24
+ * Kept outside src/evals.ts's canonical campaigns: those run build and ask cases through a world's CLI and are sealed
25
+ * as release policy (`evals/`); this eval drives runLeadTurn directly over an in-memory world. Folding it in as a third
26
+ * case kind is a follow-up.
27
+ */
28
+ import { createHash } from "node:crypto";
29
+ import { openStore, setLeadQuickAnswers } from "./store.js";
30
+ import { fileTaskProposal } from "./proposal.js";
31
+ import { verifyApproverStanding } from "./principal.js";
32
+ import { subscriptionCredentialKey } from "./converse.js";
33
+ import { runLeadTurn } from "./lead.js";
34
+ import { quickLeadModel, quickRoute } from "./lead-route.js";
35
+ export const LEAD_ANSWER_K = 3;
36
+ const T0 = new Date("2026-11-08T12:00:00.000Z");
37
+ const REPO = "/eval/shop";
38
+ const MAIN_MODEL = "opus";
39
+ export const LEAD_ANSWER_TASKS = Object.freeze([
40
+ { id: "csv-export", title: "Add CSV export to the orders page", state: "running" },
41
+ { id: "search-index", title: "Speed up the search index", state: "running" },
42
+ { id: "billing-tab", title: "Rename the billing settings tab", state: "queued" },
43
+ { id: "french", title: "Translate the checkout into French", state: "queued" },
44
+ { id: "invoices", title: "Archive old invoices", state: "queued" },
45
+ { id: "login-loop", title: "Fix the login redirect loop", state: "done" },
46
+ { id: "images", title: "Compress product images", state: "done" },
47
+ { id: "payment-lib", title: "Upgrade the payment library", state: "failed" },
48
+ { id: "backup-check", title: "Repair the nightly backup check", state: "failed" },
49
+ { id: "dark-emails", title: "Try a dark theme for emails", state: "cancelled" },
50
+ ]);
51
+ /** The task whose run waits on the owner's decision. */
52
+ const DECIDING = "search-index";
53
+ const titles = (state) => LEAD_ANSWER_TASKS.filter(one => one.state === state).map(one => one.title);
54
+ const facts = (state) => titles(state).map(title => [title.toLowerCase()]);
55
+ const NUMBER_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
56
+ const count = (state) => { const n = titles(state).length; return [String(n), NUMBER_WORDS[n]]; };
57
+ /** The 40 questions, each read question's oracle computed from LEAD_ANSWER_TASKS (the world's records). */
58
+ export function leadAnswerQuestions() {
59
+ const read = (topic, texts, mustName, mustNot) => texts.map((text, index) => ({ id: `${topic}-${index + 1}`, text, kind: "read", mustName, mustNot, topic }));
60
+ const settled = [...titles("done"), ...titles("cancelled")];
61
+ const csv = LEAD_ANSWER_TASKS.find(one => one.id === "csv-export");
62
+ const deciding = LEAD_ANSWER_TASKS.find(one => one.id === DECIDING);
63
+ const act = (id, text) => ({ id, text, kind: "act", mustName: [], mustNot: [], topic: "act" });
64
+ return [
65
+ ...read("running", ["what's running?", "Which tasks are being worked on right now?", "What is in progress?"], facts("running"), settled),
66
+ ...read("queued", ["what's queued?", "Which tasks haven't begun yet?", "Anything queued up?"], facts("queued"), settled),
67
+ ...read("failed", ["which tasks failed?", "What didn't work?", "Did anything fail?"], facts("failed"), titles("done")),
68
+ ...read("done", ["what's finished?", "Which tasks are done?", "What got completed?"], facts("done"), titles("failed")),
69
+ ...read("needs-me", ["what needs me?", "Is anything waiting on me?", "Do I have a decision waiting?"], [[deciding.title.toLowerCase(), "search index"]], settled),
70
+ ...read("queued-count", ["how many tasks are queued?", "How many tasks are still waiting?", "Number of queued tasks?"], [count("queued")], []),
71
+ ...read("failed-count", ["how many tasks failed?", "How many failures are there?", "Count of failed tasks?"], [count("failed")], []),
72
+ ...read("csv-status", ["What's the status of the CSV export?", "Is the CSV export done?", "How is the CSV export going?"], [["running", "in progress", "being worked", "under way", "underway"]], [`${csv.title.toLowerCase()} is done`]),
73
+ ...read("cancelled", ["what was cancelled?", "Which tasks were dropped?", "Anything cancelled?"], facts("cancelled"), titles("running")),
74
+ ...read("overview", ["status", "Give me a summary of the shop project.", "What's the state of things?"], facts("running"), []),
75
+ act("act-fix", "fix the payment library upgrade"),
76
+ act("act-retry", "can you retry the failed tasks?"),
77
+ act("act-cancel", "please cancel the French translation"),
78
+ act("act-merge", "merge the CSV export"),
79
+ act("act-add", "add a task to clean up old logs"),
80
+ act("act-pause", "pause the search index work"),
81
+ act("act-approve", "approve rebuilding the index tonight"),
82
+ act("act-release", "release the shop project"),
83
+ // No act word: only the quick step's own escalation keeps these off the quick route.
84
+ act("act-soft-order", "Could the French translation go before the invoices?"),
85
+ act("act-soft-revive", "The dark theme idea for emails, can we bring it back?"),
86
+ ];
87
+ }
88
+ /** The seeded world: its store, its owner, the conversation config. */
89
+ export function seedLeadAnswerWorld(now = T0) {
90
+ const store = openStore(":memory:");
91
+ store.saveApprover("owner", createHash("sha256").update("lead-answer-eval").digest("hex"), now);
92
+ const verified = verifyApproverStanding(store, "owner", store.accountOf("owner").generation, [REPO]);
93
+ if (!verified.ok)
94
+ throw new Error(verified.reason);
95
+ for (const task of LEAD_ANSWER_TASKS) {
96
+ const filed = fileTaskProposal(store, { id: task.id, title: task.title, repo: REPO, filedVia: "cli" }, now);
97
+ if (!filed.ok)
98
+ throw new Error(filed.reason);
99
+ if (task.state !== "queued") {
100
+ const set = store.setTaskState(task.id, task.state, now, {}, task.state === "cancelled" ? "Not needed after all." : undefined);
101
+ if (!set.ok)
102
+ throw new Error(`${task.id}: ${set.reason}`);
103
+ }
104
+ }
105
+ const run = store.startRun({ taskRef: store.refFor("built-in", DECIDING).id, leaseId: "l-eval", runner: "eval", branch: `standing-orders/${DECIDING}`, worktree: `/pool/${DECIDING}`,
106
+ route: { routeDigest: "legacy", phase: "build", provider: "claude", model: null, chosen: "legacy" }, now });
107
+ store.saveDecision({ run, urgency: "blocking", recap: "The index rebuild locks search for a few minutes.", question: "Rebuild the search index now or tonight?",
108
+ options: [{ id: "now", label: "Now", consequence: "Search is down for a few minutes now.", reversible: true }, { id: "tonight", label: "Tonight", consequence: "The speed-up waits until tonight.", reversible: true }],
109
+ recommendation: "tonight" }, now);
110
+ const config = { provider: "claude-subscription", model: MAIN_MODEL, dailyTurns: 1_000, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0, updatedAt: now.toISOString(), updatedBy: "owner" };
111
+ return { store, who: verified.who, config };
112
+ }
113
+ /** How an answer did against its question's oracle. */
114
+ export function gradeAnswer(question, answer) {
115
+ const text = answer.toLowerCase();
116
+ return { named: question.mustName.every(fact => fact.some(spelling => text.includes(spelling))), falseClaims: question.mustNot.filter(claim => text.includes(claim.toLowerCase())).length };
117
+ }
118
+ /**
119
+ * The scripted stand-in model (CI): reads with list_tasks and list_decisions, then answers from those results alone; a
120
+ * request without proposal tools (a quick step) asked to change something calls find_tools, as the contract tells a
121
+ * model to search before saying something can't be done. Each step advances `clock` by the model's simulated latency.
122
+ */
123
+ export function scriptedLeadAnswerRunner(questions, clock, quickModel) {
124
+ const byText = new Map(questions.map(one => [one.text, one]));
125
+ return async (request) => {
126
+ clock.advance(request.model === quickModel ? 900 : 2_000);
127
+ const asked = [...request.history].reverse().find(one => one.role === "operator");
128
+ const question = asked?.role === "operator" ? byText.get(asked.text) : undefined;
129
+ const reply = (text, calls = []) => ({ ok: true, answer: { text, calls, tokensIn: 100, tokensOut: 20, reportedCostMicrousd: null } });
130
+ const results = request.history.filter(one => one.role === "tool");
131
+ if (question?.kind === "act") {
132
+ if (!request.tools.some(one => one.name.startsWith("propose_")))
133
+ return reply("Let me find the right tool.", [{ id: "f", name: "find_tools", args: { query: question.text } }]);
134
+ return reply("That needs your confirmation; I can draft it as a card.");
135
+ }
136
+ if (results.length === 0)
137
+ return reply("Reading.", [{ id: "t", name: "list_tasks", args: { limit: 50 } }, { id: "d", name: "list_decisions", args: {} }]);
138
+ const tasks = results.find(one => one.role === "tool" && one.name === "list_tasks");
139
+ const decisions = results.find(one => one.role === "tool" && one.name === "list_decisions");
140
+ const listed = (tasks?.role === "tool" ? JSON.parse(tasks.result).tasks : undefined) ?? [];
141
+ const inState = (state) => listed.filter(one => one.state === state).map(one => one.title);
142
+ const topic = question?.topic ?? "overview";
143
+ const said = (state) => `${inState(state).length} ${state}: ${inState(state).join("; ") || "none"}.`;
144
+ switch (topic) {
145
+ case "running":
146
+ case "overview": return reply(`Running now: ${inState("running").join("; ")}.`);
147
+ case "queued": return reply(`Waiting to start: ${inState("queued").join("; ")}.`);
148
+ case "failed": return reply(`Failed: ${inState("failed").join("; ")}.`);
149
+ case "done": return reply(`Done: ${inState("done").join("; ")}.`);
150
+ case "cancelled": return reply(`Cancelled: ${inState("cancelled").join("; ")}.`);
151
+ case "queued-count": return reply(said("queued"));
152
+ case "failed-count": return reply(said("failed"));
153
+ case "csv-status": return reply(`The CSV export is ${listed.find(one => one.title.includes("CSV"))?.state === "running" ? "running" : "not running"}.`);
154
+ case "needs-me": {
155
+ const waiting = LEAD_ANSWER_TASKS.filter(one => decisions?.role === "tool" && decisions.result.includes(one.id)).map(one => one.title);
156
+ return reply(waiting.length === 0 ? "Nothing needs you." : `A decision waits on you: ${waiting.join("; ")}.`);
157
+ }
158
+ default: return reply("I'm not sure.");
159
+ }
160
+ };
161
+ }
162
+ const median = (values) => {
163
+ const sorted = [...values].sort((a, b) => a - b);
164
+ if (sorted.length === 0)
165
+ return 0;
166
+ const mid = Math.floor(sorted.length / 2);
167
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
168
+ };
169
+ /** Run the eval. `runner` absent: this computer's signed-in Claude (real mode). */
170
+ export async function runLeadAnswerEval(options) {
171
+ const k = options.k ?? LEAD_ANSWER_K;
172
+ const world = seedLeadAnswerWorld();
173
+ const { store, who, config } = world;
174
+ const questions = leadAnswerQuestions();
175
+ const quickModel = quickLeadModel(store);
176
+ let virtual = T0.getTime();
177
+ const clock = options.mode === "scripted" ? () => new Date(virtual) : () => new Date();
178
+ const runner = options.mode === "scripted" ? scriptedLeadAnswerRunner(questions, { advance: ms => { virtual += ms; } }, quickModel) : undefined;
179
+ const runs = [];
180
+ const once = async (question, model) => {
181
+ setLeadQuickAnswers(store, model === "quick", "owner", clock());
182
+ // A fresh conversation per run: no earlier answer leaks into the next.
183
+ const session = store.getLeadSession(store.mintLeadSession({ approver: "owner", approverGeneration: who.generation, credentialKey: subscriptionCredentialKey("claude-subscription"),
184
+ ceilingMicrousd: 0, ceilingDigest: who.ceilingDigest, termsDigest: "e".repeat(64) }, clock()));
185
+ store.closeLeadThreadsFor("owner", clock());
186
+ const thread = store.openLeadThread("owner", who.ceilingDigest, clock()).thread;
187
+ const started = clock().getTime();
188
+ const outcome = await runLeadTurn({ store, who, session, thread, config, key: null, message: question.text, clock, ...(runner === undefined ? {} : { subscriptionRunner: runner }) });
189
+ const ms = clock().getTime() - started;
190
+ const turn = "turn" in outcome ? store.getLeadTurn(outcome.turn) : null;
191
+ const answer = outcome.ok && !outcome.replayed ? outcome.reply : "";
192
+ const graded = gradeAnswer(question, answer);
193
+ const failed = outcome.ok ? null : "refused" in outcome ? `refused: ${outcome.refused}` : `failed: ${outcome.failed}`;
194
+ return { model, route: turn?.route ?? null, answer, ms, named: failed === null && graded.named, falseClaims: graded.falseClaims, failed };
195
+ };
196
+ const read = questions.filter(one => one.kind === "read");
197
+ const tally = { main: { passK: 0, falseClaims: 0, ms: [] }, quick: { passK: 0, falseClaims: 0, ms: [] } };
198
+ for (const question of read) {
199
+ for (const model of ["main", "quick"]) {
200
+ let all = true;
201
+ for (let attempt = 0; attempt < k; attempt++) {
202
+ const one = await once(question, model);
203
+ runs.push({ question: question.id, ...one });
204
+ options.onRun?.(`${question.id} ${model} #${attempt + 1}: ${one.named ? "named" : "missed"}${one.falseClaims > 0 ? `, ${one.falseClaims} false` : ""}, ${one.ms} ms, route ${one.route}${one.failed === null ? "" : `, ${one.failed}`}`);
205
+ if (!one.named)
206
+ all = false;
207
+ tally[model].falseClaims += one.falseClaims;
208
+ tally[model].ms.push(one.ms);
209
+ }
210
+ if (all)
211
+ tally[model].passK++;
212
+ }
213
+ }
214
+ const actFailures = [];
215
+ let kept = 0;
216
+ const acts = questions.filter(one => one.kind === "act");
217
+ for (const question of acts) {
218
+ const decided = quickRoute({ enabled: true, provider: config.provider, mainModel: config.model, message: question.text, hasContext: false, hasReplyImage: false, askOpen: false, quickModel, quickAllowed: true });
219
+ if (decided.route === "default") {
220
+ kept++;
221
+ options.onRun?.(`${question.id}: kept on the main model by the router`);
222
+ continue;
223
+ }
224
+ let all = true;
225
+ for (let attempt = 0; attempt < k; attempt++) {
226
+ const one = await once(question, "quick");
227
+ runs.push({ question: question.id, ...one });
228
+ options.onRun?.(`${question.id} quick #${attempt + 1}: route ${one.route}${one.failed === null ? "" : `, ${one.failed}`}`);
229
+ if (one.route !== "escalated" && one.route !== "default")
230
+ all = false;
231
+ }
232
+ if (all)
233
+ kept++;
234
+ else
235
+ actFailures.push(question.id);
236
+ }
237
+ store.close();
238
+ const mainMs = median(tally.main.ms), quickMs = median(tally.quick.ms);
239
+ const speedup = mainMs === 0 ? 0 : 1 - quickMs / mainMs;
240
+ const reasons = [];
241
+ if (tally.quick.passK < tally.main.passK)
242
+ reasons.push(`quick answers named the facts on ${tally.quick.passK} questions (all ${k} runs), the main model on ${tally.main.passK}`);
243
+ if (tally.quick.falseClaims > tally.main.falseClaims)
244
+ reasons.push(`quick answers made ${tally.quick.falseClaims} false claims, the main model ${tally.main.falseClaims}`);
245
+ if (kept !== acts.length)
246
+ reasons.push(`${acts.length - kept} of ${acts.length} act questions stayed on the quick model: ${actFailures.join(", ")}`);
247
+ if (speedup < 0.3)
248
+ reasons.push(`quick answers were ${Math.round(speedup * 100)}% faster at the median; 30% is needed`);
249
+ return {
250
+ mode: options.mode, k, quickModel,
251
+ read: { main: { passK: tally.main.passK, falseClaims: tally.main.falseClaims, medianMs: mainMs }, quick: { passK: tally.quick.passK, falseClaims: tally.quick.falseClaims, medianMs: quickMs }, questions: read.length },
252
+ act: { kept, total: acts.length, failures: actFailures },
253
+ speedup, pass: reasons.length === 0, reasons, runs,
254
+ };
255
+ }