`). 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 ,
,