agentic-comments 0.0.0-stage → 0.2.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.
@@ -0,0 +1,770 @@
1
+ // src/errors.ts
2
+ var CommentsError = class extends Error {
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = "CommentsError";
7
+ }
8
+ code;
9
+ };
10
+
11
+ // src/anchor.ts
12
+ import * as Y from "yjs";
13
+
14
+ // src/text.ts
15
+ var HIDDEN = /[\p{Cc}\p{Cf}]/u;
16
+ var ID = /^[\w-]{1,64}$/;
17
+ var BODY_FORBIDDEN = /(?![\n\r\t\u200C\u200D])[\p{Cc}\p{Cf}]/u;
18
+ var VARIATION = /[\uFE00-\uFE0F\u{E0100}-\u{E01EF}]/u;
19
+ var safeId = (value) => String(value).slice(0, 64).replace(/[^\p{L}\p{N}_-]/gu, "?");
20
+ var shown = (value) => JSON.stringify(safeId(value));
21
+ var safeText = (value) => String(value).replace(/[^\p{L}\p{N}\p{P}\p{S} ]/gu, "?").slice(0, 200);
22
+
23
+ // src/anchor.ts
24
+ var MAX_QUOTE = 2e3;
25
+ var MAX_ANCHOR = 4096;
26
+ var MAX_KEY = 256;
27
+ var bad = (message) => {
28
+ throw new CommentsError("INVALID_ANCHOR", message);
29
+ };
30
+ function key(raw, field) {
31
+ if (typeof raw !== "string" || raw.trim() === "" || raw.length > MAX_KEY || HIDDEN.test(raw)) bad(`${field} must be text of 1 to ${MAX_KEY} characters, with no control or invisible characters`);
32
+ return raw;
33
+ }
34
+ function id(raw, field) {
35
+ const { client, clock } = raw ?? {};
36
+ if (!Number.isSafeInteger(client) || !Number.isSafeInteger(clock) || client < 0 || clock < 0) {
37
+ bad(`${field} must hold a client and a clock that are whole numbers of 0 or more`);
38
+ }
39
+ return { client, clock };
40
+ }
41
+ function position(raw, field) {
42
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return bad(`${field} must be a relative position`);
43
+ const p = raw;
44
+ const out = {};
45
+ if (p.type !== void 0) out.type = id(p.type, `${field}.type`);
46
+ if (p.item !== void 0) out.item = id(p.item, `${field}.item`);
47
+ if (p.tname !== void 0) out.tname = key(p.tname, `${field}.tname`);
48
+ if (p.assoc !== void 0) {
49
+ if (p.assoc !== 0 && p.assoc !== -1) bad(`${field}.assoc must be 0 or -1`);
50
+ out.assoc = p.assoc;
51
+ }
52
+ if (out.type === void 0 && out.item === void 0 && out.tname === void 0) bad(`${field} must name an item or a type`);
53
+ return out;
54
+ }
55
+ var parseText = (raw) => {
56
+ if (typeof raw.text !== "string" || raw.text === "" || raw.text.length > MAX_QUOTE) bad(`text must be 1 to ${MAX_QUOTE} characters`);
57
+ const start = position(raw.start, "start");
58
+ const end = position(raw.end, "end");
59
+ const owner = (p) => JSON.stringify([p.type ?? null, p.tname ?? null]);
60
+ if (start.item === void 0 || end.item === void 0) bad("a text range needs an item position at each end");
61
+ if (start.type && start.tname || end.type && end.tname || owner(start) !== owner(end)) bad("both ends of a text range must name the same Yjs type");
62
+ const anchor = { type: "text", text: raw.text, start, end };
63
+ if (JSON.stringify(anchor).length > MAX_ANCHOR) bad(`an anchor holds up to ${MAX_ANCHOR} characters of JSON, so the quoted text is too long`);
64
+ return anchor;
65
+ };
66
+ var BUILT_IN_KINDS = [
67
+ { type: "text", parse: parseText },
68
+ { type: "cell", parse: (raw) => ({ type: "cell", key: key(raw.key, "key") }) },
69
+ { type: "slide", parse: (raw) => ({ type: "slide", elementId: key(raw.elementId, "elementId") }) },
70
+ { type: "canvas", parse: (raw) => ({ type: "canvas", elementId: key(raw.elementId, "elementId") }) }
71
+ ];
72
+ function textOf(type) {
73
+ return type.toDelta().map((d) => typeof d.insert === "string" ? d.insert : "\uFFFC").join("");
74
+ }
75
+ function textAnchor(type, from, to) {
76
+ try {
77
+ const value = textOf(type);
78
+ if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from < 0 || to <= from || to > value.length) {
79
+ bad(`the range ${from}..${to} is not inside the text, which has ${value.length} characters`);
80
+ }
81
+ return parseText({
82
+ text: value.slice(from, to),
83
+ start: Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(type, from, 0)),
84
+ end: Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(type, to, -1))
85
+ });
86
+ } catch (error) {
87
+ if (error instanceof CommentsError) throw error;
88
+ throw new CommentsError("INVALID_ANCHOR", `the text cannot be anchored: ${error instanceof Error ? error.message : String(error)}`);
89
+ }
90
+ }
91
+ function place(json, ydoc) {
92
+ try {
93
+ return Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(json), ydoc);
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+ function textRange(anchor, ydoc) {
99
+ const from = place(anchor.start, ydoc);
100
+ const to = place(anchor.end, ydoc);
101
+ if (!from || !to || !(from.type instanceof Y.Text) || to.type !== from.type) return null;
102
+ return { type: from.type, from: from.index, to: to.index };
103
+ }
104
+ function textState(anchor, ydoc) {
105
+ const range = textRange(anchor, ydoc);
106
+ if (!range || range.to <= range.from) return { state: "orphaned" };
107
+ const current = textOf(range.type).slice(range.from, range.to);
108
+ return current === anchor.text ? { state: "attached" } : { state: "edited", currentText: current.slice(0, MAX_QUOTE) };
109
+ }
110
+ function onlyOccurrence(type, quote) {
111
+ const value = textOf(type);
112
+ const first = value.indexOf(quote);
113
+ return first >= 0 && value.indexOf(quote, first + 1) < 0 ? first : void 0;
114
+ }
115
+
116
+ // src/principal.ts
117
+ var PRINCIPAL_KINDS = ["human", "agent_of_human", "agent_of_agent", "agent", "user_token"];
118
+ var MAX_NAME = 128;
119
+ var issued = /* @__PURE__ */ new WeakSet();
120
+ var Actor = class _Actor {
121
+ kind;
122
+ name;
123
+ scopes;
124
+ constructor(kind, name, scopes) {
125
+ this.kind = kind;
126
+ this.name = name;
127
+ this.scopes = Object.freeze([...scopes]);
128
+ Object.freeze(this);
129
+ }
130
+ static of(input) {
131
+ const author = readAuthor(input);
132
+ const given = author ? input.scopes : void 0;
133
+ const scopes = given === void 0 ? [] : Array.isArray(given) ? Array.from(given) : void 0;
134
+ if (!author || !scopes || !scopes.every((s) => typeof s === "string" && s !== "")) {
135
+ throw new CommentsError("INVALID_AUTHOR", `an author needs a kind (${PRINCIPAL_KINDS.join(", ")}), a name, and scopes that are a list of text`);
136
+ }
137
+ const actor = new _Actor(author.kind, author.name, scopes);
138
+ issued.add(actor);
139
+ return actor;
140
+ }
141
+ static is(value) {
142
+ return typeof value === "object" && value !== null && issued.has(value);
143
+ }
144
+ };
145
+ var stamp = (input) => Actor.of(input);
146
+ function assertActor(value) {
147
+ if (!Actor.is(value) || !readAuthor(value)) throw new CommentsError("INVALID_AUTHOR", "the host must pass an author made with stamp()");
148
+ return value;
149
+ }
150
+ function readAuthor(value) {
151
+ if (typeof value !== "object" || value === null) return void 0;
152
+ const { kind, name } = value;
153
+ if (!PRINCIPAL_KINDS.includes(kind)) return void 0;
154
+ if (typeof name !== "string" || name === "" || name !== name.trim() || name.length > MAX_NAME || HIDDEN.test(name) || VARIATION.test(name) || name !== name.normalize("NFC")) return void 0;
155
+ return { kind, name };
156
+ }
157
+ function authorLabel(author) {
158
+ return author.kind === "human" ? "person" : author.kind === "user_token" ? "person via agent" : "agent";
159
+ }
160
+
161
+ // src/suggestions.ts
162
+ import * as Y2 from "yjs";
163
+
164
+ // src/validate.ts
165
+ var invalid = (message) => {
166
+ throw new CommentsError("INVALID_INPUT", message);
167
+ };
168
+ function exact(input, allowed, what) {
169
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return invalid(`${what} must be an object`);
170
+ const extra = Object.keys(input).filter((k) => !allowed.includes(k));
171
+ if (extra.length > 0) invalid(`${what} does not accept ${extra.join(", ")}`);
172
+ return input;
173
+ }
174
+ var ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
175
+ var validDate = (value) => typeof value === "string" && ISO_TIME.test(value) && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value;
176
+ var copy = (value) => JSON.parse(JSON.stringify(value));
177
+ var strictCopy = (value) => JSON.parse(
178
+ JSON.stringify(value, (_key, v) => {
179
+ if (v === void 0 || typeof v === "number" && !Number.isFinite(v)) throw new Error("a value is not valid JSON data");
180
+ return v;
181
+ })
182
+ );
183
+
184
+ // src/suggestions.ts
185
+ var ACCEPT_SCOPE = "comments:accept";
186
+ var MAX_CHANGE = 64e3;
187
+ var MAX_RECEIPT = 16e3;
188
+ var MAX_SUMMARY = 1e3;
189
+ var SUGGESTIONS = "comments.suggestions";
190
+ var APPLIED = "comments.suggestion-applied";
191
+ var REJECTED = "comments.suggestion-rejected";
192
+ var decidesOwn = (author, decider) => author.kind !== "human" && decider.kind !== "human" && author.name === decider.name;
193
+ var clientOf = (text) => /^(0|[1-9]\d{0,9})$/.test(text) && Number(text) <= 4294967295 ? Number(text) : void 0;
194
+ var thenable = (value) => typeof value?.then === "function";
195
+ var Suggestions = class {
196
+ constructor(comments, host) {
197
+ this.comments = comments;
198
+ this.host = host;
199
+ if (typeof host?.apply !== "function" || typeof host.revert !== "function" && host.idempotent !== true) {
200
+ invalid("a host needs apply, and either revert or idempotent: true, so two accepts at once cannot apply a change twice");
201
+ }
202
+ void [this.map, this.applied, this.rejected];
203
+ this.onTransaction = (tr) => {
204
+ if (!tr.local && [this.applied, this.map].some((type) => tr.changedParentTypes.has(type))) this.settle();
205
+ };
206
+ this.ydoc.on("afterTransaction", this.onTransaction);
207
+ this.settle();
208
+ }
209
+ comments;
210
+ host;
211
+ onTransaction;
212
+ failures = [];
213
+ /** Stop listening to the document. */
214
+ close() {
215
+ this.ydoc.off("afterTransaction", this.onTransaction);
216
+ }
217
+ get ydoc() {
218
+ return this.comments.ydoc;
219
+ }
220
+ get map() {
221
+ return this.ydoc.getMap(SUGGESTIONS);
222
+ }
223
+ /** The three Yjs maps that hold suggestions and their decisions. `Notifications` watches them. */
224
+ get maps() {
225
+ return { suggestions: this.map, applied: this.applied, rejected: this.rejected };
226
+ }
227
+ get applied() {
228
+ return this.ydoc.getMap(APPLIED);
229
+ }
230
+ get rejected() {
231
+ return this.ydoc.getMap(REJECTED);
232
+ }
233
+ change(raw, anchor) {
234
+ let clean;
235
+ try {
236
+ clean = strictCopy(raw);
237
+ if (this.host.parse) clean = strictCopy(this.host.parse(copy(clean), { anchor: copy(anchor) }));
238
+ } catch (error) {
239
+ return invalid(`the change is refused: ${safeText(error instanceof Error ? error.message : error)}`);
240
+ }
241
+ if (JSON.stringify(clean).length > MAX_CHANGE) invalid(`a change holds up to ${MAX_CHANGE} characters of JSON`);
242
+ return clean;
243
+ }
244
+ /**
245
+ * Records of one kind for one suggestion, in client order. A record that is not valid is not counted:
246
+ * one with no author or time, a receipt that is not small JSON, or a decider who decided their own work.
247
+ */
248
+ records(entries, id2, author) {
249
+ const out = [];
250
+ for (const [key2, raw] of entries) {
251
+ const client = clientOf(key2.slice(id2.length + 1));
252
+ const r = raw ?? {};
253
+ const by = readAuthor(r.by);
254
+ if (client === void 0 || !by || !validDate(r.at) || !author || decidesOwn(author, by)) continue;
255
+ let receipt = null;
256
+ try {
257
+ receipt = r.receipt === void 0 ? null : strictCopy(r.receipt);
258
+ if (JSON.stringify(receipt).length > MAX_RECEIPT) continue;
259
+ } catch {
260
+ continue;
261
+ }
262
+ out.push({ client, by, at: r.at, receipt });
263
+ }
264
+ return out.sort((a, b) => Number(a.receipt !== null) - Number(b.receipt !== null) || a.client - b.client);
265
+ }
266
+ /** Group the entries of a decision map by suggestion id in one pass, so a read does not scan the map per suggestion. */
267
+ index(map) {
268
+ const groups = /* @__PURE__ */ new Map();
269
+ for (const entry of map.entries()) {
270
+ const id2 = entry[0].slice(0, entry[0].lastIndexOf(":"));
271
+ const group = groups.get(id2);
272
+ if (group) group.push(entry);
273
+ else groups.set(id2, [entry]);
274
+ }
275
+ return groups;
276
+ }
277
+ read(id2, fields, indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) }) {
278
+ try {
279
+ if (!ID.test(id2)) throw new Error("the suggestion id is not short and plain");
280
+ if (!(fields instanceof Y2.Map)) throw new Error("the suggestion is not a map");
281
+ const author = readAuthor(fields.get("author"));
282
+ const createdAt = fields.get("createdAt");
283
+ const summary = fields.get("summary");
284
+ const change = fields.get("change");
285
+ if (!author || !validDate(createdAt)) throw new Error("the suggestion has no valid author or time");
286
+ if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
287
+ throw new Error("the summary is not valid");
288
+ }
289
+ if (change === void 0 || JSON.stringify(strictCopy(change)).length > MAX_CHANGE) throw new Error("the change is missing or too large");
290
+ const won = this.records(indexes.applied.get(id2) ?? [], id2, author)[0];
291
+ const refused = this.records(indexes.rejected.get(id2) ?? [], id2, author)[0];
292
+ const decision = won ? { status: "accepted", ...won } : refused ? { status: "rejected", ...refused } : void 0;
293
+ const anchor = this.comments.parseAnchor(fields.get("anchor"));
294
+ return {
295
+ id: id2,
296
+ anchor,
297
+ anchorState: this.comments.anchorState(anchor),
298
+ change: copy(change),
299
+ ...summary === void 0 ? {} : { summary },
300
+ author,
301
+ createdAt,
302
+ status: decision?.status ?? "pending",
303
+ ...decision ? { decidedBy: decision.by, decidedAt: decision.at } : {}
304
+ };
305
+ } catch (error) {
306
+ throw new CommentsError("CORRUPT", `suggestion ${shown(id2)} is malformed: ${safeText(error instanceof Error ? error.message : error)}`);
307
+ }
308
+ }
309
+ fields(id2) {
310
+ if (typeof id2 !== "string" || !ID.test(id2) || !this.map.has(id2)) throw new CommentsError("NO_SUGGESTION", `no suggestion with id ${shown(id2)}`);
311
+ return { id: id2, fields: this.map.get(id2) };
312
+ }
313
+ /** Propose a change. Anyone can suggest. The host stamps the author. */
314
+ add(input, actor) {
315
+ const who = assertActor(actor);
316
+ const given = exact(input, ["anchor", "change", "summary"], "a new suggestion");
317
+ const anchor = this.comments.checkAnchor(given.anchor);
318
+ const change = this.change(given.change, anchor);
319
+ const summary = given.summary;
320
+ if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
321
+ invalid(`a summary must be text of 1 to ${MAX_SUMMARY} characters, with no control or invisible characters`);
322
+ }
323
+ const id2 = globalThis.crypto.randomUUID();
324
+ const createdAt = this.comments.time();
325
+ this.ydoc.transact(() => {
326
+ const fields = new Y2.Map();
327
+ this.map.set(id2, fields);
328
+ fields.set("anchor", anchor);
329
+ fields.set("change", change);
330
+ if (summary !== void 0) fields.set("summary", summary);
331
+ fields.set("author", { kind: who.kind, name: who.name });
332
+ fields.set("createdAt", createdAt);
333
+ });
334
+ return this.get(id2);
335
+ }
336
+ /** A person decides. An agent needs the accept scope, and never decides its own suggestion. */
337
+ mayDecide(who, s, verb) {
338
+ const ok = !decidesOwn(s.author, who) && (who.kind === "human" || who.scopes.includes(ACCEPT_SCOPE));
339
+ if (!ok) throw new CommentsError("FORBIDDEN", `${who.kind}:${shown(who.name)} cannot ${verb} this suggestion: a signed-in person, or a principal with the ${ACCEPT_SCOPE} scope that did not write it, can`);
340
+ }
341
+ /**
342
+ * Apply the change as the accepting author and record the decision, in one step. Accepting an
343
+ * accepted suggestion changes nothing. When `apply` throws, the suggestion stays pending.
344
+ */
345
+ accept(suggestionId, actor) {
346
+ const who = assertActor(actor);
347
+ if (this.ydoc._transaction !== null) {
348
+ throw new CommentsError("APPLY_FAILED", "accept cannot run inside another Yjs transaction, such as an observer or an apply hook");
349
+ }
350
+ const { id: id2, fields } = this.fields(suggestionId);
351
+ const s = this.read(id2, fields);
352
+ this.mayDecide(who, s, "accept");
353
+ if (s.status === "accepted") return s;
354
+ if (s.status === "rejected") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already rejected`);
355
+ const by = { kind: who.kind, name: who.name };
356
+ const at = this.comments.time();
357
+ this.ydoc.transact((tr) => {
358
+ const edited = () => tr.changed.size > 0 || tr.deleteSet.clients.size > 0;
359
+ let receipt = null;
360
+ let failure;
361
+ try {
362
+ if (s.anchorState.state === "orphaned" || s.anchorState.state === "edited") {
363
+ throw new Error(s.anchorState.state === "orphaned" ? "the anchored content is gone" : "the anchored content has changed since the suggestion was made");
364
+ }
365
+ this.comments.checkAnchor(s.anchor);
366
+ const change = this.change(s.change, s.anchor);
367
+ receipt = this.host.apply(change, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...by }), ydoc: this.ydoc });
368
+ if (thenable(receipt)) throw new Error("apply must finish at once and cannot return a promise");
369
+ receipt = receipt === void 0 ? null : strictCopy(receipt);
370
+ if (JSON.stringify(receipt).length > MAX_RECEIPT) throw new Error(`a receipt holds up to ${MAX_RECEIPT} characters of JSON`);
371
+ } catch (error) {
372
+ failure = error;
373
+ receipt = null;
374
+ }
375
+ if (failure !== void 0) {
376
+ const reason = safeText(failure instanceof Error ? failure.message : failure);
377
+ if (!edited()) throw new CommentsError("APPLY_FAILED", `the change could not be applied: ${reason}`);
378
+ this.failures.push({ id: safeId(id2), reason: `apply failed after it edited the document, so the decision is recorded without a receipt: ${reason}` });
379
+ }
380
+ this.applied.set(`${id2}:${this.ydoc.clientID}`, { by, at, receipt });
381
+ });
382
+ return this.get(id2);
383
+ }
384
+ /** Same right as accept. A person can withdraw their own suggestion. */
385
+ reject(suggestionId, actor) {
386
+ const who = assertActor(actor);
387
+ const { id: id2, fields } = this.fields(suggestionId);
388
+ const s = this.read(id2, fields);
389
+ this.mayDecide(who, s, "reject");
390
+ if (s.status === "rejected") return s;
391
+ if (s.status === "accepted") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already accepted`);
392
+ this.rejected.set(`${id2}:${this.ydoc.clientID}`, { by: { kind: who.kind, name: who.name }, at: this.comments.time() });
393
+ return this.get(id2);
394
+ }
395
+ get(suggestionId) {
396
+ const { id: id2, fields } = this.fields(suggestionId);
397
+ return this.read(id2, fields);
398
+ }
399
+ /** In the order they were made. A malformed suggestion is skipped, and `problems()` names it. */
400
+ list(filter = {}) {
401
+ const given = exact(filter, ["status", "anchorState"], "a filter");
402
+ if (given.anchorState !== void 0 && !["attached", "edited", "orphaned", "unknown"].includes(given.anchorState)) invalid("anchorState must be attached, edited, orphaned, or unknown");
403
+ if (given.status !== void 0 && !["pending", "accepted", "rejected"].includes(given.status)) invalid("status must be pending, accepted, or rejected");
404
+ return this.readAll().suggestions.filter((s) => given.status === void 0 || s.status === given.status).filter((s) => given.anchorState === void 0 || s.anchorState.state === given.anchorState).sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
405
+ }
406
+ /** Suggestions that could not be read, and reverts that failed. */
407
+ problems() {
408
+ return [...this.readAll().problems, ...this.failures];
409
+ }
410
+ readAll() {
411
+ const suggestions = [];
412
+ const problems = [];
413
+ const indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) };
414
+ for (const [id2, fields] of this.map.entries()) {
415
+ try {
416
+ suggestions.push(this.read(id2, fields, indexes));
417
+ } catch (error) {
418
+ problems.push({ id: safeId(id2), reason: error.message });
419
+ }
420
+ }
421
+ return { suggestions, problems };
422
+ }
423
+ /**
424
+ * Two replicas can accept one suggestion before they meet. After a merge, the application of the
425
+ * lowest client id stays, and the host reverts each other one. Every replica reaches the same result.
426
+ * A revert that throws leaves its record, so the next merge tries again.
427
+ */
428
+ settle() {
429
+ const groups = this.index(this.applied);
430
+ const indexes = { applied: groups, rejected: this.index(this.rejected) };
431
+ this.failures = this.failures.filter((f) => f.reason.startsWith("apply failed") || f.reason.startsWith("a duplicate application has no receipt"));
432
+ this.ydoc.transact(() => {
433
+ for (const [id2, entries] of groups) {
434
+ if (entries.length < 2 || !ID.test(id2) || !this.map.has(id2)) continue;
435
+ let s;
436
+ try {
437
+ s = this.read(id2, this.map.get(id2), indexes);
438
+ } catch {
439
+ continue;
440
+ }
441
+ for (const loser of this.records(entries, id2, s.author).slice(1)) {
442
+ const key2 = `${id2}:${loser.client}`;
443
+ if (this.host.idempotent) {
444
+ this.applied.delete(key2);
445
+ continue;
446
+ }
447
+ if (loser.receipt === null) {
448
+ this.failures.push({ id: safeId(id2), reason: "a duplicate application has no receipt, so its edit cannot be undone and stays in the document" });
449
+ this.applied.delete(key2);
450
+ continue;
451
+ }
452
+ try {
453
+ this.host.revert?.(loser.receipt, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...loser.by }), ydoc: this.ydoc });
454
+ this.applied.delete(key2);
455
+ } catch (error) {
456
+ this.failures.push({ id: safeId(id2), reason: `a duplicate application could not be reverted: ${safeText(error instanceof Error ? error.message : error)}` });
457
+ }
458
+ }
459
+ }
460
+ });
461
+ }
462
+ };
463
+
464
+ // src/threads.ts
465
+ import * as Y3 from "yjs";
466
+ var THREADS = "comments.threads";
467
+ var MAX_BODY = 1e4;
468
+ var MAX_MENTIONS = 50;
469
+ var MAX_MESSAGES = 1e3;
470
+ var READ_LIMIT = 2 * MAX_MESSAGES;
471
+ var STATUSES = ["open", "resolved"];
472
+ var ANCHOR_STATES = ["attached", "edited", "orphaned", "unknown"];
473
+ var HANDLE = /^[\p{L}\p{N}](?:[\p{L}\p{N}\p{M}_.-]{0,62}[\p{L}\p{N}\p{M}])?$/u;
474
+ var MENTION = /(?<![\p{L}\p{N}_@.])@([\p{L}\p{N}][\p{L}\p{N}\p{M}_.-]*)(?![\p{L}\p{N}\p{M}_.-]|\/)/gu;
475
+ function body(value) {
476
+ if (typeof value !== "string" || value.trim() === "" || value.length > MAX_BODY || BODY_FORBIDDEN.test(value)) {
477
+ invalid(`a body must be text of 1 to ${MAX_BODY} characters, with no control, invisible, or direction characters`);
478
+ }
479
+ return value;
480
+ }
481
+ var handle = (value) => value.normalize("NFC").replace(/^@+/, "").toLowerCase().normalize("NFC");
482
+ var okHandle = (value) => HANDLE.test(value) && !VARIATION.test(value);
483
+ var validHandle = (value) => typeof value === "string" && okHandle(value) && handle(value) === value;
484
+ function mentionsOf(text, explicit) {
485
+ const prose = text.replace(/`[^`]*`/g, " ");
486
+ const found = [...prose.matchAll(MENTION)].map((m) => {
487
+ const name = m[1].replace(/[._-]+$/, "");
488
+ if (!okHandle(handle(name))) invalid(`"@${name.slice(0, 20)}" is not a valid handle: use up to 64 characters`);
489
+ return handle(name);
490
+ });
491
+ let listed = [];
492
+ if (explicit !== void 0) {
493
+ const snapshot = Array.isArray(explicit) ? Array.from(explicit) : [];
494
+ const ok = Array.isArray(explicit) && snapshot.length <= MAX_MENTIONS && snapshot.every((m) => typeof m === "string" && okHandle(handle(m)));
495
+ if (!ok) invalid(`mentions must be a list of up to ${MAX_MENTIONS} handles of letters, digits, and . _ -`);
496
+ listed = snapshot.map(handle);
497
+ }
498
+ const all = [.../* @__PURE__ */ new Set([...found, ...listed])];
499
+ if (all.length > MAX_MENTIONS) invalid(`a message can mention up to ${MAX_MENTIONS} handles`);
500
+ return all;
501
+ }
502
+ var Comments = class {
503
+ /** Every replica of a document must register the same custom anchor kinds. Others read those threads as malformed. */
504
+ constructor(ydoc, options = {}) {
505
+ this.ydoc = ydoc;
506
+ if (typeof options !== "object" || options === null || options.now !== void 0 && typeof options.now !== "function" || options.anchors !== void 0 && !Array.isArray(options.anchors) || options.exists !== void 0 && typeof options.exists !== "function") {
507
+ invalid("options must be an object with an optional now function, anchors list, and exists function");
508
+ }
509
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
510
+ this.exists = options.exists;
511
+ for (const kind of [...BUILT_IN_KINDS, ...options.anchors ?? []]) {
512
+ if (typeof kind?.type !== "string" || kind.type === "" || typeof kind.parse !== "function" || this.kinds.has(kind.type)) {
513
+ throw new CommentsError("INVALID_ANCHOR", `the anchor type "${String(kind?.type)}" is empty, has no parse, or is already defined`);
514
+ }
515
+ this.kinds.set(kind.type, kind);
516
+ }
517
+ }
518
+ ydoc;
519
+ kinds = /* @__PURE__ */ new Map();
520
+ now;
521
+ exists;
522
+ get map() {
523
+ return this.ydoc.getMap(THREADS);
524
+ }
525
+ /** The Yjs map that holds the threads. `Notifications` watches it. */
526
+ get threadsMap() {
527
+ return this.map;
528
+ }
529
+ /** The clock, as an ISO time. Used by `Suggestions`. */
530
+ time() {
531
+ const at = this.now();
532
+ const text = at instanceof Date && !Number.isNaN(at.getTime()) ? at.toISOString() : "";
533
+ if (!ISO_TIME.test(text)) invalid("the clock must return a date between the years 0000 and 9999");
534
+ return text;
535
+ }
536
+ /** Whether the content under an anchor is still there. This never throws. */
537
+ anchorState(anchor) {
538
+ if (anchor.type === "text") return textState(anchor, this.ydoc);
539
+ if (!this.exists) return { state: "unknown" };
540
+ try {
541
+ const answer = this.exists(anchor);
542
+ return answer === true ? { state: "attached" } : answer === false ? { state: "orphaned" } : { state: "unknown" };
543
+ } catch {
544
+ return { state: "unknown" };
545
+ }
546
+ }
547
+ /** Read an anchor from stored data. This does not check the document. */
548
+ parseAnchor(raw) {
549
+ const type = raw?.type;
550
+ const kind = typeof raw === "object" && raw !== null && !Array.isArray(raw) && typeof type === "string" ? this.kinds.get(type) : void 0;
551
+ if (!kind) throw new CommentsError("INVALID_ANCHOR", `an anchor needs a type: ${[...this.kinds.keys()].join(", ")}`);
552
+ try {
553
+ const parsed = copy(kind.parse(strictCopy(raw)));
554
+ if (JSON.stringify(parsed).length > MAX_ANCHOR) throw new Error(`an anchor holds up to ${MAX_ANCHOR} characters of JSON`);
555
+ if (parsed?.type !== kind.type) throw new Error(`the "${kind.type}" kind must return an anchor of its own type`);
556
+ return parsed;
557
+ } catch (error) {
558
+ if (error instanceof CommentsError) throw error;
559
+ throw new CommentsError("INVALID_ANCHOR", error instanceof Error ? error.message : String(error));
560
+ }
561
+ }
562
+ /** Parse an anchor from input and check it against this document. Throws `INVALID_ANCHOR` when it fails. */
563
+ checkAnchor(raw) {
564
+ const anchor = this.parseAnchor(raw);
565
+ if (JSON.stringify(this.parseAnchor(anchor)) !== JSON.stringify(anchor)) {
566
+ throw new CommentsError("INVALID_ANCHOR", "an anchor kind must parse its own output to the same anchor");
567
+ }
568
+ this.checkInDocument(anchor);
569
+ return anchor;
570
+ }
571
+ /** A text anchor must name text in this document, in order, and match its quote. */
572
+ checkInDocument(anchor) {
573
+ if (anchor.type !== "text") return;
574
+ const at = (json) => {
575
+ try {
576
+ return Y3.createAbsolutePositionFromRelativePosition(Y3.createRelativePositionFromJSON(json), this.ydoc);
577
+ } catch {
578
+ return null;
579
+ }
580
+ };
581
+ const from = at(anchor.start);
582
+ const to = at(anchor.end);
583
+ const unit = from?.type;
584
+ if (!(unit instanceof Y3.Text) || !from || !to || to.type !== unit || to.index <= from.index || textOf(unit).slice(from.index, to.index) !== anchor.text) {
585
+ throw new CommentsError("INVALID_ANCHOR", "a text anchor must name text in this document, in order, matching its quoted text");
586
+ }
587
+ }
588
+ /** Build a message, and refuse it unless a read would show it back. Nothing is written yet. */
589
+ message(text, explicit, actor) {
590
+ const message = {
591
+ id: globalThis.crypto.randomUUID(),
592
+ at: this.time(),
593
+ author: { kind: actor.kind, name: actor.name },
594
+ body: text,
595
+ mentions: mentionsOf(text, explicit)
596
+ };
597
+ try {
598
+ return this.readMessage(copy(message));
599
+ } catch (error) {
600
+ return invalid(`the message cannot be stored: ${error.message}`);
601
+ }
602
+ }
603
+ readMessage(raw) {
604
+ const m = raw ?? {};
605
+ const author = readAuthor(m.author);
606
+ if (typeof m.id !== "string" || !ID.test(m.id) || !validDate(m.at) || !author || typeof m.body !== "string" || m.body.trim() === "" || m.body.length > MAX_BODY || BODY_FORBIDDEN.test(m.body)) {
607
+ throw new Error("a message has no id, ISO time, valid author, or body");
608
+ }
609
+ if (!Array.isArray(m.mentions) || m.mentions.length > MAX_MENTIONS || !m.mentions.every(validHandle)) throw new Error("a message has bad mentions");
610
+ return { id: m.id, at: m.at, author, body: m.body, mentions: [...m.mentions] };
611
+ }
612
+ /** A thread with a bad anchor or status is malformed. A bad message is skipped and named in `issues`. */
613
+ read(id2, fields) {
614
+ try {
615
+ if (!ID.test(id2)) throw new Error("the thread id is not short and plain");
616
+ if (!(fields instanceof Y3.Map)) throw new Error("the thread is not a map");
617
+ const state = fields.get("state") ?? {};
618
+ const by = readAuthor(state.by);
619
+ if (!STATUSES.includes(state.status) || !by || !validDate(state.at)) throw new Error("the thread status is not valid");
620
+ const list = fields.get("messages");
621
+ if (!(list instanceof Y3.Array)) throw new Error("the thread has no message list");
622
+ const messages = [];
623
+ const issues = [];
624
+ const raw = list.toArray();
625
+ const counts = /* @__PURE__ */ new Map();
626
+ for (const m of raw) counts.set(m?.id, (counts.get(m?.id) ?? 0) + 1);
627
+ let past = 0;
628
+ raw.forEach((entry, index) => {
629
+ try {
630
+ const message = this.readMessage(entry);
631
+ if (counts.get(message.id) > 1) throw new Error("a message id repeats");
632
+ if (messages.length >= READ_LIMIT) past++;
633
+ else messages.push(message);
634
+ } catch (error) {
635
+ issues.push(`message ${index} skipped: ${safeText(error.message)}`);
636
+ }
637
+ });
638
+ if (past > 0) issues.push(`${past} valid messages past the read limit are not shown`);
639
+ if (messages.length === 0) throw new Error("the thread has no valid message");
640
+ const anchor = this.parseAnchor(fields.get("anchor"));
641
+ const thread = { id: id2, anchor, anchorState: this.anchorState(anchor), status: state.status, statusBy: by, statusAt: state.at, messages, skipped: raw.length - messages.length };
642
+ return { thread, issues };
643
+ } catch (error) {
644
+ throw new CommentsError("CORRUPT", `thread ${shown(id2)} is malformed: ${safeText(error instanceof Error ? error.message : error)}`);
645
+ }
646
+ }
647
+ fields(id2) {
648
+ if (typeof id2 !== "string" || !ID.test(id2) || !this.map.has(id2)) throw new CommentsError("NO_THREAD", `no thread with id ${shown(id2)}`);
649
+ return { id: id2, fields: this.map.get(id2) };
650
+ }
651
+ /** Start a thread with its first message. The anchor and body are checked before anything is written. */
652
+ add(input, actor) {
653
+ const who = assertActor(actor);
654
+ const given = exact(input, ["anchor", "body", "mentions"], "a new thread");
655
+ const anchor = this.checkAnchor(given.anchor);
656
+ const first = this.message(body(given.body), given.mentions, who);
657
+ const id2 = globalThis.crypto.randomUUID();
658
+ this.ydoc.transact(() => {
659
+ const fields = new Y3.Map();
660
+ this.map.set(id2, fields);
661
+ fields.set("anchor", anchor);
662
+ fields.set("state", { status: "open", by: copy(first.author), at: first.at });
663
+ const messages = new Y3.Array();
664
+ fields.set("messages", messages);
665
+ messages.push([copy(first)]);
666
+ });
667
+ return this.get(id2);
668
+ }
669
+ /**
670
+ * Add a message. The resolved check runs on this replica only. A reply and a resolve made at once
671
+ * on two replicas both stay, and the merged thread is resolved with the reply in it.
672
+ */
673
+ reply(threadId, input, actor) {
674
+ const who = assertActor(actor);
675
+ const given = exact(input, ["body", "mentions"], "a reply");
676
+ const { id: id2, fields } = this.fields(threadId);
677
+ const text = body(given.body);
678
+ const { thread } = this.read(id2, fields);
679
+ if (thread.status === "resolved") throw new CommentsError("THREAD_RESOLVED", `thread ${shown(id2)} is resolved; reopen it to reply`);
680
+ if (thread.messages.length >= MAX_MESSAGES) invalid(`thread ${shown(id2)} shows ${MAX_MESSAGES} messages and takes no more`);
681
+ const list = fields.get("messages");
682
+ const message = this.message(text, given.mentions, who);
683
+ list.push([copy(message)]);
684
+ return message;
685
+ }
686
+ setStatus(threadId, status, actor) {
687
+ const who = assertActor(actor);
688
+ const { id: id2, fields } = this.fields(threadId);
689
+ const { thread } = this.read(id2, fields);
690
+ if (thread.status === status) return thread;
691
+ fields.set("state", { status, by: { kind: who.kind, name: who.name }, at: this.time() });
692
+ return this.get(id2);
693
+ }
694
+ /**
695
+ * Move a text anchor whose range is gone or edited to its quote, when the quote occurs exactly once in
696
+ * the same text. There is no fuzzy match. Throws `RELOCATE_FAILED` when it cannot say where. An attached
697
+ * thread is returned as it is.
698
+ */
699
+ relocate(threadId, actor) {
700
+ assertActor(actor);
701
+ const { id: id2, fields } = this.fields(threadId);
702
+ const { thread } = this.read(id2, fields);
703
+ const anchor = thread.anchor;
704
+ if (anchor.type !== "text") return invalid("only a text anchor can be relocated");
705
+ if (thread.anchorState.state === "attached") return thread;
706
+ const range = textRange(anchor, this.ydoc);
707
+ const at = range ? onlyOccurrence(range.type, anchor.text) : void 0;
708
+ if (!range || at === void 0) {
709
+ throw new CommentsError("RELOCATE_FAILED", `the quoted text does not occur exactly once, so thread ${shown(id2)} cannot be moved`);
710
+ }
711
+ fields.set("anchor", textAnchor(range.type, at, at + anchor.text.length));
712
+ return this.get(id2);
713
+ }
714
+ resolve(threadId, actor) {
715
+ return this.setStatus(threadId, "resolved", actor);
716
+ }
717
+ reopen(threadId, actor) {
718
+ return this.setStatus(threadId, "open", actor);
719
+ }
720
+ get(threadId) {
721
+ const { id: id2, fields } = this.fields(threadId);
722
+ return this.read(id2, fields).thread;
723
+ }
724
+ /** Threads in the order they were started. A malformed thread is skipped; `problems()` names it. */
725
+ list(filter = {}) {
726
+ const given = exact(filter, ["status", "anchorType", "mentioning", "anchorState"], "a filter");
727
+ if (given.status !== void 0 && !STATUSES.includes(given.status)) invalid(`status must be ${STATUSES.join(" or ")}`);
728
+ if (given.anchorType !== void 0 && typeof given.anchorType !== "string") invalid("anchorType must be text");
729
+ if (given.mentioning !== void 0 && typeof given.mentioning !== "string") invalid("mentioning must be text");
730
+ if (given.anchorState !== void 0 && !ANCHOR_STATES.includes(given.anchorState)) invalid(`anchorState must be ${ANCHOR_STATES.join(", ")}`);
731
+ const wanted = given.mentioning === void 0 ? void 0 : handle(given.mentioning);
732
+ return this.readAll().threads.filter((t) => given.status === void 0 || t.status === given.status).filter((t) => given.anchorType === void 0 || t.anchor.type === given.anchorType).filter((t) => given.anchorState === void 0 || t.anchorState.state === given.anchorState).filter((t) => wanted === void 0 || t.messages.some((m) => m.mentions.includes(wanted))).sort((a, b) => Date.parse(a.messages[0].at) - Date.parse(b.messages[0].at) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
733
+ }
734
+ /** Threads or messages that could not be read, for example from a replica that wrote bad data. */
735
+ problems() {
736
+ return this.readAll().problems;
737
+ }
738
+ readAll() {
739
+ const threads = [];
740
+ const problems = [];
741
+ for (const [id2, fields] of this.map.entries()) {
742
+ try {
743
+ const { thread, issues } = this.read(id2, fields);
744
+ threads.push(thread);
745
+ for (const reason of issues) problems.push({ id: safeId(id2), reason });
746
+ } catch (error) {
747
+ problems.push({ id: safeId(id2), reason: error.message });
748
+ }
749
+ }
750
+ return { threads, problems };
751
+ }
752
+ };
753
+
754
+ export {
755
+ CommentsError,
756
+ textOf,
757
+ textAnchor,
758
+ textRange,
759
+ PRINCIPAL_KINDS,
760
+ Actor,
761
+ stamp,
762
+ readAuthor,
763
+ authorLabel,
764
+ invalid,
765
+ exact,
766
+ validDate,
767
+ ACCEPT_SCOPE,
768
+ Suggestions,
769
+ Comments
770
+ };