agentic-comments 0.5.0 → 0.6.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
@@ -18,8 +18,6 @@ copy of `yjs`, because the package checks the Yjs types of your document. Instal
18
18
  npm install agentic-comments yjs
19
19
  ```
20
20
 
21
- To build from source, see Develop.
22
-
23
21
  ## Quick start
24
22
 
25
23
  ```ts
@@ -55,6 +53,39 @@ normal sync, and every replica sees the same threads.
55
53
  - A malformed thread from a bad replica is skipped. A bad message is left out, and `skipped` on the
56
54
  thread counts those. `problems()` names them.
57
55
 
56
+ ## Edit, delete, export, and erase
57
+
58
+ - `comments.editMessage(threadId, messageId, { body, mentions? }, actor)` changes a body. The id, author,
59
+ time, and place stay. The message gets `editedAt` and `editedBy`. A resolved thread refuses an edit.
60
+ - `comments.deleteMessage(threadId, messageId, actor)` removes one message. The last message of a thread
61
+ stays: delete the thread instead.
62
+ - `comments.deleteThread(threadId, actor)` removes a thread. Its author can do it while every message is theirs.
63
+ - `comments.exportPerson(name, actor)` returns the messages a person wrote, the status change of theirs that
64
+ stands, and their edits to other people's messages. It holds no one else's words. `unread` counts stored
65
+ entries with their name that the read cannot show (a bad message, an unreadable thread). When it is not 0,
66
+ the export is not complete. `suggestions.exportPerson` lists their suggestions and decisions, with its own `unread` count.
67
+ - `comments.erasePerson(name, actor)` replaces each message of that person with a tombstone: the body is
68
+ `[erased]`, the author name is `erased`, and mentions go. The author kind stays, so machine-made work stays
69
+ marked. The thread keeps its shape, order, and times. It works on the stored entries, so it also reaches a bad
70
+ message, a message past the read limit, and a copy that a merge hid. A status change of theirs and their name
71
+ as an editor get the tombstone name. `suggestions.erasePerson` does the same for their suggestions
72
+ (it drops the summary, and the change of a pending or rejected one, which can then no longer be accepted)
73
+ and their decisions. No principal can have the name `erased`.
74
+
75
+ A self-export or self-erase covers only the actor's own kind: a message that `human:ada` wrote and one that
76
+ `user_token:ada` wrote are two principals, and `unread` does not count the other kind. Ask a moderator for
77
+ both. Erase rewrites a status record, so an erase and a real status change made at once on two replicas keep
78
+ only one of them. An accepted suggestion keeps its `change`, which is still the author's text.
79
+
80
+ Only the author can edit or delete a message, and a person can export or erase their own data: the same kind
81
+ and name. A principal with the `comments:moderate` scope (`MODERATE_SCOPE`) can do it for anyone, and an
82
+ export or erase by a moderator covers every kind with that name. The host gives that scope to an owner or
83
+ admin. An edit made by a moderator records the moderator in `editedBy`. Nobody but a moderator can delete an
84
+ erased message or a thread that holds one.
85
+
86
+ Replicas that edit one message at once leave a copy each. When the copies agree on the author kind and time,
87
+ the read keeps the erased copy, or else the latest edit. An edit and a delete made at once keep the edit.
88
+
58
89
  ## Anchors for plain text
59
90
 
60
91
  `agentic-comments/anchor` pins a range of a plain string. It needs no Yjs and no framework.
@@ -165,10 +196,6 @@ machine. It cannot check a lock held in another container or on another machine.
165
196
  share a volume can have the same process id, and each then clears the other's lock, so they can lose writes. A save that
166
197
  would pass 50 MB is refused.
167
198
 
