agentic-comments 0.6.1 → 0.8.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.
package/README.md CHANGED
@@ -86,6 +86,36 @@ erased message or a thread that holds one.
86
86
  Replicas that edit one message at once leave a copy each. When the copies agree on the author kind and time,
87
87
  the read keeps the erased copy, or else the latest edit. An edit and a delete made at once keep the edit.
88
88
 
89
+ ## Account export and erase
90
+
91
+ `import { commentsAccount } from "agentic-comments/account"` lets a host export and erase one person's data
92
+ without reading the storage layout. Pass the subject (`{ kind, name }`) and `{ documents, actor }`. Each
93
+ document is `{ id, comments, suggestions }`. The actor is made with `stamp()` and must name the subject.
94
+ `exportAccount` returns JSON records in `messages`, `statusChanges`, `edits`, `suggestions`, `decisions`,
95
+ `documents`, and `unread`. `eraseAccount` tombstones the same data as `erasePerson` and returns counts. A second
96
+ erase counts 0, except `unread`: see below.
97
+
98
+ The package keeps no grants or audit rows. A host that runs the HTTP worker passes `worker: docs` (the
99
+ `CommentsDocs` of the worker). Then the run also covers every stored document: the subject's messages and
100
+ suggestions in it, and the creator that the worker keeps on each row. The creators are listed under `documents`
101
+ and get the tombstone name on erase. Without `worker`, that data stays. A stored document is erased in one
102
+ transaction. A stored document that `documents` also lists is refused, before any write. The check compares ids
103
+ only. A row whose creator cannot be read shows in `unread` of the export, and in the `unread` count of the erase,
104
+ which leaves the row in place. `unread` counts every row it cannot read, whoever made it, so it can stay above 0 on a repeat. A store that loses a race gives `CONFLICT`: run the erase again.
105
+
106
+ A run covers only the subject's own kind: `human:ada` does not reach `agent_of_human:ada`. Names and mentions of
107
+ the subject inside other people's messages are neither exported nor erased. Records can name other people, such
108
+ as the moderator in `editedBy` or the person in `decidedBy`. A record in `unread` means the export is not
109
+ complete: the store holds entries of the subject that a read cannot show. A missing `documents` list is an
110
+ error, not an empty run. Erase goes document by document. If one fails, the error code tells why, the message names the document when the store allows, and the earlier
111
+ documents stay erased. A failed document in `documents` may be partly erased (its threads and its suggestions
112
+ are two steps). A repeat counts only what is left. The counts of a failed run are lost.
113
+
114
+ The ids in `documents` and the ids of the worker share one namespace. Erase can make a state a little larger, so a stored
115
+ document near the size limit may stop the run with `STORE_FULL`: the host must shrink or delete that document, and
116
+ run the erase again. The run reads the list of stored documents once. Block the subject before the erase, so no
117
+ document is made during it. A repeat erase finishes what a race left.
118
+
89
119
  ## Anchors for plain text
90
120
 
91
121
  `agentic-comments/anchor` pins a range of a plain string. It needs no Yjs and no framework.
