@erdemtuna/doc-review 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +1118 -2787
  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,175 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { parse, parseFragment, serialize, serializeOuter } from "parse5";
5
+ import { documentExecutionPolicy } from "./document-execution.js";
6
+ import { isMarkdown } from "./markdown.js";
7
+ import { stripSdk } from "./html-transform.js";
8
+ import { stateDir } from "./paths.js";
9
+ import { reject, rejectSave } from "./contracts/validation.js";
10
+ import { REVISION_LIMITS } from "./revision-schema.js";
11
+ export const sourceHash = (text) => crypto.createHash("sha1").update(text).digest("hex");
12
+ export const stagedRoot = (key) => path.join(stateDir(), "conversation-pasted", key);
13
+ export function readSource(page) {
14
+ if (page.kind === "url")
15
+ return { text: null, hash: null, writable: false };
16
+ try {
17
+ if (fs.statSync(page.file).size > REVISION_LIMITS.sourceBytes)
18
+ reject("SNAPSHOT_TOO_LARGE", "Source exceeds snapshot safety bound.");
19
+ const text = fs.readFileSync(page.file, "utf8");
20
+ if (Buffer.byteLength(text) > REVISION_LIMITS.sourceBytes)
21
+ reject("SNAPSHOT_TOO_LARGE", "Source exceeds snapshot safety bound.");
22
+ const policy = documentExecutionPolicy({ ...page, markdown: isMarkdown(page.file) }, text);
23
+ return { text, hash: sourceHash(text), writable: policy.savePolicy === "writable" };
24
+ }
25
+ catch (error) {
26
+ if (["ENOENT", "EACCES", "EPERM"].includes(error.code)) {
27
+ return { text: null, hash: null, writable: false };
28
+ }
29
+ throw error;
30
+ }
31
+ }
32
+ const textOf = (node) => node.nodeName === "#text" ? node.value : (node.childNodes || []).map(textOf).join("");
33
+ function elements(root) {
34
+ const out = [];
35
+ const visit = (node) => {
36
+ if (node.tagName && !["html", "head", "body"].includes(node.tagName))
37
+ out.push(node);
38
+ for (const child of node.childNodes || [])
39
+ visit(child);
40
+ };
41
+ visit(root);
42
+ return out;
43
+ }
44
+ function matches(root, html, text) {
45
+ if (html !== undefined) {
46
+ const expected = serialize(parseFragment(html));
47
+ return elements(root).filter((node) => serializeOuter(node) === expected);
48
+ }
49
+ // Prefer the innermost exact block; ambiguity is rejected rather than guessed.
50
+ const candidates = elements(root).filter((node) => textOf(node) === text);
51
+ return candidates.filter((node) => !candidates.some((other) => other.parentNode === node));
52
+ }
53
+ function one(root, html, text) {
54
+ const found = matches(root, html, text);
55
+ if (found.length !== 1)
56
+ rejectSave("evidence-mismatch", "Edit source is absent or ambiguous; preserve it as source-pending.");
57
+ return found[0];
58
+ }
59
+ const conflict = (message) => rejectSave("evidence-mismatch", message);
60
+ function neighborMatches(node, label) {
61
+ const text = textOf(node || {});
62
+ const flat = text.replace(/\s+/g, " ").trim();
63
+ const summary = flat.length > 90 ? `${flat.slice(0, 89).trimEnd()}\u2026` : flat;
64
+ return text === label || summary === label;
65
+ }
66
+ function neighbor(parent, label) {
67
+ if (!label)
68
+ return null;
69
+ const found = parent.childNodes.filter((node) => node.tagName && neighborMatches(node, label));
70
+ if (found.length !== 1)
71
+ conflict("Move boundary is absent or ambiguous.");
72
+ return found[0];
73
+ }
74
+ /** Prove a single recorded transition, not a client assertion that HTML was saved. */
75
+ function applyEdit(document, edit, priorContent) {
76
+ if (edit.truncated)
77
+ conflict("Truncated edits cannot be saved as complete source.");
78
+ for (const content of [edit, ...(priorContent ? [priorContent] : [])]) {
79
+ for (const field of ["before", "after"]) {
80
+ if (content[field] !== undefined && content[`${field}_html`] !== undefined &&
81
+ textOf(parseFragment(content[`${field}_html`])) !== content[field]) {
82
+ conflict("HTML and text must describe the same exact edit.");
83
+ }
84
+ }
85
+ }
86
+ const before = priorContent || edit;
87
+ const node = one(document, priorContent ? before.after_html : before.before_html, priorContent ? before.after : before.before);
88
+ const parent = node.parentNode;
89
+ const index = parent.childNodes.indexOf(node);
90
+ if (edit.kind === "deleted")
91
+ parent.childNodes.splice(index, 1);
92
+ else if (edit.kind === "moved") {
93
+ parent.childNodes.splice(index, 1);
94
+ const after = neighbor(parent, edit.moved_after);
95
+ const next = neighbor(parent, edit.moved_before);
96
+ if ((!after && !next) || (after && after.parentNode !== parent) || (next && next.parentNode !== parent)) {
97
+ conflict("Move requires exact sibling boundaries.");
98
+ }
99
+ const position = after ? parent.childNodes.indexOf(after) + 1 : parent.childNodes.indexOf(next);
100
+ if (next && parent.childNodes.slice(position, parent.childNodes.indexOf(next)).some((item) => item.tagName)) {
101
+ conflict("Move boundaries disagree.");
102
+ }
103
+ parent.childNodes.splice(position, 0, node);
104
+ }
105
+ else if (edit.after_html !== undefined) {
106
+ const replacements = parseFragment(parent, edit.after_html).childNodes;
107
+ for (const replacement of replacements)
108
+ replacement.parentNode = parent;
109
+ parent.childNodes.splice(index, 1, ...replacements);
110
+ }
111
+ else {
112
+ node.childNodes = [{ nodeName: "#text", value: edit.after, parentNode: node }];
113
+ }
114
+ }
115
+ /** Every saved delta must be explained by exact, recorded transitions. */
116
+ export function proveSave(current, candidate, transitions) {
117
+ if (Buffer.byteLength(candidate) > REVISION_LIMITS.sourceBytes)
118
+ reject("SNAPSHOT_TOO_LARGE", "Saved source exceeds snapshot safety bound.");
119
+ if (candidate !== stripSdk(candidate))
120
+ rejectSave("unsafe-content", "Injected review markup cannot be saved as source.");
121
+ if (documentExecutionPolicy({ kind: "file", markdown: false }, candidate).savePolicy !== "writable") {
122
+ rejectSave("unsafe-content", "This change introduces executable content; retain it as source-pending for source-directed handling.");
123
+ }
124
+ const document = parse(current);
125
+ for (const { content, priorContent } of transitions)
126
+ applyEdit(document, content, priorContent);
127
+ if (serialize(document) !== serialize(parse(candidate))) {
128
+ conflict("Saved HTML must contain exactly the recorded transitions; record other edits separately.");
129
+ }
130
+ }
131
+ export function sourceEditContent(edit) {
132
+ const content = { ...edit.content };
133
+ for (const field of ["before_html", "after_html"]) {
134
+ if (content[field] === undefined)
135
+ continue;
136
+ for (const asset of edit.assets)
137
+ content[field] = content[field].replaceAll(asset.preview_src, `assets/${asset.id}`);
138
+ }
139
+ return content;
140
+ }
141
+ export function editIncluded(text, content) {
142
+ const document = parse(text);
143
+ if (content.kind === "deleted")
144
+ return matches(document, content.before_html, content.before).length === 0;
145
+ const found = matches(document, content.after_html ?? content.before_html, content.after ?? content.before);
146
+ if (found.length !== 1)
147
+ return false;
148
+ if (content.kind !== "moved")
149
+ return true;
150
+ const siblings = found[0].parentNode.childNodes.filter((node) => node.tagName);
151
+ const index = siblings.indexOf(found[0]);
152
+ return (!content.moved_after || neighborMatches(siblings[index - 1], content.moved_after)) &&
153
+ (!content.moved_before || neighborMatches(siblings[index + 1], content.moved_before));
154
+ }
155
+ export function resolveEditAssets(key, content) {
156
+ return content.staged_assets.map(({ id, preview_src }) => {
157
+ if (path.basename(id) !== id || /[\\/:]/.test(id) || id === "." || id === "..")
158
+ reject("INVALID_INPUT", "Invalid staged asset identity.");
159
+ const root = stagedRoot(key);
160
+ const file = path.join(root, id);
161
+ let real;
162
+ try {
163
+ real = fs.realpathSync(file);
164
+ }
165
+ catch (error) {
166
+ if (error.code !== "ENOENT")
167
+ throw error;
168
+ reject("NOT_FOUND", "Staged asset is missing.");
169
+ }
170
+ if (path.dirname(real) !== fs.realpathSync(root) || !fs.statSync(real).isFile() ||
171
+ preview_src !== `__doc_review_paste__/${id}`)
172
+ reject("SCOPE_MISMATCH", "Asset is outside this page.");
173
+ return { id, path: real, preview_src };
174
+ });
175
+ }
@@ -0,0 +1,184 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { contractFailure, ContractError, CONTRACT_LIMITS, reject, object, id, enumeration, integer, nullable, optional, timestamp, schema, } from "./contracts/validation.js";
5
+ import { reviewReadRequestSchema } from "./contracts/page-boundary.js";
6
+ import { readAgent } from "./agent-output.js";
7
+ import { stagedRoot } from "./conversation-save.js";
8
+ import { compareRevisions } from "./history-server.js";
9
+ import { normalizeSemanticSnapshot } from "./revision-schema.js";
10
+ import { normalizeView } from "./view-identity.js";
11
+ export async function readConversationBody(req) {
12
+ let size = 0;
13
+ const chunks = [];
14
+ for await (const chunk of req) {
15
+ size += chunk.length;
16
+ if (size <= CONTRACT_LIMITS.requestBytes)
17
+ chunks.push(chunk);
18
+ }
19
+ if (size > CONTRACT_LIMITS.requestBytes)
20
+ reject("INPUT_TOO_LARGE", "Request exceeds 24 MiB UTF-8.");
21
+ try {
22
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
23
+ }
24
+ catch (error) {
25
+ if (!(error instanceof SyntaxError))
26
+ throw error;
27
+ reject("MALFORMED_JSON", "Expected a JSON document.");
28
+ }
29
+ }
30
+ export function conversationFailure(error) {
31
+ if (error instanceof ContractError)
32
+ return contractFailure(error);
33
+ const known = {
34
+ SNAPSHOT_TOO_LARGE: "SNAPSHOT_TOO_LARGE", INVALID_REVISION: "INVALID_INPUT",
35
+ history_invalid_request: "INVALID_INPUT", history_frame_changed: "VERSION_CONFLICT",
36
+ history_source_changed: "SAVE_EVIDENCE_CONFLICT", history_source_unavailable: "SAVE_EVIDENCE_CONFLICT",
37
+ history_source_too_large: "SNAPSHOT_TOO_LARGE",
38
+ }[error.code];
39
+ if (known)
40
+ return contractFailure(new ContractError(known, error.message));
41
+ console.error("[doc-review] conversation request failed:", error);
42
+ return contractFailure(new ContractError("INTERNAL_ERROR", error.message));
43
+ }
44
+ export function createConversationController({ store, sessions, watchPage, json, emit, sourceWritten, currentRender, captureObservation, cliInvocation }) {
45
+ store.conversations.observationIsCurrent = (review, key, observation) => {
46
+ const session = sessions.get(observation.sessionId);
47
+ const render = currentRender(observation.renderId);
48
+ return session?.reviewId === review.reviewId && render?.sessionId === session.id && render.pageKey === key &&
49
+ render.generation === observation.generation && render.documentState === "served";
50
+ };
51
+ const attach = (reference) => {
52
+ const review = store.conversations.read({ operation: "read-review", ...reference });
53
+ const record = store.data.conversations.reviews[review.reviewId];
54
+ const sessionId = `s_${crypto.randomBytes(16).toString("hex")}`;
55
+ const session = {
56
+ id: sessionId, reviewId: review.reviewId, entryKey: review.entryKey, activeKey: review.entryKey,
57
+ generation: 0, renderId: null, executionPreferences: new Map(),
58
+ visited: new Set(Object.keys(record.pages)), clients: new Set(), lastSeen: Date.now(),
59
+ };
60
+ sessions.set(sessionId, session);
61
+ for (const key of session.visited)
62
+ watchPage(key);
63
+ return { sessionId, review, path: `/r/${review.reviewId}` };
64
+ };
65
+ const changed = () => {
66
+ // An overlapping review's permissions may have changed too. Always refetch.
67
+ for (const session of sessions.values())
68
+ if (session.reviewId) {
69
+ emit(session, "invalidate", { reviewId: session.reviewId });
70
+ }
71
+ };
72
+ const capture = (reference) => {
73
+ try {
74
+ store.conversations.captureResults(reference);
75
+ }
76
+ catch (error) {
77
+ console.error("[doc-review] result capture state not persisted; restart retries:", error.message);
78
+ }
79
+ };
80
+ for (const record of Object.values(store.data.conversations.reviews))
81
+ capture(record.review);
82
+ async function handle(req, res, url) {
83
+ if (!url.pathname.startsWith("/api/conversation"))
84
+ return false;
85
+ try {
86
+ if (req.method !== "POST")
87
+ reject("INVALID_INPUT", "Conversation endpoints require POST.");
88
+ const input = await readConversationBody(req);
89
+ let result;
90
+ if (url.pathname === "/api/conversation") {
91
+ const before = store.data;
92
+ result = store.conversations.execute(input);
93
+ if (before !== store.data && (input.operation === "save-edit" || input.operation === "revert"))
94
+ sourceWritten(input.pageKey);
95
+ if (input.operation === "respond")
96
+ capture(input);
97
+ if (input.operation === "respond" && before !== store.data) {
98
+ const submission = store.data.conversations.reviews[input.reviewId].submissions[input.submissionId];
99
+ const result = store.data.conversations.reviews[input.reviewId].results[submission.resultId];
100
+ if (result.effect === "changes-reported")
101
+ for (const session of sessions.values()) {
102
+ if (session.reviewId === input.reviewId && submission.pageKeys.includes(session.activeKey)) {
103
+ emit(session, "reload", { key: session.activeKey, reason: "submission-result" });
104
+ }
105
+ }
106
+ }
107
+ if (before !== store.data)
108
+ changed();
109
+ }
110
+ else if (url.pathname === "/api/conversation/agent") {
111
+ result = readAgent(store.conversations, input, cliInvocation);
112
+ }
113
+ else if (url.pathname === "/api/conversation/session") {
114
+ const request = reviewReadRequestSchema.parse(input);
115
+ if (request.operation !== "read-review")
116
+ reject("INVALID_INPUT", "Attach requires a read-review reference.");
117
+ result = attach({ reviewId: request.reviewId, entryKey: request.entryKey });
118
+ }
119
+ else if (url.pathname === "/api/conversation/capture") {
120
+ const request = object({
121
+ reviewId: id, entryKey: id, pageKey: id, submissionId: nullable(id),
122
+ sessionId: id, renderId: id, generation: integer(1), expectedSourceHash: nullable(id),
123
+ semantic: schema((value) => normalizeSemanticSnapshot(value)),
124
+ semanticCapturedAt: optional(timestamp), view: optional(schema((value) => normalizeView(value))),
125
+ }).parse(input);
126
+ store.conversations.read({ operation: "read-page", reviewId: request.reviewId, entryKey: request.entryKey, pageKey: request.pageKey });
127
+ if (request.submissionId === null && store.conversations.read({
128
+ operation: "read-review", reviewId: request.reviewId, entryKey: request.entryKey,
129
+ }).state !== "open")
130
+ reject("REVIEW_ENDED", "Ended review cannot replace a Send baseline.");
131
+ const session = sessions.get(request.sessionId);
132
+ if (session?.reviewId !== request.reviewId || session.entryKey !== request.entryKey)
133
+ reject("SCOPE_MISMATCH", "Capture frame belongs to another review.");
134
+ const saved = captureObservation(session, request.pageKey, request, request.submissionId === null ? "submission-baseline" : "submission-result");
135
+ result = store.conversations.observe(request, {
136
+ revisionId: saved.revisionId, sessionId: session.id, renderId: request.renderId, generation: request.generation,
137
+ });
138
+ changed();
139
+ }
140
+ else if (url.pathname === "/api/conversation/asset") {
141
+ const request = object({ reviewId: id, entryKey: id, pageKey: id,
142
+ type: enumeration(["image/png", "image/jpeg", "image/gif", "image/webp"]), base64: id }).parse(input);
143
+ const page = store.conversations.read({
144
+ operation: "read-page", reviewId: request.reviewId, entryKey: request.entryKey, pageKey: request.pageKey,
145
+ });
146
+ const review = store.conversations.read({ operation: "read-review", reviewId: request.reviewId, entryKey: request.entryKey });
147
+ if (review.state !== "open")
148
+ reject("REVIEW_ENDED", "Ended review assets are read-only.");
149
+ if (page.writeBlockedBy.length)
150
+ reject("WORK_OUTSTANDING", "Target has outstanding work.");
151
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(request.base64))
152
+ reject("INVALID_INPUT", "Invalid base64 image.");
153
+ const bytes = Buffer.from(request.base64, "base64");
154
+ if (!bytes.length)
155
+ reject("INVALID_INPUT", "Empty image.");
156
+ const extension = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" }[request.type];
157
+ const assetId = `paste_${crypto.randomBytes(16).toString("hex")}.${extension}`;
158
+ fs.mkdirSync(stagedRoot(request.pageKey), { recursive: true, mode: 0o700 });
159
+ fs.writeFileSync(path.join(stagedRoot(request.pageKey), assetId), bytes, { flag: "wx", mode: 0o600 });
160
+ result = { id: assetId, preview_src: `__doc_review_paste__/${assetId}` };
161
+ }
162
+ else if (url.pathname === "/api/conversation/comparison") {
163
+ const request = object({ reviewId: id, entryKey: id, submissionId: id, pageKey: id, mode: enumeration(["source", "content"]) }).parse(input);
164
+ store.conversations.read({ operation: "submission", reviewId: request.reviewId, entryKey: request.entryKey, submissionId: request.submissionId });
165
+ const record = store.data.conversations.reviews[request.reviewId];
166
+ const comparison = Object.values(record.comparisons).find((item) => item.submissionId === request.submissionId && item.pageKey === request.pageKey);
167
+ if (!comparison)
168
+ reject("SCOPE_MISMATCH", "Page has no comparison in this submission.");
169
+ result = compareRevisions(store.revisions, comparison.baselineRevisionId, comparison.resultRevisionId, request.mode, {
170
+ resultUnavailable: comparison.reason,
171
+ });
172
+ }
173
+ else
174
+ reject("NOT_FOUND", "Unknown conversation endpoint.");
175
+ json(res, 200, result);
176
+ }
177
+ catch (error) {
178
+ const failure = conversationFailure(error);
179
+ json(res, failure.error.status, failure);
180
+ }
181
+ return true;
182
+ }
183
+ return { attach, changed, handle };
184
+ }