168
- If the version you install has no `agentic-comments` command, build from source (see [Develop](#develop)).
169
- In the cloned repo, run `npm run build` and use `node dist/cli.js` where these examples say
170
- `npx agentic-comments`.
171
-
172
199
  ```sh
173
200
  export AGENTIC_COMMENTS_FILE=./comments.ydoc
174
201
  export AGENTIC_COMMENTS_AS=human:ada # kind:name. The operator sets this.
@@ -179,11 +206,11 @@ AGENTIC_COMMENTS_AS=agent_of_human:scribe npx agentic-comments suggest --text-qu
179
206
  npx agentic-comments accept <id>
180
207
  ```
181
208
 
182
- - Commands: `init`, `list`, `show`, `comment`, `reply`, `resolve`, `reopen`, `relocate`, `suggestions`,
183
- `suggest`, `accept`, `reject`, `mcp`. Run `agentic-comments --help` for flags.
209
+ - Commands: `init`, `list`, `show`, `comment`, `reply`, `resolve`, `reopen`, `relocate`, `edit-message`,
210
+ `delete-message`, `delete-thread`, `export-person`, `erase-person`, `suggestions`, `suggest`, `accept`, `reject`, `mcp`. Run `npx agentic-comments --help` for flags.
184
211
  - Every result is JSON. An error is JSON on stderr with a `code`, and the exit code is 1. A usage error
185
212
  exits 2.
186
- - The principal comes from `--as` or `AGENTIC_COMMENTS_AS`. Scopes come only from
213
+ - The principal comes from `--as` or `AGENTIC_COMMENTS_AS`, as `kind:name`. The name starts with A-Z a-z 0-9 `_` `-`, then uses A-Z a-z 0-9 `.` `_` `-`, and is at most 128 characters. Scopes come only from
187
214
  `AGENTIC_COMMENTS_SCOPES`, a comma list such as `comments:accept`. No flag takes a scope or an author,
188
215
  and a flag that does not apply to a command is refused. `AGENTIC_COMMENTS_LOCK_TIMEOUT_MS` sets the wait
189
216
  for a lock, in whole milliseconds.
@@ -191,8 +218,10 @@ npx agentic-comments accept <id>
191
218
  the MCP server on stdio. The operator fixes the principal and the
192
219
  scopes when the server starts. No tool takes an author or a scope. A call with any key a tool does not list is refused, so an agent
193
220
  cannot pick its own kind or rights. Tools: `list_threads`, `get_thread`, `add_comment`, `reply_to_thread`, `resolve_thread`,
194
- `reopen_thread`, `relocate_thread`, `list_suggestions`, `suggest_change`, `accept_suggestion`,
195
- `reject_suggestion`.
221
+ `reopen_thread`, `relocate_thread`, `edit_message`, `delete_message`, `delete_thread`, `export_person`,
222
+ `erase_person`, `list_suggestions`, `suggest_change`, `accept_suggestion`, `reject_suggestion`.
223
+ Another person's message, thread, or data needs `comments:moderate` in `AGENTIC_COMMENTS_SCOPES`. The delete
224
+ tools and `erase_person` carry the destructive hint.
196
225
  - A text change is `{"replace": "new text"}`. A change for a cell, slide, or canvas anchor is
197
226
  `{"value": <JSON>}`.
198
227
  - From code: `import { FileStore, CommentsService, operatorActor } from "agentic-comments/local"`.
@@ -203,13 +232,26 @@ npx agentic-comments accept <id>
203
232
  document in a D1 database through the `Store` port of `agentic-identity`. It checks every request with the
204
233
  `Auth` port, so the host picks who the caller is and no request carries an author.
205
234
 
206
- Set the principals as a secret. It is JSON from a bearer token (32 bytes or more) to `kind:name`, or to
207
- `{ "as": "kind:name", "scopes": [...] }`. One bad entry refuses the whole table. A request with no valid
208
- token gets `401`.
235
+ By default the Worker signs requests in with `agentic-identity`. Principals and their credentials live in the same
236
+ `Store` as the documents (`createStoreAuth`). A request sends `Authorization: Bearer <key>`. The scopes of the
237
+ principal decide what it may do (see the table below). A request with no valid credential gets `401`. A valid
238
+ credential whose scopes fall short gets `403`. A store failure while the credential is read gets `503`, never `401`.
239
+ Issue credentials with `issueCredential` from `agentic-identity`.
240
+ A request whose `Authorization` header is not `Bearer` followed by 1 to 512 bytes of A-Z a-z 0-9 `_` `-` gets `401`, before any custom `Auth` runs.
241
+ A write with no `Authorization` header (a session cookie, for example) gets `403` unless its `Origin` is in the `origins`
242
+ option of `createApp`. The default lists none.
243
+
244
+ **Static token list (dev fallback).** `AGENTIC_COMMENTS_TOKENS` is read only when `AGENTIC_COMMENTS_UNSAFE_STATIC_TOKENS=1`.
245
+ Identity credentials are tried first, then the list. A Worker that sets the list without the switch answers every
246
+ `/api/v1` request with `503 NOT_CONFIGURED`. The list is JSON from a bearer token to `kind:name`, or to
247
+ `{ "as": "kind:name", "scopes": [...] }`. A token is 32 to 512 bytes, from A-Z a-z 0-9 `_` `-`.
248
+ A name starts with A-Z a-z 0-9 `_` `-`, then uses A-Z a-z 0-9 `.` `_` `-`, and is at most 128 characters.
249
+ One bad entry refuses the whole table. A token with no scopes holds `comments:comment`. Never use the list in production.
209
250
 
210
251
  ```sh
211
252
  wrangler secret put AGENTIC_COMMENTS_TOKENS
212
253
  # {"<openssl rand -hex 32>": {"as": "human:ada", "scopes": ["comments:write"]}, "<another>": "agent_of_human:scribe"}
254
+ wrangler secret put AGENTIC_COMMENTS_UNSAFE_STATIC_TOKENS # the value is 1
213
255
  ```
214
256
 
215
257
  Also set `AGENTIC_COMMENTS_REGION` to `eu` or `us`. Without it every document route fails with `500 MISCONFIGURED`. Use `unknown` with
@@ -218,9 +260,13 @@ Also set `AGENTIC_COMMENTS_REGION` to `eu` or `us`. Without it every document ro
218
260
  | Scope | Allows |
219
261
  | --- | --- |
220
262
  | `comments:read` | Read documents, threads, and suggestions. |
221
- | `comments:comment` | Also add threads, reply, resolve, reopen, and suggest. This is the default for a token with no scopes. |
263
+ | `comments:comment` | Also add threads, reply, resolve, reopen, and suggest. This is the default for a static-list token with no scopes. |
222
264
  | `comments:write` | Also create documents, relocate, and accept or reject. |
223
- | `comments:admin` | Also let an agent accept or reject (the `comments:accept` right). |
265
+ | `comments:admin` | Also let an agent accept or reject (the `comments:accept` right), and edit, delete, export, or erase what another person wrote (the `comments:moderate` right). |
266
+
267
+ With `comments:comment` a principal edits and deletes its own messages, deletes a thread when every message is
268
+ its own, and erases its own data. With `comments:read` it can export its own data, and it cannot change anything.
269
+ Export of another person's data and any change to another person's words need `comments:admin`.
224
270
 
225
271
  | Route | Action |
226
272
  | --- | --- |
@@ -231,6 +277,10 @@ Also set `AGENTIC_COMMENTS_REGION` to `eu` or `us`. Without it every document ro
231
277
  | `GET /api/v1/docs/:doc/threads/:id` | Read one thread. |
232
278
  | `POST /api/v1/docs/:doc/threads/:id/replies` | Reply. |
233
279
  | `POST .../threads/:id/resolve`, `reopen`, `relocate` | Change a thread. |
280
+ | `PATCH`, `DELETE .../threads/:id/messages/:message` | Edit (`{body, mentions?}`) or delete a message. |
281
+ | `DELETE .../threads/:id` | Delete a thread. |
282
+ | `GET .../people/:name/export` | Export what a person wrote in this document (`comments:read` for yourself, `comments:admin` for anyone). |
283
+ | `DELETE .../people/:name` | Erase what a person wrote in this document: replace it with a tombstone (`comments:comment` for yourself, `comments:admin` for anyone). |
234
284
  | `GET`, `POST /api/v1/docs/:doc/suggestions` | List or suggest. |
235
285
  | `POST .../suggestions/:id/accept`, `reject` | Decide. An agent never decides its own suggestion. |
236
286
 
@@ -263,7 +313,7 @@ try {
263
313
  ```
264
314
 
265
315
  - The methods match the routes: `me`, `createDocument`, `getDocument`, `listThreads`, `getThread`, `addThread`, `reply`,
266
- `resolve`, `reopen`, `relocate`, `listSuggestions`, `suggest`, `accept`, `reject`. Each takes an optional `{ signal }`.
316
+ `resolve`, `reopen`, `relocate`, `editMessage`, `deleteMessage`, `deleteThread`, `exportPerson`, `erasePerson`, `listSuggestions`, `suggest`, `accept`, `reject`. Each takes an optional `{ signal }`.
267
317
  - `baseUrl` is the Worker URL, `http` or `https`, with no query, hash, or credentials.
268
318
  - The client never sends an author. The Worker stamps one from the token.
269
319
  - A failed call throws `CommentsApiError`. `code` is the API error code, or `UNKNOWN` when the reply is not an API
@@ -282,7 +332,7 @@ To deploy your own copy, log in with `wrangler login` or set `CLOUDFLARE_API_TOK
282
332
 
283
333
  ```sh
284
334
  npx wrangler d1 create agentic-comments-demo --location weur # once. Put the id in wrangler.demo.jsonc.
285
- npx wrangler secret put AGENTIC_COMMENTS_TOKENS --config wrangler.demo.jsonc # see HTTP API
335
+ npx wrangler secret put AGENTIC_COMMENTS_TOKENS --config wrangler.demo.jsonc # the demo uses the static list. See HTTP API
286
336
  npm run deploy:demo
287
337
  ```
288
338
 
@@ -363,7 +413,8 @@ const stop = notifications.subscribe(async (event) => {
363
413
  - Events come from the data and not from the calls, so a status change that two replicas make at once gives
364
414
  each replica its own event, and a thread that arrives whole gives events for its current state only.
365
415
  - Every replica of a document must register the same custom anchor kinds.
366
- - A repeated message id hides every copy. A peer that appends a copy of a thread's only message makes
416
+ - Export and erase work on one document. A person who wrote in many documents needs one call for each.
417
+ - A repeated message id hides every copy, unless every copy is an edit: then the read keeps one. A peer that appends a copy of a thread's only message makes
367
418
  the thread unreadable.
368
419
  - The quoted text of a text anchor is not checked when a thread is read. A peer can store any text there.
369
420
  `textAnchor` refuses a quote whose JSON form passes 4,096 characters. Quotes with many newlines or
@@ -374,6 +425,14 @@ const stop = notifications.subscribe(async (event) => {
374
425
  - A body mentions anyone named as `@handle`. Put `@` text in backticks to avoid that. Any two backticks in a body pair up, so a loose backtick
375
426
  can hide a real mention that sits between two of them. A scoped
376
427
  package name such as `@scope/pkg` is not a mention.
428
+ - Erase does not touch a name that another person typed in their own message.
429
+ - Replicas that delete two messages of one thread at once can leave a thread with no message. Only a moderator
430
+ can delete it. A reply made on another replica at the same time as `deleteThread` is lost with the thread.
431
+ - After you merge replicas, run `exportPerson`. When `unread` is not 0, a copy that the merge hid or a bad entry
432
+ still holds the person's name: run `erasePerson` again. An entry whose author is not valid cannot be tied to
433
+ a person, so export and erase skip it. An edit or an erase makes no notification event, also when two replicas edit one message at once. A delete works on a resolved thread, and an edit does not.
434
+ - Yjs drops the old text of a deleted or replaced message only when the document has garbage collection on
435
+ (the default). A copy that you saved before an erase or delete still holds it.
377
436
  - Mentions are stored in lowercase, with no leading `@`. A handle holds up to 64 letters, digits, marks,
378
437
  and `. _ -`. A longer handle in a body is refused.
379
438
 
package/SKILL.md CHANGED
@@ -25,8 +25,12 @@ Check `thread.anchorState`. An `orphaned` thread has lost its content. Read it,
25
25
  Suggest a change with `suggestions.add({ anchor, change, summary }, actor)`. Accept needs a person or
26
26
  the `comments:accept` scope. You never accept or reject your own suggestion.
27
27
 
28
+ Change your own words with `edit_message`, `delete_message`, and `delete_thread`. Another person's messages need the
29
+ `comments:moderate` scope. `export_person` and `erase_person` read or tombstone what one person wrote (your own, or
30
+ anyone's with that scope). Check `unread` in an export.
31
+
28
32
  Over MCP, call `list_threads`, `add_comment`, `reply_to_thread`, `resolve_thread`, `suggest_change`,
29
- `accept_suggestion`, and `reject_suggestion`. You never pass an author or a scope.
33
+ `accept_suggestion`, `reject_suggestion`, and the tools above. You never pass an author or a scope.
30
34
 
31
35
  ## Rules
32
36
 
@@ -2,7 +2,7 @@ import {
2
2
  CommentsError,
3
3
  PRINCIPAL_KINDS,
4
4
  stamp
5
- } from "./chunk-BSNBDKR2.js";
5
+ } from "./chunk-VAVKBAHM.js";
6
6
 
7
7
  // src/file-store.ts
8
8
  import { createHash } from "crypto";
@@ -244,6 +244,7 @@ var FileStore = class {
244
244
  };
245
245
 
246
246
  // src/operator.ts
247
+ import { PrincipalParseError, parsePrincipal } from "agentic-identity";
247
248
  function operatorActor(as, scopes) {
248
249
  const value = as?.trim();
249
250
  const cut = value === void 0 ? -1 : value.indexOf(":");
@@ -251,7 +252,13 @@ function operatorActor(as, scopes) {
251
252
  throw new CommentsError("INVALID_AUTHOR", `set the principal as kind:name, for example human:ada. Kinds: ${PRINCIPAL_KINDS.join(", ")}`);
252
253
  }
253
254
  const list = scopes === void 0 || scopes.trim() === "" ? [] : scopes.split(",").map((s) => s.trim());
254
- return stamp({ kind: value.slice(0, cut), name: value.slice(cut + 1), scopes: list });
255
+ try {
256
+ const { kind, id } = parsePrincipal({ kind: value.slice(0, cut), id: value.slice(cut + 1) });
257
+ return stamp({ kind, name: id, scopes: list });
258
+ } catch (error) {
259
+ if (!(error instanceof PrincipalParseError)) throw error;
260
+ throw new CommentsError("INVALID_AUTHOR", `the principal needs a known kind (${PRINCIPAL_KINDS.join(", ")}) and a name of A-Z a-z 0-9 . _ -`);
261
+ }
255
262
  }
256
263
 
257
264
  export {
@@ -1,6 +1,6 @@
1
1
  // src/version.ts
2
2
  import { createRequire } from "module";
3
- var VERSION = true ? "0.5.0" : readPackageVersion();
3
+ var VERSION = true ? "0.6.0" : readPackageVersion();
4
4
 
5
5
  export {
6
6
  VERSION
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-WQHJNHAR.js";
3
+ } from "./chunk-ES3NZ7SX.js";
4
4
  import {
5
5
  CommentsService
6
- } from "./chunk-NQWRFKJ6.js";
6
+ } from "./chunk-T77DPNIV.js";
7
7
  import {
8
8
  CommentsError
9
- } from "./chunk-BSNBDKR2.js";
9
+ } from "./chunk-VAVKBAHM.js";
10
10
 
11
11
  // src/mcp.ts
12
12
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -21,6 +21,8 @@ var anchor = z.discriminatedUnion("type", [
21
21
  ]).describe("Where the thread or suggestion points.");
22
22
  var id = z.string().describe("The thread or suggestion id.");
23
23
  var body = z.string().describe("The message text. Write @handle to mention someone.");
24
+ var messageId = z.string().describe("The message id, from get_thread.");
25
+ var person = z.string().describe("The name of the person, as shown on their messages.");
24
26
  var mentions = z.array(z.string()).optional().describe("Extra handles to mention.");
25
27
  function ok(value) {
26
28
  return { content: [{ type: "text", text: JSON.stringify(value) }] };
@@ -33,10 +35,10 @@ function createMcpServer(store, actor) {
33
35
  const service = new CommentsService(store, actor);
34
36
  const server = new McpServer({ name: "agentic-comments", version: VERSION });
35
37
  const tools = /* @__PURE__ */ new Map();
36
- const tool = (name, description, shape, readOnly, run) => {
38
+ const tool = (name, description, shape, readOnly, run, destructive = false) => {
37
39
  const strict = z.object(shape).strict();
38
40
  tools.set(name, { strict, run });
39
- server.registerTool(name, { description, inputSchema: strict, annotations: { readOnlyHint: readOnly, destructiveHint: false, openWorldHint: false } }, (() => {
41
+ server.registerTool(name, { description, inputSchema: strict, annotations: { readOnlyHint: readOnly, destructiveHint: destructive, openWorldHint: false } }, (() => {
40
42
  throw new Error("calls are handled by the server");
41
43
  }));
42
44
  };
@@ -48,6 +50,11 @@ function createMcpServer(store, actor) {
48
50
  tool("resolve_thread", "Resolve a thread.", { id }, false, (i) => service.resolve(i.id));
49
51
  tool("reopen_thread", "Reopen a resolved thread.", { id }, false, (i) => service.reopen(i.id));
50
52
  tool("relocate_thread", "Move a text anchor to its quote when the quote occurs exactly once.", { id }, false, (i) => service.relocate(i.id));
53
+ tool("edit_message", "Change the body of a message you wrote. Needs the moderate scope for another person's message.", { id, messageId, body, mentions }, false, (i) => service.editMessage(i.id, i.messageId, { body: i.body, mentions: i.mentions }), true);
54
+ tool("delete_message", "Delete a message you wrote. The last message of a thread stays: delete the thread instead.", { id, messageId }, false, (i) => service.deleteMessage(i.id, i.messageId), true);
55
+ tool("delete_thread", "Delete a thread. Works while every message is yours. Otherwise it needs the moderate scope.", { id }, false, (i) => service.deleteThread(i.id), true);
56
+ tool("export_person", "Export the messages, status changes, and suggestions one person wrote. Your own, or any with the moderate scope.", { person }, true, (i) => service.exportPerson(i.person));
57
+ tool("erase_person", "Replace everything one person wrote with a tombstone. The thread shape stays. Your own, or any with the moderate scope.", { person }, false, (i) => service.erasePerson(i.person), true);
51
58
  tool("list_suggestions", "List suggested changes.", { status: z.enum(["pending", "accepted", "rejected"]).optional(), anchorState: state }, true, (i) => service.suggestions(i));
52
59
  tool(
53
60
  "suggest_change",
@@ -7,7 +7,7 @@ import {
7
7
  textAnchor,
8
8
  textOf,
9
9
  textRange
10
- } from "./chunk-BSNBDKR2.js";
10
+ } from "./chunk-VAVKBAHM.js";
11
11
 
12
12
  // src/local-host.ts
13
13
  import * as Y from "yjs";
@@ -144,6 +144,26 @@ var CommentsService = class {
144
144
  relocate(threadId) {
145
145
  return this.write(({ comments }) => comments.relocate(threadId, this.actor));
146
146
  }
147
+ editMessage(threadId, messageId, input) {
148
+ return this.write(({ comments }) => comments.editMessage(threadId, messageId, exact(input, ["body", "mentions"], "an edit"), this.actor));
149
+ }
150
+ deleteMessage(threadId, messageId) {
151
+ return this.write(({ comments }) => comments.deleteMessage(threadId, messageId, this.actor));
152
+ }
153
+ deleteThread(threadId) {
154
+ return this.write(({ comments }) => comments.deleteThread(threadId, this.actor));
155
+ }
156
+ /** Everything the store holds that this person wrote: messages, status changes, and suggestions. */
157
+ exportPerson(person) {
158
+ return this.read(({ comments, suggestions }) => ({ ...comments.exportPerson(person, this.actor), suggestions: suggestions.exportPerson(person, this.actor) }));
159
+ }
160
+ /** Tombstone everything this person wrote. Both parts are checked before either is written. */
161
+ erasePerson(person) {
162
+ return this.write(({ comments, suggestions }) => {
163
+ comments.checkPerson(person, this.actor);
164
+ return { ...comments.erasePerson(person, this.actor), ...suggestions.erasePerson(person, this.actor) };
165
+ });
166
+ }
147
167
  suggestions(filter = {}) {
148
168
  const given = exact(filter, ["status", "anchorState"], "a filter");
149
169
  return this.read(({ suggestions }) => suggestions.list(given));