@erdemtuna/doc-review 0.8.1 → 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.
- package/README.md +52 -137
- package/{src → lib}/SKILL.md +28 -4
- package/lib/anchor-text.js +155 -0
- package/lib/atomic-write.js +54 -0
- package/lib/chrome-api.js +78 -0
- package/lib/chrome-client.js +2255 -0
- package/lib/chrome-session.js +76 -0
- package/{src → lib}/chrome.css +253 -8
- package/lib/chrome.html +196 -0
- package/lib/cli.js +255 -0
- package/lib/click-target.js +28 -0
- package/lib/comment-anchor.js +23 -0
- package/lib/comment-target.js +164 -0
- package/lib/comparison-view.js +380 -0
- package/lib/contracts/feedback.js +1 -0
- package/lib/contracts/frame.js +7 -0
- package/lib/contracts/history.js +1 -0
- package/lib/contracts/index.js +2 -0
- package/lib/contracts/page.js +23 -0
- package/lib/document-execution.js +115 -0
- package/lib/document-trust.js +2 -0
- package/lib/edit-limits.js +24 -0
- package/lib/editing.js +107 -0
- package/lib/execution-client.js +69 -0
- package/lib/feedback-controller.js +106 -0
- package/lib/frame-channel.js +42 -0
- package/lib/frame-controller.js +313 -0
- package/lib/frame-host.js +136 -0
- package/lib/frame-policy.js +50 -0
- package/lib/history-client.js +112 -0
- package/lib/history-coordinator.js +210 -0
- package/lib/history-policy.js +43 -0
- package/lib/history-server.js +464 -0
- package/lib/html-transform.js +52 -0
- package/lib/icons.js +249 -0
- package/{src → lib}/markdown.js +25 -26
- package/lib/paths.js +83 -0
- package/lib/poll-transport.js +220 -0
- package/lib/positioning.js +97 -0
- package/lib/review-controller.js +93 -0
- package/lib/review-mode.js +66 -0
- package/lib/revision-diff.js +482 -0
- package/lib/revision-schema.js +235 -0
- package/lib/revision-store.js +190 -0
- package/lib/save-controller.js +300 -0
- package/lib/sdk.js +2629 -0
- package/lib/semantic-snapshot.js +268 -0
- package/lib/serialize.js +32 -0
- package/lib/server-entry.js +26 -0
- package/lib/server-lock.js +109 -0
- package/lib/server.js +1501 -0
- package/lib/setup-guidance.js +119 -0
- package/{src → lib}/setup.js +44 -55
- package/lib/state.js +733 -0
- package/lib/view-identity.js +112 -0
- package/package.json +24 -12
- package/src/anchor-text.js +0 -160
- package/src/atomic-write.js +0 -53
- package/src/chrome-client.js +0 -2006
- package/src/chrome-session.js +0 -85
- package/src/chrome.html +0 -129
- package/src/cli.js +0 -267
- package/src/click-target.js +0 -26
- package/src/comment-anchor.js +0 -22
- package/src/comment-target.js +0 -77
- package/src/edit-limits.js +0 -23
- package/src/editing.js +0 -99
- package/src/frame-channel.js +0 -44
- package/src/frame-policy.js +0 -16
- package/src/html-transform.js +0 -62
- package/src/icons.js +0 -251
- package/src/paths.js +0 -91
- package/src/poll-transport.js +0 -222
- package/src/positioning.js +0 -107
- package/src/review-mode.js +0 -59
- package/src/sdk.js +0 -2243
- package/src/serialize.js +0 -32
- package/src/server-entry.js +0 -26
- package/src/server-lock.js +0 -101
- package/src/server.js +0 -1298
- package/src/setup-guidance.js +0 -128
- package/src/state.js +0 -507
package/src/setup-guidance.js
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
export const GUIDANCE_BEGIN = "<!-- BEGIN doc-review -->";
|
|
2
|
-
export const GUIDANCE_END = "<!-- END doc-review -->";
|
|
3
|
-
|
|
4
|
-
// Historical generated text is deliberately independent of the current guidance.
|
|
5
|
-
const LEGACY_TAIL = `
|
|
6
|
-
\`npx -y @erdemtuna/doc-review <file.html>\`. For a locally running web page, open the real
|
|
7
|
-
route with \`npx -y @erdemtuna/doc-review http://localhost:3000/path\` instead of recreating
|
|
8
|
-
it as a static file. Then block on
|
|
9
|
-
\`npx -y @erdemtuna/doc-review poll <target> --timeout 600\` until they send feedback.
|
|
10
|
-
If it prints \`{"status":"timeout"}\`, no feedback arrived yet — run the same
|
|
11
|
-
poll command again to keep waiting. When a \`{"status":"feedback"}\` batch
|
|
12
|
-
arrives, apply it, then run the exact acknowledgement command in its
|
|
13
|
-
\`next_step\`, which uses \`--ack <batch_id>\`.
|
|
14
|
-
|
|
15
|
-
Keep the poll command in the foreground and do not end the turn while it waits.
|
|
16
|
-
If the shell returns a process or session handle, keep waiting on that handle until
|
|
17
|
-
the command exits. \`npx -y @erdemtuna/doc-review status <target>\` reports instantly
|
|
18
|
-
whether feedback is already waiting, without blocking.
|
|
19
|
-
|
|
20
|
-
The batch groups feedback by page under \`pages\`, so fix every page listed. Items
|
|
21
|
-
under \`edits\` are changes the user already made: \`after\` is their exact wording,
|
|
22
|
-
so carry it across verbatim and never revert it — and if the HTML was generated
|
|
23
|
-
from MDX or Markdown, apply it to the source too. Markdown files open rendered
|
|
24
|
-
and are never written by doc-review: apply their comments and edits to the
|
|
25
|
-
Markdown source, keeping its syntax. There is no reply channel; the user sees
|
|
26
|
-
your work when the page reloads. For a localhost page, direct edits and deletions
|
|
27
|
-
arrive with \`kind: "url"\`; find and update the matching MDX, TSX, template, or
|
|
28
|
-
component source. Never write the rendered HTTP response over project source.`;
|
|
29
|
-
|
|
30
|
-
export const LEGACY_GUIDANCE = Object.freeze([
|
|
31
|
-
`## Reviewing files and localhost pages with doc-review
|
|
32
|
-
|
|
33
|
-
After writing an HTML or Markdown file the user will read, open it for them with${LEGACY_TAIL}`,
|
|
34
|
-
`## Reviewing files and localhost pages with doc-review
|
|
35
|
-
|
|
36
|
-
Start only when the user explicitly invokes /doc-review or requests an
|
|
37
|
-
interactive browser review. Writing, updating, discussing, or generically reviewing
|
|
38
|
-
content does not authorize opening a review or polling. Another skill's automatic
|
|
39
|
-
review step is not user permission. Otherwise respond normally.
|
|
40
|
-
|
|
41
|
-
After that explicit request, open the requested HTML or Markdown file with${LEGACY_TAIL}`,
|
|
42
|
-
]);
|
|
43
|
-
|
|
44
|
-
const migrationMessage = "AGENTS.md contains custom or unrecognized doc-review guidance — left it unchanged. " +
|
|
45
|
-
"Manually reconcile that guidance, then wrap only the setup-owned section in " +
|
|
46
|
-
`${GUIDANCE_BEGIN} and ${GUIDANCE_END}, or remove it and re-run setup.`;
|
|
47
|
-
|
|
48
|
-
function invalidMarkers() {
|
|
49
|
-
throw new Error("AGENTS.md has malformed, duplicate, or ambiguous doc-review ownership markers. " +
|
|
50
|
-
"Keep exactly one standalone BEGIN/END pair in order, then re-run setup. No setup files were changed.");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function render(body, newline) {
|
|
54
|
-
return `${GUIDANCE_BEGIN}\n${body.trim()}\n${GUIDANCE_END}`.replaceAll("\n", newline);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Plan the complete edit before setup writes any files. Never normalize user text. */
|
|
58
|
-
export function updateGuidance(existing, body) {
|
|
59
|
-
const lines = [...existing.matchAll(/[^\n]*(?:\n|$)/g)]
|
|
60
|
-
.filter((match) => match[0])
|
|
61
|
-
.map((match) => ({
|
|
62
|
-
text: match[0].replace(/\r?\n$/, ""),
|
|
63
|
-
start: match.index,
|
|
64
|
-
end: match.index + match[0].replace(/\r?\n$/, "").length,
|
|
65
|
-
}));
|
|
66
|
-
const markers = lines.filter(({ text }) => /doc-review/i.test(text) &&
|
|
67
|
-
(/\b(?:BEGIN|END)\b/i.test(text) && /<!--|-->|^\s*(?:BEGIN|END)\b/i.test(text)));
|
|
68
|
-
|
|
69
|
-
if (markers.length) {
|
|
70
|
-
if (markers.length !== 2 || markers[0].text !== GUIDANCE_BEGIN || markers[1].text !== GUIDANCE_END) {
|
|
71
|
-
invalidMarkers();
|
|
72
|
-
}
|
|
73
|
-
const [begin, end] = markers;
|
|
74
|
-
const newline = existing.slice(begin.end).startsWith("\r\n") ? "\r\n" : "\n";
|
|
75
|
-
return {
|
|
76
|
-
contents: existing.slice(0, begin.start) + render(body, newline) + existing.slice(end.end),
|
|
77
|
-
message: "Updated setup-owned AGENTS.md guidance (Codex)",
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const candidates = [];
|
|
82
|
-
for (const legacy of LEGACY_GUIDANCE) {
|
|
83
|
-
for (const command of ["npx -y @erdemtuna/doc-review", "doc-review"]) {
|
|
84
|
-
for (const newline of ["\n", "\r\n"]) {
|
|
85
|
-
const text = legacy.replaceAll("npx -y @erdemtuna/doc-review", command).replaceAll("\n", newline);
|
|
86
|
-
let start = existing.indexOf(text);
|
|
87
|
-
while (start !== -1) {
|
|
88
|
-
const end = start + text.length;
|
|
89
|
-
const before = existing.slice(0, start);
|
|
90
|
-
const after = existing.slice(end);
|
|
91
|
-
// A complete generated section, not a substring of customized prose.
|
|
92
|
-
if ((start === 0 || before.endsWith("\n") || before === "\uFEFF") &&
|
|
93
|
-
/^(?:\r?\n|$)/.test(after) &&
|
|
94
|
-
/^(?:\s*$|(?:\r?\n)+(?=#{1,2} ))/.test(after)) {
|
|
95
|
-
candidates.push({ start, end, newline });
|
|
96
|
-
}
|
|
97
|
-
start = existing.indexOf(text, start + text.length);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
if (candidates.length > 1) {
|
|
103
|
-
throw new Error("AGENTS.md contains multiple legacy doc-review sections; ownership is ambiguous. " +
|
|
104
|
-
"Reconcile them before re-running setup. No setup files were changed.");
|
|
105
|
-
}
|
|
106
|
-
if (candidates.length === 1) {
|
|
107
|
-
const { start, end, newline } = candidates[0];
|
|
108
|
-
const surrounding = existing.slice(0, start) + existing.slice(end);
|
|
109
|
-
if (!/doc-review/i.test(surrounding)) {
|
|
110
|
-
return {
|
|
111
|
-
contents: existing.slice(0, start) + render(body, newline) + existing.slice(end),
|
|
112
|
-
message: "Migrated legacy AGENTS.md guidance to setup-owned markers (Codex)",
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
if (/doc-review/i.test(existing)) {
|
|
117
|
-
return { contents: existing, message: migrationMessage };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const newline = existing.includes("\r\n") ? "\r\n" : "\n";
|
|
121
|
-
const separator = !existing || existing.endsWith(newline + newline)
|
|
122
|
-
? ""
|
|
123
|
-
: existing.endsWith("\n") ? newline : newline + newline;
|
|
124
|
-
return {
|
|
125
|
-
contents: existing + separator + render(body, newline) + newline,
|
|
126
|
-
message: `${existing ? "Updated" : "Created"} AGENTS.md (Codex)`,
|
|
127
|
-
};
|
|
128
|
-
}
|
package/src/state.js
DELETED
|
@@ -1,507 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { normalizeCommentAnchor } from "./comment-anchor.js";
|
|
5
|
-
import { canonicalTarget, ensureStateDir, pageKey, realFile, statePath, targetKey } from "./paths.js";
|
|
6
|
-
export { atomicWrite } from "./atomic-write.js";
|
|
7
|
-
import { atomicWrite } from "./atomic-write.js";
|
|
8
|
-
|
|
9
|
-
/** Anything untouched this long is review debris, not work in progress. */
|
|
10
|
-
const PRUNE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
11
|
-
const DELIVERY_STATES = new Set(["queued", "possibly_delivered", "delivered"]);
|
|
12
|
-
|
|
13
|
-
const fresh = (entry, now) => !!entry && now - (entry.updatedAt || 0) < PRUNE_AGE_MS;
|
|
14
|
-
const batchId = () => `b_${crypto.randomBytes(12).toString("hex")}`;
|
|
15
|
-
const emptyState = () => ({ pages: {}, batches: {}, receipts: {} });
|
|
16
|
-
|
|
17
|
-
function pruneData(data, now = Date.now()) {
|
|
18
|
-
let changed = false;
|
|
19
|
-
for (const [key, page] of Object.entries(data.pages)) {
|
|
20
|
-
const missingFile = page.kind !== "url" && !fs.existsSync(page.file);
|
|
21
|
-
if (!fresh(page, now) || missingFile) {
|
|
22
|
-
delete data.pages[key];
|
|
23
|
-
delete data.batches[key];
|
|
24
|
-
changed = true;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
for (const [key, batch] of Object.entries(data.batches)) {
|
|
28
|
-
if (!fresh(batch, now)) {
|
|
29
|
-
delete data.batches[key];
|
|
30
|
-
changed = true;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
for (const [id, receipt] of Object.entries(data.receipts)) {
|
|
34
|
-
if (!fresh(receipt, now)) {
|
|
35
|
-
delete data.receipts[id];
|
|
36
|
-
changed = true;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return changed;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function normalizeState(parsed, makeBatchId) {
|
|
43
|
-
if (!parsed || typeof parsed !== "object" || !parsed.pages || typeof parsed.pages !== "object") {
|
|
44
|
-
throw new Error("Invalid doc-review state: expected a pages object.");
|
|
45
|
-
}
|
|
46
|
-
const data = {
|
|
47
|
-
pages: parsed.pages,
|
|
48
|
-
batches: parsed.batches && typeof parsed.batches === "object" ? parsed.batches : {},
|
|
49
|
-
receipts: parsed.receipts && typeof parsed.receipts === "object" ? parsed.receipts : {},
|
|
50
|
-
};
|
|
51
|
-
let changed = !parsed.batches || !parsed.receipts;
|
|
52
|
-
const normalizeAnchor = (comment) => {
|
|
53
|
-
if (!comment || comment.anchor == null) return;
|
|
54
|
-
const normalized = normalizeCommentAnchor(comment.kind === "element" ? "element" : "selection", comment.anchor);
|
|
55
|
-
if (JSON.stringify(normalized) !== JSON.stringify(comment.anchor)) {
|
|
56
|
-
comment.anchor = normalized;
|
|
57
|
-
changed = true;
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
for (const page of Object.values(data.pages)) {
|
|
61
|
-
for (const comment of page?.comments || []) normalizeAnchor(comment);
|
|
62
|
-
}
|
|
63
|
-
for (const record of Object.values(data.batches)) {
|
|
64
|
-
if (!record || typeof record !== "object" || !record.batch || !Array.isArray(record.cleanup)) {
|
|
65
|
-
throw new Error("Invalid doc-review state: malformed feedback batch.");
|
|
66
|
-
}
|
|
67
|
-
const existingId = record.batch_id || record.batch.batch_id;
|
|
68
|
-
if (record.batch_id && record.batch.batch_id && record.batch_id !== record.batch.batch_id) {
|
|
69
|
-
throw new Error("Invalid doc-review state: feedback batch IDs disagree.");
|
|
70
|
-
}
|
|
71
|
-
if (!existingId) {
|
|
72
|
-
record.batch_id = makeBatchId();
|
|
73
|
-
record.batch.batch_id = record.batch_id;
|
|
74
|
-
record.delivery_state = "possibly_delivered";
|
|
75
|
-
changed = true;
|
|
76
|
-
} else {
|
|
77
|
-
record.batch_id = existingId;
|
|
78
|
-
if (record.batch.batch_id !== existingId) {
|
|
79
|
-
record.batch.batch_id = existingId;
|
|
80
|
-
changed = true;
|
|
81
|
-
}
|
|
82
|
-
if (!record.delivery_state) {
|
|
83
|
-
record.delivery_state = "possibly_delivered";
|
|
84
|
-
changed = true;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (!DELIVERY_STATES.has(record.delivery_state)) {
|
|
88
|
-
throw new Error(`Invalid doc-review state: unknown delivery state ${record.delivery_state}.`);
|
|
89
|
-
}
|
|
90
|
-
for (const page of record.batch.pages || []) {
|
|
91
|
-
for (const comment of page.comments || []) normalizeAnchor(comment);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return { data, changed };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* All durable state lives in one JSON file. No database, no network.
|
|
99
|
-
*
|
|
100
|
-
* Shape:
|
|
101
|
-
* {
|
|
102
|
-
* pages: { <key>: { key, file, pristine, comments[], edits[], updatedAt } },
|
|
103
|
-
* batches: { <entryKey>: { batch_id, batch, cleanup, delivery_state, updatedAt } },
|
|
104
|
-
* receipts:{ <batchId>: { cleanup, delivery_state, updatedAt } },
|
|
105
|
-
* }
|
|
106
|
-
*
|
|
107
|
-
* Pages are fully independent: no page ever references another. Batches are
|
|
108
|
-
* feedback the user sent that no agent has acknowledged yet; persisting them
|
|
109
|
-
* means "your feedback is safe" stays true across server restarts.
|
|
110
|
-
*/
|
|
111
|
-
export class Store {
|
|
112
|
-
constructor({ write = atomicWrite, makeBatchId = batchId } = {}) {
|
|
113
|
-
this.data = emptyState();
|
|
114
|
-
this.write = write;
|
|
115
|
-
this.makeBatchId = makeBatchId;
|
|
116
|
-
this.load();
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
load() {
|
|
120
|
-
let parsed;
|
|
121
|
-
try {
|
|
122
|
-
const raw = fs.readFileSync(statePath(), "utf8");
|
|
123
|
-
parsed = JSON.parse(raw);
|
|
124
|
-
} catch (err) {
|
|
125
|
-
if (err.code === "ENOENT") return this.data;
|
|
126
|
-
throw err;
|
|
127
|
-
}
|
|
128
|
-
const normalized = normalizeState(parsed, this.makeBatchId);
|
|
129
|
-
this.data = normalized.data;
|
|
130
|
-
const changed = pruneData(this.data);
|
|
131
|
-
if (normalized.changed || changed) this.persist(this.data);
|
|
132
|
-
return this.data;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
persist(data) {
|
|
136
|
-
ensureStateDir();
|
|
137
|
-
this.write(statePath(), JSON.stringify(data, null, 2));
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Replace durable state once, then publish the committed draft in memory. */
|
|
141
|
-
transaction(mutator) {
|
|
142
|
-
const draft = structuredClone(this.data);
|
|
143
|
-
const result = mutator(draft);
|
|
144
|
-
if (result && typeof result.then === "function") {
|
|
145
|
-
throw new Error("Store.transaction mutators must be synchronous.");
|
|
146
|
-
}
|
|
147
|
-
pruneData(draft);
|
|
148
|
-
this.persist(draft);
|
|
149
|
-
this.data = draft;
|
|
150
|
-
return result;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Persist deliberate direct changes used by maintenance and tests. */
|
|
154
|
-
save() {
|
|
155
|
-
const draft = structuredClone(this.data);
|
|
156
|
-
pruneData(draft);
|
|
157
|
-
this.persist(draft);
|
|
158
|
-
this.data = draft;
|
|
159
|
-
return this.data;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Register a file as a reviewable page, capturing the agent's version. */
|
|
163
|
-
openPage(file, pristine) {
|
|
164
|
-
const key = pageKey(file);
|
|
165
|
-
return this.transaction((draft) => {
|
|
166
|
-
const existing = draft.pages[key];
|
|
167
|
-
const page = existing || {
|
|
168
|
-
key,
|
|
169
|
-
kind: "file",
|
|
170
|
-
file: realFile(file),
|
|
171
|
-
pristine: "",
|
|
172
|
-
comments: [],
|
|
173
|
-
edits: [],
|
|
174
|
-
updatedAt: 0,
|
|
175
|
-
};
|
|
176
|
-
page.kind = "file";
|
|
177
|
-
page.file = realFile(file);
|
|
178
|
-
delete page.url;
|
|
179
|
-
if (!existing || typeof pristine === "string") {
|
|
180
|
-
page.pristine = typeof pristine === "string" ? pristine : page.pristine;
|
|
181
|
-
}
|
|
182
|
-
page.updatedAt = Date.now();
|
|
183
|
-
draft.pages[key] = page;
|
|
184
|
-
return page;
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Register a rendered localhost route. Browser edits are never written to it. */
|
|
189
|
-
openUrl(url) {
|
|
190
|
-
const target = canonicalTarget(url);
|
|
191
|
-
if (target.kind !== "url") throw new Error("Expected a localhost URL.");
|
|
192
|
-
const key = targetKey(target.value);
|
|
193
|
-
return this.transaction((draft) => {
|
|
194
|
-
const existing = draft.pages[key];
|
|
195
|
-
const page = existing || {
|
|
196
|
-
key,
|
|
197
|
-
kind: "url",
|
|
198
|
-
url: target.value,
|
|
199
|
-
pristine: "",
|
|
200
|
-
comments: [],
|
|
201
|
-
edits: [],
|
|
202
|
-
updatedAt: 0,
|
|
203
|
-
};
|
|
204
|
-
page.kind = "url";
|
|
205
|
-
page.url = target.value;
|
|
206
|
-
delete page.file;
|
|
207
|
-
page.updatedAt = Date.now();
|
|
208
|
-
draft.pages[key] = page;
|
|
209
|
-
return page;
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
page(key) {
|
|
214
|
-
return this.data.pages[key] || null;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
pageForFile(file) {
|
|
218
|
-
return this.page(pageKey(file));
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
pageForTarget(target) {
|
|
222
|
-
return this.page(targetKey(target));
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
update(key, mutate) {
|
|
226
|
-
if (!this.page(key)) return null;
|
|
227
|
-
return this.transaction((draft) => {
|
|
228
|
-
const page = draft.pages[key];
|
|
229
|
-
mutate(page);
|
|
230
|
-
page.updatedAt = Date.now();
|
|
231
|
-
return page;
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
addComment(key, comment) {
|
|
236
|
-
const anchor = comment.anchor == null
|
|
237
|
-
? comment.anchor
|
|
238
|
-
: normalizeCommentAnchor(comment.kind === "element" ? "element" : "selection", comment.anchor);
|
|
239
|
-
if (comment.anchor != null && !anchor) throw new Error("Invalid comment anchor.");
|
|
240
|
-
return this.update(key, (page) => {
|
|
241
|
-
page.comments.push({ ...comment, ...(comment.anchor !== undefined ? { anchor } : {}) });
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
removeComment(key, id) {
|
|
246
|
-
return this.update(key, (page) => {
|
|
247
|
-
page.comments = page.comments.filter((c) => c.id !== id);
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** Reword feedback, optionally turning a delivered instruction into a correction. */
|
|
252
|
-
updateComment(key, id, feedback, { replacementId = "", correctionOf = "" } = {}) {
|
|
253
|
-
let found = false;
|
|
254
|
-
const page = this.update(key, (p) => {
|
|
255
|
-
const index = p.comments.findIndex((c) => c.id === id);
|
|
256
|
-
const comment = p.comments[index];
|
|
257
|
-
if (comment) {
|
|
258
|
-
const updated = {
|
|
259
|
-
...comment,
|
|
260
|
-
...(replacementId ? { id: replacementId } : {}),
|
|
261
|
-
feedback,
|
|
262
|
-
updatedAt: Date.now(),
|
|
263
|
-
...(correctionOf ? { correction: true, correctionOf } : {}),
|
|
264
|
-
};
|
|
265
|
-
p.comments[index] = updated;
|
|
266
|
-
found = true;
|
|
267
|
-
}
|
|
268
|
-
});
|
|
269
|
-
return found ? page : null;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Reword a comment and every queued copy in one commit. Any evidence that
|
|
274
|
-
* the old ID may have shipped turns the edit into a replacement correction.
|
|
275
|
-
*/
|
|
276
|
-
reviseComment(key, id, feedback, { replacementId } = {}) {
|
|
277
|
-
if (!this.page(key)?.comments.some((comment) => comment.id === id)) return null;
|
|
278
|
-
return this.transaction((draft) => {
|
|
279
|
-
const page = draft.pages[key];
|
|
280
|
-
const index = page.comments.findIndex((comment) => comment.id === id);
|
|
281
|
-
const existing = page.comments[index];
|
|
282
|
-
const matching = (record) => record.cleanup.some((item) => item.key === key && item.ids.includes(id));
|
|
283
|
-
const mayHaveShipped =
|
|
284
|
-
Object.values(draft.batches).some((record) => record.delivery_state !== "queued" && matching(record)) ||
|
|
285
|
-
Object.values(draft.receipts).some((record) => matching(record));
|
|
286
|
-
|
|
287
|
-
if (mayHaveShipped) {
|
|
288
|
-
page.comments[index] = {
|
|
289
|
-
...existing,
|
|
290
|
-
id: replacementId || this.makeBatchId().replace(/^b_/, "c_"),
|
|
291
|
-
feedback,
|
|
292
|
-
updatedAt: Date.now(),
|
|
293
|
-
correction: true,
|
|
294
|
-
correctionOf: existing.feedback,
|
|
295
|
-
};
|
|
296
|
-
page.updatedAt = Date.now();
|
|
297
|
-
return { delivery: "correction", page };
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
page.comments[index] = { ...existing, feedback, updatedAt: Date.now() };
|
|
301
|
-
page.updatedAt = Date.now();
|
|
302
|
-
let updatedPending = false;
|
|
303
|
-
for (const record of Object.values(draft.batches)) {
|
|
304
|
-
if (record.delivery_state !== "queued" || !matching(record)) continue;
|
|
305
|
-
for (const batchPage of record.batch.pages || []) {
|
|
306
|
-
const comment = (batchPage.comments || []).find((item) => item.id === id);
|
|
307
|
-
if (comment) {
|
|
308
|
-
comment.feedback = feedback;
|
|
309
|
-
updatedPending = true;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
record.updatedAt = Date.now();
|
|
313
|
-
}
|
|
314
|
-
return { delivery: updatedPending ? "updated-pending" : "unsent", page };
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Edits are deduped by label+kind so retyping one block stays one row, but
|
|
320
|
-
* the text is refreshed every time so `after` is always the latest wording.
|
|
321
|
-
*/
|
|
322
|
-
addEdit(key, label, kind, before, after, beforeHtml, afterHtml, extra) {
|
|
323
|
-
return this.update(key, (page) => {
|
|
324
|
-
const row = page.edits.find((e) => e.label === label && e.kind === kind);
|
|
325
|
-
if (row) {
|
|
326
|
-
if (after !== undefined) row.after = after;
|
|
327
|
-
if (afterHtml !== undefined) row.after_html = afterHtml;
|
|
328
|
-
// A re-move of the same block replaces its landing spot.
|
|
329
|
-
if (extra) {
|
|
330
|
-
if (Array.isArray(extra.truncated_fields)) {
|
|
331
|
-
const replaced = new Set([
|
|
332
|
-
...(after !== undefined ? ["after"] : []),
|
|
333
|
-
...(afterHtml !== undefined ? ["after_html"] : []),
|
|
334
|
-
...["moved_after", "moved_before"].filter((field) => extra[field] !== undefined),
|
|
335
|
-
]);
|
|
336
|
-
// Original before text is retained across edits, including its truncation.
|
|
337
|
-
const truncatedFields = [...new Set([
|
|
338
|
-
...(row.truncated_fields || []).filter((field) => !replaced.has(field)),
|
|
339
|
-
...extra.truncated_fields.filter((field) => replaced.has(field)),
|
|
340
|
-
])];
|
|
341
|
-
extra = { ...extra, truncated: truncatedFields.length > 0, truncated_fields: truncatedFields };
|
|
342
|
-
}
|
|
343
|
-
if (extra.staged_assets) {
|
|
344
|
-
const assets = [...(row.staged_assets || []), ...extra.staged_assets];
|
|
345
|
-
extra = { ...extra, staged_assets: [...new Map(assets.map((asset) => [asset.path, asset])).values()] };
|
|
346
|
-
}
|
|
347
|
-
Object.assign(row, extra);
|
|
348
|
-
}
|
|
349
|
-
row.updatedAt = Date.now();
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
page.edits.push({ label, kind, before, after, before_html: beforeHtml, after_html: afterHtml, ...(extra || {}), at: Date.now(), updatedAt: Date.now() });
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
clearEdits(key) {
|
|
357
|
-
return this.update(key, (page) => {
|
|
358
|
-
page.edits = [];
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/** After the agent writes, its version becomes the new revert target. */
|
|
363
|
-
setPristine(key, html, { keepEdits = false } = {}) {
|
|
364
|
-
return this.update(key, (page) => {
|
|
365
|
-
page.pristine = html;
|
|
366
|
-
if (!keepEdits) page.edits = [];
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Drop exactly what the acknowledged batch carried. Comments made after
|
|
372
|
-
* Send have unknown ids; edits made (or retyped) after Send have a newer
|
|
373
|
-
* timestamp than the batch. Both must survive for the next batch.
|
|
374
|
-
*/
|
|
375
|
-
clearSent(key, ids, sentAt) {
|
|
376
|
-
return this.update(key, (page) => {
|
|
377
|
-
const drop = new Set(ids);
|
|
378
|
-
page.comments = page.comments.filter((c) => !drop.has(c.id));
|
|
379
|
-
// >= not >: an edit stamped the same millisecond as the send may not
|
|
380
|
-
// have shipped — resending it is harmless, dropping it loses work.
|
|
381
|
-
page.edits = typeof sentAt === "number" ? page.edits.filter((e) => (e.updatedAt || e.at || 0) >= sentAt) : [];
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Sent-but-unacked feedback, keyed by the entry page the agent polls.
|
|
386
|
-
|
|
387
|
-
batch(entryKey) {
|
|
388
|
-
return this.data.batches[entryKey] || null;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
allBatches() {
|
|
392
|
-
return this.data.batches;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
setBatch(entryKey, { batch, cleanup, deliveryState = "queued" }) {
|
|
396
|
-
if (!DELIVERY_STATES.has(deliveryState)) throw new Error(`Unknown delivery state: ${deliveryState}`);
|
|
397
|
-
return this.transaction((draft) => {
|
|
398
|
-
const existing = draft.batches[entryKey];
|
|
399
|
-
if (existing && existing.delivery_state !== "queued") {
|
|
400
|
-
draft.receipts[existing.batch_id] = {
|
|
401
|
-
cleanup: existing.cleanup,
|
|
402
|
-
delivery_state: existing.delivery_state,
|
|
403
|
-
updatedAt: Date.now(),
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
const id = batch.batch_id || this.makeBatchId();
|
|
407
|
-
const storedBatch = { ...batch, batch_id: id };
|
|
408
|
-
const record = {
|
|
409
|
-
batch_id: id,
|
|
410
|
-
batch: storedBatch,
|
|
411
|
-
cleanup,
|
|
412
|
-
delivery_state: deliveryState,
|
|
413
|
-
updatedAt: Date.now(),
|
|
414
|
-
};
|
|
415
|
-
draft.batches[entryKey] = record;
|
|
416
|
-
return record;
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
markBatchDelivered(entryKey) {
|
|
421
|
-
if (!this.batch(entryKey)) return null;
|
|
422
|
-
return this.transaction((draft) => {
|
|
423
|
-
const record = draft.batches[entryKey];
|
|
424
|
-
record.delivery_state = "delivered";
|
|
425
|
-
record.updatedAt = Date.now();
|
|
426
|
-
return record;
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Clear exactly one delivered receipt and its shipped page contents.
|
|
432
|
-
* Stale, duplicate, queued, and legacy possibly-delivered IDs are no-ops.
|
|
433
|
-
*/
|
|
434
|
-
acknowledgeBatch(entryKey, id) {
|
|
435
|
-
const current = this.batch(entryKey);
|
|
436
|
-
if (!current || current.batch_id !== id || current.delivery_state !== "delivered") {
|
|
437
|
-
return { acknowledged: false, staged: [], keys: [] };
|
|
438
|
-
}
|
|
439
|
-
return this.transaction((draft) => {
|
|
440
|
-
const record = draft.batches[entryKey];
|
|
441
|
-
delete draft.batches[entryKey];
|
|
442
|
-
draft.receipts[id] = {
|
|
443
|
-
cleanup: record.cleanup,
|
|
444
|
-
delivery_state: "acknowledged",
|
|
445
|
-
updatedAt: Date.now(),
|
|
446
|
-
};
|
|
447
|
-
const staged = [];
|
|
448
|
-
const keys = [];
|
|
449
|
-
for (const { key, ids, staged: assets = [], sentAt } of record.cleanup) {
|
|
450
|
-
staged.push(...assets);
|
|
451
|
-
keys.push(key);
|
|
452
|
-
const page = draft.pages[key];
|
|
453
|
-
if (!page) continue;
|
|
454
|
-
const drop = new Set(ids);
|
|
455
|
-
page.comments = page.comments.filter((comment) => !drop.has(comment.id));
|
|
456
|
-
page.edits =
|
|
457
|
-
typeof sentAt === "number"
|
|
458
|
-
? page.edits.filter((edit) => (edit.updatedAt || edit.at || 0) >= sentAt)
|
|
459
|
-
: [];
|
|
460
|
-
page.updatedAt = Date.now();
|
|
461
|
-
}
|
|
462
|
-
return { acknowledged: true, staged, keys };
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
clearBatch(entryKey) {
|
|
467
|
-
if (!this.batch(entryKey)) return null;
|
|
468
|
-
return this.transaction((draft) => {
|
|
469
|
-
const record = draft.batches[entryKey];
|
|
470
|
-
delete draft.batches[entryKey];
|
|
471
|
-
return record;
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/** Resolve a sibling asset request without escaping the artifact's directory. */
|
|
477
|
-
export function resolveAsset(pageFile, relative) {
|
|
478
|
-
let decoded;
|
|
479
|
-
try {
|
|
480
|
-
decoded = decodeURIComponent(relative);
|
|
481
|
-
} catch {
|
|
482
|
-
return null;
|
|
483
|
-
}
|
|
484
|
-
const base = path.dirname(pageFile);
|
|
485
|
-
const target = path.resolve(base, decoded);
|
|
486
|
-
const contained = (candidate, root) => {
|
|
487
|
-
const rel = path.relative(root, candidate);
|
|
488
|
-
return !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
489
|
-
};
|
|
490
|
-
if (!contained(target, base)) return null;
|
|
491
|
-
// The lexical check alone would follow a symlink out of the directory, so
|
|
492
|
-
// the resolved filesystem path must land inside it too.
|
|
493
|
-
let real;
|
|
494
|
-
try {
|
|
495
|
-
real = fs.realpathSync(target);
|
|
496
|
-
} catch {
|
|
497
|
-
// Nothing readable at that path — anything a symlink could point to would
|
|
498
|
-
// have resolved. The caller's read fails with a plain 404.
|
|
499
|
-
return target;
|
|
500
|
-
}
|
|
501
|
-
let realBase = base;
|
|
502
|
-
try {
|
|
503
|
-
realBase = fs.realpathSync(base);
|
|
504
|
-
} catch {}
|
|
505
|
-
if (!contained(real, realBase)) return null;
|
|
506
|
-
return real;
|
|
507
|
-
}
|