@erdemtuna/doc-review 0.12.0 → 0.13.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 (49) hide show
  1. package/README.md +23 -25
  2. package/lib/SKILL.md +65 -202
  3. package/lib/agent-handoff.js +24 -0
  4. package/lib/agent-output.js +287 -0
  5. package/lib/anchor-text.js +31 -19
  6. package/lib/chrome-api.js +23 -4
  7. package/lib/chrome.html +0 -25
  8. package/lib/cli.js +282 -104
  9. package/lib/comment-target.js +4 -0
  10. package/lib/contracts/agent.js +122 -0
  11. package/lib/contracts/feedback.js +369 -1
  12. package/lib/contracts/frame.js +81 -0
  13. package/lib/contracts/history.js +189 -1
  14. package/lib/contracts/index.js +7 -2
  15. package/lib/contracts/page-boundary.js +187 -0
  16. package/lib/contracts/validation.js +200 -0
  17. package/lib/conversation-anchor-controller.js +84 -0
  18. package/lib/conversation-capture.js +81 -0
  19. package/lib/conversation-controller.js +798 -0
  20. package/lib/conversation-save.js +175 -0
  21. package/lib/conversation-server.js +184 -0
  22. package/lib/conversation-shell.js +1013 -0
  23. package/lib/conversation-store.js +809 -0
  24. package/lib/frame-controller.js +8 -8
  25. package/lib/frame-policy.js +1 -0
  26. package/lib/history-policy.js +4 -15
  27. package/lib/history-server.js +42 -326
  28. package/lib/html-transform.js +19 -4
  29. package/lib/icons.js +323 -0
  30. package/lib/new-message-target.js +27 -0
  31. package/lib/paths.js +2 -2
  32. package/lib/poll-transport.js +109 -26
  33. package/lib/positioning.js +51 -0
  34. package/lib/references/context-and-recovery.md +130 -0
  35. package/lib/references/response-contract.md +108 -0
  36. package/lib/references/source-edits.md +47 -0
  37. package/lib/revision-store.js +1 -1
  38. package/lib/save-controller.js +28 -14
  39. package/lib/sdk.js +357 -35
  40. package/lib/server.js +73 -595
  41. package/lib/setup.js +17 -48
  42. package/lib/state.js +21 -7
  43. package/lib/thread-anchor-controller.js +56 -0
  44. package/lib/toolbar-controller.js +3 -3
  45. package/lib/ui/THIRD_PARTY_NOTICES.md +127 -8
  46. package/lib/ui/chrome.css +1079 -2782
  47. package/lib/ui/chrome.js +81 -17
  48. package/package.json +2 -2
  49. package/lib/chrome-client.js +0 -1706
