diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3d31e39 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# da-agent — Project Memory + +## Incremental content editing: `content_replace` tool (added 2026-05) + +**Why:** Editing a live doc previously meant rewriting the whole AEM HTML body via +`content_replace_doc` → `CollabClient.applyContent` (clears the whole prosemirror fragment, re-runs +`aem2doc`). The dominant cost is the LLM regenerating the entire document as output tokens for even a +one-line edit; it also wiped `daMetadata`, disrupted other users' cursors/undo, and forced a full +da-live re-render. `content_replace` does an inline splice of a contiguous range of top-level nodes +so the agent emits only the changed fragment. + +### Design decisions +- **Granularity:** top-level node range (whole blocks/paragraphs). No intra-paragraph character + ranges — ProseMirror positions aren't LLM-derivable from HTML, and a paragraph is already one node. +- **Addressing = encoded yjs *relative positions*** (NOT raw integer indices). `content_read` + surfaces a per-block opaque base64 `locator`; the agent copies it back. Relative positions survive + concurrent edits (raw indices silently shift and corrupt). Verified: a locator captured before a + concurrent insert still resolves to the right block. +- **Scope:** fully contained in da-agent. Reuses `aem2doc`/`doc2aem` from `@da-tools/da-parser` via a + `
` wrapper into a throwaway `Y.Doc` — no parser changes. +- **Collab-only:** locators only exist for the live collab doc. Non-collab/whole-page edits still use + `content_replace_doc`. + +### Implementation (key files) +- `src/collab-client.ts` — on `CollabClient`: + - `readBlocks()` → `[{ index, locator, html }]`; filters out `horizontal_rule` separators and empty + structural paragraphs. `locator` = base64 of `Y.encodeRelativePosition(createRelativePositionFromTypeIndex(frag, i))`. + - `replaceRange(startLocator, endLocator|null, html)` → parses+validates first, then in one + `transact`: resolve locators to indices (`createAbsolutePositionFromRelativePosition`), + `frag.delete(start, count)` + `frag.insert(start, clones.map(n => n.clone()))`. Leaves + `daMetadata` untouched. + - private `serializeNode` (clone node → temp doc → `doc2aem` → strip shell + section `
`) and + `parseFragment` (wrap → `aem2doc` → `toArray()`). + - base64 via `btoa`/`atob` helpers (Workers-safe; `nodejs_compat` is on but these avoid `Buffer`). +- `src/tools/tools.ts` — `content_read` collab path now returns `blocks`; new `content_replace` tool + (`needsApproval: () => true`), persists full HTML via `updateSource(..., { initiator: 'collab' })`, + and **does NOT disconnect** (so batched/successive edits in a turn reuse the session + locators). +- `src/prompt-builder.ts` (NOT server.ts — the prompt was extracted here) — edit-view rules steer the + agent to prefer `content_replace` for targeted edits, `content_replace_doc` for whole-page rewrites. + +### Gotchas / known limits (found via a trace harness) +- **Verification is a structural parse guard, not an HTML validator.** It rejects unparseable, empty/ + whitespace-only (explicit guard added), and stale-locator cases before mutating. It does NOT catch + malformed-but-coercible HTML: the HTML parser is lenient and turns junk (e.g. `<<`, which passes and gets applied. The `needsApproval` human gate is the semantic backstop. +- **Disconnect lifecycle:** `content_replace` never calls `disconnect()`. Session teardown happens at + the turn level — `streamText` `onFinish`/`onError` (`server.ts:~270-276`), the pending-approval path + (`server.ts:~195`), or the chat-context setup timeout. Because `content_replace` requires approval, + the session drops at the approval boundary and a fresh `Y.Doc` is created on approval — locators + cross a reconnect. Relative positions *should* survive (Durable Object persists stable item IDs) but + this reconnect case was NOT verified live (only concurrent-insert was). Verify in da-collab. + +### How to verify +- vitest (`npx vitest run`), node env, `fs` available. Drive real tools by `createDATools(stubClient, + { pageContext: {view:'edit'}, collab, org, repo })`, inject a real `Y.Doc` into a `CollabClient` + (`(collab as any).ydoc = ydoc; collab.isConnected = true`), then call `tools.content_read.execute(args, ctx)`. +- Manual e2e: in da-collab + da-live, `content_read` then `content_replace`; confirm only the edited + block re-renders, another user's cursor survives, and da-collab's debounced `doc2aem` PUT fires. + +## Live edits not appearing until refresh = collab backend mismatch (added 2026-05) + +**Symptom:** after an approved `content_replace`, the edit was in da-admin (a manual page refresh +rendered it) but did NOT appear live in da-live, and the agent's *own* next `content_read` returned +stale content. + +**Actual root cause: da-agent and the browser were connected to *different* da-collab backends** (a +different `DACOLLAB` websocket endpoint) while pointing at the *same* da-admin DB. So edits persisted +to the shared da-admin (→ visible on refresh) but the live Yjs broadcast never crossed, because the +two clients were in rooms on different collab servers. The agent's next turn re-synced from da-agent's +own (correct, updated) backend — but if you were comparing against the browser's view, they simply +weren't the same room. + +**Fix is configuration, not code:** make da-agent's `DACOLLAB` binding and da-live's collab websocket +point at the *same* da-collab instance. There is no teardown/flush race to fix — `content_replace` +mutates the live `Y.Doc` (y-websocket broadcasts it) and awaits the `updateSource` PUT, which already +keeps the request context alive long enough for the broadcast. A `flush()`-ack barrier was prototyped +and **reverted** once the mismatch was understood; don't re-add it unless a *same-backend* drop is +actually observed. + +**How to tell next time:** if a refresh shows the change but live doesn't, and the agent's re-read +disagrees with the browser, suspect mismatched collab backends before suspecting Yjs delivery. Quick +check: confirm both sides resolve to the same da-collab origin/room URL. + +## Local dev gotcha: wrangler version skew (added 2026-05) +da-agent, da-collab, da-admin run as separate `wrangler dev` processes wired by cross-process service +bindings (the dev registry). That IPC channel is **version-coupled to the bundled workerd**: if the +wrangler versions are far apart (seen with da-agent 4.68 vs da-collab/da-admin 4.92/4.93), bindings +still report `[connected]` but every call across them throws `Error: Network connection lost.` (incl. +the collab WebSocket). A small skew (4.92↔4.93) is fine; a large one is not. Fix = align versions +(bumped da-agent to wrangler ^4.95). Recurs whenever one repo bumps wrangler significantly; tell is +`[connected]` bindings that nonetheless throw "Network connection lost." + +## Repo notes +- Pre-existing unrelated type error at `src/server.ts:290` (`AttributeValue` / `undefined`) — present + before this work; not ours. +- Reference doc: `docs/incremental-content-updates.md` (format trace, read/write paths, code seams). diff --git a/docs/agent-markdown-outputs.md b/docs/agent-markdown-outputs.md index 74e450f..8c90921 100644 --- a/docs/agent-markdown-outputs.md +++ b/docs/agent-markdown-outputs.md @@ -16,7 +16,7 @@ The page has been updated with the new hero section. **Source:** `src/server.ts:869-886` — Edit view rules explicitly instruct: > "After updating, give a brief confirmation in plain prose." -**When triggered:** Any `content_update`, `content_create`, `content_delete`, `content_move`, or `content_copy` operation completes. +**When triggered:** Any `content_replace_doc`, `content_create`, `content_delete`, `content_move`, or `content_copy` operation completes. --- diff --git a/docs/incremental-content-updates.md b/docs/incremental-content-updates.md new file mode 100644 index 0000000..7e3def8 --- /dev/null +++ b/docs/incremental-content-updates.md @@ -0,0 +1,233 @@ +# Incremental Content Updates — Format Trace & Implementation Notes + +> **Status:** Investigation / design input. No code has been changed. +> **Goal:** Let the content-update tool make *incremental* edits to a document (e.g. replace a +> single paragraph) instead of re-writing the full HTML body on every change. +> +> This document traces every format a document passes through on read and on write, identifies +> the exact code seams, and recommends where to implement incremental updates. It is written to +> be self-contained: an implementing agent should be able to start from here. + +--- + +## 1. Repos involved + +| Repo | Role | +|---|---| +| **da-agent** | The AI agent worker. Hosts the content tools (`src/tools/tools.ts`) and the collab client (`src/collab-client.ts`). This is where the change will most likely land. | +| **da-tools/da-parser** (`@da-tools/da-parser`) | Shared format-conversion library. Exposes `aem2doc`, `doc2aem`, `getSchema`, and re-exports `y-prosemirror` helpers. Used by **both** da-collab and da-live. | +| **da-collab** | Cloudflare Worker hosting the Yjs collaboration server (Durable Objects). Loads docs from da-admin into a `Y.Doc`, persists changes back. | +| **da-admin** | Source-of-truth storage (S3). Accessed by da-agent and da-collab via Cloudflare service bindings. Stores documents as **AEM/EDS HTML**. | +| **da-live** | The browser editor (ProseMirror + y-prosemirror). The "other" client on the same collab session. | + +All repos are siblings under the workspace root (e.g. `../da-tools`, `../da-collab` relative to da-agent). + +--- + +## 2. The formats + +| Format | Description | +|---|---| +| **AEM / EDS HTML** | The persisted source-of-truth string and the format the agent reads/writes. Shape: `
…sections separated by
`. EDS blocks are `
` wrappers (each row a child `
`, each column a nested `
`). Stored in S3 by da-admin. | +| **HAST tree** | Intermediate virtual DOM from parsing HTML. `hast-util-from-html` in Cloudflare Workers (no DOM); native `DOMParser` in the browser. | +| **ProseMirror Node / JSON** | The editor's structured document model, governed by the DA schema (`getSchema()`). | +| **Y.Doc (Yjs CRDT)** | The collaborative state. Document body lives in `ydoc.getXmlFragment('prosemirror')`; doc-level metadata in `ydoc.getMap('daMetadata')`. This is what travels over the collab WebSocket as binary CRDT deltas. | + +### Conversion utilities (in `da-tools/da-parser`) + +| Function | File | Direction | +|---|---|---| +| `aem2doc(html, ydoc)` | `da-tools/da-parser/src/doc/parser.js` (~line 418) | AEM HTML → Y.Doc (writes into `getXmlFragment('prosemirror')`) | +| `doc2aem(ydoc)` | `da-tools/da-parser/src/doc/parser.js` (~line 733) | Y.Doc → AEM HTML string | +| `parseHTML(html)` | `da-tools/da-parser/src/doc/html-parser.js` | HTML string → HAST tree | +| `getSchema()` | `da-tools/da-parser/src/doc/schema.js` | Builds the ProseMirror schema | +| `prosemirrorToYXmlFragment`, `yDocToProsemirrorJSON`, `yDocToProsemirror` | re-exported from `y-prosemirror` via `da-tools/da-parser/src/index.js` | PM ⟷ Yjs | + +The same `@da-tools/da-parser` schema and conversions run **server-side in da-collab** and +**client-side in da-live** (da-live bundles it under `deps/da-parser/dist/index.js`). Keeping the +schema identical on both sides is what makes the CRDT interoperate. + +--- + +## 3. READ trace + +The read tool is `content_read` — `da-agent/src/tools/tools.ts:100`. Two paths: + +### Path A — live document (collab session connected) +`content_read` → `opts.collab.getContent()` (`src/collab-client.ts:256`) → `doc2aem(ydoc)`. + +``` +Y.Doc (XmlFragment 'prosemirror' + Map 'daMetadata') [already synced in memory] + → yDocToProsemirrorJSON(ydoc) ProseMirror JSON + → PMNode.fromJSON(schema, state) ProseMirror Node + → DOMSerializer.serializeFragment(...) lightweight JS-object tree (virtual DOM proxy) + → tableToBlock / section reconstruction / tohtml() + → AEM HTML string returned as { path, content, source: 'collab' } +``` + +`useCollabForDoc(org, repo, path, opts)` gates Path A: only when the requested doc *is* the +active page context, the `view` is `edit`/`canvas`, an IMS token is present, and the `DACOLLAB` +service binding is configured. Collab is created in `src/chat-context.ts` (`buildChatContext` → +`createCollabClient`). + +### Path B — any other document (no collab) +`content_read` → `client.getSource(org, repo, path)` → HTTP GET da-admin +`/source/{org}/{repo}/{path}` → returns the stored **AEM HTML** verbatim. No conversion. + +### (Reference) da-collab's own initial load +When the editor/agent first connects, da-collab builds the `Y.Doc` from source — the inverse of +`doc2aem`: + +``` +da-admin S3 → AEM HTML + → parseHTML → HAST tree + → block→table transforms, section flatten, diff/image fixups + → DOM proxy → DOMParser.fromSchema(getSchema()).parse → ProseMirror Node + → prosemirrorToYXmlFragment(node, ydoc.getXmlFragment('prosemirror')) → Y.XmlFragment +``` + +See `da-collab/src/shareddoc.js` → `persistence.bindState` → `aem2doc`. Subsequent loads may be +restored directly from the Durable Object's serialized Yjs binary (`Y.applyUpdate`) without +re-parsing HTML. + +--- + +## 4. WRITE trace + +The write tool is `content_replace_doc` — `da-agent/src/tools/tools.ts:162`. The agent input is +**always a full AEM HTML string** (`content`, must start with `` and end with ``). +Two paths: + +### Path A — live document (collab connected) — `src/tools/tools.ts:189` +1. `opts.collab.applyContent(content)` → `src/collab-client.ts:265` +2. `client.updateSource(org, repo, path, content, contentType, { initiator: 'collab' })` + → POST da-admin with header `X-DA-Initiator: collab` +3. `opts.collab.disconnect()` + +`applyContent` is where the **full rewrite** happens today: + +```js +// src/collab-client.ts:265 +applyContent(html: string): void { + if (!this.ydoc) return; + this.ydoc.transact(() => { + const rootType = this.ydoc!.getXmlFragment('prosemirror'); + rootType.delete(0, rootType.length); // ← wipes the ENTIRE fragment + this.ydoc!.share.forEach((type) => { + if (type instanceof Y.Map) type.clear(); // ← clears daMetadata etc. + }); + aem2doc(html, this.ydoc!); // ← re-parses & re-inserts the WHOLE doc + }); +} +``` + +Format flow inside `applyContent`: + +``` +full AEM HTML string + → parseHTML → HAST tree + → block→table transforms, section flattening, diff/image fixups + → DOM proxy → DOMParser.fromSchema(getSchema()).parse → ProseMirror Node + → prosemirrorToYXmlFragment(node, ydoc.getXmlFragment('prosemirror')) → Y.XmlFragment +``` + +Because the fragment is cleared then rebuilt, the emitted Yjs update is effectively +**delete-all + insert-all**. Consequences: +- da-live re-renders the entire document. +- Any other user's cursor/selection and the undo stack are disrupted. +- da-collab debounce-saves `doc2aem(ydoc)` back to da-admin/S3 (2s debounce, 10s max wait). + +### Path B — any other document (no collab) — `src/tools/tools.ts:198` +`client.updateSource(...)` → POST da-admin → stored verbatim as AEM HTML. da-admin then notifies +da-collab via `notifyCollab('syncadmin', …)` to invalidate live sessions — suppressed when +`X-DA-Initiator: collab` is set, which avoids a write ping-pong. + +--- + +## 5. Format-transformation summary (in order) + +``` +Read (collab): Y.Doc → PM JSON → PM Node → virtual DOM → AEM HTML +Read (no collab): S3 AEM HTML → (verbatim) → agent +Write (collab): AEM HTML → HAST → PM Node → Y.XmlFragment (full clear + rebuild) + …then async: Y.Doc → PM → virtual DOM → AEM HTML → S3 +Write (no collab): AEM HTML → (verbatim) → S3 +``` + +--- + +## 6. Candidate levels for incremental updates + +| # | Level | What it would do | Trade-off | +|---|---|---|---| +| 1 | **Agent/tool** (`da-agent/src/tools/tools.ts`) | Add a new tool (e.g. `content_replace_node`) that addresses a single node instead of the whole body. | Needs a node-addressing scheme (index/anchor/selector) the model can target reliably. | +| 2 | **CollabClient** (`da-agent/src/collab-client.ts`) | Replace the clear-all in `applyContent` with a **targeted `Y.XmlFragment` splice**: `rootType.delete(i, n)` then insert only the re-parsed node(s). | The Y.XmlFragment API is positional, so this is the most natural seam. Needs a way to parse one HTML fragment → Y nodes (level 3). | +| 3 | **Parser** (`da-tools/da-parser`) | Add a partial conversion: parse an HTML *fragment* → ProseMirror node(s) → Y nodes, returnable for splicing. | `aem2doc` currently assumes a full ``; a fragment-scoped variant is needed to support level 2 cleanly. | +| 4 | **Diff** | Keep the full-HTML tool contract; diff old vs. new (HTML or PM tree) and emit only the changed `Y.XmlFragment` ranges. | Most transparent to the model (no new tool, no addressing), but the most logic to build and test. | + +### Recommended approach: levels 2 + 3 + +A targeted splice in `applyContent` backed by a fragment-parsing helper in da-parser: + +- Produces a **minimal CRDT delta** → preserves other users' cursors/undo and avoids full + re-renders in da-live. +- Keeps the existing **AEM-HTML-in / AEM-HTML-out** contract intact — the read path, the + da-admin POST, and da-collab's save logic are all unchanged. +- Smallest blast radius: the rewrite is localized to one function (`applyContent`) plus one new + helper in the shared parser. + +Open questions for the design pass (resolve before implementing): +1. **Node addressing.** How does the agent identify *which* node to replace? Options: a 0-based + top-level child index of the prosemirror fragment; a stable `dataId` attribute (the schema + already carries `topLevelAttrs.dataId` per block — see `da-tools/da-parser/src/doc/schema.js`); + or a content-match/anchor. The schema's existing `dataId` is the most promising — confirm it is + populated and stable across the round trip. +2. **Fragment parse granularity.** Can da-parser parse a single block/paragraph of AEM HTML into + exactly the Y node(s) that occupy one slot of the `prosemirror` fragment? Tables (EDS blocks) + and `
` section separators are special-cased in `aem2doc`; verify a fragment parse handles + them. +3. **daMetadata.** The current full rewrite also clears/rebuilds `getMap('daMetadata')`. An + incremental body edit should leave metadata untouched — make sure the new path does not clear it. +4. **da-admin persistence.** After an incremental Yjs splice, the source of truth still updates via + da-collab's debounced `doc2aem` save (and/or the existing `updateSource` POST in the tool). Decide + whether the tool should still POST the *full* serialized HTML (via `collab.getContent()`) or rely + on da-collab's save — to keep the contract simple, having the tool POST the full current + `getContent()` after the splice is the low-risk choice. + +--- + +## 7. Key code locations (quick index) + +| Symbol | Location | +|---|---| +| `content_read` tool | `da-agent/src/tools/tools.ts:100` | +| `content_create` tool | `da-agent/src/tools/tools.ts:128` | +| `content_replace_doc` tool | `da-agent/src/tools/tools.ts:162` | +| `useCollabForDoc` gate | `da-agent/src/tools/tools.ts` (helper) | +| `CollabClient.getContent` | `da-agent/src/collab-client.ts:256` | +| `CollabClient.applyContent` ← **main seam** | `da-agent/src/collab-client.ts:265` | +| `createCollabClient` factory | `da-agent/src/collab-client.ts:307` | +| Collab wiring (when collab connects) | `da-agent/src/chat-context.ts` (`buildChatContext`) | +| `aem2doc` | `da-tools/da-parser/src/doc/parser.js` (~418) | +| `doc2aem` | `da-tools/da-parser/src/doc/parser.js` (~733) | +| `getSchema` (incl. `dataId` attrs) | `da-tools/da-parser/src/doc/schema.js` | +| da-parser exports | `da-tools/da-parser/src/index.js` | +| da-collab load/save persistence | `da-collab/src/shareddoc.js` (`persistence.bindState` / `update`) | + +--- + +## 8. Verification + +To confirm this trace and validate any incremental implementation end-to-end: + +- **Static:** Read the anchor functions above — `content_read` / `content_replace_doc`, + `getContent` / `applyContent`, and `aem2doc` / `doc2aem`. +- **Round trip (manual):** In a da-collab + da-live dev session, have the agent `content_read` + then `content_replace_doc` and observe (a) the editor re-rendering and (b) da-collab's debounced + `doc2aem` PUT to da-admin. For the incremental version, confirm only the edited node changes in + the editor and other users' cursors survive. +- **da-collab side:** Inspect `da-collab/src/shareddoc.js` `persistence.bindState` / `update` to + confirm the initial-load and save conversions match this trace. +- **Tests:** da-agent uses vitest (`vitest.config.ts`, `test/`). Add unit coverage for the new + `applyContent` splice path (assert the Yjs delta touches only the targeted slot, and that + `daMetadata` is preserved). diff --git a/package-lock.json b/package-lock.json index 41f9c56..83948f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "semantic-release": "^24.2.7", "typescript": "^5.9.3", "vitest": "^2.1.8", - "wrangler": "^4.68.1" + "wrangler": "^4.95.0" } }, "node_modules/@adobe/eslint-config-helix": { @@ -277,24 +277,24 @@ } }, "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", - "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", "dev": true, "license": "MIT OR Apache-2.0", "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@cloudflare/unenv-preset": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.14.0.tgz", - "integrity": "sha512-XKAkWhi1nBdNsSEoNG9nkcbyvfUrSjSf+VYVPfOto3gLTZVc3F4g6RASCMh6IixBKCG2yDgZKQIHGKtjcnLnKg==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", "dev": true, "license": "MIT OR Apache-2.0", "peerDependencies": { "unenv": "2.0.0-rc.24", - "workerd": "^1.20260218.0" + "workerd": ">1.20260305.0 <2.0.0-0" }, "peerDependenciesMeta": { "workerd": { @@ -302,10 +302,95 @@ } } }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260526.1.tgz", + "integrity": "sha512-/pR3GH3gfv0PUp7DjI8v0aAIDOqFwibq4bg5xT7TZgcVdBV/cJQWckdXCMqiRtHiawLwogUX00EIOINkYJ1Zqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260526.1.tgz", + "integrity": "sha512-rcyu0iANYfaiezKh3Mcao1O4IIgVfQldxduiL5TZT1sP0NIeRY4YReSTrzPxNnXxSYaIqaqRHMcHbUM/ic4knA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260526.1.tgz", + "integrity": "sha512-5EZAEnlLwa9oGJRo8Nd3iY5Wcd9ROGNNG90xNIGp8MEjj8v2jTn42NC47fCZKFdnLj3+S+vWEhu1x0GVJnALjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260526.1.tgz", + "integrity": "sha512-X/YBQXeXFeCN7QTStoWrATEBc9WKl7PIqkw/dQkjyJ72gh3rkLe0+Xkzp3wO7gtxTDQMa7NPGy1W4+sdMf8q1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260526.1.tgz", + "integrity": "sha512-R+tqpFFdcfZIljx8fIW9rj9fRTtDgfoA2yonsfAGa6e8snrmr+38mdFHtkRC0D3UyZpn/hOtmXiUBfdX2gMR7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260305.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260305.0.tgz", - "integrity": "sha512-sCgPFnQ03SVpC2OVW8wysONLZW/A8hlp9Mq2ckG/h1oId4kr9NawA6vUiOmOjCWRn2hIohejBYVQ+Vu20rCdKA==", + "version": "4.20260529.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260529.1.tgz", + "integrity": "sha512-33n3nsaWELSgn4DLKj1X9dwZc3kVDnO+jF/hLH9fdaXG9mQzKDeUkQaVRWLJXvrPXPa9RaIuSAFO4Zh9YOqOog==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -333,17 +418,6 @@ "node": ">=12" } }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@da-tools/da-parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@da-tools/da-parser/-/da-parser-1.2.0.tgz", @@ -361,9 +435,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -987,9 +1061,9 @@ "license": "BSD-3-Clause" }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", "engines": { @@ -1493,6 +1567,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2793,9 +2878,9 @@ } }, "node_modules/@speed-highlight/core": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz", - "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", "dev": true, "license": "CC0-1.0" }, @@ -4169,6 +4254,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -7695,6 +7794,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/miniflare": { + "version": "4.20260526.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260526.0.tgz", + "integrity": "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.8", + "workerd": "1.20260526.1", + "ws": "8.20.1", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -11537,6 +11657,66 @@ "fsevents": "~2.3.2" } }, + "node_modules/rosie-skills": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills/-/rosie-skills-0.6.4.tgz", + "integrity": "sha512-ojfhSiQRdZ2QyWbmKAHOSAUbaLYrTc5zIH7mS1jKoP8KCFSQddwVhMyFqldckTeybTfW3zNcsZzyOTzGTN1SBA==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "rosie-skills": "dist/bin.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "rosie-skills-darwin-arm64": "0.6.4", + "rosie-skills-freebsd-x64": "0.6.4", + "rosie-skills-linux-x64": "0.6.4" + } + }, + "node_modules/rosie-skills-darwin-arm64": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills-darwin-arm64/-/rosie-skills-darwin-arm64-0.6.4.tgz", + "integrity": "sha512-rn1s5hqFKcxeiDEWWoFa1hdGPshR8TkwHLzy/cBavb9XJNAaUxbe3oQ78W9sQkRHAgRyzJYyk9tw68Qrdnizgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rosie-skills-freebsd-x64": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills-freebsd-x64/-/rosie-skills-freebsd-x64-0.6.4.tgz", + "integrity": "sha512-SxCRduPBMtfjkQ+q56Yw9OLA3PyaqoALzt7kER7IDKuUVfM2O/1w8sa5xhTDiCvWkZJixnH5d5Ya6KT+/Mwcng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rosie-skills-linux-x64": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills-linux-x64/-/rosie-skills-linux-x64-0.6.4.tgz", + "integrity": "sha512-D9Y9mfu7goB0s0X59uU3hcFeUTef3VbpCIDwFMzyvJrAq3XhRACWBDMHQsHlyWdHxTXPX/ILyW65RXyrJlgqng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -13058,9 +13238,9 @@ } }, "node_modules/undici": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", - "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", "dev": true, "license": "MIT", "engines": { @@ -13988,34 +14168,56 @@ "dev": true, "license": "MIT" }, + "node_modules/workerd": { + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260526.1.tgz", + "integrity": "sha512-IHzymht98p10JH1zzwdCpbViAqw97HrwKl7+KfZeASFMsYSrIsAULWdPn0LRC5FTUzBpamLNyKCCKxbgXHgRHQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260526.1", + "@cloudflare/workerd-darwin-arm64": "1.20260526.1", + "@cloudflare/workerd-linux-64": "1.20260526.1", + "@cloudflare/workerd-linux-arm64": "1.20260526.1", + "@cloudflare/workerd-windows-64": "1.20260526.1" + } + }, "node_modules/wrangler": { - "version": "4.68.1", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.68.1.tgz", - "integrity": "sha512-G+TI3k/olEGBAVkPtUlhAX/DIbL/190fv3aK+r+45/wPclNEymjxCc35T8QGTDhc2fEMXiw51L5bH9aNsBg+yQ==", + "version": "4.95.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.95.0.tgz", + "integrity": "sha512-vgXzFVSCdUbeCadgVXvu8fK5tzNm8T9W+7lriyGWZMx0B1+CAdr4d8JTlZszHfgjypRAHmAxb49etZGIRD9pgg==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { - "@cloudflare/kv-asset-handler": "0.4.2", - "@cloudflare/unenv-preset": "2.14.0", + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", - "miniflare": "4.20260302.0", + "miniflare": "4.20260526.0", "path-to-regexp": "6.3.0", + "rosie-skills": "^0.6.3", "unenv": "2.0.0-rc.24", - "workerd": "1.20260302.0" + "workerd": "1.20260526.1" }, "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260302.0" + "@cloudflare/workers-types": "^4.20260526.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -14023,112 +14225,6 @@ } } }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260302.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260302.0.tgz", - "integrity": "sha512-cGtxPByeVrgoqxbmd8qs631wuGwf8yTm/FY44dEW4HdoXrb5jhlE4oWYHFafedkQCvGjY1Vbs3puAiKnuMxTXQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260302.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260302.0.tgz", - "integrity": "sha512-WRGqV6RNXM3xoQblJJw1EHKwx9exyhB18cdnToSCUFPObFhk3fzMLoQh7S+nUHUpto6aUrXPVj6R/4G3UPjCxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260302.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260302.0.tgz", - "integrity": "sha512-gG423mtUjrmlQT+W2+KisLc6qcGcBLR+QcK5x1gje3bu/dF3oNiYuqY7o58A+sQk6IB849UC4UyNclo1RhP2xw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260302.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260302.0.tgz", - "integrity": "sha512-7M25noGI4WlSBOhrIaY8xZrnn87OQKtJg9YWAO2EFqGjF1Su5QXGaLlQVF4fAKbqTywbHnI8BAuIsIlUSNkhCg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260302.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260302.0.tgz", - "integrity": "sha512-jK1L3ADkiWxFzlqZTq2iHW1Bd2Nzu1fmMWCGZw4sMZ2W1B2WCm2wHwO2SX/py4BgylyEN3wuF+5zagbkNKht9A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/wrangler/node_modules/miniflare": { - "version": "4.20260302.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260302.0.tgz", - "integrity": "sha512-joGFywlo7HdfHXXGOkc6tDCVkwjEncM0mwEsMOLWcl+vDVJPj9HRV7JtEa0+lCpNOLdYw7mZNHYe12xz9KtJOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", - "undici": "7.18.2", - "workerd": "1.20260302.0", - "ws": "8.18.0", - "youch": "4.1.0-beta.10" - }, - "bin": { - "miniflare": "bootstrap.js" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/wrangler/node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -14136,27 +14232,6 @@ "dev": true, "license": "MIT" }, - "node_modules/wrangler/node_modules/workerd": { - "version": "1.20260302.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260302.0.tgz", - "integrity": "sha512-FhNdC8cenMDllI6bTktFgxP5Bn5ZEnGtofgKipY6pW9jtq708D1DeGI6vGad78KQLBGaDwFy1eThjCoLYgFfog==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "bin": { - "workerd": "bin/workerd" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260302.0", - "@cloudflare/workerd-darwin-arm64": "1.20260302.0", - "@cloudflare/workerd-linux-64": "1.20260302.0", - "@cloudflare/workerd-linux-arm64": "1.20260302.0", - "@cloudflare/workerd-windows-64": "1.20260302.0" - } - }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -14225,9 +14300,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "dev": true, "license": "MIT", "engines": { @@ -14476,20 +14551,6 @@ "error-stack-parser-es": "^1.0.5" } }, - "node_modules/youch/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 5519085..9e6bd2f 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "semantic-release": "^24.2.7", "typescript": "^5.9.3", "vitest": "^2.1.8", - "wrangler": "^4.68.1" + "wrangler": "^4.95.0" }, "lint-staged": { "*.{js,ts}": [ diff --git a/src/collab-client.ts b/src/collab-client.ts index e03c5d0..ae8731d 100644 --- a/src/collab-client.ts +++ b/src/collab-client.ts @@ -14,6 +14,30 @@ import { aem2doc, doc2aem } from '@da-tools/da-parser'; type ActivityState = 'connected' | 'thinking' | 'previewing' | 'done'; +/** + * One addressable top-level block of the document, as surfaced to the agent on read. + * - `index` : 0-based position in the prosemirror XmlFragment (for human/debug reference only). + * - `locator` : opaque, base64-encoded Yjs relative position. The agent copies this back verbatim + * to target the block in `replaceRange`; it stays valid under concurrent edits. + * - `html` : the block's EDS/AEM HTML (block markup only, no /
/section
). + */ +export type DocBlock = { index: number; locator: string; html: string }; + +/** Encode bytes to base64 using Web APIs (works in the Workers runtime). */ +function encodeBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]); + return btoa(binary); +} + +/** Decode a base64 string back to bytes. */ +function decodeBase64(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes; +} + /** * Creates a WebSocket class that establishes connections via a Cloudflare service binding. * Required because WebsocketProvider needs a WebSocket constructor, not an instance. @@ -276,6 +300,121 @@ export class CollabClient { }); } + /** + * Serialize one top-level node to its EDS/AEM HTML (block markup only). + * Clones the node into a throwaway Y.Doc and reuses doc2aem, then strips the + * /
shell and the section wrapper
so the agent sees clean block HTML. + */ + private static serializeNode(node: Y.XmlElement | Y.XmlText): string { + const tmp = new Y.Doc(); + tmp.getXmlFragment('prosemirror').insert(0, [node.clone()]); + const full = doc2aem(tmp); + const match = full.match(/
([\s\S]*)<\/main>/); + let inner = (match ? match[1] : '').trim(); + if (inner.startsWith('
') && inner.endsWith('
')) { + inner = inner.slice('
'.length, -'
'.length).trim(); + } + return inner; + } + + /** + * Parse an EDS HTML fragment (block markup only) into detached top-level Y nodes. + * Wraps the fragment in a single section
and reuses aem2doc on a throwaway Y.Doc. + * Throws if the HTML cannot be parsed. + */ + private static parseFragment(html: string): Y.XmlElement[] { + const tmp = new Y.Doc(); + aem2doc(`
${html}
`, tmp); + return tmp.getXmlFragment('prosemirror').toArray() as Y.XmlElement[]; + } + + /** + * Return the document as a list of addressable top-level blocks. + * Section separators (horizontal_rule) and empty structural paragraphs are filtered out; + * each remaining block carries a stable relative-position `locator` for `replaceRange`. + */ + readBlocks(): DocBlock[] | null { + if (!this.ydoc) return null; + const frag = this.ydoc.getXmlFragment('prosemirror'); + const blocks: DocBlock[] = []; + frag.toArray().forEach((node, index) => { + if (node instanceof Y.XmlElement && node.nodeName === 'horizontal_rule') return; + const html = CollabClient.serializeNode(node as Y.XmlElement | Y.XmlText); + if (!html) return; // skip empty/structural paragraphs + const rel = Y.createRelativePositionFromTypeIndex(frag, index); + const locator = encodeBase64(Y.encodeRelativePosition(rel)); + blocks.push({ index, locator, html }); + }); + return blocks; + } + + /** + * Replace the contiguous range of top-level nodes [startLocator..endLocator] (inclusive) with + * the nodes parsed from `html`. `endLocator` omitted/null ⇒ replace just the start block. + * + * Fails safe: validates `html` parses to real EDS nodes BEFORE mutating, and errors (rather than + * corrupting) if a locator no longer resolves. Leaves daMetadata untouched. + */ + replaceRange( + startLocator: string, + endLocator: string | null, + html: string, + ): { ok: true } | { error: string } { + if (!this.ydoc) return { error: 'No active document' }; + + // Reject empty/whitespace-only content: the HTML parser would coerce it into an empty + // node and silently delete the target block. Use content_delete to remove a block. + if (!html || !html.trim()) { + return { error: 'Replacement content is empty; use content_delete to remove a block' }; + } + + // Verify the replacement parses to real EDS nodes before touching the live doc. + let parsed: Y.XmlElement[]; + try { + parsed = CollabClient.parseFragment(html); + } catch (e) { + return { error: `Replacement content is not valid EDS HTML: ${String(e)}` }; + } + if (parsed.length === 0) { + return { error: 'Replacement content produced no nodes' }; + } + + const frag = this.ydoc.getXmlFragment('prosemirror'); + + const startAbs = Y.createAbsolutePositionFromRelativePosition( + Y.decodeRelativePosition(decodeBase64(startLocator)), + this.ydoc, + ); + if (!startAbs) { + return { error: 'Start locator no longer resolves; re-read the document and retry' }; + } + const start = startAbs.index; + + let end = start; // inclusive index of the last node to replace + if (endLocator) { + const endAbs = Y.createAbsolutePositionFromRelativePosition( + Y.decodeRelativePosition(decodeBase64(endLocator)), + this.ydoc, + ); + if (!endAbs) { + return { error: 'End locator no longer resolves; re-read the document and retry' }; + } + end = endAbs.index; + } + + if (start < 0 || end < start || end >= frag.length) { + return { error: 'Invalid locator range' }; + } + + const count = end - start + 1; + const clones = parsed.map((n) => n.clone()); + this.ydoc.transact(() => { + frag.delete(start, count); + frag.insert(start, clones); + }); + return { ok: true }; + } + /** * Disconnect from da-collab */ diff --git a/src/prompt-builder.ts b/src/prompt-builder.ts index 7733a36..4b842c8 100644 --- a/src/prompt-builder.ts +++ b/src/prompt-builder.ts @@ -126,7 +126,7 @@ CRITICAL INSTRUCTION - TOOL USAGE: - Bad: "Let me update that using da_update_source..." - Good: "Done! The page now contains..." - Bad: "Here is the updated HTML: \`\`\`html ... \`\`\`" -- Good: (call the update tool directly, then confirm in plain prose) +- Good: (call content_replace_doc directly, then confirm in plain prose) ## Rich Response Formatting When presenting structured information in your responses (NOT in HTML content for tools), use these block syntaxes for richer display. Wrap content in triple-colon fences: @@ -186,7 +186,7 @@ ALL content you create or update via tools MUST be valid Edge Delivery Services - Start a new section with a new top level \`
\` tag, do not use \`
\` for this. - Minimal valid structure: \`
...
\` - NEVER wrap the content in \`\`, XML declarations, \`\`, \`\`, or \`\` tags -- The content passed to create/update tools MUST be a plain HTML string — no markdown code fences, no JSON encoding, no escaping of angle brackets +- The content passed to content_create and content_replace_doc MUST be a plain HTML string — no markdown code fences, no JSON encoding, no escaping of angle brackets **Blocks** - Represent EDS blocks as \`
\` elements @@ -255,20 +255,24 @@ When making DA tool calls, always use these values: ${ isCollabEligibleView(pageContext.view) ? ` -## Edit / canvas view — Content Update Rules +## Edit / canvas view — Document replace rules The user is in the document editor (classic edit or canvas). Apply these rules for EVERY message in this session: **Reading before writing** -- ALWAYS call the get content tool to read the current page content before making any changes +- ALWAYS call content_read to read the current page content before making any changes - Never assume or invent the current content — always fetch it first **Writing changes** -- For ANY content change the user requests (edits, rewrites, additions, deletions, reformatting) you MUST call the update content tool — never describe, preview, or return HTML in your response text +- PREFER content_replace for TARGETED edits (changing/adding/removing one or a few blocks — a paragraph, heading, list, or single block). It is much faster because you only emit the changed block(s), not the whole page. content_read returns a \`blocks\` array where each block has a \`locator\`; pass the chosen block's locator as startLocator (and the last block's as endLocator for a range), with \`content\` set to ONLY the replacement block markup (no /
/
) +- Use content_replace_doc for WHOLE-PAGE rewrites, large structural changes across many sections, or when content_replace reports a stale locator after a re-read +- For ANY change you MUST use one of these tools — never describe, preview, or return HTML in your response text - NEVER output HTML in your response — not as a code block, not as plain text, not as a preview - NEVER ask the user to copy-paste HTML — always write it directly via the tool -- Apply ALL requested changes in a single update call — do not make partial updates +- Apply all requested changes before confirming — do not leave partial updates -**After updating** +**After replacing** +- A successful content_replace / content_replace_doc HAS applied the change — do NOT re-read the page to verify it. Only re-read if a tool returned an error (e.g. a stale locator). +- Locators from the content_read you already ran this turn stay valid after a content_replace — reuse them for further edits in the same turn instead of re-reading. - Briefly confirm what was changed in plain prose (e.g. "Updated the hero headline and added a cards block with three items.") - Never repeat or quote the HTML back to the user` : '' diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 75f3451..630f59e 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -113,6 +113,9 @@ export function createDATools( return { path: ensureHtmlExtension(path), content, + // Per-block locators for incremental edits via content_replace. + // Each block's `locator` targets that block; pass it back to content_replace. + blocks: opts.collab.readBlocks() ?? undefined, source: 'collab', }; } @@ -159,9 +162,9 @@ export function createDATools( }, }); - tools.content_update = tool({ + tools.content_replace_doc = tool({ description: - 'Update an existing source file in a DA repository with new content. ' + + 'Replace an existing source file in a DA repository with new content (full document). ' + 'Content MUST be a plain HTML string (no CDATA, no markdown fences) starting with and ending with , ' + 'with all page content wrapped in
inside . ' + 'Separate sections with
, represent EDS blocks as
elements where each ' + @@ -205,6 +208,88 @@ export function createDATools( }, }); + tools.content_replace = tool({ + description: + 'Incrementally replace a contiguous range of top-level blocks in the CURRENT live document, ' + + 'instead of rewriting the whole page. Much faster than content_replace_doc for targeted edits ' + + '(a paragraph, heading, list, or single block). ' + + 'Workflow: call content_read first — its `blocks` array gives each top-level block an opaque ' + + '`locator`. Set startLocator to the first block to replace and (optionally) endLocator to the ' + + 'last block in the range; omit endLocator to replace just the one block. ' + + 'The `content` is an EDS HTML FRAGMENT — the replacement block markup ONLY (e.g. "

" or ' + + '"
"). Do NOT include ,
,
,