@erdemtuna/doc-review 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +52 -227
  2. package/lib/anchor-text.js +155 -0
  3. package/lib/atomic-write.js +54 -0
  4. package/lib/chrome-api.js +78 -0
  5. package/lib/chrome-client.js +2255 -0
  6. package/lib/chrome-session.js +76 -0
  7. package/lib/cli.js +255 -0
  8. package/lib/click-target.js +28 -0
  9. package/lib/comment-anchor.js +23 -0
  10. package/lib/comment-target.js +164 -0
  11. package/lib/comparison-view.js +380 -0
  12. package/lib/contracts/feedback.js +1 -0
  13. package/lib/contracts/frame.js +7 -0
  14. package/lib/contracts/history.js +1 -0
  15. package/lib/contracts/index.js +2 -0
  16. package/lib/contracts/page.js +23 -0
  17. package/lib/document-execution.js +115 -0
  18. package/lib/edit-limits.js +24 -0
  19. package/lib/editing.js +107 -0
  20. package/lib/execution-client.js +69 -0
  21. package/lib/feedback-controller.js +106 -0
  22. package/lib/frame-channel.js +42 -0
  23. package/lib/frame-controller.js +313 -0
  24. package/lib/frame-host.js +136 -0
  25. package/lib/frame-policy.js +50 -0
  26. package/lib/history-client.js +112 -0
  27. package/lib/history-coordinator.js +210 -0
  28. package/lib/history-policy.js +43 -0
  29. package/lib/history-server.js +464 -0
  30. package/lib/html-transform.js +52 -0
  31. package/lib/icons.js +249 -0
  32. package/{src → lib}/markdown.js +25 -26
  33. package/lib/paths.js +83 -0
  34. package/lib/poll-transport.js +220 -0
  35. package/lib/positioning.js +97 -0
  36. package/lib/review-controller.js +93 -0
  37. package/lib/review-mode.js +66 -0
  38. package/lib/revision-diff.js +482 -0
  39. package/lib/revision-schema.js +235 -0
  40. package/lib/revision-store.js +190 -0
  41. package/lib/save-controller.js +300 -0
  42. package/lib/sdk.js +2629 -0
  43. package/lib/semantic-snapshot.js +268 -0
  44. package/lib/serialize.js +32 -0
  45. package/lib/server-entry.js +26 -0
  46. package/lib/server-lock.js +109 -0
  47. package/lib/server.js +1501 -0
  48. package/lib/setup-guidance.js +119 -0
  49. package/{src → lib}/setup.js +44 -55
  50. package/lib/state.js +733 -0
  51. package/lib/view-identity.js +112 -0
  52. package/package.json +24 -14
  53. package/src/anchor-text.js +0 -160
  54. package/src/atomic-write.js +0 -53
  55. package/src/chrome-client.js +0 -2747
  56. package/src/chrome-session.js +0 -85
  57. package/src/cli.js +0 -271
  58. package/src/click-target.js +0 -26
  59. package/src/comment-anchor.js +0 -22
  60. package/src/comment-target.js +0 -164
  61. package/src/comparison-view.js +0 -337
  62. package/src/document-execution.js +0 -103
  63. package/src/edit-limits.js +0 -23
  64. package/src/editing.js +0 -99
  65. package/src/execution-client.js +0 -63
  66. package/src/frame-channel.js +0 -44
  67. package/src/frame-policy.js +0 -54
  68. package/src/history-client.js +0 -104
  69. package/src/history-coordinator.js +0 -186
  70. package/src/history-policy.js +0 -43
  71. package/src/history-server.js +0 -463
  72. package/src/html-transform.js +0 -62
  73. package/src/icons.js +0 -251
  74. package/src/paths.js +0 -91
  75. package/src/poll-transport.js +0 -222
  76. package/src/positioning.js +0 -107
  77. package/src/review-mode.js +0 -59
  78. package/src/revision-diff.js +0 -440
  79. package/src/revision-schema.js +0 -219
  80. package/src/revision-store.js +0 -177
  81. package/src/sdk.js +0 -2607
  82. package/src/semantic-snapshot.js +0 -230
  83. package/src/serialize.js +0 -32
  84. package/src/server-entry.js +0 -26
  85. package/src/server-lock.js +0 -101
  86. package/src/server.js +0 -1466
  87. package/src/setup-guidance.js +0 -128
  88. package/src/state.js +0 -732
  89. package/src/view-identity.js +0 -99
  90. /package/{src → lib}/SKILL.md +0 -0
  91. /package/{src → lib}/chrome.css +0 -0
  92. /package/{src → lib}/chrome.html +0 -0
  93. /package/{src → lib}/document-trust.js +0 -0