@@ -0,0 +1,809 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { acceptedMutationSchema, abandonRequestSchema, agentMessageSchema, conversationThreadSchema, directEditSchema, directEditContentSchema, exactReplay, exclusionKeys, handlingReceiptSchema, reviewerMessageSchema, reviewerMutationSchema, completeResponseSchema, resultTitle, responseEffect, submissionReadSchema, submissionSchema, submissionResultSchema, validateAbandonment, validateResponseCommit, validateResponseCoverage, assertNoOutstandingWork, } from "./contracts/feedback.js";
5
+ import { assertReviewScope, openReviewRequestSchema, pollResponseSchema, reviewPageSchema, reviewReadRequestSchema, reviewSchema, reviewStatusSchema, validateReviewerMutation, } from "./contracts/page-boundary.js";
6
+ import { comparisonReferenceSchema, contextWindow, conversationListRequestSchema, latestExchange, paginate, submissionHistoryItemSchema, threadSummarySchema, validatePageOutput, editAttentionSchema, } from "./contracts/history.js";
7
+ import { canonicalJson, reject, rejectSave, object, id, integer, nullable, timestamp, optional } from "./contracts/validation.js";
8
+ import { compareCapturedViews } from "./view-identity.js";
9
+ import { canonicalTarget, targetKey } from "./paths.js";
10
+ import { atomicWrite } from "./atomic-write.js";
11
+ import { editIncluded, proveSave, readSource, resolveEditAssets, sourceHash, sourceEditContent } from "./conversation-save.js";
12
+ const uid = (prefix) => `${prefix}_${crypto.randomBytes(16).toString("hex")}`;
13
+ const own = (record, key) => Object.hasOwn(record, key) ? record[key] : undefined;
14
+ const values = (record) => Object.values(record);
15
+ const outstanding = (submission) => ["queued", "delivered"].includes(submission.state);
16
+ const seq = (record) => ++record.sequence;
17
+ export const emptyConversations = () => ({ reviews: {}, requests: {}, writes: {} });
18
+ function exactKeys(value, keys) {
19
+ if (!value || typeof value !== "object" || Array.isArray(value) ||
20
+ Object.keys(value).length !== keys.length || keys.some((key) => !Object.hasOwn(value, key))) {
21
+ throw new Error("Unsupported or corrupt conversation state shape.");
22
+ }
23
+ }
24
+ function recordFor(data, reference) {
25
+ const record = own(data.conversations.reviews, reference.reviewId);
26
+ if (!record)
27
+ reject("NOT_FOUND", "Unknown durable review.");
28
+ assertReviewScope(scopeFor(record), reference);
29
+ return record;
30
+ }
31
+ const scopeFor = (record) => ({ authenticated: true, review: record.review, pageKeys: Object.keys(record.pages) });
32
+ const allSubmissions = (data) => values(data.conversations.reviews).flatMap((record) => values(record.submissions));
33
+ const submittedEdit = (record, edit) => values(record.submissions).some((submission) => submission.edits.some((item) => item.editId === edit.editId && item.version === edit.version));
34
+ function editAttention(record) {
35
+ const submitted = new Map(), outcomes = new Map();
36
+ const key = (editId, version) => canonicalJson([editId, version]);
37
+ for (const submission of values(record.submissions)) {
38
+ for (const edit of submission.edits)
39
+ submitted.set(key(edit.editId, edit.version), submission);
40
+ const result = submission.resultId ? record.results[submission.resultId] : null;
41
+ for (const outcome of result?.editOutcomes ?? []) {
42
+ outcomes.set(key(outcome.editId, outcome.editVersion), outcome);
43
+ }
44
+ }
45
+ return values(record.edits).flatMap((edit) => {
46
+ const identity = key(edit.editId, edit.version);
47
+ const submission = submitted.get(identity), outcome = outcomes.get(identity);
48
+ if (outcome && ["applied", "already-saved"].includes(outcome.outcome))
49
+ return [];
50
+ return [editAttentionSchema.parse({
51
+ sequence: edit.sequence, edit, stage: outcome ? "deferred" : submission?.state ?? "unsent",
52
+ submissionId: submission?.submissionId ?? null, outcome: outcome ?? null,
53
+ reason: outcome?.reason ?? submission?.abandonment?.reason ?? null,
54
+ })];
55
+ });
56
+ }
57
+ function entity(record, collection, id) {
58
+ const result = own(record[collection], id);
59
+ if (!result)
60
+ reject("NOT_FOUND", `Unknown ${collection} identity in this review.`);
61
+ return result;
62
+ }
63
+ function sourceItems(data, record) {
64
+ return values(record.pages).map((membership) => {
65
+ const source = readSource(data.pages[membership.pageKey]);
66
+ const write = own(data.conversations.writes, membership.pageKey);
67
+ return {
68
+ reviewId: record.review.reviewId, pageKey: membership.pageKey, sourceHash: source.hash,
69
+ writable: source.writable,
70
+ revert: write?.reviewId === record.review.reviewId && write.hash === source.hash
71
+ ? { baselineRevisionId: membership.baselineRevisionId, sourceHash: write.hash } : null,
72
+ };
73
+ });
74
+ }
75
+ function itemsFor(data, record) {
76
+ return {
77
+ threads: values(record.threads), messages: values(record.messages).filter((message) => message.author === "reviewer"),
78
+ edits: values(record.edits), sources: sourceItems(data, record),
79
+ };
80
+ }
81
+ function receiptFor(request, record, value) {
82
+ return handlingReceiptSchema.parse({
83
+ receiptId: uid("receipt"), requestId: request.requestId,
84
+ reviewId: record.review.reviewId, entryKey: record.review.entryKey,
85
+ operation: request.operation, acceptedAt: Date.now(),
86
+ value: { reviewVersion: record.review.version, ...value },
87
+ });
88
+ }
89
+ function requestKey(request, openEntryKey) {
90
+ return canonicalJson(request.operation === "open"
91
+ ? ["open", openEntryKey, request.requestId]
92
+ : ["review", request.reviewId, request.requestId]);
93
+ }
94
+ function remember(data, request, receipt) {
95
+ Object.defineProperty(data.conversations.requests, requestKey(request, receipt.entryKey), {
96
+ value: { canonicalPayload: canonicalJson(request), receipt }, enumerable: true, configurable: true, writable: true,
97
+ });
98
+ return acceptedMutationSchema.parse({ ok: true, receipt });
99
+ }
100
+ function snapshot(store, page, source, reason) {
101
+ if (source.text === null)
102
+ return null;
103
+ return store.revisions.put({
104
+ documentId: page.key, reason,
105
+ source: { text: source.text, mediaType: "text/plain", provenance: { pageKey: page.key, sourceHash: source.hash } },
106
+ }).revisionId;
107
+ }
108
+ function resolveReviewTarget(target) {
109
+ try {
110
+ return canonicalTarget(target);
111
+ }
112
+ catch (error) {
113
+ if (error instanceof Error && !error.code)
114
+ reject("INVALID_INPUT", error.message);
115
+ throw error;
116
+ }
117
+ }
118
+ function joinPage(store, data, record, targetText) {
119
+ const target = resolveReviewTarget(targetText);
120
+ const key = targetKey(target.value);
121
+ if (own(record.pages, key))
122
+ return key;
123
+ const page = own(data.pages, key) || {
124
+ key, kind: target.kind, ...(target.kind === "url" ? { url: target.value } : { file: target.value }),
125
+ pristine: "", comments: [], edits: [], updatedAt: Date.now(),
126
+ };
127
+ const source = readSource(page);
128
+ if (target.kind === "file" && source.text === null)
129
+ reject("NOT_FOUND", "Source is missing or unreadable.");
130
+ const baselineRevisionId = snapshot(store, page, source, "review-open");
131
+ data.pages[key] = page;
132
+ record.pages[key] = { pageKey: key, sequence: seq(record), joinedAt: Date.now(), baselineRevisionId };
133
+ return key;
134
+ }
135
+ /** Strictly validate the new durable store, never repair/import obsolete records. */
136
+ export function validateConversations(data) {
137
+ exactKeys(data, ["schemaVersion", "conversations", "pages", "batches", "receipts", "histories"]);
138
+ const c = data.conversations;
139
+ exactKeys(c, ["reviews", "requests", "writes"]);
140
+ if (data.schemaVersion !== 1 || !c || !c.reviews || !c.requests || !c.writes)
141
+ throw new Error("Unsupported or corrupt conversation state.");
142
+ for (const map of [data.pages, data.batches, data.receipts, data.histories, c.reviews, c.requests, c.writes]) {
143
+ if (!map || typeof map !== "object" || Array.isArray(map))
144
+ throw new Error("Corrupt conversation state map.");
145
+ }
146
+ const open = new Set();
147
+ const receiptResults = new Map();
148
+ for (const [key, request] of Object.entries(c.requests)) {
149
+ exactKeys(request, ["canonicalPayload", "receipt"]);
150
+ handlingReceiptSchema.parse(request.receipt);
151
+ const payload = JSON.parse(request.canonicalPayload);
152
+ (payload.operation === "open" ? openReviewRequestSchema : payload.operation === "respond" ? completeResponseSchema :
153
+ payload.operation === "abandon" ? abandonRequestSchema : reviewerMutationSchema).parse(payload);
154
+ if (key !== requestKey(payload, request.receipt.entryKey) ||
155
+ own(c.reviews, request.receipt.reviewId)?.review.entryKey !== request.receipt.entryKey ||
156
+ !exactReplay(payload, request))
157
+ throw new Error("Corrupt receipt scope.");
158
+ if (request.receipt.value.resultId) {
159
+ const resultKey = canonicalJson([request.receipt.reviewId, request.receipt.value.resultId]);
160
+ if (receiptResults.has(resultKey))
161
+ throw new Error("Duplicate handling receipt.");
162
+ receiptResults.set(resultKey, request);
163
+ }
164
+ }
165
+ for (const [reviewId, record] of Object.entries(c.reviews)) {
166
+ exactKeys(record, ["review", "sequence", "pages", "threads", "messages", "edits", "editBases", "submissions", "results", "comparisons"]);
167
+ reviewSchema.parse(record.review);
168
+ if (reviewId !== record.review.reviewId || !Number.isSafeInteger(record.sequence) || record.sequence < 1)
169
+ throw new Error("Corrupt review identity/sequence.");
170
+ if (record.review.state === "open") {
171
+ if (open.has(record.review.entryKey))
172
+ throw new Error("Multiple open reviews for one entry.");
173
+ open.add(record.review.entryKey);
174
+ }
175
+ for (const name of ["pages", "threads", "messages", "edits", "editBases", "submissions", "results", "comparisons"]) {
176
+ if (!record[name] || typeof record[name] !== "object" || Array.isArray(record[name]))
177
+ throw new Error(`Corrupt review ${name}.`);
178
+ }
179
+ if (!own(record.pages, record.review.entryKey))
180
+ throw new Error("Review has no entry membership.");
181
+ const sequences = new Set();
182
+ const checkSequence = (item) => {
183
+ if (!Number.isSafeInteger(item.sequence) || item.sequence < 1 || item.sequence > record.sequence || sequences.has(item.sequence)) {
184
+ throw new Error("Corrupt or duplicate review sequence.");
185
+ }
186
+ sequences.add(item.sequence);
187
+ };
188
+ for (const [key, membership] of Object.entries(record.pages)) {
189
+ object({ pageKey: id, sequence: integer(1), joinedAt: timestamp, baselineRevisionId: nullable(id),
190
+ observation: optional(object({ revisionId: id, sessionId: id, renderId: id, generation: integer(1) })),
191
+ }).parse(membership);
192
+ checkSequence(membership);
193
+ if (membership.pageKey !== key || !own(data.pages, key) || !Number.isSafeInteger(membership.sequence) ||
194
+ membership.sequence < 1 || membership.sequence > record.sequence)
195
+ throw new Error("Corrupt page membership.");
196
+ }
197
+ for (const [name, schema, idField] of [
198
+ ["threads", conversationThreadSchema, "threadId"], ["edits", directEditSchema, "editId"],
199
+ ["submissions", submissionSchema, "submissionId"], ["results", submissionResultSchema, "resultId"],
200
+ ["comparisons", comparisonReferenceSchema, "sequence"],
201
+ ])
202
+ for (const [key, item] of Object.entries(record[name])) {
203
+ schema.parse(item);
204
+ checkSequence(item);
205
+ if (String(item[idField]) !== key || item.reviewId !== reviewId || item.sequence > record.sequence ||
206
+ (item.pageKey && !own(record.pages, item.pageKey)))
207
+ throw new Error(`Corrupt ${name} scope.`);
208
+ }
209
+ for (const [key, message] of Object.entries(record.messages)) {
210
+ (message.author === "reviewer" ? reviewerMessageSchema : agentMessageSchema).parse(message);
211
+ checkSequence(message);
212
+ if (key !== message.messageId || message.reviewId !== reviewId || !own(record.threads, message.threadId) ||
213
+ message.sequence > record.sequence)
214
+ throw new Error("Corrupt message scope.");
215
+ }
216
+ for (const [editId, base] of Object.entries(record.editBases)) {
217
+ entity(record, "edits", editId);
218
+ object({ sourceHash: nullable(id), priorContent: nullable(directEditContentSchema) }).parse(base);
219
+ }
220
+ for (const edit of values(record.edits))
221
+ entity(record, "editBases", edit.editId);
222
+ for (const submission of values(record.submissions)) {
223
+ const lifecycleVersion = 1 + Number(submission.deliveredAt !== null) + Number(submission.completedAt !== null);
224
+ if (submission.version !== lifecycleVersion)
225
+ throw new Error("Corrupt submission lifecycle version.");
226
+ if (submission.entryKey !== record.review.entryKey || submission.pageKeys.some((key) => !own(record.pages, key))) {
227
+ throw new Error("Corrupt submission membership.");
228
+ }
229
+ for (const item of submission.messages) {
230
+ const message = entity(record, "messages", item.message.messageId);
231
+ const thread = entity(record, "threads", message.threadId);
232
+ if (canonicalJson(message) !== canonicalJson(item.message) || thread.pageKey !== item.pageKey ||
233
+ canonicalJson(thread.target) !== canonicalJson(item.target))
234
+ throw new Error("Corrupt immutable submitted message.");
235
+ }
236
+ for (const edit of submission.edits) {
237
+ if (canonicalJson(entity(record, "edits", edit.editId)) !== canonicalJson(edit))
238
+ throw new Error("Corrupt immutable submitted edit.");
239
+ }
240
+ const result = submission.resultId ? entity(record, "results", submission.resultId) : null;
241
+ const responseRecord = result ? receiptResults.get(canonicalJson([reviewId, result.resultId])) : null;
242
+ const receipt = responseRecord?.receipt ?? null;
243
+ submissionReadSchema.parse({ submission, result, receipt });
244
+ if (responseRecord)
245
+ validateResponseCommit({
246
+ ...submission, version: submission.version - 1, state: "delivered", completedAt: null, resultId: null,
247
+ }, JSON.parse(responseRecord.canonicalPayload), result, receipt);
248
+ }
249
+ for (const result of values(record.results)) {
250
+ if (entity(record, "submissions", result.submissionId).resultId !== result.resultId)
251
+ throw new Error("Orphan result.");
252
+ for (const reply of result.responses) {
253
+ if (canonicalJson(entity(record, "messages", reply.messageId)) !== canonicalJson(reply))
254
+ throw new Error("Corrupt persisted inline reply.");
255
+ }
256
+ }
257
+ for (const message of values(record.messages)) {
258
+ if (message.submissionId !== null)
259
+ entity(record, "submissions", message.submissionId);
260
+ }
261
+ for (const comparison of values(record.comparisons)) {
262
+ if (!entity(record, "submissions", comparison.submissionId).pageKeys.includes(comparison.pageKey))
263
+ throw new Error("Orphan comparison.");
264
+ }
265
+ }
266
+ for (const [pageKey, write] of Object.entries(c.writes)) {
267
+ object({ reviewId: id, hash: id }).parse(write);
268
+ const record = own(c.reviews, write.reviewId);
269
+ if (!record || !own(record.pages, pageKey))
270
+ throw new Error("Corrupt write ownership.");
271
+ }
272
+ const held = new Set();
273
+ for (const submission of allSubmissions(data).filter(outstanding)) {
274
+ for (const key of submission.exclusionKeys) {
275
+ if (held.has(key))
276
+ throw new Error("Corrupt overlapping outstanding submissions.");
277
+ held.add(key);
278
+ }
279
+ }
280
+ return data;
281
+ }
282
+ export class Conversations {
283
+ constructor(store, { writeSource = atomicWrite } = {}) {
284
+ this.store = store;
285
+ this.writeSource = writeSource;
286
+ this.observationIsCurrent = () => false;
287
+ }
288
+ execute(input) {
289
+ if (input?.operation === "open")
290
+ return this.open(input);
291
+ if (input?.operation === "list")
292
+ return this.list(input);
293
+ if (["read-review", "read-page", "status", "poll", "submission", "receipt", "open-receipt"].includes(input?.operation))
294
+ return this.read(input);
295
+ return this.mutate(input);
296
+ }
297
+ open(input) {
298
+ const request = openReviewRequestSchema.parse(input);
299
+ const entryKey = targetKey(resolveReviewTarget(request.target).value);
300
+ const replay = exactReplay(request, own(this.store.data.conversations.requests, requestKey(request, entryKey)));
301
+ if (replay)
302
+ return { ok: true, receipt: structuredClone(replay) };
303
+ return this.store.transaction((data) => {
304
+ let record = values(data.conversations.reviews).find((item) => item.review.entryKey === entryKey && item.review.state === "open");
305
+ if (!record) {
306
+ const reviewId = uid("review");
307
+ record = {
308
+ review: { reviewId, entryKey, version: 1, state: "open", createdAt: Date.now(), endedAt: null },
309
+ sequence: 0, pages: {}, threads: {}, messages: {}, edits: {}, editBases: {}, submissions: {}, results: {}, comparisons: {},
310
+ };
311
+ joinPage(this.store, data, record, request.target);
312
+ data.conversations.reviews[reviewId] = record;
313
+ }
314
+ return remember(data, request, receiptFor(request, record, {}));
315
+ });
316
+ }
317
+ mutate(input) {
318
+ const request = (input?.operation === "respond" ? completeResponseSchema :
319
+ input?.operation === "abandon" ? abandonRequestSchema : reviewerMutationSchema).parse(input);
320
+ recordFor(this.store.data, request);
321
+ const replay = exactReplay(request, own(this.store.data.conversations.requests, requestKey(request)));
322
+ if (replay)
323
+ return { ok: true, receipt: structuredClone(replay) };
324
+ return this.store.transaction((data) => {
325
+ const record = recordFor(data, request);
326
+ if (request.operation === "respond")
327
+ return this.respond(data, record, request);
328
+ if (request.operation === "abandon") {
329
+ const submission = entity(record, "submissions", request.submissionId);
330
+ validateAbandonment(request, submission);
331
+ submission.state = "abandoned";
332
+ submission.version++;
333
+ submission.completedAt = Date.now();
334
+ submission.abandonment = { requestId: request.requestId, at: submission.completedAt, reason: request.reason };
335
+ record.review.version++;
336
+ return remember(data, request, receiptFor(request, record, { submissionId: submission.submissionId }));
337
+ }
338
+ validateReviewerMutation(request, scopeFor(record), itemsFor(data, record), allSubmissions(data));
339
+ const now = Date.now();
340
+ let value = {};
341
+ const addMessage = (threadId) => {
342
+ const messageId = uid("message");
343
+ record.messages[messageId] = reviewerMessageSchema.parse({
344
+ messageId, reviewId: request.reviewId, threadId, version: 1, sequence: seq(record),
345
+ createdAt: now, updatedAt: now, body: request.body, author: "reviewer",
346
+ intent: request.intent, submissionId: null,
347
+ });
348
+ return messageId;
349
+ };
350
+ switch (request.operation) {
351
+ case "join-page":
352
+ value = { pageKey: joinPage(this.store, data, record, request.target) };
353
+ break;
354
+ case "create-thread": {
355
+ const threadId = uid("thread");
356
+ record.threads[threadId] = conversationThreadSchema.parse({
357
+ threadId, reviewId: request.reviewId, pageKey: request.pageKey, version: 1, sequence: seq(record),
358
+ target: request.target, status: "open", createdAt: now, updatedAt: now,
359
+ });
360
+ value = { threadId, messageId: addMessage(threadId) };
361
+ break;
362
+ }
363
+ case "reply":
364
+ value = { threadId: request.threadId, messageId: addMessage(request.threadId) };
365
+ record.threads[request.threadId].updatedAt = now;
366
+ record.threads[request.threadId].version++;
367
+ break;
368
+ case "update-message": {
369
+ const message = record.messages[request.messageId];
370
+ Object.assign(message, { body: request.body, intent: request.intent, version: message.version + 1, updatedAt: now });
371
+ value = { threadId: request.threadId, messageId: request.messageId };
372
+ break;
373
+ }
374
+ case "set-thread-status":
375
+ Object.assign(record.threads[request.threadId], {
376
+ status: request.status, updatedAt: now, version: record.threads[request.threadId].version + 1,
377
+ });
378
+ value = { threadId: request.threadId };
379
+ break;
380
+ case "delete-thread":
381
+ for (const message of values(record.messages))
382
+ if (message.threadId === request.threadId)
383
+ delete record.messages[message.messageId];
384
+ delete record.threads[request.threadId];
385
+ value = { threadId: request.threadId };
386
+ break;
387
+ case "record-edit": {
388
+ const editId = request.editId || uid("edit");
389
+ const previous = own(record.edits, editId);
390
+ const source = readSource(data.pages[request.pageKey]);
391
+ record.editBases[editId] = {
392
+ sourceHash: source.hash,
393
+ priorContent: previous?.source.state === "saved" && previous.source.evidence.sourceHash === source.hash
394
+ ? sourceEditContent(previous) : record.editBases[editId]?.priorContent ?? null,
395
+ };
396
+ record.edits[editId] = directEditSchema.parse({
397
+ editId, reviewId: request.reviewId, pageKey: request.pageKey, version: (previous?.version || 0) + 1,
398
+ sequence: previous?.sequence || seq(record), author: "reviewer",
399
+ createdAt: previous?.createdAt || now, updatedAt: now,
400
+ content: request.content, source: { state: "pending" }, assets: resolveEditAssets(request.pageKey, request.content),
401
+ });
402
+ value = { editId };
403
+ break;
404
+ }
405
+ case "save-edit":
406
+ this.saveEdit(data, record, request);
407
+ value = { editId: request.editId };
408
+ break;
409
+ case "revert":
410
+ this.revert(data, record, request);
411
+ break;
412
+ case "send":
413
+ value = { submissionId: this.send(data, record, request) };
414
+ break;
415
+ case "end":
416
+ record.review.state = "ended";
417
+ record.review.endedAt = now;
418
+ break;
419
+ }
420
+ record.review.version++;
421
+ return remember(data, request, receiptFor(request, record, value));
422
+ });
423
+ }
424
+ saveEdit(data, record, request) {
425
+ const page = data.pages[request.pageKey];
426
+ const source = readSource(page);
427
+ const edit = record.edits[request.editId];
428
+ const base = record.editBases[edit.editId];
429
+ const previousWrite = own(data.conversations.writes, page.key);
430
+ if (base.sourceHash !== source.hash && !(previousWrite?.reviewId === request.reviewId && previousWrite.hash === source.hash)) {
431
+ rejectSave("source-changed", "Source changed after this edit was recorded; record a new version against current source.");
432
+ }
433
+ let html = request.html;
434
+ const pending = values(record.edits).filter((item) => item.pageKey === page.key && !submittedEdit(record, item) && item.source.state === "pending");
435
+ // A live frame retains preview paths after saving or submitting an edit.
436
+ // Translate only this review/page's server-resolved assets; proveSave still
437
+ // requires every resulting source delta to have an exact pending transition.
438
+ for (const item of values(record.edits).filter((item) => item.pageKey === page.key)) {
439
+ for (const asset of item.assets)
440
+ html = html.replaceAll(asset.preview_src, `assets/${asset.id}`);
441
+ }
442
+ const included = pending.filter((item) => item.editId === edit.editId || (!item.content.truncated && editIncluded(html, sourceEditContent(item))));
443
+ if (!included.some((item) => item.editId === edit.editId))
444
+ rejectSave("evidence-mismatch", "Save requires a pending recorded transition.");
445
+ for (const item of included) {
446
+ const itemBase = record.editBases[item.editId];
447
+ if (itemBase.sourceHash !== source.hash && !(previousWrite?.reviewId === request.reviewId && previousWrite.hash === source.hash)) {
448
+ rejectSave("source-changed", "An included edit was recorded against a different source.");
449
+ }
450
+ }
451
+ proveSave(source.text, html, included.sort((a, b) => a.sequence - b.sequence).map((item) => ({
452
+ content: sourceEditContent(item), priorContent: record.editBases[item.editId].priorContent,
453
+ })));
454
+ // No source writes occur until all state/evidence checks and snapshots succeeded.
455
+ const nextHash = sourceHash(html);
456
+ const membership = record.pages[page.key];
457
+ const baselineHash = membership.baselineRevisionId
458
+ ? this.store.revisions.get(membership.baselineRevisionId)?.source?.provenance?.sourceHash : null;
459
+ if (previousWrite ? previousWrite.reviewId !== request.reviewId || previousWrite.hash !== source.hash : baselineHash !== source.hash) {
460
+ record.pages[page.key].baselineRevisionId = snapshot(this.store, page, source, "review-write-baseline");
461
+ }
462
+ for (const other of values(record.edits)) {
463
+ if (other.pageKey !== page.key || submittedEdit(record, other))
464
+ continue;
465
+ const eligible = included.some((item) => item.editId === other.editId) ||
466
+ (other.source.state === "saved" && other.source.evidence.sourceHash === source.hash);
467
+ if (!eligible)
468
+ continue;
469
+ other.source = editIncluded(html, sourceEditContent(other)) ? {
470
+ state: "saved", evidence: {
471
+ evidenceId: uid("save"), reviewId: request.reviewId, pageKey: page.key,
472
+ editId: other.editId, editVersion: other.version, sourceHash: nextHash, savedAt: Date.now(),
473
+ },
474
+ } : { state: "pending" };
475
+ }
476
+ if (edit.source.state !== "saved")
477
+ reject("SAVE_EVIDENCE_CONFLICT", "Exact recorded edit is not present in saved output.");
478
+ for (const asset of included.flatMap((item) => item.assets)) {
479
+ const destination = path.join(path.dirname(page.file), "assets", asset.id);
480
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
481
+ const bytes = fs.readFileSync(asset.path);
482
+ if (fs.existsSync(destination)) {
483
+ if (!fs.readFileSync(destination).equals(bytes))
484
+ reject("SAVE_EVIDENCE_CONFLICT", "Destination asset differs; no overwrite was authorized.");
485
+ }
486
+ else
487
+ fs.writeFileSync(destination, bytes, { flag: "wx" });
488
+ }
489
+ data.conversations.writes[page.key] = { reviewId: request.reviewId, hash: nextHash };
490
+ this.writeSource(page.file, html);
491
+ }
492
+ revert(data, record, request) {
493
+ const page = data.pages[request.pageKey];
494
+ this.store.revisions.verify(request.baselineRevisionId, page.key);
495
+ const baseline = this.store.revisions.readSource(request.baselineRevisionId);
496
+ if (baseline === null)
497
+ reject("SAVE_EVIDENCE_CONFLICT", "Revert has no source baseline.");
498
+ for (const edit of values(record.edits)) {
499
+ if (edit.pageKey === page.key && !submittedEdit(record, edit)) {
500
+ // Keep exact human records, but do not claim their reverted content is saved.
501
+ edit.source = { state: "pending" };
502
+ }
503
+ }
504
+ delete data.conversations.writes[page.key];
505
+ this.writeSource(page.file, baseline);
506
+ }
507
+ send(data, record, request) {
508
+ const submissionId = uid("submission");
509
+ const messages = request.messages.map(({ messageId }) => {
510
+ const message = record.messages[messageId];
511
+ message.submissionId = submissionId;
512
+ const thread = record.threads[message.threadId];
513
+ return { pageKey: thread.pageKey, target: thread.target, message: structuredClone(message) };
514
+ });
515
+ const edits = request.edits.map(({ editId }) => structuredClone(record.edits[editId]));
516
+ record.submissions[submissionId] = submissionSchema.parse({
517
+ submissionId, reviewId: request.reviewId, entryKey: request.entryKey, version: 1, sequence: seq(record),
518
+ state: "queued", createdAt: Date.now(), deliveredAt: null, completedAt: null,
519
+ pageKeys: request.pageKeys, exclusionKeys: exclusionKeys(request.entryKey, request.pageKeys),
520
+ messages, edits, ...(request.overallNote ? { overallNote: request.overallNote } : {}),
521
+ resultId: null, abandonment: null,
522
+ });
523
+ for (const key of request.pageKeys) {
524
+ const page = data.pages[key];
525
+ const source = readSource(page);
526
+ const observation = record.pages[key].observation;
527
+ const observed = observation && this.observationIsCurrent(record.review, key, observation)
528
+ ? this.store.revisions.get(observation.revisionId) : null;
529
+ const baselineRevisionId = observed && (page.kind === "url" || observed.source?.provenance?.sourceHash === source.hash)
530
+ ? observed.revisionId : snapshot(this.store, page, source, "submission-send");
531
+ const sequence = seq(record);
532
+ record.comparisons[sequence] = {
533
+ sequence, reviewId: request.reviewId, submissionId, pageKey: key,
534
+ baselineRevisionId,
535
+ resultRevisionId: null, status: "not-requested", reason: null,
536
+ };
537
+ }
538
+ return submissionId;
539
+ }
540
+ respond(data, record, request) {
541
+ const submission = entity(record, "submissions", request.submissionId);
542
+ validateResponseCoverage(request, submission);
543
+ const resultId = uid("result");
544
+ const now = Date.now();
545
+ const responses = request.responses.map((reply) => agentMessageSchema.parse({
546
+ messageId: uid("message"), reviewId: request.reviewId, threadId: reply.threadId,
547
+ version: 1, sequence: seq(record), createdAt: now, updatedAt: now,
548
+ author: "agent", submissionId: submission.submissionId, replyToMessageId: reply.messageId,
549
+ body: reply.body, outcome: reply.outcome,
550
+ }));
551
+ const result = submissionResultSchema.parse({
552
+ resultId, reviewId: request.reviewId, submissionId: submission.submissionId,
553
+ createdAt: now, sequence: seq(record), author: "agent", body: request.resultNote,
554
+ ...(request.summary === undefined ? {} : { summary: request.summary }),
555
+ title: resultTitle(request), effect: responseEffect(request), responses,
556
+ editOutcomes: request.editOutcomes,
557
+ ...(request.overallOutcome === undefined ? {} : { overallOutcome: request.overallOutcome }),
558
+ });
559
+ record.review.version++;
560
+ const receipt = receiptFor(request, record, { submissionId: submission.submissionId, resultId });
561
+ validateResponseCommit(submission, request, result, receipt);
562
+ for (const response of responses)
563
+ record.messages[response.messageId] = response;
564
+ record.results[resultId] = result;
565
+ submission.state = "handled";
566
+ submission.version++;
567
+ submission.resultId = resultId;
568
+ submission.completedAt = now;
569
+ for (const comparison of values(record.comparisons).filter((item) => item.submissionId === submission.submissionId)) {
570
+ if (result.effect !== "changes-reported")
571
+ continue;
572
+ // Result acceptance is independent of snapshot I/O. Retry capture separately.
573
+ comparison.status = "pending";
574
+ }
575
+ return remember(data, request, receipt);
576
+ }
577
+ captureResults(reference) {
578
+ const record = recordFor(this.store.data, reference);
579
+ for (const comparison of values(record.comparisons).filter((item) => item.status === "pending" && !item.resultRevisionId)) {
580
+ let revisionId = null;
581
+ let reason = null;
582
+ try {
583
+ const page = this.store.data.pages[comparison.pageKey];
584
+ const source = readSource(page);
585
+ revisionId = snapshot(this.store, page, source, "submission-result");
586
+ if (!revisionId)
587
+ reason = page.kind === "url" ? "Rendered capture unavailable; source is not writable." : "Source unavailable.";
588
+ }
589
+ catch (error) {
590
+ console.error("[doc-review] result capture failed:", error.message);
591
+ reason = `Source capture failed: ${error.message}`;
592
+ }
593
+ this.store.transaction((data) => {
594
+ const current = recordFor(data, reference).comparisons[comparison.sequence];
595
+ if (current.status !== "pending")
596
+ return;
597
+ current.resultRevisionId = revisionId;
598
+ current.status = revisionId ? "partial" : "unavailable";
599
+ current.reason = reason || "Source observed after response acceptance; rendered result has not been captured.";
600
+ });
601
+ }
602
+ }
603
+ observe(reference, observation) {
604
+ return this.store.transaction((data) => {
605
+ const record = recordFor(data, reference);
606
+ const membership = entity(record, "pages", reference.pageKey);
607
+ this.store.revisions.verify(observation.revisionId, reference.pageKey);
608
+ if (reference.submissionId === null) {
609
+ if (record.review.state !== "open")
610
+ reject("REVIEW_ENDED", "Ended review cannot replace a Send baseline.");
611
+ membership.observation = observation;
612
+ return { revisionId: observation.revisionId };
613
+ }
614
+ const submission = entity(record, "submissions", reference.submissionId);
615
+ const result = submission.resultId && record.results[submission.resultId];
616
+ const comparison = values(record.comparisons).find((item) => item.submissionId === reference.submissionId && item.pageKey === reference.pageKey);
617
+ if (!comparison)
618
+ reject("SCOPE_MISMATCH", "Page is outside this submission.");
619
+ if (submission.state !== "handled" || result.effect !== "changes-reported") {
620
+ reject("INVALID_INPUT", "Only reported source work needs a result capture.");
621
+ }
622
+ const before = comparison.baselineRevisionId && this.store.revisions.get(comparison.baselineRevisionId);
623
+ const previous = comparison.resultRevisionId && this.store.revisions.get(comparison.resultRevisionId);
624
+ if (previous?.semantic)
625
+ reject("VERSION_CONFLICT", "Rendered result endpoint is already immutable.");
626
+ const current = this.store.revisions.get(observation.revisionId);
627
+ if (previous?.source && current.source?.hash !== previous.source.hash) {
628
+ reject("VERSION_CONFLICT", "Source changed since the accepted response observation; do not attribute this later DOM to that result.");
629
+ }
630
+ if (compareCapturedViews(before?.semantic?.view, current.semantic?.view).status === "mismatch") {
631
+ reject("VERSION_CONFLICT", "Result shows a different rendered view from the Send baseline.");
632
+ }
633
+ if (previous?.source) {
634
+ // Preserve the first source observation even if the DOM was captured later.
635
+ const merged = this.store.revisions.put({
636
+ documentId: reference.pageKey, reason: "submission-result",
637
+ source: {
638
+ text: this.store.revisions.readSource(previous.revisionId), mediaType: previous.source.mediaType,
639
+ capturedAt: previous.source.capturedAt, provenance: previous.source.provenance,
640
+ },
641
+ semantic: {
642
+ snapshot: this.store.revisions.readSemantic(current.revisionId), capturedAt: current.semantic.capturedAt,
643
+ provenance: current.semantic.provenance, ...(current.semantic.view ? { view: current.semantic.view } : {}),
644
+ },
645
+ });
646
+ comparison.resultRevisionId = merged.revisionId;
647
+ }
648
+ else
649
+ comparison.resultRevisionId = current.revisionId;
650
+ comparison.status = before?.semantic ? "ready" : "partial";
651
+ comparison.reason = before?.semantic ? null : "The Send baseline has no rendered snapshot.";
652
+ return comparisonReferenceSchema.parse(comparison);
653
+ });
654
+ }
655
+ page(data, record, key) {
656
+ const membership = entity(record, "pages", key);
657
+ const page = data.pages[key];
658
+ const source = readSource(page);
659
+ const work = allSubmissions(data).filter((submission) => outstanding(submission) &&
660
+ submission.exclusionKeys.some((target) => [record.review.entryKey, key].includes(target)));
661
+ const owner = own(data.conversations.writes, key);
662
+ const revert = owner?.reviewId === record.review.reviewId && owner.hash === source.hash && membership.baselineRevisionId
663
+ ? { baselineRevisionId: membership.baselineRevisionId, sourceHash: owner.hash } : null;
664
+ const threads = new Set(values(record.threads).filter((thread) => thread.pageKey === key).map((thread) => thread.threadId));
665
+ return reviewPageSchema.parse({
666
+ reviewId: record.review.reviewId, sequence: membership.sequence, joinedAt: membership.joinedAt,
667
+ page: { pageKey: key, target: page.kind === "url" ? { kind: "url", url: page.url } : { kind: "file", path: page.file }, sourceHash: source.hash },
668
+ savePolicy: source.writable ? "writable" : "feedback-only",
669
+ pendingMessageCount: values(record.messages).filter((message) => threads.has(message.threadId) && message.submissionId === null).length,
670
+ pendingEditCount: values(record.edits).filter((edit) => edit.pageKey === key && !submittedEdit(record, edit)).length,
671
+ writeBlockedBy: work.map(({ reviewId, submissionId }) => ({ reviewId, submissionId })),
672
+ revert, canRevert: record.review.state === "open" && source.writable && !!revert && !work.length,
673
+ });
674
+ }
675
+ read(input) {
676
+ const request = reviewReadRequestSchema.parse(input);
677
+ const data = this.store.data;
678
+ if (request.operation === "open-receipt") {
679
+ const openRequest = { operation: "open", target: request.target, requestId: request.requestId };
680
+ const entryKey = targetKey(resolveReviewTarget(request.target).value);
681
+ const previous = own(data.conversations.requests, requestKey(openRequest, entryKey));
682
+ if (!previous)
683
+ return { state: "not-found", requestId: request.requestId };
684
+ const receipt = exactReplay(openRequest, previous);
685
+ return { state: "accepted", receipt: structuredClone(receipt) };
686
+ }
687
+ const record = recordFor(data, request);
688
+ switch (request.operation) {
689
+ case "read-review": return structuredClone(record.review);
690
+ case "read-page": return this.page(data, record, request.pageKey);
691
+ case "receipt": {
692
+ const receipt = own(data.conversations.requests, requestKey(request))?.receipt;
693
+ if (receipt && (receipt.reviewId !== request.reviewId || receipt.entryKey !== request.entryKey))
694
+ reject("SCOPE_MISMATCH", "Receipt belongs to another review.");
695
+ return receipt ? { state: "accepted", receipt: structuredClone(receipt) } : { state: "not-found", requestId: request.requestId };
696
+ }
697
+ case "submission": {
698
+ const submission = entity(record, "submissions", request.submissionId);
699
+ const result = submission.resultId ? record.results[submission.resultId] : null;
700
+ const receipt = result ? values(data.conversations.requests).find((item) => item.receipt.reviewId === request.reviewId && item.receipt.value.resultId === result.resultId)?.receipt : null;
701
+ return submissionReadSchema.parse({ submission, result, receipt: receipt ?? null });
702
+ }
703
+ case "status": {
704
+ const work = values(record.submissions).find(outstanding);
705
+ return reviewStatusSchema.parse({
706
+ review: record.review,
707
+ openThreadCount: values(record.threads).filter((thread) => thread.status === "open").length,
708
+ attentionEditCount: editAttention(record).length,
709
+ pendingMessageCount: values(record.messages).filter((message) => message.submissionId === null).length,
710
+ pendingEditCount: values(record.edits).filter((edit) => !submittedEdit(record, edit)).length,
711
+ work: work ? { submissionId: work.submissionId, state: work.state, version: work.version } : null,
712
+ blockers: allSubmissions(data).filter((item) => outstanding(item) &&
713
+ item.exclusionKeys.some((key) => key === record.review.entryKey || own(record.pages, key))).map((item) => ({
714
+ reviewId: item.reviewId, submissionId: item.submissionId,
715
+ targetKeys: item.exclusionKeys.filter((key) => key === record.review.entryKey || own(record.pages, key)),
716
+ })),
717
+ });
718
+ }
719
+ case "poll": {
720
+ const work = values(record.submissions).find(outstanding);
721
+ if (!work)
722
+ return pollResponseSchema.parse({ state: record.review.state === "ended" ? "ended" : "waiting", review: record.review });
723
+ if (work.state === "delivered")
724
+ return pollResponseSchema.parse({ state: "work", review: record.review, submission: work });
725
+ return this.store.transaction((draft) => {
726
+ const current = recordFor(draft, request);
727
+ const submission = current.submissions[work.submissionId];
728
+ submission.state = "delivered";
729
+ submission.version++;
730
+ submission.deliveredAt = Date.now();
731
+ return pollResponseSchema.parse({ state: "work", review: current.review, submission });
732
+ });
733
+ }
734
+ }
735
+ }
736
+ list(input) {
737
+ const { scope, query } = conversationListRequestSchema.parse(input);
738
+ const data = this.store.data;
739
+ const record = recordFor(data, scope);
740
+ if (scope.pageKey !== null)
741
+ entity(record, "pages", scope.pageKey);
742
+ const inPage = (item) => scope.pageKey === null || item.pageKey === scope.pageKey;
743
+ let rows;
744
+ let schema;
745
+ switch (scope.collection) {
746
+ case "pages":
747
+ rows = values(record.pages).filter(inPage).map((item) => this.page(data, record, item.pageKey));
748
+ schema = reviewPageSchema;
749
+ break;
750
+ case "edits":
751
+ rows = values(record.edits).filter(inPage).filter((edit) => !submittedEdit(record, edit));
752
+ schema = directEditSchema;
753
+ break;
754
+ case "edit-attention":
755
+ rows = editAttention(record).filter((item) => inPage(item.edit));
756
+ schema = editAttentionSchema;
757
+ break;
758
+ case "context": {
759
+ const thread = entity(record, "threads", scope.threadId);
760
+ if (!inPage(thread))
761
+ reject("SCOPE_MISMATCH", "Thread belongs to another page.");
762
+ const messages = values(record.messages).filter((item) => item.threadId === scope.threadId);
763
+ return contextWindow(messages.filter((item) => item.author === "reviewer"), messages.filter((item) => item.author === "agent"), scope, query, record.sequence);
764
+ }
765
+ case "threads":
766
+ rows = values(record.threads).filter(inPage).filter((thread) => scope.status === "all" || thread.status === scope.status).map((thread) => {
767
+ const messages = values(record.messages).filter((item) => item.threadId === thread.threadId);
768
+ const reviewers = messages.filter((item) => item.author === "reviewer");
769
+ return {
770
+ thread, sequence: thread.sequence, messageCount: reviewers.length,
771
+ pendingMessageCount: reviewers.filter((item) => item.submissionId === null).length,
772
+ latestExchange: latestExchange(reviewers, messages.filter((item) => item.author === "agent")),
773
+ };
774
+ });
775
+ schema = threadSummarySchema;
776
+ break;
777
+ case "history":
778
+ rows = values(record.submissions).filter((submission) => scope.pageKey === null || submission.pageKeys.includes(scope.pageKey)).map((submission) => {
779
+ const result = submission.resultId ? record.results[submission.resultId] : null;
780
+ const comparisons = values(record.comparisons).filter((item) => item.submissionId === submission.submissionId);
781
+ const statuses = comparisons.map((item) => item.status);
782
+ return {
783
+ sequence: submission.sequence, reviewId: scope.reviewId, submissionId: submission.submissionId,
784
+ createdAt: submission.createdAt, state: submission.state,
785
+ result: result ? { resultId: result.resultId, body: result.body, createdAt: result.createdAt, title: result.title, effect: result.effect,
786
+ ...(result.summary === undefined ? {} : { summary: result.summary }) } : null,
787
+ comparisonStatus: statuses.includes("pending") ? "pending" : statuses.every((status) => status === "ready") ? "ready" :
788
+ statuses.every((status) => status === "not-requested") ? "not-requested" :
789
+ statuses.some((status) => status === "ready" || status === "partial") ? "partial" : "unavailable",
790
+ comparisonCount: comparisons.filter((item) => item.resultRevisionId).length,
791
+ };
792
+ });
793
+ schema = submissionHistoryItemSchema;
794
+ break;
795
+ case "comparisons":
796
+ entity(record, "submissions", scope.submissionId);
797
+ rows = values(record.comparisons).filter(inPage).filter((item) => item.submissionId === scope.submissionId);
798
+ schema = comparisonReferenceSchema;
799
+ break;
800
+ }
801
+ return validatePageOutput(paginate(rows, scope, query, record.sequence), schema, scope, query);
802
+ }
803
+ assertWritableTarget(key) {
804
+ assertNoOutstandingWork([key], allSubmissions(this.store.data));
805
+ if (values(this.store.data.conversations.reviews).some((record) => own(record.pages, key))) {
806
+ reject("SCOPE_MISMATCH", "Conversation pages require review-scoped, versioned write operations.");
807
+ }
808
+ }
809
+ }