Skip to content

patch: revise a document as a diff, not as a re-upload (#59) - #114

Merged
alcor merged 1 commit into
mainfrom
feat/59-patch-tool
Sep 14, 2026
Merged

alcor merged 1 commit into
mainfrom
feat/59-patch-tool

Conversation

@alcor

@alcor alcor commented Sep 14, 2026

Copy link
Copy Markdown
Member

Closes #59.

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 for the whole document rather than for the change. With RATE_LIMIT_CHARS_PER_HOUR at 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.

patch(doc_id, markdown, anchors?) -> { ok, replaced, inserted, deleted, charged }

How it keeps ids

app/shared/block-patch.ts is 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 agentPatch applies 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 anchors is 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.

anchors is optional, as it is on replace. The tool description says plainly what happens without it.

empty_patch

markdown is the whole document, so an empty or truncated argument deletes it. A patch that would empty a non-empty document is refused with a new empty_patch code pointing the caller at replace. This came out of writing the test: I expected an empty string to fail parsing, and instead watched it wipe the fixture.

Charging

patchCharge counts 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 DocumentAgent integration tests on the tool. 953 tests run, 937 pass; the 16 failures are the three localStorage suites on Node 25, which fail identically on main. 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

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>
@alcor
alcor merged commit 88120f9 into main Sep 14, 2026
3 checks passed
@alcor
alcor deleted the feat/59-patch-tool branch September 14, 2026 04:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent rewrites should patch the latest document, not replace it wholesale

1 participant