toolroll 0.9.96 → 0.9.98
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/CHANGELOG.md +18 -0
- package/dist/approval-authority.d.ts +10 -1
- package/dist/approval-authority.js +18 -2
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +32 -32
- package/dist/browser-workspace.d.ts +6 -3
- package/dist/chat-actions.d.ts +40 -5
- package/dist/chat-actions.js +124 -13
- package/dist/chat-card.js +4 -1
- package/dist/chat-channel.d.ts +3 -0
- package/dist/chat-channel.js +24 -7
- package/dist/chat-decide.js +4 -2
- package/dist/chat-delivery.d.ts +5 -1
- package/dist/chat-delivery.js +74 -25
- package/dist/chat-follow.d.ts +6 -3
- package/dist/chat-follow.js +23 -7
- package/dist/chat-live-schema.d.ts +17 -0
- package/dist/chat-live-schema.js +32 -0
- package/dist/chat-live.d.ts +161 -0
- package/dist/chat-live.js +491 -0
- package/dist/chat-task-actions.js +1 -1
- package/dist/chat-terms.d.ts +8 -0
- package/dist/chat-terms.js +14 -0
- package/dist/chat-voice.js +1 -1
- package/dist/contracts/chat-actions.d.ts +6 -0
- package/dist/contracts/chat-actions.js +2 -0
- package/dist/contracts/cli.d.ts +6 -0
- package/dist/contracts/cli.js +3 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/lead-tools.d.ts +17 -0
- package/dist/contracts/lead-tools.js +4 -1
- package/dist/contracts/store-json.d.ts +1 -0
- package/dist/discord-api.d.ts +3 -1
- package/dist/discord-api.js +9 -3
- package/dist/discord-chat.d.ts +13 -3
- package/dist/discord-chat.js +77 -21
- package/dist/discord.js +8 -1
- package/dist/fast-start.d.ts +83 -6
- package/dist/fast-start.js +112 -15
- package/dist/home-groups.d.ts +39 -0
- package/dist/home-groups.js +33 -0
- package/dist/lead-answer-eval.d.ts +93 -0
- package/dist/lead-answer-eval.js +259 -0
- package/dist/lead-capabilities.js +1 -1
- package/dist/lead-cli-live.d.ts +56 -0
- package/dist/lead-cli-live.js +200 -0
- package/dist/lead-cli.d.ts +3 -0
- package/dist/lead-cli.js +34 -4
- package/dist/lead-context.js +2 -2
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +10 -7
- package/dist/lead-doors.d.ts +32 -0
- package/dist/lead-doors.js +80 -14
- package/dist/lead-latency-schema.d.ts +24 -0
- package/dist/lead-latency-schema.js +36 -0
- package/dist/lead-progress.d.ts +10 -1
- package/dist/lead-progress.js +1 -1
- package/dist/lead-queue-runtime.d.ts +1 -1
- package/dist/lead-queue-runtime.js +1 -1
- package/dist/lead-route.d.ts +56 -0
- package/dist/lead-route.js +104 -0
- package/dist/lead-status.d.ts +3 -0
- package/dist/lead-status.js +8 -3
- package/dist/lead-tools.d.ts +20 -0
- package/dist/lead-tools.js +66 -3
- package/dist/lead-voice.js +1 -1
- package/dist/lead.d.ts +4 -2
- package/dist/lead.js +97 -28
- package/dist/live-reply.d.ts +83 -0
- package/dist/live-reply.js +302 -0
- package/dist/local-owner.js +2 -0
- package/dist/one-step-chat-schema.d.ts +22 -0
- package/dist/one-step-chat-schema.js +37 -0
- package/dist/operate.d.ts +27 -1
- package/dist/operate.js +245 -33
- package/dist/project-delete.js +9 -0
- package/dist/project-knowledge.d.ts +10 -10
- package/dist/publish.d.ts +2 -0
- package/dist/publish.js +1 -1
- package/dist/pull-request-flow.d.ts +4 -0
- package/dist/pull-request-flow.js +16 -1
- package/dist/reply-shape.d.ts +9 -0
- package/dist/reply-shape.js +13 -0
- package/dist/reply-voice.d.ts +23 -0
- package/dist/reply-voice.js +37 -0
- package/dist/scope.d.ts +6 -1
- package/dist/scope.js +3 -2
- package/dist/serve.js +11 -4
- package/dist/server/chat.d.ts +2 -1
- package/dist/server/chat.js +22 -10
- package/dist/server/chrome.js +2 -2
- package/dist/server/home.d.ts +4 -3
- package/dist/server/home.js +33 -28
- package/dist/server/live.js +18 -21
- package/dist/server/render-chat.d.ts +5 -0
- package/dist/server/render-chat.js +15 -8
- package/dist/server/render-pages.js +2 -2
- package/dist/server/render-settings.d.ts +8 -0
- package/dist/server/render-settings.js +13 -2
- package/dist/server/route-table.js +2 -0
- package/dist/server/settings.js +14 -1
- package/dist/setup-guide.d.ts +2 -2
- package/dist/slack-api.js +11 -0
- package/dist/slack-chat.d.ts +11 -1
- package/dist/slack-chat.js +50 -5
- package/dist/slack-settings.js +8 -1
- package/dist/slack.js +10 -3
- package/dist/store.d.ts +33 -2
- package/dist/store.js +92 -16
- package/dist/subscription-chat.d.ts +2 -0
- package/dist/subscription-chat.js +3 -1
- package/dist/teams-chat.d.ts +9 -2
- package/dist/teams-chat.js +50 -2
- package/dist/teams.js +5 -2
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +132 -14
- package/dist/telegram-live.d.ts +116 -0
- package/dist/telegram-live.js +224 -0
- package/dist/telegram-status.js +9 -4
- package/dist/telegram.d.ts +12 -0
- package/dist/telegram.js +142 -10
- package/dist/toolroll-update.js +6 -1
- package/dist/tui-contract.d.ts +2 -0
- package/dist/tui-source.js +3 -0
- package/dist/tui.js +41 -41
- package/dist/typed-start.d.ts +51 -0
- package/dist/typed-start.js +98 -0
- package/dist/wake-hub.d.ts +72 -0
- package/dist/wake-hub.js +151 -0
- package/package.json +1 -1
|
@@ -52,7 +52,7 @@ export const CODE_MAP_VIEWS = ["overview", "area", "file", "search"];
|
|
|
52
52
|
/** Task states a list filters by. */
|
|
53
53
|
export const LISTED_TASK_STATES = ["queued", "running", "done", "failed", "cancelled"];
|
|
54
54
|
/** The shared actions the lead proposes with propose_action; flows and subagents have their own tools. */
|
|
55
|
-
const ACTION_OPERATIONS = Object.keys(CHAT_ACTIONS).filter(one => !one.startsWith("flow_") && !one.startsWith("subagent_"));
|
|
55
|
+
const ACTION_OPERATIONS = Object.keys(CHAT_ACTIONS).filter(one => !one.startsWith("flow_") && !one.startsWith("subagent_") && one !== "task_start");
|
|
56
56
|
/** A card's spec as the lead sees it: one flat props object. Each element's own props are read strictly by card-catalog.ts. */
|
|
57
57
|
const cardProps = z.strictObject({
|
|
58
58
|
source: z.enum(CARD_SOURCES).optional(), chart: z.enum(CARD_CHART_TYPES).optional(), columns: z.array(z.string().max(40)).max(CARD_LIMITS.columns).optional(), facts: z.array(z.string().max(40)).max(CARD_LIMITS.facts).optional(),
|
|
@@ -147,6 +147,8 @@ export const LEAD_TOOL_INPUTS = {
|
|
|
147
147
|
not: scopeText.nullable().optional(), touches: touches.optional(), acceptance,
|
|
148
148
|
planning: z.enum(["auto", "required", "skip"]).optional(), report: z.boolean().optional(), checks: text("checksWords").optional(),
|
|
149
149
|
}),
|
|
150
|
+
// One-step start from chat (docs/plans/one-step-chat.md C3): one small change the person asked to be done now.
|
|
151
|
+
propose_start: z.strictObject({ repo: repoId, title: text("taskTitle") }),
|
|
150
152
|
// Lead fan-out (docs/plans/lead-fan-out.md): 2 to 20 independent items under one shared brief and one success test.
|
|
151
153
|
propose_sweep: z.strictObject({
|
|
152
154
|
repo: repoId, title: text("taskTitle"), brief: scopeText,
|
|
@@ -286,6 +288,7 @@ export const LEAD_TOOL_OUTPUTS = {
|
|
|
286
288
|
get_decision: decisionOutput,
|
|
287
289
|
queue: z.looseObject({ repo: str, queueRevision: int, ...queueColumnsOutput }),
|
|
288
290
|
propose_task: proposed("task", { repo: str, deliverable: z.enum(["branch", "report"]), planning: str }),
|
|
291
|
+
propose_start: z.looseObject({ proposal: int, kind: z.enum(["action", "task"]), startsNow: z.boolean(), awaiting: str }),
|
|
289
292
|
propose_sweep: cardDrafted,
|
|
290
293
|
get_sweep: z.looseObject({ sweep: int, title: str, line: str, table: str, maxParallel: int, items: z.array(z.looseObject({ item: int, label: str, task: str, state: str })) }),
|
|
291
294
|
propose_sweep_cancel: cardDrafted,
|
package/dist/discord-api.d.ts
CHANGED
|
@@ -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;
|
package/dist/discord-api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
?
|
|
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";
|
package/dist/discord-chat.d.ts
CHANGED
|
@@ -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;
|
package/dist/discord-chat.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 {
|
|
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);
|
package/dist/fast-start.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ApprovalPasswordFacts } from "./approval-password.js";
|
|
2
|
+
import type { ChatApprovalLimits } from "./chat-approval.js";
|
|
2
3
|
import { type TaskSizing } from "./phase-routing.js";
|
|
3
4
|
import { type ApproveResult, type Scope } from "./scope.js";
|
|
4
5
|
import { type Store } from "./store.js";
|
|
@@ -9,9 +10,10 @@ export declare const FAST_MAX_FILES = 3;
|
|
|
9
10
|
export declare const FAST_MAX_LINES = 150;
|
|
10
11
|
/** How long a rendered form may still be pressed. */
|
|
11
12
|
export declare const FAST_PREVIEW_MS: number;
|
|
12
|
-
/** Which person surface is filing.
|
|
13
|
-
|
|
14
|
-
export type
|
|
13
|
+
/** Which person surface is filing. `chat` is a Start card a person taps or answers yes to (one-step-chat.md); a flow, a
|
|
14
|
+
* token or an agent has no entry here. */
|
|
15
|
+
export type FastVia = "console" | "terminal-ui" | "cli" | "chat";
|
|
16
|
+
export type FastRefusal = "setting" | "size" | "classifier" | "plan" | "quality" | "rules" | "protected" | "password" | "release" | "trial" | "chain" | "monthly" | "policy" | "agents" | "machine" | "mode" | "limit";
|
|
15
17
|
export declare const fastRefusalWords: (reason: FastRefusal) => string;
|
|
16
18
|
/** Small tasks start right away: a project's own choice, else on for single-approver installs and off once there's more
|
|
17
19
|
* than one approver. */
|
|
@@ -32,10 +34,34 @@ export type FastInput = {
|
|
|
32
34
|
permissionMode?: "auto" | "bypassPermissions" | undefined;
|
|
33
35
|
};
|
|
34
36
|
export type PasswordFacts = Pick<ApprovalPasswordFacts, "passwordless" | "localOwner" | "freshAt" | "returnTo">;
|
|
37
|
+
/** How the person is proved at the one step that differs by surface: the password rule the form reads, or (a chat
|
|
38
|
+
* start) the person's own chat approval limits. */
|
|
39
|
+
export type FastProof = {
|
|
40
|
+
password: PasswordFacts;
|
|
41
|
+
} | {
|
|
42
|
+
chat: ChatApprovalLimits;
|
|
43
|
+
};
|
|
44
|
+
/** Every person surface's eligibility: the rules, then a machine is refused. */
|
|
35
45
|
export declare function fastStartEligible(store: Store, input: FastInput & {
|
|
36
46
|
taskId: string;
|
|
37
47
|
sizing: TaskSizing;
|
|
48
|
+
} & ({
|
|
38
49
|
password: PasswordFacts;
|
|
50
|
+
} | {
|
|
51
|
+
proof: FastProof;
|
|
52
|
+
}), now: Date): {
|
|
53
|
+
ok: true;
|
|
54
|
+
scope: Scope;
|
|
55
|
+
} | {
|
|
56
|
+
ok: false;
|
|
57
|
+
reason: FastRefusal;
|
|
58
|
+
};
|
|
59
|
+
/** The rules alone, the same for every surface: only the person-proof step has two forms (FastProof). A preview reads
|
|
60
|
+
* these as any caller; Build reads them again behind the machine check (fastStartEligible). */
|
|
61
|
+
export declare function fastStartRules(store: Store, input: FastInput & {
|
|
62
|
+
taskId: string;
|
|
63
|
+
sizing: TaskSizing;
|
|
64
|
+
proof: FastProof;
|
|
39
65
|
}, now: Date): {
|
|
40
66
|
ok: true;
|
|
41
67
|
scope: Scope;
|
|
@@ -59,7 +85,7 @@ export type FastPreview = {
|
|
|
59
85
|
* files exactly this, whatever is posted.
|
|
60
86
|
*/
|
|
61
87
|
export declare function prepareFastStart(store: Store, input: FastInput & {
|
|
62
|
-
via: FastVia
|
|
88
|
+
via: Exclude<FastVia, "chat">;
|
|
63
89
|
password: PasswordFacts;
|
|
64
90
|
}, now: Date): Promise<FastPreview>;
|
|
65
91
|
export type FastBuild = {
|
|
@@ -84,9 +110,14 @@ export declare function fastStartBuild(store: Store, input: {
|
|
|
84
110
|
digest: string;
|
|
85
111
|
actor: string;
|
|
86
112
|
via: FastVia;
|
|
87
|
-
password: PasswordFacts;
|
|
88
113
|
approveNow: FastApprove;
|
|
89
|
-
|
|
114
|
+
/** A chat start: the lead turn its card was drafted in. Its preview must have been prepared in that same turn. */
|
|
115
|
+
turn?: number;
|
|
116
|
+
} & ({
|
|
117
|
+
password: PasswordFacts;
|
|
118
|
+
} | {
|
|
119
|
+
proof: FastProof;
|
|
120
|
+
}), now: Date): FastBuild;
|
|
90
121
|
/**
|
|
91
122
|
* A fast start that didn't start in time: its seal is cleared, in a logged transaction, and the task shows the ordinary
|
|
92
123
|
* Approve plan. A task with a build running or a result to read is left to that.
|
|
@@ -134,3 +165,49 @@ export declare function keepGrownChange(store: Store, taskId: string, now: Date)
|
|
|
134
165
|
ok: false;
|
|
135
166
|
message: string;
|
|
136
167
|
};
|
|
168
|
+
/** What a chat start's preview is for: who asked, where, the lead's one-line title, and the lead turn it is prepared in. */
|
|
169
|
+
export type ChatStartInput = {
|
|
170
|
+
actor: string;
|
|
171
|
+
repo: string;
|
|
172
|
+
title: string;
|
|
173
|
+
turn: number;
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* A chat start's preview, prepared by the lead's turn loop before the Start card is drafted: the classifier asked (at
|
|
177
|
+
* most its budget), the filing dry-run, and the rules read. It refuses no machine caller, because a preview grants
|
|
178
|
+
* nothing (the lead's turn runs as the lead): Build refuses a machine (fastStartBuild), and only a person's own tap or
|
|
179
|
+
* typed yes reaches Build. The row is bound to the person, `via = 'chat'` and the turn; a preview without a turn is
|
|
180
|
+
* unusable. The person's own chat limits are read again at the tap, for the chat it comes from; here only the loosest
|
|
181
|
+
* limit that still means something applies (no full access, no cap), so a card is never drafted for terms no chat
|
|
182
|
+
* approval could allow.
|
|
183
|
+
*/
|
|
184
|
+
export declare function prepareChatStart(store: Store, input: ChatStartInput, now: Date): Promise<FastPreview>;
|
|
185
|
+
/** A chat start's preview, as the Start card reads it again: this person's, `via = 'chat'`, prepared in this turn,
|
|
186
|
+
* unexpired and unused. Null otherwise. */
|
|
187
|
+
export declare function chatStartPreview(store: Store, preview: string, actor: string, turn: number, now: Date): {
|
|
188
|
+
taskId: string;
|
|
189
|
+
repo: string;
|
|
190
|
+
title: string;
|
|
191
|
+
sizing: TaskSizing;
|
|
192
|
+
digest: string;
|
|
193
|
+
} | null;
|
|
194
|
+
/** A dismissed Start card's preview can't be used: consumed, in the dismissal's transaction. */
|
|
195
|
+
export declare function consumeChatStartPreview(store: Store, preview: string, now: Date): void;
|
|
196
|
+
/**
|
|
197
|
+
* A chat start's filing dry-run, for its card: the scope filing would make, read in a savepoint that is always rolled
|
|
198
|
+
* back, with the rules read on it (any proof form; the card shows the terms, the tap proves the person). The card is made
|
|
199
|
+
* from these words; the tap files again and compares.
|
|
200
|
+
*/
|
|
201
|
+
export declare function chatStartDryRun(store: Store, input: {
|
|
202
|
+
actor: string;
|
|
203
|
+
repo: string;
|
|
204
|
+
title: string;
|
|
205
|
+
taskId: string;
|
|
206
|
+
sizing: TaskSizing;
|
|
207
|
+
}, now: Date): {
|
|
208
|
+
ok: true;
|
|
209
|
+
scope: Scope;
|
|
210
|
+
} | {
|
|
211
|
+
ok: false;
|
|
212
|
+
reason: FastRefusal;
|
|
213
|
+
};
|
package/dist/fast-start.js
CHANGED
|
@@ -11,11 +11,18 @@
|
|
|
11
11
|
* - **the Accept backstop**: a machine can't complete it, the project's grant doesn't push it until a person accepted it,
|
|
12
12
|
* and completion, publication and merge all read one verified measure of the whole change (verifiedChange).
|
|
13
13
|
*
|
|
14
|
-
* Only a person's own surfaces reach here (the browser, the terminal UI as the local owner, interactive `task new
|
|
15
|
-
*
|
|
14
|
+
* Only a person's own surfaces reach here (the browser, the terminal UI as the local owner, interactive `task new`, and
|
|
15
|
+
* a person's own tap or typed yes on a chat's Start card: docs/plans/one-step-chat.md). Every machine is refused in the
|
|
16
|
+
* writer from trusted context. `task add` never comes this way.
|
|
17
|
+
*
|
|
18
|
+
* A chat start (`via: "chat"`) runs the same rules (fastStartRules). Its words are the lead's, so it is filed as the
|
|
19
|
+
* lead's text (`proposedVia: "mate"`, quarantined), and only its person-proof step differs: chat has no password, so
|
|
20
|
+
* instead of the password field it passes only within the person's own chat approval limits (chatLimitProblem), and
|
|
21
|
+
* quarantined text is approvable in chat only on a card that showed every term whole (store.ts sealScopeApproval).
|
|
16
22
|
*/
|
|
17
23
|
import { randomBytes } from "node:crypto";
|
|
18
24
|
import { approvalPasswordField } from "./approval-password.js";
|
|
25
|
+
import { chatLimitProblem } from "./chat-terms.js";
|
|
19
26
|
import { isProtectedWork, protectedChanges } from "./approval-policy.js";
|
|
20
27
|
import { originalTaskBase } from "./observations.js";
|
|
21
28
|
import { legOf, makesNoPlan, routeFromJson } from "./phase-routing.js";
|
|
@@ -53,6 +60,7 @@ const FAST_WORDS = {
|
|
|
53
60
|
agents: "Choose an agent first.",
|
|
54
61
|
machine: "Only a person starts work this way.",
|
|
55
62
|
mode: "This project's mode approves it.",
|
|
63
|
+
limit: "This is more than your chat approval setting allows.",
|
|
56
64
|
};
|
|
57
65
|
export const fastRefusalWords = (reason) => FAST_WORDS[reason];
|
|
58
66
|
// ---- the project setting ---------------------------------------------------------------------------------------------
|
|
@@ -78,10 +86,16 @@ export function setFastStart(store, repo, on, by, now) {
|
|
|
78
86
|
// ---- eligibility: one function, read by the form and again inside the transaction ------------------------------------
|
|
79
87
|
/** Filed this way, the plan's words are the person's words, and they inspect the result. */
|
|
80
88
|
export const fastAcceptance = (title) => [{ id: "c1", statement: title, how: null, evidence: ["manual-review"] }];
|
|
89
|
+
/** Every person surface's eligibility: the rules, then a machine is refused. */
|
|
81
90
|
export function fastStartEligible(store, input, now) {
|
|
82
|
-
const no = (reason) => ({ ok: false, reason });
|
|
83
91
|
if (machineActing())
|
|
84
|
-
return
|
|
92
|
+
return { ok: false, reason: "machine" };
|
|
93
|
+
return fastStartRules(store, { ...input, proof: "proof" in input ? input.proof : { password: input.password } }, now);
|
|
94
|
+
}
|
|
95
|
+
/** The rules alone, the same for every surface: only the person-proof step has two forms (FastProof). A preview reads
|
|
96
|
+
* these as any caller; Build reads them again behind the machine check (fastStartEligible). */
|
|
97
|
+
export function fastStartRules(store, input, now) {
|
|
98
|
+
const no = (reason) => ({ ok: false, reason });
|
|
85
99
|
if (!fastStartOn(store, input.repo))
|
|
86
100
|
return no("setting");
|
|
87
101
|
const account = store.accountOf(input.actor);
|
|
@@ -126,9 +140,13 @@ export function fastStartEligible(store, input, now) {
|
|
|
126
140
|
const profile = scope.profile;
|
|
127
141
|
if (levelOfProfile(scope.profile) === "escalated" || profile["checkAccess"] !== undefined || profile["machLookup"] !== undefined)
|
|
128
142
|
return no("password");
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
143
|
+
if ("password" in input.proof) {
|
|
144
|
+
const field = approvalPasswordField(store, { name: input.actor, taskId: input.taskId, scope, ...input.proof.password });
|
|
145
|
+
if (field.field === "required" || field.field === "local-refused")
|
|
146
|
+
return no("password");
|
|
147
|
+
}
|
|
148
|
+
else if (chatLimitProblem(scope, input.proof.chat, "your chat approval setting") !== null)
|
|
149
|
+
return no("limit");
|
|
132
150
|
const ref = store.lookupRef(input.taskId);
|
|
133
151
|
if (ref === null || store.budgetGate(now)({ ...store.budgetSubject(ref.id), agents: store.agentsFor([scope.profile.provider]) }).over !== null)
|
|
134
152
|
return no("monthly");
|
|
@@ -136,12 +154,14 @@ export function fastStartEligible(store, input, now) {
|
|
|
136
154
|
return no("policy");
|
|
137
155
|
return { ok: true, scope };
|
|
138
156
|
}
|
|
139
|
-
/** File the task, inside the caller's transaction, exactly as Build will.
|
|
157
|
+
/** File the task, inside the caller's transaction, exactly as Build will. A chat start's words are the lead's: its text
|
|
158
|
+
* stays quarantined (`proposedVia: "mate"`), as every lead-written scope is. */
|
|
140
159
|
function fileIt(store, id, input, now) {
|
|
141
160
|
const filed = fileTaskProposal(store, {
|
|
142
161
|
id, title: titleWithin(input.title), repo: input.repo, goal: input.title, acceptance: fastAcceptance(input.title), planning: "skip", sizing: input.sizing,
|
|
143
162
|
...(input.permissionMode === undefined ? {} : { permissionMode: input.permissionMode }),
|
|
144
|
-
filedVia: input.via === "console" ? "console" : input.via === "terminal-ui" ? "tui" : "cli", filedBy: { name: input.actor, kind: "person" },
|
|
163
|
+
filedVia: input.via === "console" ? "console" : input.via === "terminal-ui" ? "tui" : input.via === "chat" ? "chat" : "cli", filedBy: { name: input.actor, kind: "person" },
|
|
164
|
+
...(input.via === "chat" ? { proposedVia: "mate" } : {}),
|
|
145
165
|
}, now);
|
|
146
166
|
return filed.ok ? { ok: true } : { ok: false, message: filed.message };
|
|
147
167
|
}
|
|
@@ -152,10 +172,11 @@ class RolledBack extends Error {
|
|
|
152
172
|
this.answer = answer;
|
|
153
173
|
}
|
|
154
174
|
}
|
|
155
|
-
/** Run `body` in a
|
|
175
|
+
/** Run `body` in a savepoint that never commits: a preview writes nothing, at any nesting depth (a nested transact runs
|
|
176
|
+
* in place, so only the savepoint undoes the writes when a caller already holds a transaction). */
|
|
156
177
|
function dryRun(store, body) {
|
|
157
178
|
try {
|
|
158
|
-
store.transact(() => { throw new RolledBack(body()); });
|
|
179
|
+
store.transact(() => store.savepoint(() => { throw new RolledBack(body()); }));
|
|
159
180
|
}
|
|
160
181
|
catch (error) {
|
|
161
182
|
if (error instanceof RolledBack)
|
|
@@ -172,6 +193,9 @@ function dryRun(store, body) {
|
|
|
172
193
|
export async function prepareFastStart(store, input, now) {
|
|
173
194
|
if (machineActing())
|
|
174
195
|
return { eligible: false, reason: "machine" };
|
|
196
|
+
// A chat start is prepared in the lead's turn (prepareChatStart), bound to it.
|
|
197
|
+
if (input.via === "chat")
|
|
198
|
+
return { eligible: false, reason: "rules" };
|
|
175
199
|
if (!fastStartOn(store, input.repo))
|
|
176
200
|
return { eligible: false, reason: "setting" };
|
|
177
201
|
if (heuristicSizing({ title: input.title, goal: input.title }).size !== "small")
|
|
@@ -203,6 +227,10 @@ export async function prepareFastStart(store, input, now) {
|
|
|
203
227
|
export function fastStartBuild(store, input, now) {
|
|
204
228
|
if (machineActing())
|
|
205
229
|
return { ok: false, reason: "machine", message: FAST_WORDS.machine };
|
|
230
|
+
const proof = "proof" in input ? input.proof : { password: input.password };
|
|
231
|
+
// The chat form of the proof is a chat start's alone; the form's surfaces keep the password rule.
|
|
232
|
+
if ("chat" in proof && input.via !== "chat")
|
|
233
|
+
return { ok: false, reason: "rules", message: FAST_WORDS.rules };
|
|
206
234
|
class Refused extends Error {
|
|
207
235
|
answer;
|
|
208
236
|
constructor(answer) {
|
|
@@ -212,29 +240,37 @@ export function fastStartBuild(store, input, now) {
|
|
|
212
240
|
}
|
|
213
241
|
const refuse = (answer) => { throw new Refused(answer); };
|
|
214
242
|
try {
|
|
215
|
-
|
|
243
|
+
// A savepoint inside the transaction: a refusal undoes the filing even when the caller already holds a transaction
|
|
244
|
+
// (a nested transact runs in place, and this function catches its own refusal).
|
|
245
|
+
return store.transact(() => store.savepoint(() => {
|
|
216
246
|
const row = store.handle.prepare("SELECT * FROM fast_preview WHERE id = ? AND consumed_at IS NULL AND expires_at > ?").get(input.preview, now.toISOString());
|
|
217
247
|
if (row === undefined || row["actor"] !== input.actor || row["via"] !== input.via)
|
|
218
248
|
return refuse({ ok: false, reason: "gone", message: "This form has expired. Start again." });
|
|
249
|
+
// A chat preview without a turn, or from another turn than its card's, is nobody's card.
|
|
250
|
+
if (input.via === "chat" && (row["turn"] === null || row["turn"] === undefined || input.turn === undefined || Number(row["turn"]) !== input.turn))
|
|
251
|
+
return refuse({ ok: false, reason: "gone", message: "This card has expired. Ask again." });
|
|
219
252
|
const sizing = JSON.parse(String(row["sizing_json"]));
|
|
220
253
|
const taskId = String(row["task_id"]);
|
|
221
254
|
const filing = { actor: input.actor, repo: String(row["repo"]), title: String(row["title"]), ...(row["permission"] === null ? {} : { permissionMode: row["permission"] }) };
|
|
222
255
|
const filed = fileIt(store, taskId, { ...filing, via: input.via, sizing }, now);
|
|
223
256
|
if (!filed.ok)
|
|
224
257
|
return refuse({ ok: false, reason: "changed", message: filed.message });
|
|
225
|
-
const eligible = fastStartEligible(store, { ...filing, taskId, sizing,
|
|
258
|
+
const eligible = fastStartEligible(store, { ...filing, taskId, sizing, proof }, now);
|
|
226
259
|
if (!eligible.ok)
|
|
227
260
|
return refuse({ ok: false, reason: eligible.reason, message: FAST_WORDS[eligible.reason] });
|
|
228
261
|
const at = now.toISOString();
|
|
229
262
|
// approve() compares the digest the person saw with the plan just filed: changed terms refuse "changed".
|
|
230
263
|
const approved = input.approveNow(taskId, input.digest, { rule: FAST_START_RULE, at });
|
|
264
|
+
if (!approved.ok && approved.reason === "changed")
|
|
265
|
+
return refuse({ ok: false, reason: "changed", message: "This changed since you read it. Read it again." });
|
|
266
|
+
// Chat has no password: a refusal there says the rule, not "takes your password".
|
|
231
267
|
if (!approved.ok)
|
|
232
|
-
return refuse(
|
|
268
|
+
return refuse(input.via === "chat" ? { ok: false, reason: "rules", message: approved.reason === "policy" ? approved.message : FAST_WORDS.rules } : { ok: false, reason: "password", message: FAST_WORDS.password });
|
|
233
269
|
store.handle.prepare("UPDATE fast_preview SET consumed_at = ? WHERE id = ?").run(at, input.preview);
|
|
234
270
|
store.handle.prepare("UPDATE task_ref SET fast_start_at = ?, fast_start_until = ? WHERE backend = ? AND external_id = ?").run(at, new Date(now.getTime() + FAST_WINDOW_MS).toISOString(), BUILT_IN, taskId);
|
|
235
271
|
store.bumpWake();
|
|
236
272
|
return { ok: true, taskId };
|
|
237
|
-
});
|
|
273
|
+
}));
|
|
238
274
|
}
|
|
239
275
|
catch (error) {
|
|
240
276
|
if (error instanceof Refused)
|
|
@@ -429,3 +465,64 @@ export function keepGrownChange(store, taskId, now) {
|
|
|
429
465
|
return { ok: true, digest: proposed.digest };
|
|
430
466
|
});
|
|
431
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* A chat start's preview, prepared by the lead's turn loop before the Start card is drafted: the classifier asked (at
|
|
470
|
+
* most its budget), the filing dry-run, and the rules read. It refuses no machine caller, because a preview grants
|
|
471
|
+
* nothing (the lead's turn runs as the lead): Build refuses a machine (fastStartBuild), and only a person's own tap or
|
|
472
|
+
* typed yes reaches Build. The row is bound to the person, `via = 'chat'` and the turn; a preview without a turn is
|
|
473
|
+
* unusable. The person's own chat limits are read again at the tap, for the chat it comes from; here only the loosest
|
|
474
|
+
* limit that still means something applies (no full access, no cap), so a card is never drafted for terms no chat
|
|
475
|
+
* approval could allow.
|
|
476
|
+
*/
|
|
477
|
+
export async function prepareChatStart(store, input, now) {
|
|
478
|
+
if (!Number.isSafeInteger(input.turn) || input.turn < 1)
|
|
479
|
+
return { eligible: false, reason: "rules" };
|
|
480
|
+
if (!fastStartOn(store, input.repo))
|
|
481
|
+
return { eligible: false, reason: "setting" };
|
|
482
|
+
if (heuristicSizing({ title: input.title, goal: input.title }).size !== "small")
|
|
483
|
+
return { eligible: false, reason: "size" };
|
|
484
|
+
const mode = store.activeMode(input.repo, now);
|
|
485
|
+
if (mode !== null && /"autoApproveFiling":true/.test(mode.termsJson))
|
|
486
|
+
return { eligible: false, reason: "mode" };
|
|
487
|
+
const sizer = filingSizer();
|
|
488
|
+
const sizing = await classifyTask({ title: input.title, goal: input.title }, sizer.sizer, sizer.budget);
|
|
489
|
+
const taskId = `task-${randomBytes(8).toString("hex")}`;
|
|
490
|
+
const filing = { actor: input.actor, repo: input.repo, title: input.title };
|
|
491
|
+
const read = dryRun(store, () => {
|
|
492
|
+
const filed = fileIt(store, taskId, { ...filing, via: "chat", sizing }, now);
|
|
493
|
+
if (!filed.ok)
|
|
494
|
+
return { ok: false, reason: "plan" };
|
|
495
|
+
return fastStartRules(store, { ...filing, taskId, sizing, proof: { chat: { fullAccess: false, capMicrousd: null } } }, now);
|
|
496
|
+
});
|
|
497
|
+
if (!read.ok)
|
|
498
|
+
return { eligible: false, reason: read.reason };
|
|
499
|
+
const preview = randomBytes(16).toString("hex");
|
|
500
|
+
store.handle.prepare(`INSERT INTO fast_preview (id, actor, via, task_id, repo, title, permission, sizing_json, digest, created_at, expires_at, turn) VALUES (?, ?, 'chat', ?, ?, ?, NULL, ?, ?, ?, ?, ?)`)
|
|
501
|
+
.run(preview, input.actor, taskId, input.repo, input.title, JSON.stringify(sizing), read.scope.digest, now.toISOString(), new Date(now.getTime() + FAST_PREVIEW_MS).toISOString(), input.turn);
|
|
502
|
+
return { eligible: true, preview, digest: read.scope.digest, scope: read.scope, taskId };
|
|
503
|
+
}
|
|
504
|
+
/** A chat start's preview, as the Start card reads it again: this person's, `via = 'chat'`, prepared in this turn,
|
|
505
|
+
* unexpired and unused. Null otherwise. */
|
|
506
|
+
export function chatStartPreview(store, preview, actor, turn, now) {
|
|
507
|
+
const row = store.handle.prepare("SELECT * FROM fast_preview WHERE id = ? AND consumed_at IS NULL AND expires_at > ?").get(preview, now.toISOString());
|
|
508
|
+
if (row === undefined || row["actor"] !== actor || row["via"] !== "chat" || row["turn"] === null || Number(row["turn"]) !== turn)
|
|
509
|
+
return null;
|
|
510
|
+
return { taskId: String(row["task_id"]), repo: String(row["repo"]), title: String(row["title"]), sizing: JSON.parse(String(row["sizing_json"])), digest: String(row["digest"]) };
|
|
511
|
+
}
|
|
512
|
+
/** A dismissed Start card's preview can't be used: consumed, in the dismissal's transaction. */
|
|
513
|
+
export function consumeChatStartPreview(store, preview, now) {
|
|
514
|
+
store.handle.prepare("UPDATE fast_preview SET consumed_at = ? WHERE id = ? AND via = 'chat' AND consumed_at IS NULL").run(now.toISOString(), preview);
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* A chat start's filing dry-run, for its card: the scope filing would make, read in a savepoint that is always rolled
|
|
518
|
+
* back, with the rules read on it (any proof form; the card shows the terms, the tap proves the person). The card is made
|
|
519
|
+
* from these words; the tap files again and compares.
|
|
520
|
+
*/
|
|
521
|
+
export function chatStartDryRun(store, input, now) {
|
|
522
|
+
return dryRun(store, () => {
|
|
523
|
+
const filed = fileIt(store, input.taskId, { actor: input.actor, repo: input.repo, title: input.title, via: "chat", sizing: input.sizing }, now);
|
|
524
|
+
if (!filed.ok)
|
|
525
|
+
return { ok: false, reason: "plan" };
|
|
526
|
+
return fastStartRules(store, { actor: input.actor, repo: input.repo, title: input.title, taskId: input.taskId, sizing: input.sizing, proof: { chat: { fullAccess: false, capMicrousd: null } } }, now);
|
|
527
|
+
});
|
|
528
|
+
}
|