package/README.md CHANGED
@@ -1,260 +1,85 @@
1
1
  # Doc Review
2
2
 
3
- Review HTML, Markdown, and localhost pages, edit when you choose, leave contextual comments, and send all feedback to your AI agent at once.
3
+ **Review your coding agent's work in the browser, not in a wall of chat.**
4
4
 
5
- [Read the original Human Review launch post](https://creatoreconomy.so/p/use-my-human-review-skill-to-edit-html-markdown-visually)
5
+ Open an HTML file, a Markdown document, or a localhost page. Point to what needs
6
+ changing, edit the small things yourself, and send your feedback to the agent
7
+ in one batch.
6
8
 
7
- https://github.com/user-attachments/assets/7cab09c9-eaa0-4e8b-984d-2925e810b5c2
9
+ ![A landing page in Doc Review with highlighted text and a comment asking for more specific wording](https://raw.githubusercontent.com/erdemtuna/doc-review/main/assets/doc-review.png)
8
10
 
9
- ## Problem
11
+ *Feedback stays beside the work. Your agent gets the comments, edits, and overall note together.*
10
12
 
11
- Giving AI feedback on files in chat is painful.
13
+ ## Get started
12
14
 
13
- Sometimes you want to change one sentence yourself. Instead, you end up typing:
14
-
15
- > In the third paragraph, change X to Y. Cut the third card because it repeats the first one. Also rewrite the CTA.
16
-
17
- Then the agent changes the file and you have to check whether it understood every instruction. This gets even harder when you’re reviewing a long plan, Markdown document, landing page, or multi-page website.
18
-
19
- ## How to install /doc-review
20
-
21
- The easiest way to install the skill is to paste this into ChatGPT, Claude Code, Codex, or your favorite coding agent:
22
-
23
- ```text
24
- Install the /doc-review skill globally from https://github.com/erdemtuna/doc-review
25
- ```
26
-
27
- You can also install it with `npx`:
15
+ Requires **Node.js 24.21.0 or newer** and a coding agent that can run shell commands.
16
+ Install the skill once:
28
17
 
29
18
  ```sh
30
19
  npx -y @erdemtuna/doc-review setup --global
31
20
  ```
32
21
 
33
- This fork uses the `doc-review` command, `/doc-review` skill, and
34
- `~/.doc-review` state directory. It does not install a `human-review` alias or
35
- migrate upstream state and skill files; remove those old files manually if you
36
- no longer need them.
37
-
38
- ## How to use /doc-review
39
-
40
- Review is opt-in: explicitly invoke `/doc-review` or ask to open an interactive
41
- browser review. Writing, updating, or asking for a general review of content does
42
- not automatically open the browser or start polling. An explicitly started review
43
- continues until you end it or switch tasks.
44
-
45
- After updating installed skill instructions, start a fresh agent session or reload
46
- skills (in Copilot CLI, `/skills reload`). Setup copies the executing package's
47
- skill template; rerunning setup from an older package can restore older behavior.
48
- Setup updates its own `AGENTS.md` guidance inside `<!-- BEGIN doc-review -->` and
49
- `<!-- END doc-review -->` markers. Exact, known older generated blocks are migrated.
50
- Custom guidance is preserved with a migration message rather than overwritten;
51
- update it yourself if it requests automatic review. Invalid or duplicate markers
52
- must be corrected before setup can proceed.
53
-
54
- ![Doc Review visual editor](assets/doc-review.png)
55
-
56
- Open an HTML or Markdown file:
22
+ Start a fresh agent session, then ask it to open a review:
57
23
 
58
24
  ```text
59
- /doc-review (your file)
25
+ /doc-review path/to/landing-page.html
60
26
  ```
61
27
 
62
- Review a page running on localhost:
28
+ The same command works with Markdown or a running local app:
63
29
 
64
30
  ```text
65
- /doc-review (localhost URL)
66
- ```
67
-
68
- Doc Review opens in **Review**, with **View** selected, so you can read, use page controls, and comment. Switch to **Edit** to change content directly. Plain HTML saves edits to the file; scripted HTML, Markdown, and localhost pages send edits to your agent. Commenting stays available in both modes.
69
-
70
- Select text, or hover or focus an element, then use the nearby comment icon. `Ctrl+Alt+M` (`Cmd+Option+M` on macOS) opens a comment for the current selection. Press Enter to submit or Shift+Enter for a new line. On desktop the composer stays beside its target, or pins to the effective top or bottom scrolling edge when that target leaves view. **Back to selection** reveals the target without changing the draft. The full-width sheet is used only at the narrow responsive breakpoint.
71
-
72
- **Comments** is the single toolbar entry point for the closed-by-default review drawer. Its feedback inventory scrolls independently while the overall note and **Send to agent** controls remain fixed. Submitting a comment creates its normal highlight and increments the count, but keeps the card closed until you activate its mark or choose **Jump to**. Focus returns to the exact element or selection you reviewed.
73
-
74
- Aligned cards show **Edit**, **Close**, and **More**; drawer cards show **Jump to**, **Edit**, and **More**. Delete lives only in **More** and requires an inline confirmation. Quote hints preserve both the beginning and ending of long selections. Editing has explicit **Save** and **Cancel** controls: Enter saves, Shift+Enter adds a line, Escape cancels, and moving focus never autosaves. A draft follows its comment between aligned and drawer cards and keeps its caret through target movement.
75
-
76
- ### Files and page interactions
77
-
78
- Self-contained HTML runs its inline scripts and event handlers automatically.
79
- There is no approval switch or renewed approval after an agent updates the file.
80
- Plain HTML, including ordinary JSON data blocks and escaped code examples, keeps
81
- direct autosave and Revert. Scripted HTML is **feedback-only**: your edits are sent
82
- to the agent to apply to the original source, rather than writing script-generated
83
- DOM into the file. The renderer re-evaluates that distinction when the source changes.
84
- Markdown and localhost also remain feedback-only.
85
-
86
- If a scripted page is broken, **More > Reload without scripts** provides a
87
- temporary recovery mode. **Use page interactions** restores automatic behavior.
88
- That preference belongs to this page in this review session, not to a saved
89
- approval record. Disabling scripts does not make a scripted file's preview
90
- edits writable. A changed preference takes effect when the frame is replaced;
91
- reload handling preserves comment drafts and reports genuine source conflicts.
92
-
93
- The supported interactive-file mode is for self-contained documents. Separate
94
- script files, application imports, workers, and embedded applications are not
95
- made compatible by this change. Use the existing localhost review route for
96
- application workflows. Dependency limitations appear in contextual details,
97
- not as a permission task or text inserted into the authored document.
98
-
99
- Files retain an opaque iframe origin. The parent owns API authentication and
100
- checks the frame identity for messages and writes. Authored code shares the
101
- document with the SDK, so frame correlation is not proof of human authorship.
102
- Relative assets stay scoped to the reviewed file. Localhost reviews retain their
103
- existing nonopaque artifact origin, popup, and download behavior; they are fetched
104
- and rewritten by the review server, not guaranteed to reproduce the application's
105
- original cookies, storage, or origin-sensitive requests.
106
-
107
- **Compatibility:** the old per-version `/trust` API is retired and returns 410.
108
- Existing decision files are left unused. The CLI does not reuse background
109
- servers with the previous protocol. Writable HTML save and revert requests must
110
- identify their current served frame and source hash so delayed edits cannot
111
- overwrite a newer file.
112
-
113
- Agent polling returns an immutable `batch_id`. After applying the batch, the
114
- agent acknowledges that exact receipt and keeps waiting:
115
-
116
- ```sh
117
- npx -y @erdemtuna/doc-review poll path/to/file.html --ack b_0123456789abcdef --timeout 600
31
+ /doc-review path/to/plan.md
32
+ /doc-review http://localhost:3000
118
33
  ```
119
34
 
120
- A stale or repeated batch ID is harmless: it never clears newer feedback. The
121
- complete acknowledgement command is included in each response's `next_step`.
122
-
123
- ### Review and Changes
124
-
125
- **Review** keeps the document interactive. **Changes** shows a selected review
126
- round, with round selection and comparison navigation instead of live-page controls.
127
- The default comparison is the content captured when feedback was sent against
128
- the result captured after the agent acknowledged that exact batch. Previous
129
- rounds retain their own fixed endpoints rather than changing on every reload.
130
-
131
- Content comparisons use a continuous aligned before/after reading surface with
132
- expandable unchanged context. Source comparisons use line-numbered source hunks.
133
- At narrow widths, a unified view retains each change's before/after attribution.
134
- Content comparisons cover text, structure, formatting, links, and image
135
- references. File-backed reviews also retain source for a source comparison.
136
- Deleted passages remain readable even when there is no current element to jump
137
- to, and uncertain matches do not pretend to identify an exact current target.
138
- These are changes observed during a round, not proof of agent authorship or that
139
- every comment was resolved.
140
-
141
- History works in the normal browser for HTML, Markdown, and localhost pages.
142
- It does not use screenshots, a browser extension, or automatic Git commits.
143
- Live history captures the reviewed page's available content, not every possible
144
- application state. Changes to image bytes at an unchanged URL, canvas pixels,
145
- external stylesheet appearance, and inaccessible embedded content are outside
146
- the comparison guarantee.
147
-
148
- Send waits for edit persistence and writable HTML saves, then makes a short
149
- best-effort baseline capture. Missing comparison data does not require a second
150
- decision or prevent feedback from being sent. Available snapshots are retained;
151
- inactive pages can have incomplete Content coverage. A last-visited live page is
152
- never described as a fresh Send-time snapshot. Actual save or delivery failures
153
- are reported without discarding your feedback.
154
-
155
- When an active tab has reliable authored tab/panel identifiers, Content capture
156
- records that visible view. A result on a different tab remains pending and asks
157
- you to return to the original tab. Returning retries capture without clicking
158
- through other tabs automatically. Unknown views and older snapshots are labelled
159
- as visible-content comparisons whose matching view is unverified. This does not
160
- capture all hidden panels or every application state. File Source comparison
161
- still covers the whole file independently.
162
-
163
- After acknowledgement, file-source results are captured independently of the
164
- browser. Content results are captured when the appropriate reviewed page is
165
- ready. These observations can have different timestamps; neither timestamp
166
- claims that the document was captured at the instant of acknowledgement.
167
- Background capture does not disable document interaction and retries transient
168
- failures a bounded number of times. An unchanged file does not need a new reload
169
- solely to capture an acknowledged result. Available Source remains readable
170
- while Content is pending. Contextual retry or return-to-view actions handle
171
- remaining failures; a continuously changing page may still have no stable Content
172
- snapshot. Permanent finalization is optional in capture details, not a prerequisite
173
- for reading the comparison. Failed or limited captures are not reported as zero
174
- changes, and handled feedback remains archived even when history is unavailable.
175
-
176
- Snapshots stay in the local Doc Review state directory. The latest five
177
- completed rounds are retained automatically, while active rounds, pending
178
- captures, and unsent feedback protect their referenced revisions. History starts
179
- with this capability; earlier documents cannot be reconstructed from old
180
- feedback receipts. Review snapshots can contain document content, so treat the
181
- state directory as private.
182
- Unreferenced snapshot files are collected at startup and during periodic
183
- maintenance, with a one-hour grace period protecting interrupted publications.
184
- Capture and comparison limits fail explicitly rather than publishing truncated
185
- content as a complete comparison.
186
-
187
- Default capture limits are 8 MiB of file source and 4 MiB of normalized content,
188
- with at most 2,000 semantic blocks and 1,000,000 text characters. A single block
189
- is limited to 100,000 characters. Comparisons have independent size, token, and
190
- edit-work limits, including a 1,000,000-character budget and a 250 ms processing
191
- budget. A stored snapshot can therefore exceed comparison limits; the UI reports
192
- that limitation rather than claiming there were no changes.
193
-
194
- ### Feedback reliability and limits
195
-
196
- `poll` without `--timeout` waits for at most 12 hours. Explicit timeouts include
197
- server discovery, reconnection, and retry backoff, not just time spent connected.
198
- Recoverable connection drops are retried; invalid responses, authorization errors,
199
- and incompatible servers fail visibly. A timeout does not discard feedback: use
200
- `status` to check it, then start another poll if the review is still wanted.
201
-
202
- Each edit text or HTML field is limited to 200,000 Unicode code points. Oversized
203
- edits carry `truncated: true` and `truncated_fields` naming incomplete fields.
204
- The agent must not use partial text or HTML as a complete replacement or invent
205
- the rest. It must obtain the full edit from an authoritative source or ask you
206
- for it before acknowledging the batch.
207
-
208
- External writes to an HTML or Markdown source refresh its rendered baseline without
209
- clearing unsent feedback. That preserves your edits; it does not automatically
210
- resolve conflicts with the changed source. HTML autosaves keep their existing
211
- behavior.
35
+ Reviews start only when you request them. Your agent uses the installed skill
36
+ to open the page, wait for feedback, and apply the changes.
212
37
 
213
- ### Upgrading from servers using protocol 14 or earlier
38
+ ## From feedback to the next version
214
39
 
215
- The updated CLI requires server protocol 15. An older server that is still
216
- running is not silently reused or forcibly replaced. End active reviews and
217
- shut down that specific old server (or let it exit when idle) before restarting
218
- Doc Review. Keep the `.doc-review` state directory: pending batches and their
219
- exact receipt IDs survive a controlled restart.
40
+ 1. **Open and explore.** Reviews start in **View**, so you can read and use page
41
+ controls without accidentally editing.
42
+ 2. **Point out what matters.** Select text or choose an element to leave a
43
+ comment. Switch to **Edit** for direct changes to wording, formatting, images,
44
+ or layout. Commenting works in either mode.
45
+ 3. **Send one batch.** Open **Comments**, add an overall note if needed, and
46
+ choose **Send to agent**. No need to describe where every sentence lives.
47
+ 4. **Check the result.** Use **Changes** to compare a review round's captured
48
+ before and after content, then continue reviewing. Comparisons show observed
49
+ changes, not a guarantee that every request was resolved.
220
50
 
221
- After installing the updated package, rerun `doc-review setup --global` for
222
- personal skills and `doc-review setup` in projects with generated guidance.
223
- Reload skills or start a fresh agent session afterward. A newer CLI paired with
224
- older instructions is not a complete upgrade.
51
+ You can review a plan, refine a landing page, or walk through a local app without
52
+ moving your feedback into a separate document.
225
53
 
226
- ## What this skill lets you do
54
+ ## What happens to your edits?
227
55
 
228
- - **Edit text directly and tweak basic formatting** (e.g., bold, italic).
229
- - **Make bulleted and numbered lists** — type `- ` or `1. ` at the start of a line, or press ⌘⇧8 / ⌘⇧7. Tab and Shift+Tab indent and outdent.
230
- - **Add links** — select text and press ⌘K. ⌘K inside an existing link edits or removes it.
231
- - **Resize images** by dragging their corner, and **move images** by dragging them to a new spot.
232
- - **Rearrange the page** — hover any block and drag the handle on its left edge to move the whole block somewhere else.
233
- - **Paste images** from your clipboard — file reviews save them beside the document; localhost reviews stage them for the agent to place in the app source.
234
- - **Select a phrase and leave a comment** from the contextual icon, or press Ctrl+Alt+M / Cmd+Option+M.
235
- - **Comment on an image, chart, control, or section** from its hover or keyboard-focus affordance without taking over its normal click.
236
- - **Remove elements** without explaining the deletion in chat.
237
- - **Command-click links** to review multiple pages without losing your feedback.
238
- - **Send every edit and comment at once** instead of writing a long chat message.
56
+ | What you open | Where edits go |
57
+ | --- | --- |
58
+ | Plain HTML | Saved directly to the file, with Revert available |
59
+ | Scripted HTML or Markdown | Sent to the agent to apply to the original source |
60
+ | Localhost page | Sent to the agent to update the app's source |
239
61
 
240
- Doc Review works well for editing AI-generated plans, updating landing pages, reviewing localhost apps, and removing extra copy from a UX.
62
+ Self contained HTML can run inline scripts. For an app with separate script
63
+ dependencies, use its localhost URL instead.
241
64
 
242
- ## What’s inside
65
+ Doc Review runs locally and needs no Doc Review account, hosted backend, or API
66
+ key. The page you review and the coding agent you use may still contact external
67
+ services.
243
68
 
244
- - [`cli.js`](src/cli.js) contains the `doc-review`, `poll`, `status`, and `setup` commands.
245
- - [`server.js`](src/server.js) runs the local review session.
246
- - [`sdk.js`](src/sdk.js) handles editing, comments, highlights, and feedback.
247
- - [`chrome-client.js`](src/chrome-client.js) contains the visual review interface.
248
- - [`markdown.js`](src/markdown.js) renders Markdown files for review.
249
- - [`SKILL.md`](src/SKILL.md) teaches Claude Code, Codex, and other agents how to use Doc Review.
69
+ ## Learn more
250
70
 
251
- Everything runs on your computer. Doc Review doesn’t require an account, cloud service, database, or API key.
252
- Comment geometry remains local, transient presentation data and is never written to review state or sent to the agent. After the agent acknowledges the exact delivered `batch_id`, the comments carried by that batch leave the active feedback inventory; their round archive remains, and newer comments and corrections stay active.
71
+ [Usage guide](https://github.com/erdemtuna/doc-review/blob/main/docs/usage.md):
72
+ setup options, comments, comparisons, limitations, and upgrades.
253
73
 
254
- ## Upstream project
74
+ [Development](https://github.com/erdemtuna/doc-review/blob/main/docs/development.md):
75
+ build, test, architecture, and package checks.
255
76
 
256
- Doc Review is an independent fork of [Human Review](https://github.com/petergyang/human-review), originally created by Peter Yang. The fork preserves the upstream MIT license and continues from the upstream `v0.6.1` release.
77
+ [Releasing](https://github.com/erdemtuna/doc-review/blob/main/RELEASING.md):
78
+ the maintainers' release process.
257
79
 
258
- ## License
80
+ ## Credits and license
259
81
 
260
- MIT
82
+ Doc Review is an independent fork of Peter Yang's
83
+ [Human Review](https://github.com/petergyang/human-review), continuing from
84
+ upstream v0.6.1. Licensed under
85
+ [MIT](https://github.com/erdemtuna/doc-review/blob/main/LICENSE).
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Quote-with-context anchoring, in the spirit of the W3C TextQuoteSelector.
3
+ *
4
+ * These functions are pure string maths so they can be unit-tested in Node and
5
+ * shipped verbatim to the browser, where the DOM half of anchoring lives.
6
+ */
7
+ export const CONTEXT_PAD = 32;
8
+ /** Capture `quote` plus surrounding context so it can be re-found after edits. */
9
+ export function buildContext(text, start, end, pad = CONTEXT_PAD) {
10
+ return {
11
+ prefix: text.slice(Math.max(0, start - pad), start),
12
+ quote: text.slice(start, end),
13
+ suffix: text.slice(end, Math.min(text.length, end + pad)),
14
+ };
15
+ }
16
+ function commonSuffixLength(a, b) {
17
+ let n = 0;
18
+ while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n])
19
+ n += 1;
20
+ return n;
21
+ }
22
+ function commonPrefixLength(a, b) {
23
+ let n = 0;
24
+ while (n < a.length && n < b.length && a[n] === b[n])
25
+ n += 1;
26
+ return n;
27
+ }
28
+ function occurrences(text, quote) {
29
+ const found = [];
30
+ let at = text.indexOf(quote);
31
+ while (at !== -1) {
32
+ found.push(at);
33
+ at = text.indexOf(quote, at + 1);
34
+ }
35
+ return found;
36
+ }
37
+ function bestHit(text, hits, quote, prefix, suffix) {
38
+ let best = hits[0];
39
+ let bestScore = -1;
40
+ for (const at of hits) {
41
+ const before = text.slice(Math.max(0, at - prefix.length), at);
42
+ const after = text.slice(at + quote.length, at + quote.length + suffix.length);
43
+ const score = commonSuffixLength(prefix, before) + commonPrefixLength(suffix, after);
44
+ if (score > bestScore) {
45
+ bestScore = score;
46
+ best = at;
47
+ }
48
+ }
49
+ return { at: best, score: bestScore };
50
+ }
51
+ const collapse = (s) => String(s || "").replace(/\s+/g, " ");
52
+ /** Collapse whitespace runs, keeping a map from each kept char to its original index. */
53
+ function collapseWithMap(text) {
54
+ let flat = "";
55
+ const map = [];
56
+ let pendingWs = -1;
57
+ for (let i = 0; i < text.length; i += 1) {
58
+ if (/\s/.test(text[i])) {
59
+ if (pendingWs === -1)
60
+ pendingWs = i;
61
+ continue;
62
+ }
63
+ if (pendingWs !== -1 && flat) {
64
+ flat += " ";
65
+ map.push(pendingWs);
66
+ }
67
+ pendingWs = -1;
68
+ flat += text[i];
69
+ map.push(i);
70
+ }
71
+ return { flat, map };
72
+ }
73
+ /**
74
+ * Locate `ctx.quote` in `text`, using prefix/suffix to disambiguate repeats.
75
+ * Returns `{ start, end, exact }` or null when the quote is gone entirely.
76
+ */
77
+ export function findQuote(text, ctx) {
78
+ const quote = ctx && ctx.quote;
79
+ if (!quote)
80
+ return null;
81
+ const hits = occurrences(text, quote);
82
+ if (hits.length === 1) {
83
+ return { start: hits[0], end: hits[0] + quote.length, exact: true };
84
+ }
85
+ if (hits.length > 1) {
86
+ const { at, score } = bestHit(text, hits, quote, ctx.prefix || "", ctx.suffix || "");
87
+ return { start: at, end: at + quote.length, exact: score > 0 };
88
+ }
89
+ // Reformatting (a prettier run, an agent rewrite) reflows whitespace without
90
+ // changing any words. Match again on whitespace-collapsed text and map the
91
+ // hit back to real offsets, so those comments survive instead of orphaning.
92
+ const { flat, map } = collapseWithMap(text);
93
+ const flatQuote = collapse(quote).trim();
94
+ if (!flatQuote || !map.length)
95
+ return null;
96
+ const flatHits = occurrences(flat, flatQuote);
97
+ if (!flatHits.length)
98
+ return null;
99
+ const { at } = bestHit(flat, flatHits, flatQuote, collapse(ctx.prefix || ""), collapse(ctx.suffix || ""));
100
+ return { start: map[at], end: map[at + flatQuote.length - 1] + 1, exact: false };
101
+ }
102
+ /** Collapse runs of whitespace for display in a comment card. */
103
+ export function tidy(text, limit = 0) {
104
+ const flat = String(text == null ? "" : text)
105
+ .replace(/\s+/g, " ")
106
+ .trim();
107
+ if (!limit || flat.length <= limit)
108
+ return flat;
109
+ return `${flat.slice(0, limit - 1).trimEnd()}…`;
110
+ }
111
+ function graphemes(text) {
112
+ if (typeof Intl?.Segmenter === "function") {
113
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
114
+ return [...segmenter.segment(text)].map(({ segment }) => segment);
115
+ }
116
+ // Array.from is deterministic and, unlike string slicing, never splits a
117
+ // surrogate pair. Older engines may split a multi-code-point grapheme.
118
+ return Array.from(text);
119
+ }
120
+ function nearbyHeadBoundary(parts, ideal) {
121
+ for (let index = ideal; index >= Math.max(1, ideal - 8); index -= 1) {
122
+ if (/^\s$/u.test(parts[index - 1]))
123
+ return index - 1;
124
+ }
125
+ return ideal;
126
+ }
127
+ function nearbyTailBoundary(parts, ideal) {
128
+ for (let index = ideal; index < Math.min(parts.length - 1, ideal + 8); index += 1) {
129
+ if (/^\s$/u.test(parts[index]))
130
+ return index + 1;
131
+ }
132
+ return ideal;
133
+ }
134
+ /**
135
+ * Collapse whitespace and preserve both ends of a long quote. `limit` counts
136
+ * grapheme clusters, including the ellipsis.
137
+ */
138
+ export function tidyMiddle(text, limit = 0) {
139
+ const flat = String(text == null ? "" : text)
140
+ .replace(/\s+/g, " ")
141
+ .trim();
142
+ if (!limit)
143
+ return flat;
144
+ const parts = graphemes(flat);
145
+ if (parts.length <= limit)
146
+ return flat;
147
+ if (limit <= 1)
148
+ return "…";
149
+ const available = limit - 1;
150
+ const idealHead = Math.ceil(available * 0.6);
151
+ const idealTailStart = parts.length - (available - idealHead);
152
+ const headEnd = nearbyHeadBoundary(parts, idealHead);
153
+ const tailStart = nearbyTailBoundary(parts, idealTailStart);
154
+ return `${parts.slice(0, headEnd).join("").trimEnd()}…${parts.slice(tailStart).join("").trimStart()}`;
155
+ }
@@ -0,0 +1,54 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ const RETRY_DELAYS_MS = [10, 20, 40, 80, 160];
4
+ const TRANSIENT_RENAME_ERRORS = new Set(["EPERM", "EACCES", "EBUSY"]);
5
+ const waitState = new Int32Array(new SharedArrayBuffer(4));
6
+ // Keep Store transactions synchronous; rename backoff blocks for at most 310ms.
7
+ const sleepSync = (ms) => Atomics.wait(waitState, 0, 0, ms);
8
+ export function createAtomicWriter({ fileSystem = fs, sleep = sleepSync, report = console.error } = {}) {
9
+ return function atomicWrite(file, data) {
10
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.doc-review.tmp`;
11
+ let fd;
12
+ let created = false;
13
+ try {
14
+ fd = fileSystem.openSync(tmp, "wx");
15
+ created = true;
16
+ fileSystem.writeFileSync(fd, data);
17
+ fileSystem.closeSync(fd);
18
+ fd = undefined;
19
+ for (let attempt = 0;; attempt += 1) {
20
+ try {
21
+ fileSystem.renameSync(tmp, file);
22
+ return;
23
+ }
24
+ catch (err) {
25
+ if (!TRANSIENT_RENAME_ERRORS.has(err.code) || attempt === RETRY_DELAYS_MS.length)
26
+ throw err;
27
+ sleep(RETRY_DELAYS_MS[attempt]);
28
+ }
29
+ }
30
+ }
31
+ catch (err) {
32
+ if (fd !== undefined) {
33
+ try {
34
+ fileSystem.closeSync(fd);
35
+ }
36
+ catch (cleanupError) {
37
+ report(`Could not close atomic-write temporary file ${tmp}: ${cleanupError.message}`);
38
+ }
39
+ }
40
+ if (created) {
41
+ try {
42
+ fileSystem.unlinkSync(tmp);
43
+ }
44
+ catch (cleanupError) {
45
+ if (cleanupError.code !== "ENOENT") {
46
+ report(`Could not remove atomic-write temporary file ${tmp}: ${cleanupError.message}`);
47
+ }
48
+ }
49
+ }
50
+ throw err;
51
+ }
52
+ };
53
+ }
54
+ export const atomicWrite = createAtomicWriter();
@@ -0,0 +1,78 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ code;
4
+ targets;
5
+ constructor(message, status, code, targets) {
6
+ super(message);
7
+ this.status = status;
8
+ this.code = code;
9
+ this.targets = targets;
10
+ this.name = "ApiError";
11
+ }
12
+ }
13
+ export function record(value) {
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
15
+ throw new Error("The review server returned an invalid response.");
16
+ }
17
+ return value;
18
+ }
19
+ export function createReviewApi({ token, fetch: request = globalThis.fetch, }) {
20
+ const pending = new Set();
21
+ let disposed = false;
22
+ async function send(path, options = {}) {
23
+ if (disposed)
24
+ throw new Error("The review connection is closed.");
25
+ const controller = new AbortController();
26
+ const abort = () => controller.abort(options.signal?.reason);
27
+ if (options.signal?.aborted)
28
+ abort();
29
+ else
30
+ options.signal?.addEventListener("abort", abort, { once: true });
31
+ pending.add(controller);
32
+ try {
33
+ const headers = new Headers(options.headers);
34
+ if (!headers.has("content-type"))
35
+ headers.set("content-type", "application/json");
36
+ headers.set("x-doc-review-token", token);
37
+ const response = await request(path, { ...options, headers, signal: controller.signal });
38
+ if (!response.ok) {
39
+ let detail = {};
40
+ try {
41
+ detail = record(await response.json());
42
+ }
43
+ catch (error) {
44
+ if (controller.signal.aborted)
45
+ throw error;
46
+ // Preserve the HTTP failure even when the error body is not JSON.
47
+ }
48
+ throw new ApiError(typeof detail.error === "string" ? detail.error : `Request failed (${response.status})`, response.status, typeof detail.code === "string" ? detail.code : undefined, Array.isArray(detail.targets) ? detail.targets : []);
49
+ }
50
+ return await response.json();
51
+ }
52
+ finally {
53
+ pending.delete(controller);
54
+ options.signal?.removeEventListener("abort", abort);
55
+ }
56
+ }
57
+ async function requestJson(path, options, decode) {
58
+ const value = await send(path, options);
59
+ return decode ? decode(value) : value;
60
+ }
61
+ return {
62
+ request: requestJson,
63
+ dispose() {
64
+ if (disposed)
65
+ return;
66
+ disposed = true;
67
+ for (const controller of pending)
68
+ controller.abort(new Error("Review ended"));
69
+ pending.clear();
70
+ },
71
+ };
72
+ }
73
+ import { isPageResponse } from "./contracts/page.js";
74
+ export function decodePage(value) {
75
+ if (!isPageResponse(value))
76
+ throw new Error("The review server returned an invalid page response.");
77
+ return value;
78
+ }