@erdemtuna/doc-review 0.12.0 → 0.14.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 +23 -25
- package/lib/SKILL.md +65 -202
- package/lib/agent-handoff.js +24 -0
- package/lib/agent-output.js +287 -0
- package/lib/anchor-text.js +31 -19
- package/lib/chrome-api.js +23 -4
- package/lib/chrome.html +0 -25
- package/lib/cli.js +282 -104
- package/lib/comment-target.js +4 -0
- package/lib/contracts/agent.js +122 -0
- package/lib/contracts/feedback.js +369 -1
- package/lib/contracts/frame.js +81 -0
- package/lib/contracts/history.js +189 -1
- package/lib/contracts/index.js +7 -2
- package/lib/contracts/page-boundary.js +187 -0
- package/lib/contracts/validation.js +200 -0
- package/lib/conversation-anchor-controller.js +84 -0
- package/lib/conversation-capture.js +81 -0
- package/lib/conversation-controller.js +798 -0
- package/lib/conversation-save.js +175 -0
- package/lib/conversation-server.js +184 -0
- package/lib/conversation-shell.js +1013 -0
- package/lib/conversation-store.js +809 -0
- package/lib/frame-controller.js +8 -8
- package/lib/frame-policy.js +1 -0
- package/lib/history-policy.js +4 -15
- package/lib/history-server.js +42 -326
- package/lib/html-transform.js +19 -4
- package/lib/icons.js +323 -0
- package/lib/new-message-target.js +27 -0
- package/lib/paths.js +2 -2
- package/lib/poll-transport.js +109 -26
- package/lib/positioning.js +51 -0
- package/lib/references/context-and-recovery.md +130 -0
- package/lib/references/response-contract.md +108 -0
- package/lib/references/source-edits.md +47 -0
- package/lib/revision-store.js +1 -1
- package/lib/save-controller.js +28 -14
- package/lib/sdk.js +357 -35
- package/lib/server.js +73 -595
- package/lib/setup.js +17 -48
- package/lib/state.js +21 -7
- package/lib/thread-anchor-controller.js +56 -0
- package/lib/toolbar-controller.js +3 -3
- package/lib/ui/THIRD_PARTY_NOTICES.md +127 -8
- package/lib/ui/chrome.css +1118 -2787
- package/lib/ui/chrome.js +81 -17
- package/package.json +2 -2
- package/lib/chrome-client.js +0 -1706
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { AGENT_OUTPUT_BYTES, agentReadRequestSchema, agentReadResponseSchema, } from "./contracts/agent.js";
|
|
3
|
+
import { canonicalJson, reject } from "./contracts/validation.js";
|
|
4
|
+
import { agentHandoff } from "./agent-handoff.js";
|
|
5
|
+
import { shellQuote } from "./setup.js";
|
|
6
|
+
export const serializeAgent = (value) => {
|
|
7
|
+
const output = `${JSON.stringify(value)}\n`;
|
|
8
|
+
if (Buffer.byteLength(output) > AGENT_OUTPUT_BYTES) {
|
|
9
|
+
reject("INPUT_TOO_LARGE", "Agent envelope exceeds 16 KiB; use scoped content/export for oversized metadata.");
|
|
10
|
+
}
|
|
11
|
+
return output;
|
|
12
|
+
};
|
|
13
|
+
const bytes = (value) => Buffer.byteLength(`${JSON.stringify(value)}\n`);
|
|
14
|
+
const hash = (text) => createHash("sha256").update(text).digest("hex");
|
|
15
|
+
const integrity = (text) => ({ utf8Bytes: Buffer.byteLength(text), sha256: hash(text) });
|
|
16
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
17
|
+
function cursor(raw, binding, total) {
|
|
18
|
+
if (raw === undefined)
|
|
19
|
+
return 0;
|
|
20
|
+
let decoded;
|
|
21
|
+
try {
|
|
22
|
+
decoded = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
reject("INVALID_CURSOR", "Malformed continuation.");
|
|
26
|
+
}
|
|
27
|
+
if (decoded.binding !== binding || !Number.isSafeInteger(decoded.offset) || decoded.offset < 0 || decoded.offset > total) {
|
|
28
|
+
reject("INVALID_CURSOR", "Continuation belongs to different scope, content or version.");
|
|
29
|
+
}
|
|
30
|
+
return decoded.offset;
|
|
31
|
+
}
|
|
32
|
+
const token = (binding, offset, highWater) => encode({ binding, offset, ...(highWater === undefined ? {} : { highWater }) });
|
|
33
|
+
const commandScope = (scope) => `--review ${shellQuote(scope.reviewId)} --entry ${shellQuote(scope.entryKey)}`;
|
|
34
|
+
export function responseTemplate(submission, requestId) {
|
|
35
|
+
if (submission.state !== "delivered")
|
|
36
|
+
reject("RESPONSE_COVERAGE", "Templates require delivered work.");
|
|
37
|
+
return {
|
|
38
|
+
operation: "respond", reviewId: submission.reviewId, entryKey: submission.entryKey,
|
|
39
|
+
submissionId: submission.submissionId, expectedVersion: submission.version, requestId,
|
|
40
|
+
responses: submission.messages.map(({ message }) => ({
|
|
41
|
+
threadId: message.threadId, messageId: message.messageId, messageVersion: message.version, body: "", outcome: "",
|
|
42
|
+
})),
|
|
43
|
+
editOutcomes: submission.edits.map((edit) => ({ editId: edit.editId, editVersion: edit.version, outcome: "", reason: "" })),
|
|
44
|
+
...(submission.overallNote ? { overallOutcome: "" } : {}), summary: "", resultNote: "",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** A separate producer projection: browser reads deliberately retain pending messages. */
|
|
48
|
+
export function readAgent(conversations, input, command, source = "server") {
|
|
49
|
+
const request = agentReadRequestSchema.parse(input);
|
|
50
|
+
command = request.invocation ?? command;
|
|
51
|
+
const scope = { reviewId: request.reviewId, entryKey: request.entryKey };
|
|
52
|
+
const review = conversations.read({ operation: "read-review", ...scope });
|
|
53
|
+
const record = conversations.store.data.conversations.reviews[review.reviewId];
|
|
54
|
+
const sub = (submissionId) => {
|
|
55
|
+
if (!submissionId)
|
|
56
|
+
reject("INVALID_INPUT", "This command requires --submission.");
|
|
57
|
+
return conversations.read({ operation: "submission", ...scope, submissionId });
|
|
58
|
+
};
|
|
59
|
+
const cmd = (verb, submissionId) => `${command} ${verb} ${commandScope(scope)}${submissionId ? ` --submission ${shellQuote(submissionId)}` : ""}`;
|
|
60
|
+
const page = (pageKey) => conversations.page(conversations.store.data, record, pageKey).page;
|
|
61
|
+
const roots = new WeakMap();
|
|
62
|
+
const root = (read) => {
|
|
63
|
+
if (!roots.has(read))
|
|
64
|
+
roots.set(read, { ...read, pages: read.submission.pageKeys.map(page) });
|
|
65
|
+
return roots.get(read);
|
|
66
|
+
};
|
|
67
|
+
function valueAt(read, field) {
|
|
68
|
+
if (field === "")
|
|
69
|
+
return root(read);
|
|
70
|
+
let value = field.startsWith("pages/") ? root(read) : read;
|
|
71
|
+
for (const part of field.split("/")) {
|
|
72
|
+
if (!part || !value || typeof value !== "object" || !Object.hasOwn(value, part))
|
|
73
|
+
reject("SCOPE_MISMATCH", "Field is outside this exact submitted artifact.");
|
|
74
|
+
value = value[part];
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
function contentVersion(read, field) {
|
|
79
|
+
const parts = field.split("/");
|
|
80
|
+
if (parts[0] === "submission" && parts[1] === "edits" && parts.length >= 3) {
|
|
81
|
+
return read.submission.edits[Number(parts[2])]?.version ?? read.submission.version;
|
|
82
|
+
}
|
|
83
|
+
if (parts[0] === "submission" && parts[1] === "messages" && parts.length >= 3) {
|
|
84
|
+
return read.submission.messages[Number(parts[2])]?.message.version ?? read.submission.version;
|
|
85
|
+
}
|
|
86
|
+
// Frozen submission evidence and committed results do not inherit mutable lifecycle versions.
|
|
87
|
+
if ((parts[0] === "submission" && ["messages", "edits", "overallNote", "pageKeys", "createdAt"].includes(parts[1])) ||
|
|
88
|
+
["result", "receipt"].includes(parts[0]))
|
|
89
|
+
return 1;
|
|
90
|
+
return read.submission.version;
|
|
91
|
+
}
|
|
92
|
+
function content(read, field, forceReference = false, budget = 2048) {
|
|
93
|
+
const value = valueAt(read, field);
|
|
94
|
+
const isText = typeof value === "string";
|
|
95
|
+
const text = isText ? value : JSON.stringify(value);
|
|
96
|
+
if (!forceReference && bytes(value) <= budget)
|
|
97
|
+
return isText ? { kind: "inline", text } : { kind: "inline", value };
|
|
98
|
+
const version = contentVersion(read, field);
|
|
99
|
+
return {
|
|
100
|
+
kind: "reference",
|
|
101
|
+
identity: { ...scope, submissionId: read.submission.submissionId, version, field: field || "." },
|
|
102
|
+
encoding: isText ? "utf8" : "json", ...integrity(text),
|
|
103
|
+
command: `${cmd("content", read.submission.submissionId)} --version ${version} --field ${shellQuote(field || ".")}`,
|
|
104
|
+
...(isText ? { preview: [...text].slice(0, 80).join("") } : {}),
|
|
105
|
+
...(!isText && value && typeof value === "object" && !Array.isArray(value) ? {
|
|
106
|
+
fields: Object.entries(value).map(([name, data]) => ({
|
|
107
|
+
name, encoding: typeof data === "string" ? "utf8" : "json",
|
|
108
|
+
...integrity(typeof data === "string" ? data : JSON.stringify(data)),
|
|
109
|
+
})),
|
|
110
|
+
} : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const lifecycle = (read) => {
|
|
114
|
+
const s = read.submission;
|
|
115
|
+
return {
|
|
116
|
+
submissionId: s.submissionId, version: s.version, state: s.state, createdAt: s.createdAt,
|
|
117
|
+
deliveredAt: s.deliveredAt, completedAt: s.completedAt,
|
|
118
|
+
abandonment: s.abandonment ? content(read, "submission/abandonment") : null,
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
const note = (read, small = false) => read.submission.overallNote ? {
|
|
122
|
+
overallNote: { intent: read.submission.overallNote.intent, body: content(read, "submission/overallNote/body", false, small ? 256 : 2048) },
|
|
123
|
+
} : {};
|
|
124
|
+
const result = (read, preview = false, budget = 2048) => read.result ? {
|
|
125
|
+
resultId: read.result.resultId, title: read.result.title, effect: read.result.effect,
|
|
126
|
+
body: content(read, "result/body", preview, budget),
|
|
127
|
+
...(read.result.summary === undefined ? {} : { summary: content(read, "result/summary", preview) }),
|
|
128
|
+
...(read.result.overallOutcome ? { overallOutcome: read.result.overallOutcome } : {}),
|
|
129
|
+
} : null;
|
|
130
|
+
function paginate(rows, binding, envelope, build = (row) => row, highWater) {
|
|
131
|
+
const offset = cursor(request.cursor, binding, rows.length);
|
|
132
|
+
const items = [];
|
|
133
|
+
let end = offset;
|
|
134
|
+
const output = () => ({
|
|
135
|
+
...envelope, totalCount: rows.length, returnedCount: items.length,
|
|
136
|
+
complete: end === rows.length, nextCursor: end < rows.length ? token(binding, end, highWater) : null, items,
|
|
137
|
+
});
|
|
138
|
+
while (end < rows.length && items.length < (request.limit ?? 50)) {
|
|
139
|
+
items.push(build(rows[end]));
|
|
140
|
+
end++;
|
|
141
|
+
if (bytes({ operation: request.operation, value: output() }) > AGENT_OUTPUT_BYTES - 1024) {
|
|
142
|
+
items.pop();
|
|
143
|
+
end--;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (end === offset && end < rows.length)
|
|
148
|
+
reject("INPUT_TOO_LARGE", "One inventory item exceeds the envelope budget; export the scoped submission artifact.");
|
|
149
|
+
return output();
|
|
150
|
+
}
|
|
151
|
+
let value;
|
|
152
|
+
if (request.operation === "content" || request.operation === "response-template") {
|
|
153
|
+
const read = sub(request.submissionId);
|
|
154
|
+
if (request.operation === "response-template" && !request.requestId)
|
|
155
|
+
reject("INVALID_INPUT", "Template requires a stable requestId.");
|
|
156
|
+
const field = request.operation === "response-template" ? "response-template" : (request.field === "." ? "" : request.field);
|
|
157
|
+
if (field === undefined)
|
|
158
|
+
reject("INVALID_INPUT", "content requires --field (or --output-file for the whole artifact).");
|
|
159
|
+
const version = contentVersion(read, field);
|
|
160
|
+
if (request.version !== undefined && request.version !== version)
|
|
161
|
+
reject("VERSION_CONFLICT", "Content version does not match this field.");
|
|
162
|
+
const data = request.operation === "response-template" ? responseTemplate(read.submission, request.requestId) : valueAt(read, field);
|
|
163
|
+
const text = typeof data === "string" ? data : JSON.stringify(data);
|
|
164
|
+
const identity = { ...scope, submissionId: read.submission.submissionId, version, field: field || "." };
|
|
165
|
+
const info = integrity(text);
|
|
166
|
+
const binding = hash(canonicalJson({ identity, sha256: info.sha256 }));
|
|
167
|
+
const offset = cursor(request.cursor, binding, text.length);
|
|
168
|
+
if (offset && /[\uDC00-\uDFFF]/.test(text[offset]) && /[\uD800-\uDBFF]/.test(text[offset - 1]))
|
|
169
|
+
reject("INVALID_CURSOR", "Continuation splits a Unicode character.");
|
|
170
|
+
const endAt = (end) => end < text.length && /[\uDC00-\uDFFF]/.test(text[end]) && /[\uD800-\uDBFF]/.test(text[end - 1]) ? end - 1 : end;
|
|
171
|
+
const chunk = (end) => ({
|
|
172
|
+
identity, encoding: typeof data === "string" ? "utf8" : "json", ...info,
|
|
173
|
+
offset: Buffer.byteLength(text.slice(0, offset)), returnedBytes: Buffer.byteLength(text.slice(offset, end)),
|
|
174
|
+
complete: end === text.length, nextCursor: end === text.length ? null : token(binding, end), text: text.slice(offset, end),
|
|
175
|
+
});
|
|
176
|
+
let low = offset, high = text.length;
|
|
177
|
+
while (low < high) {
|
|
178
|
+
const mid = Math.ceil((low + high) / 2);
|
|
179
|
+
if (bytes({ operation: request.operation, value: chunk(endAt(mid)) }) <= AGENT_OUTPUT_BYTES)
|
|
180
|
+
low = mid;
|
|
181
|
+
else
|
|
182
|
+
high = mid - 1;
|
|
183
|
+
}
|
|
184
|
+
const end = endAt(low);
|
|
185
|
+
if (end === offset && offset < text.length)
|
|
186
|
+
reject("INPUT_TOO_LARGE", "Content identity exceeds the delivery budget.");
|
|
187
|
+
value = chunk(end);
|
|
188
|
+
}
|
|
189
|
+
else if (request.operation === "submission") {
|
|
190
|
+
const read = sub(request.submissionId), s = read.submission;
|
|
191
|
+
const items = s.pageKeys.map((pageKey, i) => ({ kind: "page", pageKey, data: content(read, `pages/${i}`) }));
|
|
192
|
+
s.messages.forEach(({ pageKey, message }, i) => items.push({
|
|
193
|
+
kind: "message", pageKey, threadId: message.threadId, messageId: message.messageId, messageVersion: message.version,
|
|
194
|
+
intent: message.intent, target: content(read, `submission/messages/${i}/target`),
|
|
195
|
+
body: content(read, `submission/messages/${i}/message/body`),
|
|
196
|
+
contextCommand: `${cmd("context", s.submissionId)} --thread ${shellQuote(message.threadId)} --limit 1`,
|
|
197
|
+
}));
|
|
198
|
+
s.edits.forEach((edit, i) => items.push({
|
|
199
|
+
kind: "edit", pageKey: edit.pageKey, editId: edit.editId, editVersion: edit.version,
|
|
200
|
+
editKind: edit.content.kind, label: content(read, `submission/edits/${i}/content/label`),
|
|
201
|
+
source: content(read, `submission/edits/${i}/source`), assets: content(read, `submission/edits/${i}/assets`),
|
|
202
|
+
captureTruncated: edit.content.truncated, truncatedFields: edit.content.truncated_fields,
|
|
203
|
+
content: content(read, `submission/edits/${i}/content`),
|
|
204
|
+
}));
|
|
205
|
+
read.result?.responses.forEach((reply, i) => items.push({
|
|
206
|
+
kind: "response", threadId: reply.threadId, messageId: reply.messageId, replyToMessageId: reply.replyToMessageId,
|
|
207
|
+
outcome: reply.outcome, body: content(read, `result/responses/${i}/body`),
|
|
208
|
+
}));
|
|
209
|
+
read.result?.editOutcomes.forEach((outcome, i) => items.push({
|
|
210
|
+
kind: "edit-outcome", editId: outcome.editId, editVersion: outcome.editVersion,
|
|
211
|
+
outcome: outcome.outcome, reason: content(read, `result/editOutcomes/${i}/reason`),
|
|
212
|
+
}));
|
|
213
|
+
const envelope = {
|
|
214
|
+
...scope, ...lifecycle(read), ...note(read), result: result(read),
|
|
215
|
+
receipt: read.receipt ? content(read, "receipt") : null, handoff: agentHandoff(scope, s.submissionId, command),
|
|
216
|
+
};
|
|
217
|
+
const binding = hash(canonicalJson({ scope, operation: request.operation, submissionId: s.submissionId, version: s.version }));
|
|
218
|
+
// Budget the enclosing manifest as well as the individual inventory page.
|
|
219
|
+
const paged = paginate(items, binding, { manifest: envelope });
|
|
220
|
+
const { manifest, ...inventory } = paged;
|
|
221
|
+
value = { ...manifest, inventory };
|
|
222
|
+
}
|
|
223
|
+
else if (request.operation === "history" || request.operation === "context") {
|
|
224
|
+
const bound = request.operation === "context" ? sub(request.submissionId).submission :
|
|
225
|
+
request.before ? sub(request.before).submission : null;
|
|
226
|
+
let highWater = bound?.sequence ?? record.sequence;
|
|
227
|
+
if (request.cursor !== undefined) {
|
|
228
|
+
let decoded;
|
|
229
|
+
try {
|
|
230
|
+
decoded = JSON.parse(Buffer.from(request.cursor, "base64url").toString("utf8"));
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
reject("INVALID_CURSOR", "Malformed continuation.");
|
|
234
|
+
}
|
|
235
|
+
if (!Number.isSafeInteger(decoded.highWater) || decoded.highWater < 0 || decoded.highWater > highWater ||
|
|
236
|
+
(bound && decoded.highWater !== highWater))
|
|
237
|
+
reject("INVALID_CURSOR", "Invalid historical boundary.");
|
|
238
|
+
highWater = decoded.highWater;
|
|
239
|
+
}
|
|
240
|
+
let rows = Object.values(record.submissions).filter((s) => s.sequence <= highWater && (!bound || s.sequence < bound.sequence)).sort((a, b) => b.sequence - a.sequence);
|
|
241
|
+
// Include the immutable IDs in the cursor binding: later submissions cannot leak into continuations.
|
|
242
|
+
const binding = hash(canonicalJson({ scope, operation: request.operation, boundary: bound?.submissionId ?? null, threadId: request.threadId ?? null, highWater }));
|
|
243
|
+
if (request.operation === "context") {
|
|
244
|
+
if (bound.deliveredAt === null)
|
|
245
|
+
reject("RESPONSE_COVERAGE", "Context requires a delivered submission boundary.");
|
|
246
|
+
if (!request.threadId || !bound.messages.some(({ message }) => message.threadId === request.threadId)) {
|
|
247
|
+
reject("SCOPE_MISMATCH", "Thread is not part of the bound submitted work.");
|
|
248
|
+
}
|
|
249
|
+
rows = rows.flatMap((s) => s.messages.filter(({ message }) => message.threadId === request.threadId)
|
|
250
|
+
.sort((a, b) => b.message.sequence - a.message.sequence).map((item) => ({ s, item })));
|
|
251
|
+
value = paginate(rows, binding, { ...scope, submissionId: bound.submissionId, threadId: request.threadId }, ({ s, item }) => {
|
|
252
|
+
const read = sub(s.submissionId), i = s.messages.indexOf(item);
|
|
253
|
+
const responseIndex = read.result?.responses.findIndex((r) => r.replyToMessageId === item.message.messageId) ?? -1;
|
|
254
|
+
return {
|
|
255
|
+
submissionId: s.submissionId, state: s.state, deliveredAt: s.deliveredAt, completedAt: s.completedAt,
|
|
256
|
+
messageId: item.message.messageId, messageVersion: item.message.version, intent: item.message.intent,
|
|
257
|
+
body: content(read, `submission/messages/${i}/message/body`),
|
|
258
|
+
response: responseIndex < 0 ? null : {
|
|
259
|
+
outcome: read.result.responses[responseIndex].outcome, body: content(read, `result/responses/${responseIndex}/body`),
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}, highWater);
|
|
263
|
+
}
|
|
264
|
+
else
|
|
265
|
+
value = paginate(rows, binding, { ...scope, before: request.before ?? null }, (s) => {
|
|
266
|
+
const read = sub(s.submissionId);
|
|
267
|
+
return { ...lifecycle(read), ...note(read), result: result(read, false, 10 * 1024), command: cmd("submission", s.submissionId) };
|
|
268
|
+
}, highWater);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
const status = conversations.read({ operation: "status", ...scope });
|
|
272
|
+
const latest = Object.values(record.submissions).sort((a, b) => b.sequence - a.sequence)[0];
|
|
273
|
+
const read = latest ? sub(latest.submissionId) : null;
|
|
274
|
+
const { blockers, ...rest } = status;
|
|
275
|
+
const envelope = {
|
|
276
|
+
source, ...rest, latestSubmission: read ? { ...lifecycle(read), result: result(read, true) } : null,
|
|
277
|
+
handoff: agentHandoff(scope, null, command),
|
|
278
|
+
};
|
|
279
|
+
const binding = hash(canonicalJson({ scope, operation: "status", blockers }));
|
|
280
|
+
const paged = paginate(blockers, binding, { manifest: envelope });
|
|
281
|
+
const { manifest, ...inventory } = paged;
|
|
282
|
+
value = { ...manifest, blockers: inventory };
|
|
283
|
+
}
|
|
284
|
+
const output = agentReadResponseSchema.parse({ operation: request.operation, value });
|
|
285
|
+
serializeAgent(output);
|
|
286
|
+
return output;
|
|
287
|
+
}
|
package/lib/anchor-text.js
CHANGED
|
@@ -37,6 +37,7 @@ function occurrences(text, quote) {
|
|
|
37
37
|
function bestHit(text, hits, quote, prefix, suffix) {
|
|
38
38
|
let best = hits[0];
|
|
39
39
|
let bestScore = -1;
|
|
40
|
+
let candidateCount = 0;
|
|
40
41
|
for (const at of hits) {
|
|
41
42
|
const before = text.slice(Math.max(0, at - prefix.length), at);
|
|
42
43
|
const after = text.slice(at + quote.length, at + quote.length + suffix.length);
|
|
@@ -44,9 +45,12 @@ function bestHit(text, hits, quote, prefix, suffix) {
|
|
|
44
45
|
if (score > bestScore) {
|
|
45
46
|
bestScore = score;
|
|
46
47
|
best = at;
|
|
48
|
+
candidateCount = 1;
|
|
47
49
|
}
|
|
50
|
+
else if (score === bestScore)
|
|
51
|
+
candidateCount++;
|
|
48
52
|
}
|
|
49
|
-
return { at: best, score: bestScore };
|
|
53
|
+
return { at: best, score: bestScore, candidateCount };
|
|
50
54
|
}
|
|
51
55
|
const collapse = (s) => String(s || "").replace(/\s+/g, " ");
|
|
52
56
|
/** Collapse whitespace runs, keeping a map from each kept char to its original index. */
|
|
@@ -60,7 +64,7 @@ function collapseWithMap(text) {
|
|
|
60
64
|
pendingWs = i;
|
|
61
65
|
continue;
|
|
62
66
|
}
|
|
63
|
-
if (pendingWs !== -1
|
|
67
|
+
if (pendingWs !== -1) {
|
|
64
68
|
flat += " ";
|
|
65
69
|
map.push(pendingWs);
|
|
66
70
|
}
|
|
@@ -68,36 +72,44 @@ function collapseWithMap(text) {
|
|
|
68
72
|
flat += text[i];
|
|
69
73
|
map.push(i);
|
|
70
74
|
}
|
|
75
|
+
if (pendingWs !== -1) {
|
|
76
|
+
flat += " ";
|
|
77
|
+
map.push(pendingWs);
|
|
78
|
+
}
|
|
71
79
|
return { flat, map };
|
|
72
80
|
}
|
|
73
81
|
/**
|
|
74
82
|
* Locate `ctx.quote` in `text`, using prefix/suffix to disambiguate repeats.
|
|
75
|
-
* Returns
|
|
83
|
+
* Returns a unique match, a missing target, or tied equally plausible candidates.
|
|
76
84
|
*/
|
|
77
|
-
export function
|
|
85
|
+
export function resolveQuote(text, ctx) {
|
|
78
86
|
const quote = ctx && ctx.quote;
|
|
79
87
|
if (!quote)
|
|
80
|
-
return
|
|
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
|
-
}
|
|
88
|
+
return { state: "missing" };
|
|
89
89
|
// Reformatting (a prettier run, an agent rewrite) reflows whitespace without
|
|
90
|
-
// changing
|
|
91
|
-
//
|
|
90
|
+
// changing identity. Rank exact and reflowed candidates together so an exact
|
|
91
|
+
// duplicate cannot steal a target whose original whitespace changed.
|
|
92
92
|
const { flat, map } = collapseWithMap(text);
|
|
93
93
|
const flatQuote = collapse(quote).trim();
|
|
94
94
|
if (!flatQuote || !map.length)
|
|
95
|
-
return
|
|
95
|
+
return { state: "missing" };
|
|
96
96
|
const flatHits = occurrences(flat, flatQuote);
|
|
97
97
|
if (!flatHits.length)
|
|
98
|
-
return
|
|
99
|
-
const
|
|
100
|
-
|
|
98
|
+
return { state: "missing" };
|
|
99
|
+
const prefix = collapse(`${ctx.prefix || ""}${/^\s/.test(quote) ? " " : ""}`);
|
|
100
|
+
const suffix = collapse(`${/\s$/.test(quote) ? " " : ""}${ctx.suffix || ""}`);
|
|
101
|
+
const { at, candidateCount } = bestHit(flat, flatHits, flatQuote, prefix, suffix);
|
|
102
|
+
if (candidateCount > 1)
|
|
103
|
+
return { state: "ambiguous", candidateCount };
|
|
104
|
+
const start = map[at], end = map[at + flatQuote.length - 1] + 1;
|
|
105
|
+
const exact = occurrences(text, quote).find((offset) => offset <= start && offset + quote.length >= end);
|
|
106
|
+
return exact === undefined ? { state: "found", start, end, exact: false }
|
|
107
|
+
: { state: "found", start: exact, end: exact + quote.length, exact: true };
|
|
108
|
+
}
|
|
109
|
+
/** Legacy callers cannot distinguish unavailable targets, but must never guess. */
|
|
110
|
+
export function findQuote(text, ctx) {
|
|
111
|
+
const result = resolveQuote(text, ctx);
|
|
112
|
+
return result.state === "found" ? { start: result.start, end: result.end, exact: result.exact } : null;
|
|
101
113
|
}
|
|
102
114
|
/** Collapse runs of whitespace for display in a comment card. */
|
|
103
115
|
export function tidy(text, limit = 0) {
|
package/lib/chrome-api.js
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
|
+
import { failureSchema } from "./contracts/validation.js";
|
|
2
|
+
export function saveFailureMessage(reason) {
|
|
3
|
+
const cause = reason === "source-changed" ? "The source changed since this edit was recorded."
|
|
4
|
+
: reason === "source-unavailable" ? "The source is unavailable or no longer writable."
|
|
5
|
+
: reason === "evidence-mismatch" ? "The page contains changes that do not match the recorded edits."
|
|
6
|
+
: reason === "unsafe-content" ? "The edit includes executable content or review markup that cannot be saved."
|
|
7
|
+
: "The server rejected this save; the cause could not be determined.";
|
|
8
|
+
return `${cause} These page changes were not saved by this request. Inspect them before reloading; conversation drafts are kept.`;
|
|
9
|
+
}
|
|
1
10
|
export class ApiError extends Error {
|
|
2
11
|
status;
|
|
3
12
|
code;
|
|
4
13
|
targets;
|
|
5
|
-
|
|
6
|
-
|
|
14
|
+
saveReason;
|
|
15
|
+
constructor(message, status, code, targets, saveReason) {
|
|
16
|
+
super(code === "SAVE_EVIDENCE_CONFLICT" ? saveFailureMessage(saveReason) : message);
|
|
7
17
|
this.status = status;
|
|
8
18
|
this.code = code;
|
|
9
19
|
this.targets = targets;
|
|
20
|
+
this.saveReason = saveReason;
|
|
21
|
+
this.diagnosticMessage = message;
|
|
10
22
|
this.name = "ApiError";
|
|
11
23
|
}
|
|
24
|
+
diagnosticMessage;
|
|
12
25
|
}
|
|
13
26
|
export function record(value) {
|
|
14
27
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -16,7 +29,7 @@ export function record(value) {
|
|
|
16
29
|
}
|
|
17
30
|
return value;
|
|
18
31
|
}
|
|
19
|
-
export function createReviewApi({ token, fetch: request = globalThis.fetch, }) {
|
|
32
|
+
export function createReviewApi({ token, fetch: request = globalThis.fetch, timeoutMs = 30_000, }) {
|
|
20
33
|
const pending = new Set();
|
|
21
34
|
let disposed = false;
|
|
22
35
|
async function send(path, options = {}) {
|
|
@@ -29,6 +42,7 @@ export function createReviewApi({ token, fetch: request = globalThis.fetch, }) {
|
|
|
29
42
|
else
|
|
30
43
|
options.signal?.addEventListener("abort", abort, { once: true });
|
|
31
44
|
pending.add(controller);
|
|
45
|
+
const deadline = setTimeout(() => controller.abort(new Error("Request timed out; mutation acceptance may be unknown.")), timeoutMs);
|
|
32
46
|
try {
|
|
33
47
|
const headers = new Headers(options.headers);
|
|
34
48
|
if (!headers.has("content-type"))
|
|
@@ -45,11 +59,15 @@ export function createReviewApi({ token, fetch: request = globalThis.fetch, }) {
|
|
|
45
59
|
throw error;
|
|
46
60
|
// Preserve the HTTP failure even when the error body is not JSON.
|
|
47
61
|
}
|
|
48
|
-
|
|
62
|
+
let failure = null;
|
|
63
|
+
if (detail.ok === false)
|
|
64
|
+
failure = failureSchema.parse(detail);
|
|
65
|
+
throw new ApiError(failure?.error.message ?? (typeof detail.error === "string" ? detail.error : `Request failed (${response.status})`), response.status, failure?.error.code ?? (typeof detail.code === "string" ? detail.code : undefined), Array.isArray(detail.targets) ? detail.targets : [], failure?.error.saveReason);
|
|
49
66
|
}
|
|
50
67
|
return await response.json();
|
|
51
68
|
}
|
|
52
69
|
finally {
|
|
70
|
+
clearTimeout(deadline);
|
|
53
71
|
pending.delete(controller);
|
|
54
72
|
options.signal?.removeEventListener("abort", abort);
|
|
55
73
|
}
|
|
@@ -60,6 +78,7 @@ export function createReviewApi({ token, fetch: request = globalThis.fetch, }) {
|
|
|
60
78
|
}
|
|
61
79
|
return {
|
|
62
80
|
request: requestJson,
|
|
81
|
+
setToken(value) { token = value; },
|
|
63
82
|
dispose() {
|
|
64
83
|
if (disposed)
|
|
65
84
|
return;
|
package/lib/chrome.html
CHANGED
|
@@ -15,39 +15,14 @@
|
|
|
15
15
|
<div id="toolbarRoot"></div>
|
|
16
16
|
</header>
|
|
17
17
|
<main class="stage">
|
|
18
|
-
<div id="noticesRoot" class="review-ui"></div>
|
|
19
18
|
<div class="document-host">
|
|
20
19
|
<iframe
|
|
21
20
|
id="frame"
|
|
22
21
|
title="Artifact under review"
|
|
23
22
|
sandbox="allow-scripts allow-forms allow-modals"></iframe>
|
|
24
|
-
<section id="historyPanel" class="history-panel review-ui" aria-labelledby="historyTitle" hidden>
|
|
25
|
-
<h1 id="historyTitle" class="sr-only">Changes</h1>
|
|
26
|
-
<div id="changesControlsRoot" class="review-ui"></div>
|
|
27
|
-
<div id="comparisonHeader" class="comparison-header">
|
|
28
|
-
<div id="changesNavigationRoot" class="review-ui"></div>
|
|
29
|
-
<div class="comparison-heading-slot"></div>
|
|
30
|
-
</div>
|
|
31
|
-
<article id="changeDetail" class="change-detail" aria-label="Continuous before and after comparison"></article>
|
|
32
|
-
<div id="changesDiagnosticsRoot" class="review-ui"></div>
|
|
33
|
-
</section>
|
|
34
23
|
</div>
|
|
35
24
|
</main>
|
|
36
25
|
|
|
37
|
-
<section id="compose" class="contextual-compose review-ui" role="dialog" aria-modal="false" aria-labelledby="composeTitle" hidden></section>
|
|
38
|
-
|
|
39
|
-
<div id="alignedCard" class="aligned-card review-ui" hidden></div>
|
|
40
|
-
|
|
41
|
-
<aside class="drawer" id="drawer" aria-label="Feedback" aria-hidden="true">
|
|
42
|
-
<div id="drawerHeadRoot" class="review-ui"></div>
|
|
43
|
-
<div id="commentsSection" class="drawer-inventory" tabindex="-1" aria-label="Feedback items">
|
|
44
|
-
<div id="inventoryRoot" class="review-ui"></div>
|
|
45
|
-
<div id="editsRoot" class="review-ui"></div>
|
|
46
|
-
|
|
47
|
-
</div>
|
|
48
|
-
<footer id="sendSection" class="drawer-footer send-section review-ui" tabindex="-1" aria-label="Send feedback"></footer>
|
|
49
|
-
</aside>
|
|
50
|
-
<div id="drawerBackdropRoot"></div>
|
|
51
26
|
<div id="liveRegion" class="sr-only" role="status" aria-live="polite"></div>
|
|
52
27
|
</div>
|
|
53
28
|
|