@@ -321,12 +351,42 @@ try {
321
351
  - The client never follows a redirect and never retries. A write that you abort after the client sends it can still apply:
322
352
  read the thread before you try again.
323
353
 
354
+ ## React panel
355
+
356
+ `agentic-comments/ui` is a threaded comments panel for a React page. It shows the thread list, the quote each thread
357
+ points at, a reply box, resolve and reopen, and an author mark on every message. A person shows a name. An agent
358
+ shows the `AgentBadge` of `agentic-design`, with the words "Comment by an agent". Each suggestion has Accept and
359
+ Reject buttons. React 19 and `react-dom` are peer dependencies. The package depends on `agentic-design`.
360
+
361
+ ```tsx
362
+ import { CommentsPanel, createClient } from "agentic-comments/ui";
363
+ import "agentic-design/styles.css";
364
+ import "agentic-comments/ui.css";
365
+
366
+ const client = createClient({ baseUrl: "https://comments.example.com", token });
367
+ <CommentsPanel client={client} docId="report-q3" />;
368
+ ```
369
+
370
+ `client` is any object with the methods of `PanelClient`, so a host can pass its own. The panel reads who is signed
371
+ in from `client.me()`. It follows the product rule for suggestions: an agent never gets Accept or Reject for its own
372
+ suggestion. Through the Worker, an agent needs the `comments:admin` scope to decide any suggestion. With a client of your own, the `comments:accept` right is enough too. A person who holds
373
+ `comments:write` decides any suggestion. `mayDecide(me, suggestion)` is exported, so a host can use the same rule.
374
+ A hidden button is a help and not a lock: the Worker checks every call. The panel shows an error with its code, and
375
+ it never retries a call with other input. It needs a document whose text holds the quote, because a new thread
376
+ points at a quote. It meets WCAG 2.2 AA (axe runs on light, dark, and 390px) and its classes all start with `agd-`.
377
+
378
+ The demo page in `ui/` runs the panel with no token on in-memory data, with a switch for the signed-in principal. Open
379
+ `/#token=<bearer>&doc=<id>` on a Worker that serves it, and it talks to that Worker. `npm run test:ui` drives it in
380
+ Chromium against `wrangler dev`.
381
+
382
+ ![The panel in the light theme](docs/screenshots/panel-light.png)
383
+
324
384
  ## Live demo
325
385
 
326
386
  A demo Worker runs at <https://agentic-comments-demo.hello-fd1.workers.dev>. It is a staging site. Check it with
327
387
  `GET /health`. Every `/api/v1` route needs a bearer token, and the demo does not publish one, so a call
328
388
  with no token gets `401`. The data lives in a D1 database in the EU (`weur`), and the Worker sets the
329
- region to `eu`.
389
+ region to `eu`. The panel demo page is at the root of the demo Worker.
330
390
 
331
391
  To deploy your own copy, log in with `wrangler login` or set `CLOUDFLARE_API_TOKEN`, then:
332
392
 
@@ -436,13 +496,29 @@ const stop = notifications.subscribe(async (event) => {
436
496
  - Mentions are stored in lowercase, with no leading `@`. A handle holds up to 64 letters, digits, marks,
437
497
  and `. _ -`. A longer handle in a body is refused.
438
498
 
499
+ ## Run locally
500
+
501
+ ```sh
502
+ git clone https://github.com/pooriaarab/agentic-comments
503
+ cd agentic-comments
504
+ npm install && npm run dev
505
+ ```
506
+
507
+ You can also use `bun install && bun run dev`. The `dev` script calls npm, so npm must be installed. The scripts need macOS, Linux, or WSL. The `dev` script builds the package, then starts `wrangler dev` on <http://localhost:8794>. Wrangler keeps a local D1 database. The `dev` script turns off Wrangler telemetry. Set `WRANGLER_SEND_METRICS=false` when you run other wrangler commands.
508
+
509
+ ```sh
510
+ curl -i http://localhost:8794/health # 200
511
+ ```
512
+
513
+ `npm run dev:smoke` runs the same check by itself. It starts the dev server. It waits for `/health` to answer 200. It stops the server. CI runs it as the "dev smoke" step. To use another port, change `8794` in the `dev` and `dev:smoke` scripts.
514
+
439
515
  ## Develop
440
516
 
441
517
  ```sh
442
518
  git clone https://github.com/pooriaarab/agentic-comments
443
519
  cd agentic-comments
444
520
  npm ci
445
- npm run ci:local
521
+ npm run ci:local # build, typecheck, unit tests, then the browser tests (npx playwright install chromium once)
446
522
  ```
447
523
 
448
524
  ## License
package/SKILL.md CHANGED
@@ -32,6 +32,11 @@ anyone's with that scope). Check `unread` in an export.
32
32
  Over MCP, call `list_threads`, `add_comment`, `reply_to_thread`, `resolve_thread`, `suggest_change`,
33
33
  `accept_suggestion`, `reject_suggestion`, and the tools above. You never pass an author or a scope.
34
34
 
35
+ ## Show threads in a React page
36
+
37
+ `import { CommentsPanel, createClient } from "agentic-comments/ui"` and load `agentic-design/styles.css` and `agentic-comments/ui.css`.
38
+ `<CommentsPanel client={createClient({ baseUrl, token })} docId="..." />`. The panel never offers an agent Accept or Reject for its own suggestion.
39
+
35
40
  ## Rules
36
41
 
37
42
  - The host stamps the author. Never call `stamp()` yourself, and never pass an author in an input.
@@ -0,0 +1,62 @@
1
+ import { C as Comments, c as Suggestions, b as Author, A as Actor } from './suggestions-G1seGf5d.js';
2
+ import 'yjs';
3
+ import 'agentic-identity';
4
+
5
+ /** One document the host holds comments on. The host loads it; the package never lists documents. */
6
+ interface CommentsAccountDocument {
7
+ /** A stable id for the document. It goes in each exported record. */
8
+ id: string;
9
+ comments: Comments;
10
+ suggestions: Suggestions;
11
+ }
12
+ /** What `CommentsDocs` offers the plug-in. */
13
+ interface AccountWorker {
14
+ /** The id of each stored document. */
15
+ accountDocumentIds(): Promise<string[]>;
16
+ /** Runs `fn` on each stored document. With `save`, the state is written back in the same transaction. A document deleted meanwhile is skipped. `committed` is called with the id after its transaction commits. */
17
+ eachAccountDocument(fn: (doc: CommentsAccountDocument) => void, save: boolean, committed?: (id: string) => void): Promise<void>;
18
+ /** The documents the subject created. `unread` names the rows whose creator cannot be read, so the export is not complete for them. */
19
+ exportCreated(author: Author): Promise<{
20
+ documents: {
21
+ documentId: string;
22
+ createdAt: unknown;
23
+ }[];
24
+ unread: string[];
25
+ }>;
26
+ /** `count` is the creators it tombstoned. `unread` names the rows whose creator cannot be read, which it leaves in place. */
27
+ eraseCreated(author: Author): Promise<{
28
+ count: number;
29
+ unread: string[];
30
+ }>;
31
+ }
32
+ interface CommentsAccountDeps {
33
+ /** Every document that may hold data of the subject. */
34
+ documents: Iterable<CommentsAccountDocument>;
35
+ /** Made with `stamp()`. It must name the subject: the same kind and name. A moderate scope does not widen the run. */
36
+ actor: Actor;
37
+ /**
38
+ * The document store of the HTTP worker (`CommentsDocs`), when the host runs it. Its rows hold the threads of each
39
+ * document and the creator of each document, outside what `documents` can reach. Pass it, and the run covers every
40
+ * stored document. Without it, a worker host leaves that data in place.
41
+ */
42
+ worker?: AccountWorker;
43
+ }
44
+ type JsonRecord = Record<string, unknown>;
45
+ interface CommentsAccount {
46
+ id: "comments";
47
+ /**
48
+ * Collections, each record tagged with `documentId`: `messages` (with the thread anchor), `statusChanges`,
49
+ * `edits` (the subject's edits of other people's messages), `suggestions` (the ones the subject wrote),
50
+ * `decisions` (accepts and rejects by the subject), `documents` (the worker documents the subject created, only
51
+ * when `deps.worker` is set), and `unread` (stored entries of the subject that a read
52
+ * cannot show; the export is not complete when this has a record). The package keeps no grants or audit rows. Its
53
+ * one owner-like record is the creator of a worker document, which `deps.worker` covers. A host that runs the
54
+ * worker and omits `worker` leaves that creator in place.
55
+ */
56
+ exportAccount(subject: Author, deps: CommentsAccountDeps): Promise<Record<"messages" | "statusChanges" | "edits" | "suggestions" | "decisions" | "documents" | "unread", JsonRecord[]>>;
57
+ /** Replaces what the subject wrote with a tombstone (see `Comments.erasePerson`). Counts by kind, summed over documents. The thread shape stays. A repeat finds nothing and counts 0. A decision that the subject made on their own suggestion is deleted and not counted, while `unread` in the export counts it. */
58
+ eraseAccount(subject: Author, deps: CommentsAccountDeps): Promise<Record<"messages" | "statuses" | "edits" | "suggestions" | "decisions" | "documents" | "unread", number>>;
59
+ }
60
+ declare const commentsAccount: CommentsAccount;
61
+
62
+ export { type AccountWorker, type CommentsAccount, type CommentsAccountDeps, type CommentsAccountDocument, commentsAccount };
@@ -0,0 +1,114 @@
1
+ import {
2
+ Actor,
3
+ Comments,
4
+ CommentsError,
5
+ Suggestions,
6
+ assertActor,
7
+ readAuthor,
8
+ shown
9
+ } from "./chunk-4G5BDTBA.js";
10
+
11
+ // src/account.ts
12
+ import { StoreConflictError } from "agentic-identity";
13
+ var plain = (value) => JSON.parse(JSON.stringify(value));
14
+ function check(subject, deps) {
15
+ const who = readAuthor(subject);
16
+ if (!who) throw new CommentsError("INVALID_INPUT", "subject needs a known kind and a name");
17
+ const actor = assertActor(deps?.actor);
18
+ if (actor.kind !== who.kind || actor.name !== who.name) throw new CommentsError("FORBIDDEN", "actor does not match the subject");
19
+ if (typeof deps?.documents?.[Symbol.iterator] !== "function") throw new CommentsError("INVALID_INPUT", "deps.documents must be a list of documents");
20
+ const documents = [...deps.documents];
21
+ const ids = /* @__PURE__ */ new Set();
22
+ const seen = /* @__PURE__ */ new Set();
23
+ const bad = (why) => {
24
+ throw new CommentsError("INVALID_INPUT", `deps: ${why}`);
25
+ };
26
+ if (deps.worker !== void 0 && ["accountDocumentIds", "eachAccountDocument", "exportCreated", "eraseCreated"].some((m) => typeof deps.worker?.[m] !== "function")) bad("worker needs accountDocumentIds, eachAccountDocument, exportCreated, and eraseCreated");
27
+ for (const d of documents) {
28
+ if (typeof d?.id !== "string" || d.id === "" || ids.has(d.id)) bad("each document needs a unique id");
29
+ if (!(d.comments instanceof Comments) || !(d.suggestions instanceof Suggestions)) bad("each document needs Comments and Suggestions made by this package");
30
+ if (d.suggestions.comments.ydoc !== d.comments.ydoc) bad("suggestions must be made on the same comments");
31
+ if (seen.has(d.comments.ydoc)) bad("two documents share one Yjs document");
32
+ ids.add(d.id);
33
+ seen.add(d.comments.ydoc);
34
+ }
35
+ return { documents, own: Actor.of(who) };
36
+ }
37
+ function collect({ id: documentId, comments, suggestions }, own, out) {
38
+ const c = comments.exportPerson(own.name, own);
39
+ const s = suggestions.exportPerson(own.name, own);
40
+ for (const t of c.threads) {
41
+ for (const message of t.messages) out.messages.push({ ...message, documentId, threadId: t.threadId, anchor: t.anchor });
42
+ if (t.statusChange) out.statusChanges.push({ ...t.statusChange, documentId, threadId: t.threadId });
43
+ }
44
+ for (const e of c.edits) out.edits.push({ ...e, documentId });
45
+ for (const x of s.authored) out.suggestions.push({ ...x, documentId });
46
+ for (const d of s.decided) out.decisions.push({ documentId, suggestionId: d.id, status: d.status, at: d.at });
47
+ const unread = c.unread + s.unread;
48
+ if (unread > 0) out.unread.push({ documentId, count: unread });
49
+ }
50
+ async function stopAt(where, run) {
51
+ try {
52
+ return await run();
53
+ } catch (cause) {
54
+ const known = cause instanceof CommentsError;
55
+ const reason = cause instanceof Error ? cause.message : "unknown error";
56
+ if (!known && !(cause instanceof StoreConflictError)) throw cause;
57
+ throw new CommentsError(known ? cause.code : "CONFLICT", `erase stopped at ${where}: ${reason}`, { cause });
58
+ }
59
+ }
60
+ async function refuseOverlap(documents, worker) {
61
+ const listed = new Set(documents.map((d) => d.id));
62
+ if ((await worker.accountDocumentIds()).some((id) => listed.has(id))) throw new CommentsError("INVALID_INPUT", "deps.worker holds a document that deps.documents also lists");
63
+ }
64
+ function wipe({ comments, suggestions }, own, counts) {
65
+ const c = comments.erasePerson(own.name, own);
66
+ const s = suggestions.erasePerson(own.name, own);
67
+ counts.messages += c.messages;
68
+ counts.statuses += c.statuses;
69
+ counts.edits += c.edits;
70
+ counts.suggestions += s.suggestions;
71
+ counts.decisions += s.decisions;
72
+ }
73
+ var commentsAccount = {
74
+ id: "comments",
75
+ async exportAccount(subject, deps) {
76
+ const { documents, own } = check(subject, deps);
77
+ const out = { messages: [], statusChanges: [], edits: [], suggestions: [], decisions: [], documents: [], unread: [] };
78
+ if (deps.worker) await refuseOverlap(documents, deps.worker);
79
+ for (const doc of documents) collect(doc, own, out);
80
+ if (deps.worker) {
81
+ await deps.worker.eachAccountDocument((doc) => collect(doc, own, out), false);
82
+ const created = await deps.worker.exportCreated(own);
83
+ out.documents.push(...created.documents);
84
+ for (const documentId of created.unread) out.unread.push({ documentId, count: 1 });
85
+ }
86
+ return plain(out);
87
+ },
88
+ async eraseAccount(subject, deps) {
89
+ const { documents, own } = check(subject, deps);
90
+ if (deps.worker) await refuseOverlap(documents, deps.worker);
91
+ const counts = { messages: 0, statuses: 0, edits: 0, suggestions: 0, decisions: 0, documents: 0, unread: 0 };
92
+ for (const doc of documents) await stopAt(`document ${shown(doc.id)}`, () => wipe(doc, own, counts));
93
+ if (deps.worker) {
94
+ const each = /* @__PURE__ */ new Map();
95
+ const done = /* @__PURE__ */ new Set();
96
+ await stopAt(
97
+ "a worker document",
98
+ () => deps.worker.eachAccountDocument((doc) => {
99
+ const one = { messages: 0, statuses: 0, edits: 0, suggestions: 0, decisions: 0, documents: 0, unread: 0 };
100
+ wipe(doc, own, one);
101
+ each.set(doc.id, one);
102
+ }, true, (id) => void done.add(id))
103
+ );
104
+ for (const [id, c] of each) if (done.has(id)) for (const k of Object.keys(counts)) counts[k] += c[k];
105
+ const creators = await stopAt("the worker creators", () => deps.worker.eraseCreated(own));
106
+ counts.documents = creators.count;
107
+ counts.unread = creators.unread.length;
108
+ }
109
+ return counts;
110
+ }
111
+ };
112
+ export {
113
+ commentsAccount
114
+ };