/** * Agent edits to the plan, addressed by block. * * An agent cannot hold a cursor, so it edits the way it reads: by position in * the list of top-level blocks. Positions move under concurrent editing, so a * batch names the revision it was written against and is refused outright if * the plan has moved on — a partly-applied batch would be worse than none. * * Operations run against the parsed document rather than the live tree, so * reading and editing share one index space and the whole batch either * serialises or fails before anything is written. Only once it has proven * itself is it reconciled into the live document, where untouched blocks keep * their identity and nobody loses a cursor to somebody else's edit. */ import { createHash } from "node:crypto"; import { lookup } from "@chopin/dialect/dialect"; import { parse } from "@chopin/dialect/parse"; import { serialize } from "@chopin/dialect/serialize"; import { ulid } from "@chopin/dialect/ulid"; import { assert } from "@chopin/dialect/validate"; import * as room from "./room"; import type { Root, RootContent } from "mdast"; import type { Plan } from "./service"; /** Enough leading text to tell two blocks apart without resending the source. */ const PREVIEW = 120; /** How many past outlines to keep, so a stale batch can be told what moved. */ const HISTORY = 8; export type Block = { index: number; type: string; preview: string; /** Content fingerprint. A collision only costs a needless rejection. */ digest: string; }; export type Operation = | { op: "insert"; index: number; source: string } | { op: "insert_root"; source: string } | { op: "replace"; index: number; source: string } | { op: "replace_root"; source: string } | { op: "move"; index: number; to: number } | { op: "delete"; index: number } | { op: "detach_question"; id: string }; /** Enough of a block to recognise it in a list, without hashing it. */ export type Excerpt = { type: string; preview: string }; /** * Where a gap sits, in post-edit indices. * * A block that has gone cannot be addressed, so the space it left is named by * a block still beside it and the side the content was on. */ export type Spot = { index: number; side: "before" | "after" }; /** * One thing a batch did, as somewhere a reader can be pointed. * * A rewrite is `added` alone. Something is still there to look at, and a batch * that also reported a gap would put a tombstone on the most common edit the * agent makes. */ export type Change = | ({ kind: "added"; index: number } & Excerpt) | ({ kind: "moved"; index: number; from: Spot } & Excerpt) | { kind: "removed"; at: Spot; blocks: Excerpt[] }; export type Result = | { ok: true; revision: number; blocks: Block[]; /** * Indices of the blocks this batch authored. * * Told apart by object identity: a node carried over from the parsed * base is the same object, a staged one is not. That is the property * reconciliation already relies on, so it costs nothing to report. */ touched: number[]; /** Questionnaires this batch took out of the plan. */ detached: string[]; /** * What this batch did, in document order, for marking in the browser. * * Wider than `touched`, and not a superset of it: that answers which * blocks now hold the agent's prose, which is what attributing a * decision needs, and it is silent about a block that only moved or * one that has gone. This answers where a reader should be sent, so a * deletion is a place too and a rewrite is one change rather than a * disappearance and an arrival. * * Indices rather than anchors: turning one into the other needs the * live document, which is the service's to reach into, not this * module's. Empty when there is nothing worth pointing at. */ changes: Change[]; /** The change to relay, absent when the batch was a no-op. */ mutation: room.Mutation | undefined; } | { ok: false; reason: "stale"; revision: number; changed: number[]; blocks: Block[] } | { ok: false; reason: "invalid"; message: string }; /** The plan as the agent sees it. */ export function source(plan: Plan): string { return room.project(plan.document); } /** Describe the top-level blocks an agent can address. */ export function outline(plan: Plan, value = source(plan)): Block[] { let blocks = parse(value).children.map((node, index) => describe(node, index)); remember(plan, plan.revision, blocks); return blocks; } /** * Apply a batch of operations, all or nothing. * * Every operation is resolved against the document as it was read, so indices * within one batch do not shift under each other; the result is serialised and * re-parsed before it replaces anything, so a batch that would produce a * document outside the dialect fails without touching the plan. */ export function apply(plan: Plan, revision: number, operations: Operation[]): Result { if (revision !== plan.revision) { let blocks = outline(plan); return { ok: false, reason: "stale", revision: plan.revision, changed: drift(plan, revision, blocks), blocks, }; } if (operations.length === 0) return { ok: false, reason: "invalid", message: "No operations." }; if (operations.length > 1 && operations.some(operation => operation.op === "replace_root")) { return { ok: false, reason: "invalid", message: "replace_root must be the only operation in its batch.", }; } let targets = new Set(); for (let operation of operations) { if (!("index" in operation)) continue; if (targets.has(operation.index)) { return { ok: false, reason: "invalid", message: `Block ${operation.index} is targeted more than once in one batch.`, }; } targets.add(operation.index); } let root: Root; try { root = parse(source(plan)); } catch (err) { return { ok: false, reason: "invalid", message: reason(err) }; } let children = [...root.children]; let detach: string[] = []; for (let [position, operation] of operations.entries()) { let outcome = step(root.children, children, operation, detach); if (typeof outcome === "string") { return { ok: false, reason: "invalid", message: `Operation ${position + 1}: ${outcome}` }; } children = outcome; } if (children.length === 0) { return { ok: false, reason: "invalid", message: "The planner cannot clear the plan." }; } let duplicate = repeated(children); if (duplicate) { return { ok: false, reason: "invalid", message: `\`${duplicate}\` appears twice; copied source keeps the original's id, ` + "so insert new content rather than duplicating a block you read.", }; } let next: string; try { next = serialize({ ...root, children }); // Parsing only proves the result is MDX. `