agentic-comments 0.5.1 → 0.6.1

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.
@@ -115,6 +115,7 @@ function onlyOccurrence(type, quote) {
115
115
 
116
116
  // src/principal.ts
117
117
  import { PRINCIPAL_KINDS } from "agentic-identity";
118
+ var ERASED_NAME = "erased";
118
119
  var MAX_NAME = 128;
119
120
  var issued = /* @__PURE__ */ new WeakSet();
120
121
  var Actor = class _Actor {
@@ -131,6 +132,7 @@ var Actor = class _Actor {
131
132
  const author = readAuthor(input);
132
133
  const given = author ? input.scopes : void 0;
133
134
  const scopes = given === void 0 ? [] : Array.isArray(given) ? Array.from(given) : void 0;
135
+ if (author?.name === ERASED_NAME) throw new CommentsError("INVALID_AUTHOR", `the name "${ERASED_NAME}" is kept for erased authors`);
134
136
  if (!author || !scopes || !scopes.every((s) => typeof s === "string" && s !== "")) {
135
137
  throw new CommentsError("INVALID_AUTHOR", `an author needs a kind (${PRINCIPAL_KINDS.join(", ")}), a name, and scopes that are a list of text`);
136
138
  }
@@ -187,7 +189,7 @@ var MAX_SUMMARY = 1e3;
187
189
  var SUGGESTIONS = "comments.suggestions";
188
190
  var APPLIED = "comments.suggestion-applied";
189
191
  var REJECTED = "comments.suggestion-rejected";
190
- var decidesOwn = (author, decider) => author.kind !== "human" && decider.kind !== "human" && author.name === decider.name;
192
+ var decidesOwn = (author, decider) => author.name !== ERASED_NAME && author.kind !== "human" && decider.kind !== "human" && author.name === decider.name;
191
193
  var clientOf = (text) => /^(0|[1-9]\d{0,9})$/.test(text) && Number(text) <= 4294967295 ? Number(text) : void 0;
192
194
  var thenable = (value) => typeof value?.then === "function";
193
195
  var Suggestions = class {
@@ -308,6 +310,66 @@ var Suggestions = class {
308
310
  if (typeof id2 !== "string" || !ID.test(id2) || !this.map.has(id2)) throw new CommentsError("NO_SUGGESTION", `no suggestion with id ${shown(id2)}`);
309
311
  return { id: id2, fields: this.map.get(id2) };
310
312
  }
313
+ /**
314
+ * The suggestions a person wrote, and the decisions they made, in this document. `unread` counts stored
315
+ * suggestions and decision records with their name that this export cannot show: a suggestion that cannot
316
+ * be read, a decision that lost a merge, a refused decision, or a decision with no suggestion. When it is not
317
+ * 0 the export is not complete. A person may export their own data.
318
+ */
319
+ exportPerson(person, actor) {
320
+ const { match } = this.comments.checkPerson(person, actor);
321
+ const all = this.list();
322
+ const authored = all.filter((s) => match(s.author));
323
+ const decided = all.flatMap((s) => s.decidedBy && s.decidedAt && s.status !== "pending" && match(s.decidedBy) ? [{ id: s.id, status: s.status, at: s.decidedAt }] : []);
324
+ let found = 0;
325
+ for (const fields of this.map.values()) if (fields instanceof Y2.Map && this.named(fields.get("author"), match)) found++;
326
+ for (const map of [this.applied, this.rejected]) for (const raw of map.values()) if (this.named(raw?.by, match)) found++;
327
+ return { authored, decided, unread: Math.max(0, found - authored.length - decided.length) };
328
+ }
329
+ named(value, match) {
330
+ const author = readAuthor(value);
331
+ return author !== void 0 && match(author);
332
+ }
333
+ /**
334
+ * Give every suggestion a person wrote a tombstone author and drop its summary. An accepted one keeps its
335
+ * change, because that change is already part of the content. A pending or rejected one loses its change, and
336
+ * can no longer be accepted. A decision they made gets a tombstone decider. A decision that a read refuses (a
337
+ * principal that decided its own work) is deleted, so erase never turns it into a valid one. Returns how many
338
+ * suggestions and decisions it changed.
339
+ */
340
+ erasePerson(person, actor) {
341
+ const { match } = this.comments.checkPerson(person, actor);
342
+ const out = { suggestions: 0, decisions: 0 };
343
+ const authors = /* @__PURE__ */ new Map();
344
+ for (const [id2, fields] of this.map.entries()) {
345
+ const author = fields instanceof Y2.Map ? readAuthor(fields.get("author")) : void 0;
346
+ if (author) authors.set(id2, author);
347
+ }
348
+ const status = new Map(this.list().map((s) => [s.id, s.status]));
349
+ this.ydoc.transact(() => {
350
+ for (const map of [this.applied, this.rejected]) {
351
+ for (const [key2, raw] of [...map.entries()]) {
352
+ const by = readAuthor(raw?.by);
353
+ const author = authors.get(key2.slice(0, key2.lastIndexOf(":")));
354
+ if (!by || !(match(by) || author && match(author))) continue;
355
+ if (author && decidesOwn(author, by)) map.delete(key2);
356
+ else if (match(by)) {
357
+ map.set(key2, { ...raw, by: { kind: by.kind, name: ERASED_NAME } });
358
+ out.decisions++;
359
+ }
360
+ }
361
+ }
362
+ for (const [id2, author] of authors) {
363
+ if (!match(author)) continue;
364
+ const fields = this.map.get(id2);
365
+ fields.set("author", { kind: author.kind, name: ERASED_NAME });
366
+ fields.delete("summary");
367
+ if (status.get(id2) !== "accepted") fields.set("change", null);
368
+ out.suggestions++;
369
+ }
370
+ });
371
+ return out;
372
+ }
311
373
  /** Propose a change. Anyone can suggest. The host stamps the author. */
312
374
  add(input, actor) {
313
375
  const who = assertActor(actor);
@@ -335,6 +397,7 @@ var Suggestions = class {
335
397
  mayDecide(who, s, verb) {
336
398
  const ok = !decidesOwn(s.author, who) && (who.kind === "human" || who.scopes.includes(ACCEPT_SCOPE));
337
399
  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`);
400
+ if (verb === "accept" && s.author.name === ERASED_NAME && s.status !== "accepted") throw new CommentsError("FORBIDDEN", "an erased suggestion has no change left and cannot be accepted");
338
401
  }
339
402
  /**
340
403
  * Apply the change as the accepting author and record the decision, in one step. Accepting an
@@ -461,6 +524,8 @@ var Suggestions = class {
461
524
 
462
525
  // src/threads.ts
463
526
  import * as Y3 from "yjs";
527
+ var MODERATE_SCOPE = "comments:moderate";
528
+ var ERASED_BODY = "[erased]";
464
529
  var THREADS = "comments.threads";
465
530
  var MAX_BODY = 1e4;
466
531
  var MAX_MENTIONS = 50;
@@ -605,7 +670,20 @@ var Comments = class {
605
670
  throw new Error("a message has no id, ISO time, valid author, or body");
606
671
  }
607
672
  if (!Array.isArray(m.mentions) || m.mentions.length > MAX_MENTIONS || !m.mentions.every(validHandle)) throw new Error("a message has bad mentions");
608
- return { id: m.id, at: m.at, author, body: m.body, mentions: [...m.mentions] };
673
+ const editor = m.editedBy === void 0 ? void 0 : readAuthor(m.editedBy);
674
+ if (m.editedAt === void 0 !== (m.editedBy === void 0) || m.editedAt !== void 0 && !validDate(m.editedAt) || m.editedBy !== void 0 && !editor) {
675
+ throw new Error("a message has a bad edit stamp");
676
+ }
677
+ if (m.erased !== void 0 && (m.erased !== true || m.body !== ERASED_BODY || author.name !== ERASED_NAME || m.mentions.length > 0)) throw new Error("a message has a bad erased flag");
678
+ return {
679
+ id: m.id,
680
+ at: m.at,
681
+ author,
682
+ body: m.body,
683
+ mentions: [...m.mentions],
684
+ ...editor ? { editedAt: m.editedAt, editedBy: editor } : {},
685
+ ...m.erased ? { erased: true } : {}
686
+ };
609
687
  }
610
688
  /** A thread with a bad anchor or status is malformed. A bad message is skipped and named in `issues`. */
611
689
  read(id2, fields) {
@@ -622,11 +700,32 @@ var Comments = class {
622
700
  const raw = list.toArray();
623
701
  const counts = /* @__PURE__ */ new Map();
624
702
  for (const m of raw) counts.set(m?.id, (counts.get(m?.id) ?? 0) + 1);
703
+ const parsed = raw.map((entry) => {
704
+ try {
705
+ return { message: this.readMessage(entry) };
706
+ } catch (error) {
707
+ return { error };
708
+ }
709
+ });
710
+ const copies = (id3) => parsed.flatMap((p) => p.message?.id === id3 ? [p.message] : []);
625
711
  let past = 0;
626
- raw.forEach((entry, index) => {
712
+ let superseded = 0;
713
+ parsed.forEach((entry, index) => {
627
714
  try {
628
- const message = this.readMessage(entry);
629
- if (counts.get(message.id) > 1) throw new Error("a message id repeats");
715
+ if (entry.error) throw entry.error;
716
+ const message = entry.message;
717
+ if (counts.get(message.id) > 1) {
718
+ const same = copies(message.id);
719
+ const names = new Set(same.filter((c) => !c.erased).map((c) => c.author.name));
720
+ const agree = same.every((c) => c.editedAt !== void 0 && c.at === message.at && c.author.kind === message.author.kind) && names.size <= 1;
721
+ if (same.length !== counts.get(message.id) || !agree) throw new Error("a message id repeats");
722
+ const rank = (c) => `${c.erased ? 1 : 0}${c.editedAt}${JSON.stringify(c)}`;
723
+ const best = same.reduce((a, b) => rank(b) > rank(a) ? b : a);
724
+ if (best !== message) {
725
+ superseded++;
726
+ return;
727
+ }
728
+ }
630
729
  if (messages.length >= READ_LIMIT) past++;
631
730
  else messages.push(message);
632
731
  } catch (error) {
@@ -636,7 +735,7 @@ var Comments = class {
636
735
  if (past > 0) issues.push(`${past} valid messages past the read limit are not shown`);
637
736
  if (messages.length === 0) throw new Error("the thread has no valid message");
638
737
  const anchor = this.parseAnchor(fields.get("anchor"));
639
- const thread = { id: id2, anchor, anchorState: this.anchorState(anchor), status: state.status, statusBy: by, statusAt: state.at, messages, skipped: raw.length - messages.length };
738
+ const thread = { id: id2, anchor, anchorState: this.anchorState(anchor), status: state.status, statusBy: by, statusAt: state.at, messages, skipped: raw.length - messages.length - superseded };
640
739
  return { thread, issues };
641
740
  } catch (error) {
642
741
  throw new CommentsError("CORRUPT", `thread ${shown(id2)} is malformed: ${safeText(error instanceof Error ? error.message : error)}`);
@@ -715,6 +814,176 @@ var Comments = class {
715
814
  reopen(threadId, actor) {
716
815
  return this.setStatus(threadId, "open", actor);
717
816
  }
817
+ /** A principal may change what it wrote. A principal with the moderate scope may change what anyone wrote. A tombstone is nobody's. */
818
+ mayChange(who, message) {
819
+ return who.scopes.includes(MODERATE_SCOPE) || !message.erased && who.kind === message.author.kind && who.name === message.author.name;
820
+ }
821
+ forbid(who, what) {
822
+ throw new CommentsError("FORBIDDEN", `${who.kind}:${shown(who.name)} cannot ${what}: only its author, or a principal with the ${MODERATE_SCOPE} scope, can`);
823
+ }
824
+ /** Replace every stored copy of a message with `next`, or remove it when `next` is undefined. */
825
+ rewrite(fields, messageId, next) {
826
+ const list = fields.get("messages");
827
+ const at = list.toArray().flatMap((m, i) => m?.id === messageId ? [i] : []);
828
+ this.ydoc.transact(() => {
829
+ for (const i of [...at].reverse()) list.delete(i, 1);
830
+ if (next) list.insert(at[0], [copy(next)]);
831
+ });
832
+ }
833
+ find(threadId, messageId) {
834
+ const { id: id2, fields } = this.fields(threadId);
835
+ const { thread } = this.read(id2, fields);
836
+ const message = thread.messages.find((m) => m.id === messageId);
837
+ if (!message) throw new CommentsError("NO_MESSAGE", `thread ${shown(id2)} has no message with id ${shown(messageId)}`);
838
+ return { id: id2, fields, thread, message };
839
+ }
840
+ /** Change the body of a message. Its author and time stay, and it records who edited it and when. */
841
+ editMessage(threadId, messageId, input, actor) {
842
+ const who = assertActor(actor);
843
+ const given = exact(input, ["body", "mentions"], "an edit");
844
+ const text = body(given.body);
845
+ const { id: id2, fields, thread, message } = this.find(threadId, messageId);
846
+ if (message.erased) throw new CommentsError("FORBIDDEN", "an erased message cannot be edited");
847
+ if (!this.mayChange(who, message)) this.forbid(who, "edit this message");
848
+ if (thread.status === "resolved") throw new CommentsError("THREAD_RESOLVED", `thread ${shown(id2)} is resolved; reopen it to edit`);
849
+ const next = this.readEdited(message, text, given.mentions, who);
850
+ this.rewrite(fields, message.id, next);
851
+ return this.find(id2, message.id).message;
852
+ }
853
+ readEdited(message, text, explicit, who) {
854
+ const edited = { ...message, body: text, mentions: mentionsOf(text, explicit), editedAt: this.time(), editedBy: { kind: who.kind, name: who.name } };
855
+ try {
856
+ return this.readMessage(copy(edited));
857
+ } catch (error) {
858
+ return invalid(`the message cannot be stored: ${error.message}`);
859
+ }
860
+ }
861
+ /**
862
+ * Remove one message, also from a resolved thread. The last message of a thread stays: delete the thread instead. Replicas that delete
863
+ * two messages at once can leave a thread with none. A moderator can still delete that thread.
864
+ */
865
+ deleteMessage(threadId, messageId, actor) {
866
+ const who = assertActor(actor);
867
+ const { id: id2, fields, thread, message } = this.find(threadId, messageId);
868
+ if (!this.mayChange(who, message)) this.forbid(who, "delete this message");
869
+ if (thread.messages.length === 1) invalid(`thread ${shown(id2)} has only this message; delete the thread instead`);
870
+ this.rewrite(fields, message.id, void 0);
871
+ return { threadId: id2, messageId: message.id };
872
+ }
873
+ /**
874
+ * Remove a thread and all its messages. Its author may do it when every message is theirs. Once someone
875
+ * else replied, a message is erased, or a stored message cannot be read, it needs the moderate scope. A
876
+ * moderator can also remove a thread that cannot be read at all.
877
+ */
878
+ deleteThread(threadId, actor) {
879
+ const who = assertActor(actor);
880
+ const { id: id2, fields } = this.fields(threadId);
881
+ if (!who.scopes.includes(MODERATE_SCOPE)) {
882
+ const { thread } = this.read(id2, fields);
883
+ if (thread.skipped > 0 || !thread.messages.every((m) => this.mayChange(who, m))) this.forbid(who, "delete this thread");
884
+ }
885
+ this.map.delete(id2);
886
+ return { threadId: id2 };
887
+ }
888
+ /**
889
+ * Check who an export or erase is for, and that the actor may. A person may act on their own data: the same
890
+ * kind and name. A principal with the moderate scope may act on anyone's, and then every kind with that name is
891
+ * included. Returns the name and a test for an author. Used by `Suggestions`.
892
+ */
893
+ checkPerson(person, actor) {
894
+ const who = assertActor(actor);
895
+ if (typeof person !== "string" || person === "" || person !== person.trim() || person.length > 128) invalid("a person is a name of 1 to 128 characters");
896
+ if (person === ERASED_NAME) invalid("a tombstone is not a person");
897
+ const moderator = who.scopes.includes(MODERATE_SCOPE);
898
+ if (who.name !== person && !moderator) {
899
+ throw new CommentsError("FORBIDDEN", `${who.kind}:${shown(who.name)} cannot read or erase the data of another person without the ${MODERATE_SCOPE} scope`);
900
+ }
901
+ return { name: person, match: (a) => a.name === person && (moderator || a.kind === who.kind) };
902
+ }
903
+ /** The raw stored threads, for the code that must see data a read hides. `list` is missing when the message field is bad. */
904
+ stored() {
905
+ return [...this.map.entries()].flatMap(([id2, fields]) => fields instanceof Y3.Map ? [{ id: id2, fields, list: fields.get("messages") instanceof Y3.Array ? fields.get("messages") : void 0 }] : []);
906
+ }
907
+ /**
908
+ * What a person wrote in this document: their messages, the status change of theirs that stands, and the
909
+ * edits they made to other people's messages. `unread` counts stored entries with their name that this read
910
+ * cannot show: a bad message, a thread that cannot be read, a message past the read limit, or a copy that
911
+ * lost a merge. When it is not 0 the export is not complete. A person may export their own data.
912
+ */
913
+ exportPerson(person, actor) {
914
+ const { name, match } = this.checkPerson(person, actor);
915
+ const threads = [];
916
+ const edits = [];
917
+ let shownCount = 0;
918
+ for (const thread of this.list()) {
919
+ const messages = thread.messages.filter((m) => match(m.author));
920
+ shownCount += messages.length;
921
+ const status = match(thread.statusBy);
922
+ if (status) shownCount++;
923
+ for (const m of thread.messages) if (m.editedBy && match(m.editedBy) && !match(m.author)) edits.push({ threadId: thread.id, messageId: m.id, at: m.editedAt });
924
+ if (messages.length === 0 && !status) continue;
925
+ threads.push({
926
+ threadId: thread.id,
927
+ anchor: copy(thread.anchor),
928
+ messages: copy(messages),
929
+ ...status ? { statusChange: { status: thread.status, at: thread.statusAt, by: copy(thread.statusBy) } } : {}
930
+ });
931
+ }
932
+ let found = 0;
933
+ const named = (value) => {
934
+ const author = readAuthor(value);
935
+ return author !== void 0 && match(author);
936
+ };
937
+ for (const { fields, list } of this.stored()) {
938
+ for (const raw of list?.toArray() ?? []) if (named(raw?.author) || named(raw?.editedBy)) found++;
939
+ if (named(fields.get("state")?.by)) found++;
940
+ }
941
+ return { person: name, exportedAt: this.time(), threads, edits, unread: Math.max(0, found - shownCount - edits.length) };
942
+ }
943
+ /**
944
+ * Replace every message a person wrote with a tombstone: the body becomes "[erased]", the author name
945
+ * becomes "erased", and the mentions go. The author kind stays, so machine-made work stays marked. Thread
946
+ * shape, order, and times stay. A status change of theirs, and their name as the editor of another person's
947
+ * message, get the tombstone name. It works on the stored entries, so it reaches a bad message, a message past
948
+ * the read limit, a thread that cannot be read, and every copy that a merge hid. Names inside other people's
949
+ * messages are not touched. Returns how many messages, status changes, and edit stamps it erased.
950
+ */
951
+ erasePerson(person, actor) {
952
+ const { match } = this.checkPerson(person, actor);
953
+ const tomb = (a) => ({ kind: a.kind, name: ERASED_NAME });
954
+ const out = { messages: 0, statuses: 0, edits: 0 };
955
+ this.ydoc.transact(() => {
956
+ for (const { fields, list } of this.stored()) {
957
+ const entries = list?.toArray() ?? [];
958
+ for (let i = entries.length - 1; i >= 0; i--) {
959
+ const raw = entries[i];
960
+ if (typeof raw !== "object" || raw === null) continue;
961
+ const author = readAuthor(raw.author);
962
+ const editor = readAuthor(raw.editedBy);
963
+ let next;
964
+ if (author && match(author)) {
965
+ const id2 = typeof raw.id === "string" && ID.test(raw.id) ? raw.id : globalThis.crypto.randomUUID();
966
+ const at = validDate(raw.at) ? raw.at : this.time();
967
+ next = { id: id2, at, author: tomb(author), body: ERASED_BODY, mentions: [], editedAt: this.time(), editedBy: tomb(author), erased: true };
968
+ out.messages++;
969
+ } else if (editor && match(editor)) {
970
+ next = { ...raw, editedBy: tomb(editor) };
971
+ out.edits++;
972
+ }
973
+ if (!next) continue;
974
+ list.delete(i, 1);
975
+ list.insert(i, [next]);
976
+ }
977
+ const state = fields.get("state");
978
+ const by = readAuthor(state?.by);
979
+ if (state && by && match(by)) {
980
+ fields.set("state", { ...state, by: tomb(by) });
981
+ out.statuses++;
982
+ }
983
+ }
984
+ });
985
+ return out;
986
+ }
718
987
  get(threadId) {
719
988
  const { id: id2, fields } = this.fields(threadId);
720
989
  return this.read(id2, fields).thread;
@@ -755,6 +1024,7 @@ export {
755
1024
  textAnchor,
756
1025
  textRange,
757
1026
  PRINCIPAL_KINDS,
1027
+ ERASED_NAME,
758
1028
  Actor,
759
1029
  stamp,
760
1030
  readAuthor,
@@ -764,5 +1034,7 @@ export {
764
1034
  validDate,
765
1035
  ACCEPT_SCOPE,
766
1036
  Suggestions,
1037
+ MODERATE_SCOPE,
1038
+ ERASED_BODY,
767
1039
  Comments
768
1040
  };
package/dist/cli.js CHANGED
@@ -3,17 +3,17 @@ import {
3
3
  FileStore,
4
4
  lockTimeoutFrom,
5
5
  operatorActor
6
- } from "./chunk-QJXI3P5D.js";
6
+ } from "./chunk-6N53KQVU.js";
7
7
  import {
8
8
  VERSION
9
- } from "./chunk-REFQNIEP.js";
9
+ } from "./chunk-H6JK62SP.js";
10
10
  import {
11
11
  CommentsService,
12
12
  TEXT_ROOT
13
- } from "./chunk-NQWRFKJ6.js";
13
+ } from "./chunk-T77DPNIV.js";
14
14
  import {
15
15
  CommentsError
16
- } from "./chunk-BSNBDKR2.js";
16
+ } from "./chunk-VAVKBAHM.js";
17
17
 
18
18
  // src/cli.ts
19
19
  import { readFileSync, realpathSync } from "fs";
@@ -24,6 +24,7 @@ var FLAGS = {
24
24
  "--file": "value",
25
25
  "--as": "value",
26
26
  "--body": "value",
27
+ "--message": "value",
27
28
  "--mention": "many",
28
29
  "--text-quote": "value",
29
30
  "--occurrence": "value",
@@ -50,19 +51,26 @@ var ALLOWED = {
50
51
  resolve: SHARED,
51
52
  reopen: SHARED,
52
53
  relocate: SHARED,
54
+ "edit-message": [...SHARED, "--message", "--body", "--mention"],
55
+ "delete-message": [...SHARED, "--message"],
56
+ "delete-thread": SHARED,
57
+ "export-person": SHARED,
58
+ "erase-person": SHARED,
53
59
  suggestions: [...SHARED, "--status", "--anchor-state"],
54
60
  suggest: [...SHARED, ...ANCHOR, "--replace", "--value", "--summary"],
55
61
  accept: SHARED,
56
62
  reject: SHARED,
57
63
  mcp: SHARED
58
64
  };
59
- var TAKES_ID = ["show", "reply", "resolve", "reopen", "relocate", "accept", "reject"];
60
- var COMMANDS = ["init", "list", "show", "comment", "reply", "resolve", "reopen", "relocate", "suggestions", "suggest", "accept", "reject", "mcp"];
65
+ var TAKES_ID = ["show", "reply", "resolve", "reopen", "relocate", "accept", "reject", "edit-message", "delete-message", "delete-thread", "export-person", "erase-person"];
66
+ var COMMANDS = ["init", "list", "show", "comment", "reply", "resolve", "reopen", "relocate", "edit-message", "delete-message", "delete-thread", "export-person", "erase-person", "suggestions", "suggest", "accept", "reject", "mcp"];
61
67
  function parseArgs(argv) {
62
68
  const [command, ...rest] = argv;
63
69
  if (command === void 0 || command === "--help" || command === "-h") return { command: "help", flags: {}, many: {} };
64
70
  if (command === "--version" || command === "-V") return { command: "version", flags: {}, many: {} };
65
71
  if (!COMMANDS.includes(command)) throw new UsageError(`unknown command "${command}"`);
72
+ if (command === "mcp" && rest.some((a) => a === "--help" || a === "-h")) return { command: "help", flags: {}, many: {} };
73
+ if (command === "mcp" && rest.some((a) => a === "--version" || a === "-V" || a === "-v")) return { command: "version", flags: {}, many: {} };
66
74
  const parsed = { command, flags: {}, many: {} };
67
75
  for (let i = 0; i < rest.length; i++) {
68
76
  const arg = rest[i];
@@ -112,6 +120,15 @@ function need(p) {
112
120
  if (p.id === void 0) throw new UsageError(`${p.command} needs an id`);
113
121
  return p.id;
114
122
  }
123
+ function needName(p) {
124
+ if (p.id === void 0) throw new UsageError(`${p.command} needs the name of a person`);
125
+ return p.id;
126
+ }
127
+ function flag(p, name) {
128
+ const value = p.flags[name];
129
+ if (value === void 0) throw new UsageError(`${p.command} needs ${name}`);
130
+ return value;
131
+ }
115
132
  var HELP = `agentic-comments ${VERSION}
116
133
  Comment threads and suggestions anchored to any content, from people and agents.
117
134
 
@@ -121,6 +138,7 @@ Setup:
121
138
  --file <path> The store file. Or AGENTIC_COMMENTS_FILE. Default ./comments.ydoc.
122
139
  --as <kind:name> Who acts. Or AGENTIC_COMMENTS_AS. Kinds: human, agent_of_human, agent_of_agent, agent, user_token.
123
140
  Scopes come only from AGENTIC_COMMENTS_SCOPES, a comma list such as comments:accept.
141
+ Another person's message, thread, or data needs comments:moderate.
124
142
 
125
143
  Commands:
126
144
  init [--text <t> | --text-file <p>] Create the store.
@@ -129,6 +147,11 @@ Commands:
129
147
  comment <anchor> --body <t> [--mention h] Start a thread.
130
148
  reply <id> --body <t> [--mention h] Reply.
131
149
  resolve <id> | reopen <id> | relocate <id>
150
+ edit-message <id> --message <mid> --body <t> [--mention h] Change a message you wrote.
151
+ delete-message <id> --message <mid> Delete a message you wrote.
152
+ delete-thread <id> Delete a thread. Needs every message to be yours.
153
+ export-person <name> Export what a person wrote.
154
+ erase-person <name> Replace what a person wrote with a tombstone.
132
155
  suggestions [--status s] [--anchor-state s] List suggestions.
133
156
  suggest <anchor> (--replace <t> | --value <json>) [--summary <t>]
134
157
  accept <id> | reject <id>
@@ -155,7 +178,7 @@ async function runCli(argv, io) {
155
178
  return 0;
156
179
  }
157
180
  if (p.command === "mcp") {
158
- const { startMcp } = await import("./mcp-XUV3RPOO.js");
181
+ const { startMcp } = await import("./mcp-WBAHKMF4.js");
159
182
  await startMcp(store, operatorActor(p.flags["--as"] ?? io.env.AGENTIC_COMMENTS_AS, io.env.AGENTIC_COMMENTS_SCOPES));
160
183
  return 0;
161
184
  }
@@ -163,7 +186,7 @@ async function runCli(argv, io) {
163
186
  const mentions = p.many["--mention"];
164
187
  const filter = { status: p.flags["--status"], anchorState: p.flags["--anchor-state"], mentioning: p.flags["--mentioning"] };
165
188
  const clean = Object.fromEntries(Object.entries(filter).filter(([, v]) => v !== void 0));
166
- const result = p.command === "list" ? service.threads(clean) : p.command === "show" ? service.thread(need(p)) : p.command === "comment" ? service.comment({ anchor: anchorOf(p), body: p.flags["--body"], ...mentions ? { mentions } : {} }) : p.command === "reply" ? service.reply(need(p), { body: p.flags["--body"], ...mentions ? { mentions } : {} }) : p.command === "resolve" ? service.resolve(need(p)) : p.command === "reopen" ? service.reopen(need(p)) : p.command === "relocate" ? service.relocate(need(p)) : p.command === "suggestions" ? service.suggestions(Object.fromEntries(Object.entries(clean).filter(([k]) => k !== "mentioning"))) : p.command === "suggest" ? service.suggest({ anchor: anchorOf(p), change: changeOf(p), ...p.flags["--summary"] === void 0 ? {} : { summary: p.flags["--summary"] } }) : p.command === "accept" ? service.accept(need(p)) : service.reject(need(p));
189
+ const result = p.command === "list" ? service.threads(clean) : p.command === "show" ? service.thread(need(p)) : p.command === "comment" ? service.comment({ anchor: anchorOf(p), body: p.flags["--body"], ...mentions ? { mentions } : {} }) : p.command === "reply" ? service.reply(need(p), { body: p.flags["--body"], ...mentions ? { mentions } : {} }) : p.command === "edit-message" ? service.editMessage(need(p), flag(p, "--message"), { body: p.flags["--body"], ...mentions ? { mentions } : {} }) : p.command === "delete-message" ? service.deleteMessage(need(p), flag(p, "--message")) : p.command === "delete-thread" ? service.deleteThread(need(p)) : p.command === "export-person" ? service.exportPerson(needName(p)) : p.command === "erase-person" ? service.erasePerson(needName(p)) : p.command === "resolve" ? service.resolve(need(p)) : p.command === "reopen" ? service.reopen(need(p)) : p.command === "relocate" ? service.relocate(need(p)) : p.command === "suggestions" ? service.suggestions(Object.fromEntries(Object.entries(clean).filter(([k]) => k !== "mentioning"))) : p.command === "suggest" ? service.suggest({ anchor: anchorOf(p), change: changeOf(p), ...p.flags["--summary"] === void 0 ? {} : { summary: p.flags["--summary"] } }) : p.command === "accept" ? service.accept(need(p)) : service.reject(need(p));
167
190
  io.out(JSON.stringify(result));
168
191
  return 0;
169
192
  } catch (error) {
package/dist/client.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { L as ListFilter, A as AnchorSpec } from './service-hpWbnSmc.js';
2
- import { C as CommentsErrorCode } from './errors-CNgWMVQT.js';
1
+ import { L as ListFilter, A as AnchorSpec } from './service-BvIE6t5n.js';
2
+ import { C as CommentsErrorCode } from './errors-C-xvp24-.js';
3
3
  import { PrincipalKind } from 'agentic-identity';
4
4
  export { PrincipalKind } from 'agentic-identity';
5
- import { T as Thread, N as NewReply, M as Message, S as Suggestion } from './suggestions-CvJwo6r4.js';
5
+ import { T as Thread, N as NewReply, M as Message, P as PersonExport, a as PersonSuggestions, S as Suggestion } from './suggestions-CnkEcTuY.js';
6
6
  import 'yjs';
7
7
 
8
8
  /** Codes the comment rules return, plus the ones the Worker and its auth return. */
@@ -35,6 +35,18 @@ interface Me {
35
35
  }
36
36
  type ThreadFilter = ListFilter;
37
37
  type SuggestionListFilter = Pick<ListFilter, "status" | "anchorState">;
38
+ /** What `exportPerson` returns: the messages and status changes of one person, and their suggestions. */
39
+ type PersonData = PersonExport & {
40
+ suggestions: PersonSuggestions;
41
+ };
42
+ /** What `erasePerson` changed in one document. */
43
+ interface Erased {
44
+ messages: number;
45
+ statuses: number;
46
+ edits: number;
47
+ suggestions: number;
48
+ decisions: number;
49
+ }
38
50
  interface CommentsClient {
39
51
  me(options?: CallOptions): Promise<Me>;
40
52
  createDocument(input: {
@@ -58,6 +70,19 @@ interface CommentsClient {
58
70
  resolve(docId: string, threadId: string, options?: CallOptions): Promise<Thread>;
59
71
  reopen(docId: string, threadId: string, options?: CallOptions): Promise<Thread>;
60
72
  relocate(docId: string, threadId: string, options?: CallOptions): Promise<Thread>;
73
+ editMessage(docId: string, threadId: string, messageId: string, input: {
74
+ body: string;
75
+ mentions?: string[];
76
+ }, options?: CallOptions): Promise<Message>;
77
+ deleteMessage(docId: string, threadId: string, messageId: string, options?: CallOptions): Promise<{
78
+ threadId: string;
79
+ messageId: string;
80
+ }>;
81
+ deleteThread(docId: string, threadId: string, options?: CallOptions): Promise<{
82
+ threadId: string;
83
+ }>;
84
+ exportPerson(docId: string, person: string, options?: CallOptions): Promise<PersonData>;
85
+ erasePerson(docId: string, person: string, options?: CallOptions): Promise<Erased>;
61
86
  listSuggestions(docId: string, filter?: SuggestionListFilter, options?: CallOptions): Promise<Suggestion[]>;
62
87
  suggest(docId: string, input: {
63
88
  anchor: AnchorSpec;
@@ -69,4 +94,4 @@ interface CommentsClient {
69
94
  }
70
95
  declare function createClient(options: ClientOptions): CommentsClient;
71
96
 
72
- export { AnchorSpec, type ApiErrorCode, type CallOptions, type ClientOptions, CommentsApiError, type CommentsClient, CommentsErrorCode, ListFilter, type Me, Message, NewReply, Suggestion, type SuggestionListFilter, Thread, type ThreadFilter, createClient };
97
+ export { AnchorSpec, type ApiErrorCode, type CallOptions, type ClientOptions, CommentsApiError, type CommentsClient, CommentsErrorCode, type Erased, ListFilter, type Me, Message, NewReply, type PersonData, PersonExport, PersonSuggestions, Suggestion, type SuggestionListFilter, Thread, type ThreadFilter, createClient };
package/dist/client.js CHANGED
@@ -4,6 +4,7 @@ var CODES = {
4
4
  INVALID_INPUT: true,
5
5
  INVALID_ANCHOR: true,
6
6
  NO_THREAD: true,
7
+ NO_MESSAGE: true,
7
8
  THREAD_RESOLVED: true,
8
9
  CORRUPT: true,
9
10
  NO_SUGGESTION: true,
@@ -40,15 +41,21 @@ var CommentsApiError = class extends Error {
40
41
  var KINDS = { human: true, agent_of_human: true, agent_of_agent: true, agent: true, user_token: true };
41
42
  var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
42
43
  var isString = (v) => typeof v === "string";
44
+ var isAuthor = (v) => isObject(v) && typeof v.kind === "string" && Object.hasOwn(KINDS, v.kind) && isString(v.name);
45
+ var personThread = (v) => isObject(v) && isString(v.threadId) && isObject(v.anchor) && Array.isArray(v.messages) && v.messages.every(SHAPES.message) && (v.statusChange === void 0 || isObject(v.statusChange) && (v.statusChange.status === "open" || v.statusChange.status === "resolved") && isString(v.statusChange.at) && isAuthor(v.statusChange.by));
43
46
  var SHAPES = {
44
- me: (v) => isObject(v) && (typeof v.kind === "string" && Object.hasOwn(KINDS, v.kind)) && isString(v.name) && Array.isArray(v.scopes),
47
+ me: (v) => isObject(v) && (typeof v.kind === "string" && Object.hasOwn(KINDS, v.kind)) && isString(v.name) && Array.isArray(v.scopes) && v.scopes.every(isString),
45
48
  created: (v) => isObject(v) && isString(v.id),
46
49
  document: (v) => isObject(v) && isString(v.id) && isString(v.text),
47
- thread: (v) => isObject(v) && isString(v.id) && isObject(v.anchor) && (v.status === "open" || v.status === "resolved") && Array.isArray(v.messages),
50
+ thread: (v) => isObject(v) && isString(v.id) && isObject(v.anchor) && isObject(v.anchorState) && isString(v.anchorState.state) && (v.status === "open" || v.status === "resolved") && isAuthor(v.statusBy) && isString(v.statusAt) && Array.isArray(v.messages) && v.messages.every(SHAPES.message) && typeof v.skipped === "number",
48
51
  threads: (v) => Array.isArray(v) && v.every(SHAPES.thread),
49
- message: (v) => isObject(v) && isString(v.id) && isString(v.body) && isObject(v.author),
50
- suggestion: (v) => isObject(v) && isString(v.id) && isObject(v.anchor) && isString(v.status) && isObject(v.author),
51
- suggestions: (v) => Array.isArray(v) && v.every(SHAPES.suggestion)
52
+ message: (v) => isObject(v) && isString(v.id) && isString(v.at) && isString(v.body) && Array.isArray(v.mentions) && v.mentions.every(isString) && isAuthor(v.author) && (v.editedAt === void 0 || isString(v.editedAt)) && (v.editedBy === void 0 || isAuthor(v.editedBy)),
53
+ suggestion: (v) => isObject(v) && isString(v.id) && isObject(v.anchor) && isObject(v.anchorState) && isString(v.anchorState.state) && (v.status === "pending" || v.status === "accepted" || v.status === "rejected") && isAuthor(v.author) && isString(v.createdAt) && "change" in v && (v.summary === void 0 || isString(v.summary)) && (v.decidedBy === void 0 || isAuthor(v.decidedBy)) && (v.decidedAt === void 0 || isString(v.decidedAt)),
54
+ suggestions: (v) => Array.isArray(v) && v.every(SHAPES.suggestion),
55
+ deletedMessage: (v) => isObject(v) && isString(v.threadId) && isString(v.messageId),
56
+ deletedThread: (v) => isObject(v) && isString(v.threadId),
57
+ person: (v) => isObject(v) && isString(v.person) && isString(v.exportedAt) && Array.isArray(v.threads) && v.threads.every(personThread) && Array.isArray(v.edits) && v.edits.every((e) => isObject(e) && isString(e.threadId) && isString(e.messageId) && isString(e.at)) && typeof v.unread === "number" && isObject(v.suggestions) && Array.isArray(v.suggestions.authored) && v.suggestions.authored.every(SHAPES.suggestion) && Array.isArray(v.suggestions.decided) && v.suggestions.decided.every((d) => isObject(d) && isString(d.id) && (d.status === "accepted" || d.status === "rejected") && isString(d.at)) && typeof v.suggestions.unread === "number",
58
+ erased: (v) => isObject(v) && ["messages", "statuses", "edits", "suggestions", "decisions"].every((k) => typeof v[k] === "number")
52
59
  };
53
60
  function createClient(options) {
54
61
  if (typeof options.baseUrl !== "string" || options.baseUrl === "") throw new TypeError("createClient needs a baseUrl");
@@ -120,6 +127,11 @@ function createClient(options) {
120
127
  resolve: (id, threadId, o) => call("POST", `${thread(id, threadId)}/resolve`, void 0, o, "thread"),
121
128
  reopen: (id, threadId, o) => call("POST", `${thread(id, threadId)}/reopen`, void 0, o, "thread"),
122
129
  relocate: (id, threadId, o) => call("POST", `${thread(id, threadId)}/relocate`, void 0, o, "thread"),
130
+ editMessage: (id, threadId, messageId, input, o) => call("PATCH", `${thread(id, threadId)}/messages/${seg(messageId)}`, input, o, "message"),
131
+ deleteMessage: (id, threadId, messageId, o) => call("DELETE", `${thread(id, threadId)}/messages/${seg(messageId)}`, void 0, o, "deletedMessage"),
132
+ deleteThread: (id, threadId, o) => call("DELETE", thread(id, threadId), void 0, o, "deletedThread"),
133
+ exportPerson: (id, person, o) => call("GET", `${doc(id)}/people/${seg(person)}/export`, void 0, o, "person"),
134
+ erasePerson: (id, person, o) => call("DELETE", `${doc(id)}/people/${seg(person)}`, void 0, o, "erased"),
123
135
  listSuggestions: (id, filter, o) => call("GET", `${doc(id)}/suggestions${query(filter)}`, void 0, o, "suggestions"),
124
136
  suggest: (id, input, o) => call("POST", `${doc(id)}/suggestions`, input, o, "suggestion"),
125
137
  accept: (id, suggestionId, o) => call("POST", `${suggestion(id, suggestionId)}/accept`, void 0, o, "suggestion"),
@@ -0,0 +1,7 @@
1
+ type CommentsErrorCode = "INVALID_AUTHOR" | "INVALID_INPUT" | "INVALID_ANCHOR" | "NO_THREAD" | "NO_MESSAGE" | "THREAD_RESOLVED" | "CORRUPT" | "NO_SUGGESTION" | "FORBIDDEN" | "ALREADY_DECIDED" | "APPLY_FAILED" | "RELOCATE_FAILED" | "STORE_MISSING" | "STORE_EXISTS" | "STORE_CORRUPT" | "STORE_LOCKED" | "STORE_FULL" | "NO_DOCUMENT" | "DOCUMENT_EXISTS" | "CONFLICT" | "MISCONFIGURED" | "USAGE";
2
+ declare class CommentsError extends Error {
3
+ readonly code: CommentsErrorCode;
4
+ constructor(code: CommentsErrorCode, message: string);
5
+ }
6
+
7
+ export { type CommentsErrorCode as C, CommentsError as a };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { a as Author, C as Comments, b as Suggestions } from './suggestions-CvJwo6r4.js';
2
- export { c as ACCEPT_SCOPE, A as Actor, d as Anchor, e as AnchorKind, f as AnchorState, g as ApplyContext, h as CanvasAnchor, i as CellAnchor, j as CommentsOptions, M as Message, N as NewReply, k as NewSuggestion, l as NewThread, R as RelativePositionJson, m as RevertContext, n as SlideAnchor, o as StampInput, S as Suggestion, p as SuggestionFilter, q as SuggestionHost, r as TextAnchor, T as Thread, s as ThreadFilter, t as authorLabel, u as stamp, v as textAnchor, w as textOf } from './suggestions-CvJwo6r4.js';
3
- export { a as CommentsError, C as CommentsErrorCode } from './errors-CNgWMVQT.js';
1
+ import { b as Author, C as Comments, c as Suggestions } from './suggestions-CnkEcTuY.js';
2
+ export { d as ACCEPT_SCOPE, A as Actor, e as Anchor, f as AnchorKind, g as AnchorState, h as ApplyContext, i as CanvasAnchor, j as CellAnchor, k as CommentsOptions, E as ERASED_BODY, l as ERASED_NAME, m as EditMessage, n as MODERATE_SCOPE, M as Message, N as NewReply, o as NewSuggestion, p as NewThread, P as PersonExport, R as RelativePositionJson, q as RevertContext, r as SlideAnchor, s as StampInput, S as Suggestion, t as SuggestionFilter, u as SuggestionHost, v as TextAnchor, T as Thread, w as ThreadFilter, x as authorLabel, y as stamp, z as textAnchor, B as textOf } from './suggestions-CnkEcTuY.js';
3
+ export { a as CommentsError, C as CommentsErrorCode } from './errors-C-xvp24-.js';
4
4
  export { PRINCIPAL_KINDS, PrincipalKind } from 'agentic-identity';
5
5
  import 'yjs';
6
6