@erdemtuna/doc-review 0.11.1 → 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 (52) 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 +3 -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 +87 -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 +188 -23
  25. package/lib/frame-host.js +10 -0
  26. package/lib/frame-policy.js +1 -0
  27. package/lib/history-policy.js +4 -15
  28. package/lib/history-server.js +42 -326
  29. package/lib/html-transform.js +19 -4
  30. package/lib/icons.js +323 -0
  31. package/lib/new-message-target.js +27 -0
  32. package/lib/paths.js +4 -4
  33. package/lib/poll-transport.js +109 -26
  34. package/lib/positioning.js +51 -0
  35. package/lib/recovery-controller.js +9 -0
  36. package/lib/references/context-and-recovery.md +130 -0
  37. package/lib/references/response-contract.md +108 -0
  38. package/lib/references/source-edits.md +47 -0
  39. package/lib/review-palette.js +97 -0
  40. package/lib/revision-store.js +1 -1
  41. package/lib/save-controller.js +28 -14
  42. package/lib/sdk.js +568 -92
  43. package/lib/server.js +73 -595
  44. package/lib/setup.js +17 -48
  45. package/lib/state.js +21 -7
  46. package/lib/thread-anchor-controller.js +56 -0
  47. package/lib/toolbar-controller.js +3 -3
  48. package/lib/ui/THIRD_PARTY_NOTICES.md +127 -8
  49. package/lib/ui/chrome.css +1220 -2733
  50. package/lib/ui/chrome.js +81 -17
  51. package/package.json +2 -2
  52. package/lib/chrome-client.js +0 -1700
@@ -1,5 +1,6 @@
1
1
  import { record } from "./chrome-api.js";
