@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
@@ -338,15 +338,15 @@ export function createFrameController({ sessionId, host, request, suspended, tra
338
338
  }
339
339
  }, 5000);
340
340
  }
341
- function configure(mode, savePolicy, wait = true) {
341
+ function configure(mode, savePolicy, canComment, wait = true) {
342
342
  if (loading() || !state.capability || !state.execution)
343
343
  return Promise.resolve(false);
344
344
  configuration?.settle(false);
345
- expectedConfiguration = { mode, savePolicy };
345
+ expectedConfiguration = { mode, savePolicy, canComment };
346
346
  state.configurationGeneration = null;
347
347
  configurationTimeout();
348
348
  if (!wait) {
349
- send({ type: "eh:configureReview", mode, savePolicy });
349
+ send({ type: "eh:configureReview", mode, savePolicy, canComment });
350
350
  return Promise.resolve(true);
351
351
  }
352
352
  return new Promise((resolve) => {
@@ -358,8 +358,8 @@ export function createFrameController({ sessionId, host, request, suspended, tra
358
358
  cancelTimer(timer);
359
359
  resolve(applied);
360
360
  };
361
- configuration = { mode, savePolicy, settle };
362
- send({ type: "eh:configureReview", mode, savePolicy });
361
+ configuration = { mode, savePolicy, canComment, settle };
362
+ send({ type: "eh:configureReview", mode, savePolicy, canComment });
363
363
  });
364
364
  }
365
365
  function flush(strict = false) {
@@ -449,16 +449,16 @@ export function createFrameController({ sessionId, host, request, suspended, tra
449
449
  isThemePayload(value) && value.theme === currentTheme?.pending?.theme &&
450
450
  value.themeRevision === currentTheme.pending.themeRevision));
451
451
  },
452
- configured(mode, savePolicy) {
452
+ configured(mode, savePolicy, canComment) {
453
453
  if (!expectedConfiguration || expectedConfiguration.mode !== mode ||
454
- expectedConfiguration.savePolicy !== savePolicy || loading())
454
+ expectedConfiguration.savePolicy !== savePolicy || expectedConfiguration.canComment !== canComment || loading())
455
455
  return false;
456
456
  // Confirmation is complete even when background tabs pause the visual handoff.
457
457
  cancelTimer(configurationTimer);
458
458
  configurationTimer = null;
459
459
  state.configurationGeneration = state.generation;
460
460
  handoff();
461
- if (configuration && configuration.mode === mode && configuration.savePolicy === savePolicy) {
461
+ if (configuration && configuration.mode === mode && configuration.savePolicy === savePolicy && configuration.canComment === canComment) {
462
462
  configuration.settle(true);
463
463
  }
464
464
  return true;
@@ -8,6 +8,7 @@ export const TRUSTED_SDK_MODULE_PATHS = Object.freeze([
8
8
  "/editing.js", "/frame-channel.js", "/icons.js", "/review-mode.js",
9
9
  "/semantic-snapshot.js", "/serialize.js", "/positioning.js",
10
10
  "/revision-schema.js", "/view-identity.js",
11
+ "/thread-anchor-controller.js", "/contracts/frame.js", "/contracts/feedback.js", "/contracts/validation.js",
11
12
  ]);
12
13
  export function interactiveFileCsp(sdkOrigin) {
13
14
  const origin = new URL(sdkOrigin);
@@ -1,4 +1,4 @@
1
- import { COMPLETED_ROUNDS_TO_KEEP, isRevisionId } from "./revision-schema.js";
1
+ import { isRevisionId } from "./revision-schema.js";
2
2
  function references(value, out = new Set()) {
3
3
  if (isRevisionId(value))
4
4
  out.add(value);
@@ -23,21 +23,10 @@ export function historyRevisionReferences(data) {
23
23
  const out = unsentRevisionReferences(data);
24
24
  references(data.histories, out);
25
25
  references(data.batches, out);
26
+ references(data.conversations, out);
26
27
  return out;
27
28
  }
28
29
  export function retainHistory(data) {
29
- const pinned = unsentRevisionReferences(data);
30
- let changed = false;
31
- for (const history of Object.values(data.histories || {})) {
32
- const completed = history.rounds
33
- .filter((round) => round.completedAt)
34
- .sort((a, b) => b.completedAt - a.completedAt || b.ordinal - a.ordinal);
35
- const keep = new Set(completed.slice(0, COMPLETED_ROUNDS_TO_KEEP).map((round) => round.roundId));
36
- const rounds = history.rounds.filter((round) => !round.completedAt || keep.has(round.roundId) || [...references(round.targets)].some((id) => pinned.has(id)));
37
- if (rounds.length !== history.rounds.length) {
38
- history.rounds = rounds;
39
- changed = true;
40
- }
41
- }
42
- return changed;
30
+ // Referenced comparisons are durable review records, not age/count debris.
31
+ return false;
43
32
  }
@@ -1,9 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import crypto from "node:crypto";
3
- import { performance } from "node:perf_hooks";
4
3
  import { stripSdk } from "./html-transform.js";
5
4
  import { isMarkdown } from "./markdown.js";
6
- import { compareSemanticSnapshots, compareSources, DIFF_VERSION } from "./revision-diff.js";
5
+ import { compareSemanticSnapshots, compareSources } from "./revision-diff.js";
7
6
  import { REVISION_LIMITS } from "./revision-schema.js";
8
7
  import { normalizeView, compareCapturedViews } from "./view-identity.js";
9
8
  const SOURCE_LIMIT = REVISION_LIMITS.sourceBytes;
@@ -61,10 +60,39 @@ function sourceFor(page) {
61
60
  mediaType: isMarkdown(page.file) ? "text/markdown" : "text/html",
62
61
  };
63
62
  }
64
- export function createHistoryController({ store, sessions, currentRender, readBody, json, emit, pageState, }) {
65
- const cache = new Map();
66
- let cachedBytes = 0;
67
- const revision = (id) => id ? store.revisions.get(id) : null;
63
+ export function compareRevisions(revisions, beforeId, afterId, mode, { baselineUnavailable, resultUnavailable } = {}) {
64
+ if (!["content", "source"].includes(mode))
65
+ throw new HistoryRequestError("Unknown comparison mode.");
66
+ const before = beforeId ? revisions.get(beforeId) : null;
67
+ const after = afterId ? revisions.get(afterId) : null;
68
+ const representation = mode === "content" ? "semantic" : "source";
69
+ if (!before?.[representation] || !after?.[representation]) {
70
+ return {
71
+ available: false, mode,
72
+ reason: !before?.[representation] ? baselineUnavailable || `The before ${mode} snapshot is unavailable.`
73
+ : resultUnavailable || `The result ${mode} snapshot has not been captured.`,
74
+ changes: [], limitations: [mode === "source" ? "source_unavailable" : "semantic_unavailable"],
75
+ };
76
+ }
77
+ const differences = mode === "source"
78
+ ? compareSources(revisions.readSource(before.revisionId), revisions.readSource(after.revisionId))
79
+ : compareSemanticSnapshots(revisions.readSemantic(before.revisionId), revisions.readSemantic(after.revisionId));
80
+ return {
81
+ ...differences, available: differences.status === "complete",
82
+ ...(differences.status !== "complete" ? {
83
+ reason: differences.status === "limited"
84
+ ? "This comparison exceeds its processing limits. Its contents have not been partially displayed."
85
+ : "The captured content cannot be compared.",
86
+ } : {}),
87
+ mode, baselineRevisionId: before.revisionId, resultRevisionId: after.revisionId,
88
+ beforeCapturedAt: before[representation].capturedAt, afterCapturedAt: after[representation].capturedAt,
89
+ sourceHash: after.source?.provenance.sourceHash ?? null,
90
+ capturedSessionId: after.semantic?.provenance.sessionId ?? null,
91
+ capturedGeneration: after.semantic?.provenance.generation ?? null,
92
+ ...(mode === "content" ? { viewComparison: compareCapturedViews(before.semantic?.view, after.semantic?.view) } : {}),
93
+ };
94
+ }
95
+ export function createConversationCapture({ store, currentRender }) {
68
96
  const saveRevision = (value) => {
69
97
  const provenance = {
70
98
  pageKey: value.documentId,
@@ -90,13 +118,6 @@ export function createHistoryController({ store, sessions, currentRender, readBo
90
118
  } : {}),
91
119
  });
92
120
  };
93
- const roundFor = (session, roundId) => store.getRound(session.entryKey, roundId);
94
- function changed(entryKey, roundId) {
95
- for (const session of sessions.values()) {
96
- if (session.entryKey === entryKey)
97
- emit(session, "history", { roundId });
98
- }
99
- }
100
121
  function currentFrame(session, input, key) {
101
122
  const render = typeof input.renderId === "string" ? currentRender(input.renderId) : null;
102
123
  if (!render || render.sessionId !== session.id || render.pageKey !== key ||
@@ -108,11 +129,13 @@ export function createHistoryController({ store, sessions, currentRender, readBo
108
129
  }
109
130
  return render;
110
131
  }
111
- function checkedSource(page, render, expectedHash) {
132
+ function checkedSource(page, render, expectedHash, session) {
112
133
  const source = sourceFor(page);
134
+ const write = store.data.conversations.writes[page.key];
135
+ const savedInReview = write?.reviewId === session.reviewId && write?.hash === source?.hash;
113
136
  if (source && (typeof expectedHash !== "string" ||
114
137
  source.hash !== expectedHash ||
115
- render.sourceHash !== expectedHash)) {
138
+ (render.sourceHash !== expectedHash && !savedInReview))) {
116
139
  throw new HistoryRequestError("The source changed since this version was reviewed. Reload and recapture it.", {
117
140
  status: 409, code: "history_source_changed",
118
141
  });
@@ -131,7 +154,7 @@ export function createHistoryController({ store, sessions, currentRender, readBo
131
154
  input.semanticCapturedAt < render.createdAt || input.semanticCapturedAt > Date.now() + 5000)) {
132
155
  throw new HistoryRequestError("The content capture timestamp does not belong to this rendered version.");
133
156
  }
134
- const source = checkedSource(page, render, input.expectedSourceHash);
157
+ const source = checkedSource(page, render, input.expectedSourceHash, session);
135
158
  return {
136
159
  documentId: key,
137
160
  capturedAt: Date.now(),
@@ -151,314 +174,7 @@ export function createHistoryController({ store, sessions, currentRender, readBo
151
174
  },
152
175
  };
153
176
  }
154
- function prepareBaseline(session, pages, input) {
155
- if (input === undefined)
156
- return null;
157
- if (!input || typeof input !== "object" || Array.isArray(input)) {
158
- throw new HistoryRequestError("Invalid history submission.");
159
- }
160
- if (input.allowUnavailable !== undefined && typeof input.allowUnavailable !== "boolean") {
161
- throw new HistoryRequestError("Invalid missing-comparison choice.");
162
- }
163
- const keys = pages.length ? pages.map((page) => page.key) : [session.entryKey];
164
- const pending = [];
165
- const unavailable = [];
166
- for (const key of keys) {
167
- const page = store.page(key);
168
- let value = null;
169
- let reason = null;
170
- try {
171
- value = snapshot(session, key, input, "send");
172
- }
173
- catch (error) {
174
- if (!(error instanceof HistoryRequestError) && !["ENOENT", "EACCES", "EPERM"].includes(error.code))
175
- throw error;
176
- reason = error.message;
177
- unavailable.push({ key, filename: pageState(key)?.filename || key, reason });
178
- if (input.allowUnavailable) {
179
- try {
180
- const source = sourceFor(page);
181
- if (source) {
182
- value = {
183
- documentId: key, capturedAt: Date.now(), source, semantic: null,
184
- provenance: { captureReason: "send-source-only", pageKey: key },
185
- limitations: ["semantic_before_unavailable"],
186
- };
187
- }
188
- }
189
- catch (sourceError) {
190
- if (!(sourceError instanceof HistoryRequestError) && !["ENOENT", "EACCES", "EPERM"].includes(sourceError.code)) {
191
- throw sourceError;
192
- }
193
- reason = sourceError.message;
194
- }
195
- }
196
- }
197
- pending.push({ key, value, reason });
198
- }
199
- if (unavailable.length && !input.allowUnavailable) {
200
- throw new HistoryRequestError("Some pages need to be recaptured before their comparison can be saved.", {
201
- status: 409, code: "history_baseline_unavailable", targets: unavailable,
202
- });
203
- }
204
- const targets = pending.map(({ key, value, reason }) => ({
205
- key,
206
- ...(value ? { baselineRevisionId: saveRevision(value).revisionId } : {
207
- baselineUnavailable: reason || "The before snapshot is unavailable.",
208
- }),
209
- ownerSessionId: session.id,
210
- }));
211
- return { targets, unavailable };
212
- }
213
- function presentRound(round) {
214
- return {
215
- ...round,
216
- sentAt: round.sentAt ?? round.createdAt,
217
- targets: round.targets.map((target) => ({
218
- ...target,
219
- captureStatus: target.capture?.status || (target.resultRevisionId ? "ready" : "pending"),
220
- filename: pageState(target.key)?.filename || target.key,
221
- kind: store.page(target.key)?.kind === "url" ? "url" : "file",
222
- })),
223
- };
224
- }
225
- function list(session) {
226
- return {
227
- activeKey: session.activeKey,
228
- rounds: store.listHistory(session.entryKey).map(presentRound),
229
- };
230
- }
231
- function captureSources(entryKey, roundId) {
232
- const rounds = roundId ? [store.getRound(entryKey, roundId)] : store.listHistory(entryKey);
233
- for (const round of rounds) {
234
- if (round?.feedbackStatus !== "acknowledged" || round.completedAt)
235
- continue;
236
- for (const target of round.targets) {
237
- if (target.sourceResultRevisionId || target.resultRevisionId ||
238
- target.capture?.status === "unavailable" || store.page(target.key)?.kind === "url")
239
- continue;
240
- try {
241
- const saved = saveRevision({
242
- documentId: target.key, capturedAt: Date.now(),
243
- source: sourceFor(store.page(target.key)),
244
- provenance: { captureReason: "acknowledged-source-result" },
245
- });
246
- store.recordSourceResult(entryKey, round.roundId, target.key, { revisionId: saved.revisionId });
247
- console.info("[doc-review]", { event: "history-source-captured", roundId: round.roundId, key: target.key });
248
- }
249
- catch (error) {
250
- try {
251
- store.recordSourceResult(entryKey, round.roundId, target.key, { unavailable: String(error.message).slice(0, 500) });
252
- }
253
- catch (persistenceError) {
254
- console.error("[doc-review]", { event: "history-source-failure-not-persisted", code: persistenceError.code || "history_storage_failed" });
255
- }
256
- console.error("[doc-review]", { event: "history-source-unavailable", roundId: round.roundId, key: target.key, code: error.code || "history_storage_failed" });
257
- }
258
- }
259
- }
260
- }
261
- function comparison(round, key, mode) {
262
- if (!["content", "source"].includes(mode))
263
- throw new HistoryRequestError("Unknown comparison mode.");
264
- const target = round.targets.find((item) => item.key === key);
265
- if (!target)
266
- throw new HistoryRequestError("This target does not belong to the selected round.", { status: 404 });
267
- const before = revision(target.baselineRevisionId);
268
- const after = revision(mode === "source" ? target.sourceResultRevisionId || target.resultRevisionId : target.resultRevisionId);
269
- const representation = mode === "content" ? "semantic" : "source";
270
- if (!before?.[representation] || !after?.[representation]) {
271
- return {
272
- available: false,
273
- mode,
274
- reason: !before?.[representation]
275
- ? target.baselineUnavailable || `The before ${mode} snapshot is unavailable.`
276
- : (mode === "source" ? target.sourceResultUnavailable : target.resultUnavailable) || `The result ${mode} snapshot has not been captured.`,
277
- changes: [],
278
- limitations: [mode === "source" ? "source_unavailable" : "semantic_unavailable"],
279
- };
280
- }
281
- const cacheKey = `${DIFF_VERSION}:${before.revisionId}:${after.revisionId}:${mode}`;
282
- if (cache.has(cacheKey))
283
- return cache.get(cacheKey).value;
284
- const started = performance.now();
285
- const differences = mode === "source"
286
- ? compareSources(store.revisions.readSource(before.revisionId), store.revisions.readSource(after.revisionId))
287
- : compareSemanticSnapshots(store.revisions.readSemantic(before.revisionId), store.revisions.readSemantic(after.revisionId));
288
- const result = {
289
- ...differences,
290
- available: differences.status === "complete",
291
- ...(differences.status !== "complete" ? {
292
- reason: differences.status === "limited"
293
- ? "This comparison exceeds its processing limits. Its contents have not been partially displayed."
294
- : "The captured content cannot be compared.",
295
- } : {}),
296
- mode,
297
- baselineRevisionId: before.revisionId,
298
- resultRevisionId: after.revisionId,
299
- beforeCapturedAt: before[representation].capturedAt,
300
- afterCapturedAt: after[representation].capturedAt,
301
- sourceHash: after.source?.provenance.sourceHash ?? null,
302
- capturedSessionId: after.semantic?.provenance.sessionId ?? null,
303
- capturedGeneration: after.semantic?.provenance.generation ?? null,
304
- ...(mode === "content" ? { viewComparison: compareCapturedViews(before.semantic?.view, after.semantic?.view) } : {}),
305
- };
306
- const bytes = Buffer.byteLength(JSON.stringify(result));
307
- while (cache.size && (cache.size >= 16 || cachedBytes + bytes > 8 * 1024 * 1024)) {
308
- const oldest = cache.keys().next().value;
309
- cachedBytes -= cache.get(oldest).bytes;
310
- cache.delete(oldest);
311
- }
312
- if (bytes <= 8 * 1024 * 1024) {
313
- cache.set(cacheKey, { value: result, bytes });
314
- cachedBytes += bytes;
315
- }
316
- console.info("[doc-review]", {
317
- event: "history-compared", roundId: round.roundId, key, mode,
318
- durationMs: Math.round(performance.now() - started), changes: result.changes.length,
319
- });
320
- return result;
321
- }
322
- async function handle(req, res, url) {
323
- const match = url.pathname.match(/^\/api\/session\/(\w+)\/history(?:\/([\w-]+))?(?:\/(compare|capture))?$/);
324
- if (!match)
325
- return false;
326
- const session = sessions.get(match[1]);
327
- if (!session) {
328
- json(res, 404, { error: "unknown session" });
329
- return true;
330
- }
331
- try {
332
- if (!match[2] && req.method === "GET") {
333
- json(res, 200, list(session));
334
- return true;
335
- }
336
- let round = roundFor(session, match[2]);
337
- if (!round)
338
- throw new HistoryRequestError("Unknown round in this review history.", { status: 404 });
339
- if (!match[3] && req.method === "GET") {
340
- json(res, 200, { round: presentRound(round) });
341
- return true;
342
- }
343
- if (match[3] === "compare" && req.method === "GET") {
344
- json(res, 200, comparison(round, url.searchParams.get("key") || session.activeKey, url.searchParams.get("mode") || "content"));
345
- return true;
346
- }
347
- if (match[3] === "capture" && req.method === "POST") {
348
- const body = await readBody(req);
349
- if (!body || typeof body !== "object" || Array.isArray(body)) {
350
- throw new HistoryRequestError("Invalid capture request.");
351
- }
352
- round = roundFor(session, match[2]);
353
- if (!round)
354
- throw new HistoryRequestError("Unknown round in this review history.", { status: 404 });
355
- const key = typeof body.key === "string" ? body.key : session.activeKey;
356
- const target = round.targets.find((item) => item.key === key);
357
- if (!target)
358
- throw new HistoryRequestError("This target does not belong to the selected round.", { status: 404 });
359
- if (round.feedbackStatus !== "acknowledged") {
360
- throw new HistoryRequestError("The round has not been acknowledged.", { status: 409 });
361
- }
362
- if (target.resultRevisionId) {
363
- json(res, 200, { ok: true, alreadyCaptured: true, round: presentRound(round) });
364
- return true;
365
- }
366
- if (body.manual !== undefined && typeof body.manual !== "boolean") {
367
- throw new HistoryRequestError("Invalid manual capture choice.");
368
- }
369
- if (body.finalUnavailable !== undefined && body.finalUnavailable !== true) {
370
- throw new HistoryRequestError("Invalid final capture choice.");
371
- }
372
- if (body.finalUnavailable === true) {
373
- if (body.manual !== true)
374
- throw new HistoryRequestError("Finishing without content capture must be an explicit choice.");
375
- if (target.capture?.status === "unavailable") {
376
- json(res, 200, { ok: true, alreadyFinalized: true, round: presentRound(round) });
377
- return true;
378
- }
379
- captureSources(session.entryKey, round.roundId);
380
- const claimed = store.claimCapture(session.entryKey, round.roundId, key, {
381
- ownerSessionId: session.id, generation: 0,
382
- });
383
- const updated = store.markCaptureUnavailable(session.entryKey, round.roundId, key, {
384
- captureId: claimed.captureId, ownerSessionId: session.id, generation: 0,
385
- reason: "Content capture was explicitly finished with the available snapshots.", final: true,
386
- });
387
- changed(session.entryKey, round.roundId);
388
- json(res, 200, { ok: true, round: presentRound(updated.round) });
389
- return true;
390
- }
391
- const baseline = revision(target.baselineRevisionId);
392
- const owner = target.ownerSessionId || baseline?.semantic?.provenance.sessionId;
393
- if (owner && owner !== session.id && body.manual !== true) {
394
- throw new HistoryRequestError("The review window that sent this round owns automatic capture. Use Capture result to capture here.", {
395
- status: 409, code: "history_capture_owner",
396
- });
397
- }
398
- const render = currentFrame(session, body, key);
399
- if (!target.sourceResultRevisionId)
400
- captureSources(session.entryKey, round.roundId);
401
- const claimed = store.claimCapture(session.entryKey, round.roundId, key, {
402
- ownerSessionId: session.id, generation: render.generation,
403
- });
404
- const ownership = {
405
- captureId: claimed.captureId, ownerSessionId: session.id, generation: render.generation,
406
- };
407
- let updated;
408
- let saved;
409
- try {
410
- if (typeof body.error === "string" && body.error.trim()) {
411
- if (body.error.length > 500)
412
- throw new HistoryRequestError("Capture failure reason is too long.");
413
- updated = store.markCaptureUnavailable(session.entryKey, round.roundId, key, {
414
- ...ownership, reason: body.error, final: false,
415
- });
416
- }
417
- else {
418
- const value = snapshot(session, key, body, body.manual === true ? "manual-result" : "automatic-result");
419
- const viewComparison = compareCapturedViews(baseline?.semantic?.view, value.view);
420
- if (viewComparison.status === "mismatch") {
421
- throw new HistoryRequestError(viewComparison.message, { status: 409, code: "history_view_mismatch" });
422
- }
423
- value.provenance.captureId = claimed.captureId;
424
- saved = saveRevision(value);
425
- updated = store.recordCaptureResult(session.entryKey, round.roundId, key, {
426
- ...ownership, revisionId: saved.revisionId,
427
- });
428
- }
429
- }
430
- catch (error) {
431
- try {
432
- store.markCaptureUnavailable(session.entryKey, round.roundId, key, {
433
- ...ownership, reason: String(error.message).slice(0, 500), final: false,
434
- });
435
- changed(session.entryKey, round.roundId);
436
- }
437
- catch (persistenceError) {
438
- console.error("[doc-review]", { event: "history-failure-not-persisted", code: persistenceError.code || "history_failed" });
439
- }
440
- throw error;
441
- }
442
- changed(session.entryKey, round.roundId);
443
- console.info("[doc-review]", {
444
- event: saved ? "history-captured" : "history-capture-unavailable",
445
- roundId: round.roundId, key, ...(saved ? { revisionId: saved.revisionId } : {}),
446
- });
447
- json(res, 200, { ok: !!saved, round: presentRound(updated.round) });
448
- return true;
449
- }
450
- json(res, 405, { error: "method not allowed" });
451
- }
452
- catch (error) {
453
- const status = historyErrorStatus(error);
454
- console.error("[doc-review]", { event: "history-failed", status, code: error.code || "history_failed" });
455
- json(res, status, {
456
- error: error.message,
457
- code: error.code || "history_failed",
458
- ...(error.targets?.length ? { targets: error.targets } : {}),
459
- });
460
- }
461
- return true;
462
- }
463
- return { prepareBaseline, captureSources, list, handle, changed };
177
+ return {
178
+ captureObservation: (session, key, input, reason) => saveRevision(snapshot(session, key, input, reason)),
179
+ };
464
180
  }
@@ -35,11 +35,26 @@ export function injectSdk(html, key, { src = "/sdk.js", baseHref = "", nonce = "
35
35
  ? prepared.replace(/<head(\s[^>]*)?>/i, (head) => `${head}${restoreRoute}`)
36
36
  : `${restoreRoute}${prepared}`;
37
37
  }
38
- // Put the trusted module before authored markup can enter an unclosed
39
- // script/style/textarea raw-text context. Removing this exact tag restores
40
- // every authored byte.
38
+ // Consume only the document's safe opening prefix. Inserting before <html>
39
+ // invents an implicit head and redistributes authored whitespace into it.
40
+ // Never search ahead for <head>: it could be inside unclosed raw text.
41
41
  const doctype = /^(\uFEFF?\s*(?:<!--[\s\S]*?-->\s*)*<!doctype[^>]*>)/i.exec(prepared);
42
- const at = doctype ? doctype[0].length : 0;
42
+ let at = doctype ? doctype[0].length : 0;
43
+ const trivia = () => {
44
+ const match = /^(?:[\t\n\f\r ]|<!--[\s\S]*?-->)+/.exec(prepared.slice(at));
45
+ if (match)
46
+ at += match[0].length;
47
+ };
48
+ const opening = (name) => {
49
+ const match = new RegExp(`^<${name}(?=[\\t\\n\\f\\r />])(?:[^"'<>]|"[^"]*"|'[^']*')*>`, "i").exec(prepared.slice(at));
50
+ if (match)
51
+ at += match[0].length;
52
+ return !!match;
53
+ };
54
+ trivia();
55
+ if (opening("html"))
56
+ trivia();
57
+ opening("head");
43
58
  return `${prepared.slice(0, at)}${tag}${prepared.slice(at)}`;
44
59
  }
45
60
  /** Remove any injected tag, so a file saved with one never keeps it. */