/** * Control surface: `brb [payload]`, driven by one registry. * * Two things went wrong before this file was rewritten, and both are structural * rather than cosmetic: * * The tool name is not a reserved namespace. Only the exact commands in * CONTROL_REGISTRY are reserved. This project is *about* brb, so the word * appears in ordinary conversation constantly, and a scheme that consumed every * message starting with it would swallow "brb 状态机还没接好". * * The grammar used to live in four places -- the matcher, the help text, the * control envelope and the docs -- so renaming the prefix left `>>交接` in the * envelope users actually saw. Everything derives from the registry now: * matching, help, canonical display, and the docs consistency test. * * Zero dependencies and no I/O. */ import { computeQid } from "../../core/handshake.ts"; import { CONTROL_REGISTRY, type ControlAction, type ControlSpec, type PendingAction, } from "../../core/controlRegistry.ts"; export { CONTROL_REGISTRY }; export type { ControlAction, ControlSpec, PendingAction }; /** * Derived from the registry below, not written beside it. * * A hand-maintained union here already drifted once: `conversations` was added to * the registry and not to the union, and nothing noticed, because this project runs * no typechecker -- `npm test` runs the suites and nothing runs `tsc`. A mirror that * is never checked is a comment. Deriving it means the registry is the definition * and there is no second edit that can disagree. * Generated rather than written by hand, in the same spirit as `canonicalCommand`. */ /** * One reserved command. * * `payload` is declared per action rather than globally: "unified parser" does not * mean "every command has the same parameter model". Only the two actions whose * whole purpose is to describe work accept free text; the rest would only gain a * wider collision surface. */ // `as const` is load-bearing, not decoration. // // The union below is derived from this registry so the two cannot disagree. But deriving // from `readonly ControlSpec[]` yielded `string`, because `ControlSpec.action` had to be // `string` for the registry to be declarable before the type existed. A derived type that // derives to `string` constrains nothing while looking exactly like a constraint -- and // `canonicalCommand("bind")`, the defect that motivated all of this, type-checked happily // against it. `as const` keeps the literals, so the union is the real set of actions. export const CONTROL_TOOL = "brb"; export const CONTROL_PREFIX = `${CONTROL_TOOL} `; /** Abandoned spellings, understood only to be rejected with an explanation. */ export const DEPRECATED_PREFIXES = [">>", "桥控"] as const; /** * Active safety budget on one payload, in Unicode code points. * * Not a claim about the site's limit. A 36k-character paste was measured wedging * the renderer, and the site converts very large pastes into file attachments, * which stop being messages at all. 8000 is comfortably under both while still * covering task descriptions, log fragments and short code. */ export const PAYLOAD_LIMIT_CODE_POINTS = 8000; /** How much of a payload the confirmation shows. Display only, no identity. */ export const PAYLOAD_PREVIEW_CODE_POINTS = 120; export const ACTIONS_REQUIRING_CONFIRMATION: ControlAction[] = CONTROL_REGISTRY.filter( (spec) => spec.requiresConfirmation, ).map((spec) => spec.action); /** Token -> action, built from the registry so the two cannot disagree. */ const TOKEN_TO_SPEC = new Map(); for (const spec of CONTROL_REGISTRY) { TOKEN_TO_SPEC.set(spec.zh, spec); TOKEN_TO_SPEC.set(spec.en, spec); } export function specFor(action: ControlAction): ControlSpec { const spec = CONTROL_REGISTRY.find((entry) => entry.action === action); if (!spec) throw new Error(`未注册的控制动作:${action}`); return spec; } /** The canonical spelling of an action, generated rather than written by hand. */ export function canonicalCommand(action: ControlAction, language: "zh" | "en" = "zh"): string { const spec = specFor(action); return `${CONTROL_TOOL} ${language === "zh" ? spec.zh : spec.en}`; } export interface ControlCommand { action: ControlAction; /** The literal token the user typed, for echoing back. */ word: string; /** * The user's own text, canonicalised at the edges only. * * Empty when the action takes no payload or none was given. This is what * becomes the canonical request, so `qid` identifies exactly this. */ payload: string; /** `qid` of the canonical payload. Empty when there is no payload. */ qid: string; /** The message as the user typed it, for honest display. */ raw: string; } export type ControlMatch = /** Not one of the reserved commands: ordinary conversation, untouched. */ | { kind: "none" } | { kind: "command"; command: ControlCommand } /** An abandoned spelling, recognised only to explain itself once. */ | { kind: "deprecated"; word: string; replacement: string } /** * A recognised command whose payload is too large. * * Consumed, never passed through. Passing it through would let an oversized * `brb 一轮 ...` reach the agent as prose and bypass the confirmation that * guards the high-risk actions -- a fail-open hole in the one place that most * needs fail-closed. */ | { kind: "oversized"; action: ControlAction; limit: number; actual: number }; /** * Canonicalise a payload. * * Edges only: line endings normalised, outer blank space removed. Internal * whitespace, indentation, Markdown, code fences, punctuation and Unicode are all * preserved verbatim, because canonicalisation exists to remove *transport* * differences, never to change what the user expressed. */ export function canonicalPayload(raw: string): string { return raw.replace(/\r\n?/g, "\n").replace(/^[ \t\n]+/, "").replace(/[ \t\n]+$/, ""); } /** Whitespace at the edges of the whole message, including newlines. */ function normalizeMessage(raw: string): string { return raw.replace(/\r\n?/g, "\n").replace(/^[ \t\n]+/, "").replace(/[ \t\n]+$/, ""); } function codePoints(text: string): number { return [...text].length; } /** First N code points, single-line, for display. Never used for identity. */ export function payloadPreview(payload: string): string { const points = [...payload]; const head = points.slice(0, PAYLOAD_PREVIEW_CODE_POINTS).join(""); return head.replace(/\n/g, " ⏎ ") + (points.length > PAYLOAD_PREVIEW_CODE_POINTS ? "…" : ""); } /** * The whole grammar. * * `brb` then an action token, then the rest is that action's payload. Whether a * payload is allowed is the action's decision, not the parser's. The message must * still be consumed whole: no substring search, no prefix guessing, no synonym * mapping. */ // The separator between the action token and its payload accepts newlines: a // multi-line payload is a normal thing to paste, and requiring it to start on // the same line would push users back to collapsing their text by hand. The // token itself still cannot contain whitespace, so `brb 状态机还没接好` stays // prose, and because status forbids a payload so does `brb 状态` plus text. const COMMAND_RE = new RegExp(`^${CONTROL_TOOL}[ \\t]+(\\S+)(?:[ \\t\\n]+([\\s\\S]*))?$`); export function matchControlCommand(raw: string): ControlMatch { const text = normalizeMessage(raw ?? ""); const match = COMMAND_RE.exec(text); if (match) { const word = match[1]; const spec = TOKEN_TO_SPEC.get(word); if (spec) { const remainder = match[2] ?? ""; if (spec.payload === "forbidden") { // Any extra token means this is prose, not a command. That is what // keeps `brb 状态机还没接好` and `brb status X` out. if (remainder.trim().length > 0) return deprecatedMatch(text) ?? { kind: "none" }; return { kind: "command", command: { action: spec.action, word, payload: "", qid: "", raw: text }, }; } if (remainder.length === 0) { return { kind: "command", command: { action: spec.action, word, payload: "", qid: "", raw: text }, }; } const payload = canonicalPayload(remainder); const size = codePoints(payload); if (size > PAYLOAD_LIMIT_CODE_POINTS) { return { kind: "oversized", action: spec.action, limit: PAYLOAD_LIMIT_CODE_POINTS, actual: size }; } return { kind: "command", command: { action: spec.action, word, payload, qid: computeQid(payload), raw: text }, }; } // A reserved tool name followed by something that is not an action is // ordinary conversation. No alias guessing and no fuzzy matching. return deprecatedMatch(text) ?? { kind: "none" }; } return deprecatedMatch(text) ?? { kind: "none" }; } /** Recognise an abandoned spelling only when it is unmistakably an old command. */ function deprecatedMatch(text: string): ControlMatch | null { if (text.includes("\n")) return null; for (const prefix of DEPRECATED_PREFIXES) { if (!text.startsWith(prefix)) continue; const rest = text.slice(prefix.length).trim(); const spec = TOKEN_TO_SPEC.get(rest); if (!spec) return null; return { kind: "deprecated", word: rest, replacement: canonicalCommand(spec.action) }; } return null; } /** Help text, generated from the registry so it cannot describe a stale surface. */ export function controlHelp(): string { return CONTROL_REGISTRY.map((spec) => { const flags = [ spec.requiresConfirmation ? "需确认 / needs confirm" : null, spec.payload === "optional" ? "可带正文 / takes payload" : null, ].filter(Boolean); return `${canonicalCommand(spec.action, "zh")} | ${canonicalCommand(spec.action, "en")} ${spec.action}${flags.length ? ` (${flags.join(", ")})` : ""}`; }).join("\n"); } /* ------------------------------------------------------------------ */ /* Confirmation binding */ /* ------------------------------------------------------------------ */ export interface CurrentContext { sid: string; round: number; planKey: string; /** qid of the currently known request, or empty. */ qid: string; head: string; now: number; } export const PENDING_TTL_MS = 10 * 60 * 1000; export interface ConfirmationCheck { ok: boolean; reason: string; } export function confirmationStillValid( pending: PendingAction | null, current: CurrentContext, ttlMs: number = PENDING_TTL_MS, ): ConfirmationCheck { if (!pending) return { ok: false, reason: "没有待确认的动作" }; if (current.now - pending.createdAt > ttlMs) { return { ok: false, reason: `待确认动作已过期(${Math.round((current.now - pending.createdAt) / 1000)}s)` }; } if (pending.sid !== current.sid) return { ok: false, reason: `会话已变化(${pending.sid} → ${current.sid})` }; if (pending.round !== current.round) return { ok: false, reason: `轮次已变化(${pending.round} → ${current.round})` }; if (pending.planKey !== current.planKey) return { ok: false, reason: `方案已变化(${pending.planKey} → ${current.planKey})` }; if (pending.qid !== current.qid) { return { ok: false, reason: `请求已变化(qid ${pending.qid || "(无)"} → ${current.qid || "(无)"})`, }; } if (pending.head !== current.head) return { ok: false, reason: `仓库已变化(HEAD ${pending.head} → ${current.head})` }; return { ok: true, reason: "确认有效" }; } export function describePending(pending: PendingAction): string { const parts = [ `${pending.action}`, `sid=${pending.sid}`, `r=${pending.round}`, `plan=${pending.planKey.slice(0, 12)}`, ]; if (pending.qid) parts.push(`qid=${pending.qid}`); parts.push(`head=${pending.head.slice(0, 8)}`); return parts.join(" "); }