2
- export function createFrameController({ sessionId, host, request, suspended, transitioning = () => { }, failed, now = Date.now, setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, }) {
2
+ import { isThemePayload } from "./contracts/frame.js";
3
+ export function createFrameController({ sessionId, host, request, suspended, transitioning = () => { }, failed, now = Date.now, activated = () => { }, diagnostic = () => { }, setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, }) {
3
4
  const state = {
4
5
  key: null, generation: 0, renderId: null, capability: null, sourceHash: null,
5
6
  execution: null, policy: null, phase: { kind: "loading" }, readyAt: 0,
@@ -17,6 +18,10 @@ export function createFrameController({ sessionId, host, request, suspended, tra
17
18
  let flushSequence = 0;
18
19
  let configuration = null;
19
20
  let expectedConfiguration = null;
21
+ let desired = { theme: "light", themeRevision: 1 };
22
+ let currentTheme = null;
23
+ let previousTheme = null;
24
+ let retainedCandidate = null;
20
25
  const alive = () => state.phase.kind !== "disposed";
21
26
  const loading = () => state.phase.kind !== "ready";
22
27
  const publish = () => { if (alive())
@@ -49,7 +54,114 @@ export function createFrameController({ sessionId, host, request, suspended, tra
49
54
  ...message, capability: state.capability, generation: state.generation, pageKey: state.key,
50
55
  }, state.policy?.targetOrigin || "*");
51
56
  }
52
- function suspend() {
57
+ function cancelTheme(channel) {
58
+ if (!channel)
59
+ return;
60
+ cancelTimer(channel.timer);
61
+ channel.timer = null;
62
+ channel.pending = null;
63
+ channel.failed = false;
64
+ channel.settle?.(false);
65
+ channel.settle = null;
66
+ }
67
+ function clearPreviousTheme() {
68
+ cancelTheme(previousTheme);
69
+ previousTheme = null;
70
+ publish();
71
+ }
72
+ const stopRemoval = host.onPreviousRemoved(clearPreviousTheme);
73
+ const themeApplied = (channel) => channel?.applied?.themeRevision === desired.themeRevision && channel.applied.theme === desired.theme &&
74
+ !channel.pending && !channel.failed;
75
+ function handoff() {
76
+ if (!host.previous || loading() || !expectedConfiguration ||
77
+ state.configurationGeneration !== state.generation || !themeApplied(currentTheme))
78
+ return;
79
+ const started = identity();
80
+ const appliedConfiguration = expectedConfiguration;
81
+ const revision = desired.themeRevision;
82
+ host.afterPaint(() => {
83
+ if (matches(started) && !loading() && expectedConfiguration === appliedConfiguration &&
84
+ state.configurationGeneration === state.generation && desired.themeRevision === revision &&
85
+ themeApplied(currentTheme))
86
+ finishReplacement();
87
+ });
88
+ }
89
+ function exposeReady() {
90
+ if (state.phase.kind !== "confirming" || !state.execution || !themeApplied(currentTheme))
91
+ return;
92
+ state.phase = { kind: "ready" };
93
+ host.ready(state.execution);
94
+ publish();
95
+ activated(state.execution);
96
+ }
97
+ function requestTheme(channel) {
98
+ cancelTheme(channel);
99
+ channel.pending = { ...desired };
100
+ const pending = channel.pending;
101
+ const promise = new Promise((resolve) => { channel.settle = resolve; });
102
+ channel.timer = later(() => {
103
+ if (channel.pending !== pending)
104
+ return;
105
+ channel.timer = null;
106
+ channel.pending = null;
107
+ channel.failed = true;
108
+ channel.settle?.(false);
109
+ channel.settle = null;
110
+ publish();
111
+ }, 3000);
112
+ const message = {
113
+ type: "eh:setTheme", ...pending, capability: channel.capability,
114
+ generation: channel.generation, pageKey: channel.pageKey,
115
+ };
116
+ if (channel === currentTheme)
117
+ host.send(message, channel.policy.targetOrigin);
118
+ else if (channel.source && channel.source === host.previousWindow) {
119
+ channel.source.postMessage(message, channel.policy.targetOrigin);
120
+ }
121
+ publish();
122
+ return promise;
123
+ }
124
+ function handleThemeMessage(event) {
125
+ const value = event.data;
126
+ if (!alive() || !value || typeof value !== "object" || !("type" in value) ||
127
+ (value.type !== "eh:themeApplied" && value.type !== "eh:setTheme"))
128
+ return false;
129
+ const channel = [currentTheme, previousTheme].find((candidate) => candidate && candidate.source &&
130
+ candidate.source === event.source && candidate.policy.incomingOrigin === event.origin &&
131
+ "capability" in value && value.capability === candidate.capability &&
132
+ "generation" in value && value.generation === candidate.generation &&
133
+ "pageKey" in value && value.pageKey === candidate.pageKey);
134
+ if (!channel)
135
+ return true;
136
+ if (channel === previousTheme && channel.source !== host.previousWindow) {
137
+ clearPreviousTheme();
138
+ return true;
139
+ }
140
+ if (value.type !== "eh:themeApplied" || !isThemePayload(value)) {
141
+ if (!channel.diagnosed) {
142
+ channel.diagnosed = true;
143
+ diagnostic("invalid-theme-acknowledgment");
144
+ }
145
+ return true;
146
+ }
147
+ if (!channel.pending || channel.pending.themeRevision !== value.themeRevision ||
148
+ channel.pending.theme !== value.theme)
149
+ return true;
150
+ channel.applied = { theme: value.theme, themeRevision: value.themeRevision };
151
+ cancelTimer(channel.timer);
152
+ channel.timer = null;
153
+ channel.pending = null;
154
+ channel.failed = false;
155
+ channel.settle?.(true);
156
+ channel.settle = null;
157
+ if (channel === currentTheme) {
158
+ exposeReady();
159
+ handoff();
160
+ }
161
+ publish();
162
+ return true;
163
+ }
164
+ function suspend(preserveTheme = false) {
53
165
  if (!alive())
54
166
  return;
55
167
  suspended();
@@ -57,6 +169,13 @@ export function createFrameController({ sessionId, host, request, suspended, tra
57
169
  flush.settle(false);
58
170
  configuration?.settle(false);
59
171
  expectedConfiguration = null;
172
+ cancelTheme(currentTheme);
173
+ cancelTheme(previousTheme);
174
+ if (!preserveTheme) {
175
+ previousTheme = null;
176
+ retainedCandidate = null;
177
+ }
178
+ currentTheme = null;
60
179
  for (const timer of timers)
61
180
  clearTimer(timer);
62
181
  timers.clear();
@@ -73,7 +192,8 @@ export function createFrameController({ sessionId, host, request, suspended, tra
73
192
  if (!alive())
74
193
  throw new Error("Review ended");
75
194
  transitioning();
76
- suspend();
195
+ retainedCandidate = currentTheme || retainedCandidate;
196
+ suspend(true);
77
197
  state.key = key;
78
198
  state.generation++;
79
199
  state.sourceHash = null;
@@ -117,6 +237,13 @@ export function createFrameController({ sessionId, host, request, suspended, tra
117
237
  state.phase = { kind: "loading" };
118
238
  initialLoad = true;
119
239
  host.navigate(value.path, state.reloading);
240
+ if (previousTheme?.source !== host.previousWindow)
241
+ clearPreviousTheme();
242
+ if (!previousTheme && retainedCandidate?.source === host.previousWindow)
243
+ previousTheme = retainedCandidate;
244
+ retainedCandidate = null;
245
+ if (previousTheme && !themeApplied(previousTheme))
246
+ void requestTheme(previousTheme);
120
247
  publish();
121
248
  readinessTimer = later(() => {
122
249
  if (state.key !== key || state.generation !== generation || !loading())
@@ -181,10 +308,15 @@ export function createFrameController({ sessionId, host, request, suspended, tra
181
308
  feedbackOnly: value.savePolicy === "feedback-only",
182
309
  executionNotice: typeof value.executionNotice === "string" ? value.executionNotice : null,
183
310
  };
184
- state.phase = { kind: "ready" };
185
- host.ready(state.execution);
186
- publish();
187
- return state.execution;
311
+ if (!state.policy || !state.key)
312
+ throw new Error("Missing frame policy.");
313
+ currentTheme = {
314
+ source: host.currentWindow, capability: state.capability, generation: state.generation,
315
+ pageKey: state.key, policy: { ...state.policy }, applied: null, pending: null,
316
+ failed: false, timer: null, settle: null, diagnosed: false,
317
+ };
318
+ await requestTheme(currentTheme);
319
+ return matches(started) && !loading() ? state.execution : null;
188
320
  }
189
321
  catch (error) {
190
322
  if (matches(started)) {
@@ -206,14 +338,15 @@ export function createFrameController({ sessionId, host, request, suspended, tra
206
338
  }
207
339
  }, 5000);
208
340
  }
209
- function configure(mode, savePolicy, wait = true) {
341
+ function configure(mode, savePolicy, canComment, wait = true) {
210
342
  if (loading() || !state.capability || !state.execution)
211
343
  return Promise.resolve(false);
212
344
  configuration?.settle(false);
213
- expectedConfiguration = { mode, savePolicy };
345
+ expectedConfiguration = { mode, savePolicy, canComment };
346
+ state.configurationGeneration = null;
214
347
  configurationTimeout();
215
348
  if (!wait) {
216
- send({ type: "eh:configureReview", mode, savePolicy });
349
+ send({ type: "eh:configureReview", mode, savePolicy, canComment });
217
350
  return Promise.resolve(true);
218
351
  }
219
352
  return new Promise((resolve) => {
@@ -225,8 +358,8 @@ export function createFrameController({ sessionId, host, request, suspended, tra
225
358
  cancelTimer(timer);
226
359
  resolve(applied);
227
360
  };
228
- configuration = { mode, savePolicy, settle };
229
- send({ type: "eh:configureReview", mode, savePolicy });
361
+ configuration = { mode, savePolicy, canComment, settle };
362
+ send({ type: "eh:configureReview", mode, savePolicy, canComment });
230
363
  });
231
364
  }
232
365
  function flush(strict = false) {
@@ -252,6 +385,40 @@ export function createFrameController({ sessionId, host, request, suspended, tra
252
385
  return {
253
386
  get state() { return state; },
254
387
  get loading() { return loading(); },
388
+ get themeSync() {
389
+ const channels = [currentTheme, previousTheme].filter((channel) => channel !== null);
390
+ const failure = channels.some((channel) => channel.failed);
391
+ return {
392
+ desired: { ...desired },
393
+ status: failure ? "failed" : channels.some((channel) => channel.pending) ? "pending" :
394
+ themeApplied(currentTheme) ? "applied" : "idle",
395
+ appliedRevision: currentTheme?.applied?.themeRevision ?? null,
396
+ message: failure ? "Annotation theme synchronization was not confirmed. Your page and drafts are unchanged. Retry theme without reloading." : null,
397
+ };
398
+ },
399
+ setTheme(theme) {
400
+ if (!alive() || (theme !== "light" && theme !== "dark") || desired.theme === theme)
401
+ return;
402
+ if (desired.themeRevision === Number.MAX_SAFE_INTEGER)
403
+ throw new Error("Theme revision limit reached.");
404
+ desired = { theme, themeRevision: desired.themeRevision + 1 };
405
+ if (currentTheme)
406
+ void requestTheme(currentTheme);
407
+ if (previousTheme && previousTheme.source === host.previousWindow)
408
+ void requestTheme(previousTheme);
409
+ publish();
410
+ },
411
+ async retryTheme() {
412
+ if (!alive())
413
+ return false;
414
+ const pending = [];
415
+ if (currentTheme)
416
+ pending.push(requestTheme(currentTheme));
417
+ if (previousTheme && previousTheme.source === host.previousWindow)
418
+ pending.push(requestTheme(previousTheme));
419
+ return pending.length > 0 && (await Promise.all(pending)).every(Boolean);
420
+ },
421
+ handleThemeMessage,
255
422
  identity, matches, begin, register, suspend, ready, configure, flush, send, fail,
256
423
  subscribe(listener) {
257
424
  listeners.add(listener);
@@ -277,24 +444,21 @@ export function createFrameController({ sessionId, host, request, suspended, tra
277
444
  return false;
278
445
  return value.capability === state.capability && value.generation === state.generation &&
279
446
  value.pageKey === state.key && typeof value.type === "string" &&
280
- (!loading() || value.type === "eh:ready");
447
+ (!loading() || value.type === "eh:ready" ||
448
+ (state.phase.kind === "confirming" && value.type === "eh:themeApplied" &&
449
+ isThemePayload(value) && value.theme === currentTheme?.pending?.theme &&
450
+ value.themeRevision === currentTheme.pending.themeRevision));
281
451
  },
282
- configured(mode, savePolicy) {
452
+ configured(mode, savePolicy, canComment) {
283
453
  if (!expectedConfiguration || expectedConfiguration.mode !== mode ||
284
- expectedConfiguration.savePolicy !== savePolicy || loading())
454
+ expectedConfiguration.savePolicy !== savePolicy || expectedConfiguration.canComment !== canComment || loading())
285
455
  return false;
286
456
  // Confirmation is complete even when background tabs pause the visual handoff.
287
457
  cancelTimer(configurationTimer);
288
458
  configurationTimer = null;
289
459
  state.configurationGeneration = state.generation;
290
- const started = identity();
291
- const appliedConfiguration = expectedConfiguration;
292
- if (host.previous)
293
- host.afterPaint(() => {
294
- if (matches(started) && expectedConfiguration === appliedConfiguration && !loading())
295
- finishReplacement();
296
- });
297
- if (configuration && configuration.mode === mode && configuration.savePolicy === savePolicy) {
460
+ handoff();
461
+ if (configuration && configuration.mode === mode && configuration.savePolicy === savePolicy && configuration.canComment === canComment) {
298
462
  configuration.settle(true);
299
463
  }
300
464
  return true;
@@ -313,6 +477,7 @@ export function createFrameController({ sessionId, host, request, suspended, tra
313
477
  suspend();
314
478
  state.phase = { kind: "disposed" };
315
479
  stopLoad();
480
+ stopRemoval();
316
481
  host.dispose();
317
482
  listeners.clear();
318
483
  },
package/lib/frame-host.js CHANGED
@@ -4,6 +4,7 @@ export function createFrameHost(initial, artifactOrigin) {
4
4
  let disposed = false;
5
5
  const loadListeners = new Set();
6
6
  const focusListeners = new Set();
7
+ const removalListeners = new Set();
7
8
  const detach = new Map();
8
9
  const paintFrames = new Set();
9
10
  function paint(callback) {
@@ -41,6 +42,12 @@ export function createFrameHost(initial, artifactOrigin) {
41
42
  return {
42
43
  get current() { return current; },
43
44
  get previous() { return previous; },
45
+ get currentWindow() { return current.contentWindow; },
46
+ get previousWindow() { return previous?.contentWindow || null; },
47
+ onPreviousRemoved(listener) {
48
+ removalListeners.add(listener);
49
+ return () => { removalListeners.delete(listener); };
50
+ },
44
51
  get visibleExecution() {
45
52
  const visible = previous || current;
46
53
  const savePolicy = visible.dataset.savePolicy;
@@ -98,6 +105,8 @@ export function createFrameHost(initial, artifactOrigin) {
98
105
  if (previous)
99
106
  remove(previous);
100
107
  previous = null;
108
+ for (const listener of removalListeners)
109
+ listener();
101
110
  delete current.dataset.replacing;
102
111
  },
103
112
  suspend() { current.removeAttribute("data-sdk-ready"); },
@@ -131,6 +140,7 @@ export function createFrameHost(initial, artifactOrigin) {
131
140
  paintFrames.clear();
132
141
  loadListeners.clear();
133
142
  focusListeners.clear();
143
+ removalListeners.clear();
134
144
  },
135
145
  };
136
146
  }
@@ -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
  }