Conversation
Revising a draft meant one `replace` spanning the whole document. That overwrites any edit a person made in the middle since the read, gives every block a new id so anchored comments lose their text, shows up in history as one opaque rewrite, and is charged against the hourly budget for the whole document rather than for the change. `RATE_LIMIT_CHARS_PER_HOUR` is 20,000, so one 9.5k-character rewrite spends half an hour's budget restating text that did not move. `patch(doc_id, markdown, anchors?)` takes the document as it should read and applies the smallest set of block operations that gets there. Blocks that did not change are not touched at all: their ids, their comments, and their attribution survive. `app/shared/block-patch.ts` is the diff: an LCS over block texts, with each run of differences paired position by position so a rewritten block is a replace that keeps its id rather than a delete and an insert that does not. Every index is against the document as read, so the applier works back to front. The diff runs inside the Durable Object, which closes the read/write window, but not the one that matters more: the agent's markdown carries its own idea of every block, so a paragraph a person rewrote since the read would be put back without anyone noticing. `anchors` is verified for the blocks the patch would touch, and only those; an edit elsewhere is none of the patch's business. Charged for what it adds. A patch that would empty a non-empty document is refused as `empty_patch`: `markdown` is the whole document, so a truncated argument would otherwise delete it, and a caller who means it can say so with replace. That one came out of writing the test. Closes #59 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #59.
Stacked on #112; base it on
mainonce that merges. The diff againstmainis the last commit.Revising a draft meant one
replacespanning the whole document. That overwrites any edit a person made in the middle since the read, gives every block a new id so anchored comments lose their text, shows up in history as one opaque rewrite, and is charged for the whole document rather than for the change. WithRATE_LIMIT_CHARS_PER_HOURat 20,000, the 9.5k-character rewrite in Nicholas's comment on the issue spent half an hour's budget restating text that had not moved.patch(doc_id, markdown, anchors?)takes the document as it should read and applies the smallest set of block operations that gets there.How it keeps ids
app/shared/block-patch.tsis an LCS over block texts. Each run of differences between two surviving blocks is paired position by position, so a three-block run rewritten in place is three replaces that each keep their block's id, not three deletes and three inserts that do not. Leftovers become inserts or deletes.Every index is against the document as read, so
agentPatchapplies back to front. A test fixes that contract.The measured result: after patching one paragraph of a three-block document, the two untouched blocks have byte-identical anchors, and the rewritten block has the same id with a new hash.
The window it does not close, and the one it does
Running the diff inside the Durable Object closes the gap between the read and the write. It does not close the more damaging one: the agent's markdown carries its own idea of every block, so a paragraph a person rewrote since the read gets put back without anyone noticing. That is the same lost work the issue is about, arriving by a different door.
So
anchorsis checked — but only for the blocks the patch would actually touch. An edit in a block the patch leaves alone is none of its business and does not block it. Both cases have a test.anchorsis optional, as it is onreplace. The tool description says plainly what happens without it.empty_patch
markdownis the whole document, so an empty or truncated argument deletes it. A patch that would empty a non-empty document is refused with a newempty_patchcode pointing the caller atreplace. This came out of writing the test: I expected an empty string to fail parsing, and instead watched it wipe the fixture.Charging
patchChargecounts the text the patch adds. A 3,000-character paragraph left alone while a neighbour is reworded costs the length of the reworded block, which the integration test asserts exactly.Tests
15 unit tests on the pure diff, 8
DocumentAgentintegration tests on the tool. 953 tests run, 937 pass; the 16 failures are the three localStorage suites on Node 25, which fail identically onmain. Lint and typecheck clean.Not verified: no agent has driven this over a live MCP connection, and the
mode: "suggest"variant floated in the issue is not built.🤖 Generated with Claude Code