relic-mcp 0.7.0 → 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.
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "name": "relic",
12
12
  "description": "Publish a file from your machine as an encrypted, shareable link. The agent encrypts locally, uploads only ciphertext, and hands back a URL whose fragment holds the key, so the service stores something it cannot read.",
13
- "version": "0.7.0",
13
+ "version": "0.8.0",
14
14
  "source": "./",
15
15
  "author": {
16
16
  "name": "The Bushido Collective",
@@ -21,6 +21,6 @@
21
21
  }
22
22
  ],
23
23
  "metadata": {
24
- "version": "0.7.0"
24
+ "version": "0.8.0"
25
25
  }
26
26
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relic",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Publish a file from your machine as an encrypted, shareable link. The agent encrypts locally, uploads only ciphertext, and hands back a URL whose fragment holds the key, so the service stores something it cannot read.",
5
5
  "mcpServers": "./mcp-servers.json",
6
6
  "author": {
package/dist/relic-mcp.js CHANGED
@@ -4623,6 +4623,42 @@ async function resolveAnchors(relicId, comments2, deps) {
4623
4623
  }
4624
4624
 
4625
4625
  // src/comments.ts
4626
+ function previewCommentBody(body, maxChars = 80) {
4627
+ if (body === null || body === undefined)
4628
+ return "";
4629
+ const trimmed = body.trim();
4630
+ const firstLine = trimmed.split(`
4631
+ `)[0]?.trim() ?? "";
4632
+ const text = firstLine.length > 0 ? firstLine : trimmed;
4633
+ if (text.length <= maxChars)
4634
+ return text;
4635
+ return `${text.slice(0, maxChars - 3)}...`;
4636
+ }
4637
+ function formatUnaddressedRefusal(relicId, openComments, unreadableComments = []) {
4638
+ const parts = [];
4639
+ if (openComments.length > 0 && unreadableComments.length > 0) {
4640
+ parts.push(`cannot republish relic ${relicId} while comments remain unaddressed. ` + `${openComments.length} comment(s) are unanswered, and ${unreadableComments.length} comment(s) could not be decrypted. ` + "Address unanswered comments either by replying with relic_comment or by passing addresses: [{ comment_id, note }] on republish.");
4641
+ } else if (openComments.length > 0) {
4642
+ parts.push(`cannot republish relic ${relicId} while comments remain unaddressed. ` + `${openComments.length} comment(s) are unanswered. ` + "Address each open comment either by replying with relic_comment or by passing addresses: [{ comment_id, note }] on republish.");
4643
+ } else {
4644
+ parts.push(`cannot republish relic ${relicId} because ${unreadableComments.length} comment(s) could not be decrypted. ` + "A comment this client cannot read cannot be verified as addressed.");
4645
+ }
4646
+ if (openComments.length > 0) {
4647
+ const list = openComments.map((c) => `- [${c.comment_id}] from ${c.author} at ${c.created_at}: "${previewCommentBody(c.body)}"`).join(`
4648
+ `);
4649
+ parts.push(`Open comments (${openComments.length}):
4650
+ ${list}`);
4651
+ }
4652
+ if (unreadableComments.length > 0) {
4653
+ const list = unreadableComments.map((c) => `- [${c.comment_id}] from ${c.author} at ${c.created_at}: unreadable (${c.unreadable_reason ?? "it did not decrypt under this relic's comment key"})`).join(`
4654
+ `);
4655
+ parts.push(`Unreadable comments (${unreadableComments.length}):
4656
+ ${list}`);
4657
+ }
4658
+ return parts.join(`
4659
+
4660
+ `);
4661
+ }
4626
4662
  async function readComments(relicId, deps, options) {
4627
4663
  const state = await localState(relicId);
4628
4664
  const listed = await getJson(deps, `${deps.serviceOrigin}/api/relics/${relicId}/comments`);
@@ -4630,23 +4666,24 @@ async function readComments(relicId, deps, options) {
4630
4666
  throw new PublishError("app_response_unusable", "the comment list did not come back as a JSON array, so there is no " + "way to tell an empty conversation from an unreadable response", { relic_id: relicId, leg: "comments" });
4631
4667
  }
4632
4668
  const commentKey = await deriveCommentKey(decodeKey(state.key));
4633
- const comments2 = [];
4634
- let unreadable = 0;
4669
+ const entries = [];
4635
4670
  for (const [index, entry] of listed.entries()) {
4636
4671
  const row = typeof entry === "object" && entry !== null && !Array.isArray(entry) ? entry : {};
4637
4672
  const commentId = typeof row["comment_id"] === "string" ? row["comment_id"] : `unidentified-${index}`;
4638
4673
  const author = typeof row["author"] === "string" ? row["author"] : "unknown";
4639
4674
  const createdAt = typeof row["created_at"] === "string" ? row["created_at"] : "unknown";
4640
4675
  const ciphertext = row["ciphertext"];
4676
+ const version = typeof row["version"] === "number" ? row["version"] : null;
4641
4677
  if (typeof ciphertext !== "string") {
4642
- unreadable += 1;
4643
- comments2.push({
4678
+ entries.push({
4644
4679
  comment_id: commentId,
4645
4680
  author,
4646
4681
  created_at: createdAt,
4647
4682
  display_name: null,
4648
4683
  body: null,
4649
4684
  anchor: null,
4685
+ addresses: null,
4686
+ version,
4650
4687
  readable: false,
4651
4688
  unreadable_reason: "the row carried no ciphertext"
4652
4689
  });
@@ -4654,36 +4691,79 @@ async function readComments(relicId, deps, options) {
4654
4691
  }
4655
4692
  try {
4656
4693
  const plaintext = await decryptComment(commentKey, ciphertext);
4657
- comments2.push({
4694
+ entries.push({
4658
4695
  comment_id: commentId,
4659
4696
  author,
4660
4697
  created_at: createdAt,
4661
4698
  display_name: plaintext.display_name,
4662
4699
  body: plaintext.body,
4663
4700
  anchor: plaintext.anchor ?? null,
4701
+ addresses: plaintext.addresses ?? null,
4702
+ version,
4664
4703
  readable: true,
4665
4704
  unreadable_reason: null
4666
4705
  });
4667
4706
  } catch (error) {
4668
- unreadable += 1;
4669
- comments2.push({
4707
+ entries.push({
4670
4708
  comment_id: commentId,
4671
4709
  author,
4672
4710
  created_at: createdAt,
4673
4711
  display_name: null,
4674
4712
  body: null,
4675
4713
  anchor: null,
4714
+ addresses: null,
4715
+ version,
4676
4716
  readable: false,
4677
4717
  unreadable_reason: `it did not decrypt under this relic's comment key: ${error.message}`
4678
4718
  });
4679
4719
  }
4680
4720
  }
4721
+ const addressedByMap = new Map;
4722
+ for (const entry of entries) {
4723
+ if (entry.readable && entry.addresses !== null && entry.addresses !== entry.comment_id) {
4724
+ if (!addressedByMap.has(entry.addresses)) {
4725
+ addressedByMap.set(entry.addresses, {
4726
+ comment_id: entry.comment_id,
4727
+ version: entry.version
4728
+ });
4729
+ }
4730
+ }
4731
+ }
4732
+ const comments2 = entries.map((entry) => {
4733
+ const addressedBy = addressedByMap.get(entry.comment_id) ?? null;
4734
+ const addressed = addressedBy !== null;
4735
+ return {
4736
+ comment_id: entry.comment_id,
4737
+ author: entry.author,
4738
+ created_at: entry.created_at,
4739
+ display_name: entry.display_name,
4740
+ body: entry.body,
4741
+ anchor: entry.anchor,
4742
+ addresses: entry.addresses,
4743
+ readable: entry.readable,
4744
+ unreadable_reason: entry.unreadable_reason,
4745
+ addressed,
4746
+ addressed_by: addressedBy,
4747
+ addressed_by_comment_id: addressedBy?.comment_id ?? null,
4748
+ addressed_by_version: addressedBy?.version ?? null
4749
+ };
4750
+ });
4751
+ const addressedCount = comments2.filter((c) => c.addressed).length;
4752
+ const unreadableCount = comments2.filter((c) => !c.readable).length;
4753
+ const openCount = comments2.filter((c) => c.readable && !c.addressed && c.addresses === null).length;
4754
+ const summary = {
4755
+ total: comments2.length,
4756
+ addressed: addressedCount,
4757
+ open: openCount,
4758
+ unreadable: unreadableCount
4759
+ };
4681
4760
  if (options?.resolve_anchors === true) {
4682
4761
  const resolved = await resolveAnchors(relicId, comments2, deps);
4683
4762
  return {
4684
4763
  relic_id: relicId,
4685
4764
  count: comments2.length,
4686
- unreadable_count: unreadable,
4765
+ unreadable_count: unreadableCount,
4766
+ summary,
4687
4767
  comments: resolved.comments,
4688
4768
  content_blocks: resolved.imageBlocks
4689
4769
  };
@@ -4691,7 +4771,8 @@ async function readComments(relicId, deps, options) {
4691
4771
  return {
4692
4772
  relic_id: relicId,
4693
4773
  count: comments2.length,
4694
- unreadable_count: unreadable,
4774
+ unreadable_count: unreadableCount,
4775
+ summary,
4695
4776
  comments: comments2
4696
4777
  };
4697
4778
  }
@@ -4711,14 +4792,33 @@ async function postComment(input, deps) {
4711
4792
  throw new PublishError("local_comment_name_too_long", `the display name is ${nameBytes} bytes of UTF-8 and the limit is ` + `${COMMENT_DISPLAY_NAME_LIMIT_BYTES}.`, { name_bytes: nameBytes, limit_bytes: COMMENT_DISPLAY_NAME_LIMIT_BYTES });
4712
4793
  }
4713
4794
  }
4795
+ let addresses = null;
4796
+ if (input.addresses !== undefined && input.addresses !== null) {
4797
+ if (typeof input.addresses !== "string" || input.addresses.trim().length === 0) {
4798
+ throw new PublishError("local_comment_addresses_invalid", "addresses must be a non-empty string naming the comment being answered.");
4799
+ }
4800
+ const addressesBytes = new TextEncoder().encode(input.addresses).length;
4801
+ if (addressesBytes > COMMENT_ADDRESSES_LIMIT_BYTES) {
4802
+ throw new PublishError("local_comment_addresses_too_long", `addresses is ${addressesBytes} bytes of UTF-8 and the limit is ${COMMENT_ADDRESSES_LIMIT_BYTES}.`, {
4803
+ addresses_bytes: addressesBytes,
4804
+ limit_bytes: COMMENT_ADDRESSES_LIMIT_BYTES
4805
+ });
4806
+ }
4807
+ addresses = input.addresses;
4808
+ }
4714
4809
  const anchor = validateAndNormalizeAnchor(input.anchor);
4715
4810
  const commentKey = await deriveCommentKey(decodeKey(state.key));
4716
4811
  const ciphertext = await encryptComment(commentKey, {
4717
4812
  body: input.body,
4718
4813
  display_name: displayName,
4719
- anchor
4814
+ anchor,
4815
+ ...addresses == null ? {} : { addresses }
4816
+ });
4817
+ const posted = await postJson(deps, `${deps.serviceOrigin}/api/relics/${input.relic_id}/comments`, {
4818
+ publish_token: state.publish_token,
4819
+ ciphertext,
4820
+ ...addresses == null ? {} : { addresses }
4720
4821
  });
4721
- const posted = await postJson(deps, `${deps.serviceOrigin}/api/relics/${input.relic_id}/comments`, { publish_token: state.publish_token, ciphertext });
4722
4822
  return {
4723
4823
  relic_id: input.relic_id,
4724
4824
  comment_id: String(posted["comment_id"]),
@@ -5627,6 +5727,60 @@ async function republish(input, deps) {
5627
5727
  throw error;
5628
5728
  throw new PublishError("local_state_unreadable", error.message);
5629
5729
  }
5730
+ const addressesEntries = input.addresses;
5731
+ if (addressesEntries !== undefined) {
5732
+ if (!Array.isArray(addressesEntries)) {
5733
+ throw new PublishError("invalid_acknowledgements", "addresses must be an array of { comment_id, note } entries.");
5734
+ }
5735
+ const seen = new Set;
5736
+ for (const entry of addressesEntries) {
5737
+ if (typeof entry !== "object" || entry === null || typeof entry.comment_id !== "string" || entry.comment_id.trim().length === 0) {
5738
+ throw new PublishError("invalid_acknowledgement_comment_id", "acknowledgement comment_id must be a non-empty string.");
5739
+ }
5740
+ if (typeof entry.note !== "string" || entry.note.trim().length === 0) {
5741
+ throw new PublishError("empty_acknowledgement_note", `acknowledgement note for comment ${entry.comment_id} cannot be empty or whitespace-only: it must say what was done to address the comment.`);
5742
+ }
5743
+ if (seen.has(entry.comment_id)) {
5744
+ throw new PublishError("duplicate_acknowledgement", `duplicate acknowledgement entry for comment ${entry.comment_id}.`);
5745
+ }
5746
+ seen.add(entry.comment_id);
5747
+ }
5748
+ }
5749
+ const commentResult = await readComments(input.relic_id, deps);
5750
+ const unreadableComments = commentResult.comments.filter((c) => !c.readable);
5751
+ const openComments = commentResult.comments.filter((c) => c.readable && !c.addressed && c.addresses === null);
5752
+ if (addressesEntries && addressesEntries.length > 0) {
5753
+ for (const entry of addressesEntries) {
5754
+ const match = commentResult.comments.find((c) => c.comment_id === entry.comment_id);
5755
+ if (!match) {
5756
+ throw new PublishError("unknown_comment_id", `comment ${entry.comment_id} does not exist on relic ${input.relic_id}. The comment ids can only come from having read the relic's comments.`);
5757
+ }
5758
+ if (!match.readable) {
5759
+ throw new PublishError("unreadable_comment_cannot_be_addressed", `comment ${entry.comment_id} cannot be addressed because it could not be decrypted.`);
5760
+ }
5761
+ }
5762
+ }
5763
+ const addressesSet = new Set(addressesEntries?.map((e) => e.comment_id) ?? []);
5764
+ const remainingOpen = openComments.filter((c) => !addressesSet.has(c.comment_id));
5765
+ if (unreadableComments.length > 0 || remainingOpen.length > 0) {
5766
+ throw new PublishError("unaddressed_comments", formatUnaddressedRefusal(input.relic_id, remainingOpen, unreadableComments), {
5767
+ relic_id: input.relic_id,
5768
+ open_count: remainingOpen.length,
5769
+ unreadable_count: unreadableComments.length,
5770
+ open_comments: remainingOpen.map((c) => ({
5771
+ comment_id: c.comment_id,
5772
+ author: c.author,
5773
+ created_at: c.created_at,
5774
+ body: c.body
5775
+ })),
5776
+ unreadable_comments: unreadableComments.map((c) => ({
5777
+ comment_id: c.comment_id,
5778
+ author: c.author,
5779
+ created_at: c.created_at,
5780
+ unreadable_reason: c.unreadable_reason
5781
+ }))
5782
+ });
5783
+ }
5630
5784
  const source = await readSource(input.path, deps.files);
5631
5785
  const filename = input.filename ?? source.basename;
5632
5786
  const normalizedTitle = normalizeTitle(input.title ?? filename);
@@ -5658,6 +5812,17 @@ async function republish(input, deps) {
5658
5812
  } catch (error) {
5659
5813
  throw new PublishError("local_state_write_failed", "the new version is live at the existing link, but updating the " + "local record failed, so the next republish from this machine may " + `report a stale version number: ${error.message}`, { relic_id: input.relic_id, version });
5660
5814
  }
5815
+ const acknowledgements = [];
5816
+ if (addressesEntries && addressesEntries.length > 0) {
5817
+ for (const entry of addressesEntries) {
5818
+ const ack = await postComment({
5819
+ relic_id: input.relic_id,
5820
+ body: entry.note,
5821
+ addresses: entry.comment_id
5822
+ }, deps);
5823
+ acknowledgements.push(ack);
5824
+ }
5825
+ }
5661
5826
  return {
5662
5827
  relic_id: input.relic_id,
5663
5828
  version,
@@ -5668,7 +5833,8 @@ async function republish(input, deps) {
5668
5833
  resolved_path: source.resolvedPath,
5669
5834
  report_url: String(grant["report_url"]),
5670
5835
  disclosure_url: String(grant["disclosure_url"]),
5671
- key_phrase: keyToMnemonic(decodeKey(state.key)).join(" ")
5836
+ key_phrase: keyToMnemonic(decodeKey(state.key)).join(" "),
5837
+ ...acknowledgements.length > 0 ? { acknowledgements } : {}
5672
5838
  };
5673
5839
  }
5674
5840
 
@@ -5687,6 +5853,7 @@ var COMMENT_TOOL_NAME = "relic_comment";
5687
5853
  var COMMENT_MACHINE_BOUNDARY = "Only works for a relic this machine published: the comment key is derived " + "from that relic's key, which lives in local publish state and nowhere the " + "service can reach.";
5688
5854
  var MAX_TTL_DAYS = 3650;
5689
5855
  var VERSION_HISTORY_DISCLOSURE = "Anyone holding a relic's link can fetch every version it has ever held, " + "so republishing does not withdraw earlier content.";
5856
+ var REPUBLISH_DISCIPLINE_DISCLOSURE = " Before publishing, it reads the relic's comments and refuses if any are " + "unaddressed. Address open comments either by replying with `relic_comment` " + "or by passing `addresses: [{ comment_id, note }]` here to acknowledge them " + "in the new version. Unreadable comments block republishing. This is workflow " + "discipline in the client, not a boundary: the machine holding the publish " + "token can call the HTTP API directly, and the service cannot enforce this " + "because it cannot read comments.";
5690
5857
  var TOOL_DEFINITION = {
5691
5858
  name: TOOL_NAME,
5692
5859
  title: "Publish a relic",
@@ -5760,7 +5927,7 @@ var TOOL_DEFINITION = {
5760
5927
  var REPUBLISH_TOOL_DEFINITION = {
5761
5928
  name: REPUBLISH_TOOL_NAME,
5762
5929
  title: "Republish a relic",
5763
- description: "Publish a new version of a relic this machine originally published, " + "encrypting under the same key so the existing share URL keeps working. " + VERSION_HISTORY_DISCLOSURE + " Only possible from the machine that holds the relic's key and publish " + "token; a relic that was taken down can never be revived.",
5930
+ description: "Publish a new version of a relic this machine originally published, " + "encrypting under the same key so the existing share URL keeps working. " + VERSION_HISTORY_DISCLOSURE + " Only possible from the machine that holds the relic's key and publish " + "token; a relic that was taken down can never be revived." + REPUBLISH_DISCIPLINE_DISCLOSURE,
5764
5931
  inputSchema: {
5765
5932
  type: "object",
5766
5933
  properties: {
@@ -5785,6 +5952,25 @@ var REPUBLISH_TOOL_DEFINITION = {
5785
5952
  minimum: 1,
5786
5953
  maximum: MAX_TTL_DAYS,
5787
5954
  description: "Optional. A lifetime in days, forwarded on the republish " + "request. The service fixes a relic's lifetime at its first " + "publish, so treat this as reserved."
5955
+ },
5956
+ addresses: {
5957
+ type: "array",
5958
+ description: "Optional. Acknowledgement notes for open comments being addressed by this update. " + "Each entry posts an acknowledgement comment stamped with the new version after it lands. " + "Every open comment on the relic must be addressed either by an entry here or by a prior reply.",
5959
+ items: {
5960
+ type: "object",
5961
+ properties: {
5962
+ comment_id: {
5963
+ type: "string",
5964
+ description: "The id of the open comment being addressed."
5965
+ },
5966
+ note: {
5967
+ type: "string",
5968
+ description: "Required explanation of what changed in this version to address the comment. " + "Cannot be empty or whitespace-only."
5969
+ }
5970
+ },
5971
+ required: ["comment_id", "note"],
5972
+ additionalProperties: false
5973
+ }
5788
5974
  }
5789
5975
  },
5790
5976
  required: ["relic_id", "path"],
@@ -6116,7 +6302,7 @@ var SHOW_TOOL_DEFINITION = {
6116
6302
  var READ_COMMENTS_TOOL_DEFINITION = {
6117
6303
  name: READ_COMMENTS_TOOL_NAME,
6118
6304
  title: "Read a relic's comments",
6119
- description: "Read the comments people have left on a relic, oldest first, decrypted " + "on this machine. Use it before changing content somebody was asked to " + "review, and after sharing a link, because a comment is the only way a " + "reader can answer back. " + "Each comment can be a remark on the relic as a whole or an exact mark: " + "a text quote with surrounding context, a stage pin, an artifact region, " + "a timestamp or span in audio or video, or a page in a document. " + "Pass `resolve_anchors: true` to fetch and decrypt the relic content and " + "resolve anchors against the actual content (source context runs for quotes, " + "crops and annotated views for image regions, and extracted video frames). " + "This defaults to false because content resolution spends a signed download " + "URL mint and download quota. " + COMMENT_MACHINE_BOUNDARY + " Takes the relic id, never the share URL: the URL carries the key in " + "its fragment. A comment that will not decrypt is returned marked " + "unreadable rather than dropped, so a shortened list never reads as " + "agreement.",
6305
+ description: "Read the comments people have left on a relic, oldest first, decrypted " + "on this machine. Use it before changing content somebody was asked to " + "review, and after sharing a link, because a comment is the only way a " + "reader can answer back. " + "Each comment reports whether it has been addressed, and a compact summary " + "gives total, addressed, open, and unreadable counts. Its output is the input " + "to `relic_republish`, which requires every open comment to be addressed before " + "a new version can land. " + "Each comment can be a remark on the relic as a whole or an exact mark: " + "a text quote with surrounding context, a stage pin, an artifact region, " + "a timestamp or span in audio or video, or a page in a document. " + "Pass `resolve_anchors: true` to fetch and decrypt the relic content and " + "resolve anchors against the actual content (source context runs for quotes, " + "crops and annotated views for image regions, and extracted video frames). " + "This defaults to false because content resolution spends a signed download " + "URL mint and download quota. " + COMMENT_MACHINE_BOUNDARY + " Takes the relic id, never the share URL: the URL carries the key in " + "its fragment. A comment that will not decrypt is returned marked " + "unreadable rather than dropped, so a shortened list never reads as " + "agreement.",
6120
6306
  inputSchema: {
6121
6307
  type: "object",
6122
6308
  properties: {
@@ -6142,6 +6328,18 @@ var READ_COMMENTS_TOOL_DEFINITION = {
6142
6328
  minimum: 0,
6143
6329
  description: "How many of `count` did not decrypt. Above zero means part of the " + "conversation is unread, not absent."
6144
6330
  },
6331
+ summary: {
6332
+ type: "object",
6333
+ description: "Compact counts of comment states on this relic.",
6334
+ properties: {
6335
+ total: { type: "integer", minimum: 0 },
6336
+ addressed: { type: "integer", minimum: 0 },
6337
+ open: { type: "integer", minimum: 0 },
6338
+ unreadable: { type: "integer", minimum: 0 }
6339
+ },
6340
+ required: ["total", "addressed", "open", "unreadable"],
6341
+ additionalProperties: false
6342
+ },
6145
6343
  comments: {
6146
6344
  type: "array",
6147
6345
  items: {
@@ -6195,6 +6393,23 @@ var READ_COMMENTS_TOOL_DEFINITION = {
6195
6393
  },
6196
6394
  readable: { type: "boolean" },
6197
6395
  unreadable_reason: { type: ["string", "null"] },
6396
+ addresses: {
6397
+ type: ["string", "null"],
6398
+ description: "The comment id this comment replies to or acknowledges, if any."
6399
+ },
6400
+ addressed: {
6401
+ type: "boolean",
6402
+ description: "Whether this comment has been addressed by a reply or update acknowledgement."
6403
+ },
6404
+ addressed_by: {
6405
+ type: ["object", "null"],
6406
+ description: "The comment that addressed this one, if addressed.",
6407
+ properties: {
6408
+ comment_id: { type: "string" },
6409
+ version: { type: ["integer", "null"] }
6410
+ },
6411
+ required: ["comment_id"]
6412
+ },
6198
6413
  resolved: {
6199
6414
  type: ["object", "null"],
6200
6415
  description: "The resolved content and status for this anchor, when resolve_anchors is enabled."
@@ -6208,13 +6423,16 @@ var READ_COMMENTS_TOOL_DEFINITION = {
6208
6423
  "body",
6209
6424
  "anchor",
6210
6425
  "readable",
6211
- "unreadable_reason"
6426
+ "unreadable_reason",
6427
+ "addresses",
6428
+ "addressed",
6429
+ "addressed_by"
6212
6430
  ],
6213
6431
  additionalProperties: false
6214
6432
  }
6215
6433
  }
6216
6434
  },
6217
- required: ["relic_id", "count", "unreadable_count", "comments"],
6435
+ required: ["relic_id", "count", "unreadable_count", "summary", "comments"],
6218
6436
  additionalProperties: false
6219
6437
  }
6220
6438
  };
@@ -6237,6 +6455,10 @@ var COMMENT_TOOL_DEFINITION = {
6237
6455
  type: "string",
6238
6456
  description: "Optional. A name shown beside the comment, up to 64 bytes of " + "UTF-8. It aliases the attribution for presentation and never " + "replaces it."
6239
6457
  },
6458
+ addresses: {
6459
+ type: "string",
6460
+ description: "Optional. The service-minted id of the comment this one answers or acknowledges. " + "Writing this marks that comment addressed so the relic can be republished. " + "The pointer is sealed into the encrypted comment and also passed to the service in the clear for notification routing."
6461
+ },
6240
6462
  anchor: {
6241
6463
  type: "object",
6242
6464
  description: "Optional. Where the comment sits on the relic, so the reader sees " + "exactly what the comment is about. Can be a text selection " + '({kind: "quote", exact, prefix?, suffix?} or {kind: "text", quote}), ' + 'a point on the stage ({kind: "pin", x, y}), ' + 'a box on the artifact ({kind: "region", rect: {x, y, w, h}}), ' + 'a moment or span in audio/video ({kind: "time", t, t_end?, rect?}, with t ' + 'given as seconds or as a timecode like "1:23"), ' + 'or a page in a paged document ({kind: "page", page, rect?, exact?}). ' + "Omit for a remark about the whole relic.",
@@ -6326,7 +6548,7 @@ var DESCRIBE_TOOL_DEFINITION = {
6326
6548
  additionalProperties: false
6327
6549
  }
6328
6550
  };
6329
- var SERVER_VERSION = "0.7.0";
6551
+ var SERVER_VERSION = "0.8.0";
6330
6552
  var SERVER_INFO = {
6331
6553
  name: "relic",
6332
6554
  title: "Relic",
@@ -6689,8 +6911,23 @@ async function callRepublish(id2, params, deps) {
6689
6911
  if (!ttlDays.ok) {
6690
6912
  return errorResponse(id2, ERROR_CODES.invalidParams, `\`ttl_days\` must be an integer between 1 and ${MAX_TTL_DAYS}, or ` + "omitted to leave the lifetime as the first publish set it");
6691
6913
  }
6914
+ const rawAddresses = args["addresses"];
6915
+ let addresses;
6916
+ if (rawAddresses !== undefined) {
6917
+ if (!Array.isArray(rawAddresses)) {
6918
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`addresses` must be an array of { comment_id, note } objects or omitted");
6919
+ }
6920
+ addresses = rawAddresses;
6921
+ }
6692
6922
  try {
6693
- const result = await republish({ relic_id: relicId, path, filename, title: title2, ttl_days: ttlDays.days }, deps);
6923
+ const result = await republish({
6924
+ relic_id: relicId,
6925
+ path,
6926
+ filename,
6927
+ title: title2,
6928
+ ttl_days: ttlDays.days,
6929
+ addresses
6930
+ }, deps);
6694
6931
  return {
6695
6932
  jsonrpc: "2.0",
6696
6933
  id: id2,
@@ -6774,12 +7011,18 @@ async function callComment(id2, params, deps) {
6774
7011
  if (rawAnchor !== undefined && rawAnchor !== null && (typeof rawAnchor !== "object" || Array.isArray(rawAnchor))) {
6775
7012
  return errorResponse(id2, ERROR_CODES.invalidParams, "`anchor` must be an object or omitted");
6776
7013
  }
7014
+ const rawAddresses = args["addresses"];
7015
+ if (rawAddresses !== undefined && typeof rawAddresses !== "string") {
7016
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`addresses` must be a string or omitted");
7017
+ }
7018
+ const addresses = typeof rawAddresses === "string" ? rawAddresses : undefined;
6777
7019
  try {
6778
7020
  const result = await postComment({
6779
7021
  relic_id: relicId,
6780
7022
  body,
6781
7023
  display_name: displayName,
6782
- anchor: rawAnchor
7024
+ anchor: rawAnchor,
7025
+ addresses
6783
7026
  }, deps);
6784
7027
  return {
6785
7028
  jsonrpc: "2.0",
@@ -6866,11 +7109,17 @@ function commentTranscript(result) {
6866
7109
  }
6867
7110
  const mark = markLine(comment.anchor);
6868
7111
  const resolution = resolutionLine(comment.resolved);
7112
+ const replyNote = comment.addresses !== null ? `[answers comment ${comment.addresses}]
7113
+ ` : "";
7114
+ const addressedNote = comment.addressed ? `[addressed by comment ${comment.addressed_by?.comment_id ?? "unknown"}${comment.addressed_by?.version != null ? ` in v${comment.addressed_by.version}` : ""}]
7115
+ ` : "";
6869
7116
  return `${comment.created_at} ${who}:
6870
- ${mark}${resolution}${comment.body}`;
7117
+ ${mark}${resolution}${replyNote}${addressedNote}${comment.body}`;
6871
7118
  });
6872
7119
  const header = result.unreadable_count === 0 ? `${result.count} comment(s) on relic ${result.relic_id}, oldest first.` : `${result.count} comment(s) on relic ${result.relic_id}, oldest ` + `first. ${result.unreadable_count} did not decrypt and are shown as ` + "unreadable rather than dropped, so treat this conversation as " + "partially unread.";
7120
+ const summaryLine = `Summary: ${result.summary.total} total, ${result.summary.addressed} addressed, ${result.summary.open} open, ${result.summary.unreadable} unreadable.`;
6873
7121
  return `${header}
7122
+ ${summaryLine}
6874
7123
 
6875
7124
  ${lines.join(`
6876
7125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relic-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Publish a file as an encrypted relic. The key is generated on your machine and never sent to the service.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/comments.ts CHANGED
@@ -29,6 +29,7 @@
29
29
 
30
30
  import {
31
31
  type AnchorRect,
32
+ COMMENT_ADDRESSES_LIMIT_BYTES,
32
33
  COMMENT_ANCHOR_CONTEXT_LIMIT_BYTES,
33
34
  COMMENT_ANCHOR_MAX_PAGE,
34
35
  COMMENT_ANCHOR_MAX_SECONDS,
@@ -55,6 +56,11 @@ import {
55
56
  } from './resolve-anchor.ts';
56
57
  import { loadPublishState, type PublishState } from './state.ts';
57
58
 
59
+ export interface CommentAddressedBy {
60
+ readonly comment_id: string;
61
+ readonly version: number | null;
62
+ }
63
+
58
64
  /** One comment as an agent reads it. */
59
65
  export interface CommentRecord {
60
66
  readonly comment_id: string;
@@ -80,9 +86,23 @@ export interface CommentRecord {
80
86
  * the encrypted envelope, so it arrives with the body or not at all.
81
87
  */
82
88
  readonly anchor: CommentAnchor | null;
89
+ /**
90
+ * The comment this one answers or acknowledges, by service-minted id.
91
+ * Taken from the sealed copy only.
92
+ */
93
+ readonly addresses: string | null;
83
94
  readonly readable: boolean;
84
95
  /** Null exactly when `readable` is true. */
85
96
  readonly unreadable_reason: string | null;
97
+ /**
98
+ * True if some other comment carries addresses equal to this comment's id,
99
+ * taken from the sealed copy only.
100
+ */
101
+ readonly addressed: boolean;
102
+ /** The comment that addressed this one, if any. */
103
+ readonly addressed_by: CommentAddressedBy | null;
104
+ readonly addressed_by_comment_id?: string | null;
105
+ readonly addressed_by_version?: number | null;
86
106
  /**
87
107
  * Resolved content for this comment's anchor when anchor resolution is
88
108
  * requested. Null when freeform or when resolution was not requested.
@@ -90,6 +110,13 @@ export interface CommentRecord {
90
110
  readonly resolved?: ResolvedAnchor | null;
91
111
  }
92
112
 
113
+ export interface CommentsSummary {
114
+ readonly total: number;
115
+ readonly addressed: number;
116
+ readonly open: number;
117
+ readonly unreadable: number;
118
+ }
119
+
93
120
  export interface ReadCommentsResult {
94
121
  readonly relic_id: string;
95
122
  readonly count: number;
@@ -99,6 +126,7 @@ export interface ReadCommentsResult {
99
126
  * "nobody objected" when somebody did is the failure this member prevents.
100
127
  */
101
128
  readonly unreadable_count: number;
129
+ readonly summary: CommentsSummary;
102
130
  readonly comments: readonly CommentRecord[];
103
131
  readonly content_blocks?: readonly ContentBlock[];
104
132
  }
@@ -113,6 +141,66 @@ export interface ReadCommentsOptions {
113
141
  readonly resolve_anchors?: boolean;
114
142
  }
115
143
 
144
+ /** First part of a comment body for refusal summaries, truncated if long. */
145
+ export function previewCommentBody(body: string | null, maxChars = 80): string {
146
+ if (body === null || body === undefined) return '';
147
+ const trimmed = body.trim();
148
+ const firstLine = trimmed.split('\n')[0]?.trim() ?? '';
149
+ const text = firstLine.length > 0 ? firstLine : trimmed;
150
+ if (text.length <= maxChars) return text;
151
+ return `${text.slice(0, maxChars - 3)}...`;
152
+ }
153
+
154
+ /** Format the refusal message when republish is blocked by unaddressed comments. */
155
+ export function formatUnaddressedRefusal(
156
+ relicId: string,
157
+ openComments: readonly CommentRecord[],
158
+ unreadableComments: readonly CommentRecord[] = []
159
+ ): string {
160
+ const parts: string[] = [];
161
+
162
+ if (openComments.length > 0 && unreadableComments.length > 0) {
163
+ parts.push(
164
+ `cannot republish relic ${relicId} while comments remain unaddressed. ` +
165
+ `${openComments.length} comment(s) are unanswered, and ${unreadableComments.length} comment(s) could not be decrypted. ` +
166
+ 'Address unanswered comments either by replying with relic_comment or by passing addresses: [{ comment_id, note }] on republish.'
167
+ );
168
+ } else if (openComments.length > 0) {
169
+ parts.push(
170
+ `cannot republish relic ${relicId} while comments remain unaddressed. ` +
171
+ `${openComments.length} comment(s) are unanswered. ` +
172
+ 'Address each open comment either by replying with relic_comment or by passing addresses: [{ comment_id, note }] on republish.'
173
+ );
174
+ } else {
175
+ parts.push(
176
+ `cannot republish relic ${relicId} because ${unreadableComments.length} comment(s) could not be decrypted. ` +
177
+ 'A comment this client cannot read cannot be verified as addressed.'
178
+ );
179
+ }
180
+
181
+ if (openComments.length > 0) {
182
+ const list = openComments
183
+ .map(
184
+ (c) =>
185
+ `- [${c.comment_id}] from ${c.author} at ${c.created_at}: "${previewCommentBody(c.body)}"`
186
+ )
187
+ .join('\n');
188
+ parts.push(`Open comments (${openComments.length}):\n${list}`);
189
+ }
190
+
191
+ if (unreadableComments.length > 0) {
192
+ const list = unreadableComments
193
+ .map(
194
+ (c) =>
195
+ `- [${c.comment_id}] from ${c.author} at ${c.created_at}: unreadable (${c.unreadable_reason ?? "it did not decrypt under this relic's comment key"})`
196
+ )
197
+ .join('\n');
198
+ parts.push(`Unreadable comments (${unreadableComments.length}):\n${list}`);
199
+ }
200
+
201
+ return parts.join('\n\n');
202
+ }
203
+
116
204
  export type CommentAnchorInput =
117
205
  | CommentAnchor
118
206
  | {
@@ -145,6 +233,11 @@ export interface CommentInput {
145
233
  readonly body: string;
146
234
  readonly display_name?: string | undefined;
147
235
  readonly anchor?: CommentAnchorInput | null | undefined;
236
+ /**
237
+ * Optional service-minted id of the comment this one answers or acknowledges.
238
+ * Sealed into the ciphertext and passed in the clear to the service for notification routing.
239
+ */
240
+ readonly addresses?: string | null | undefined;
148
241
  }
149
242
 
150
243
  export async function readComments(
@@ -171,8 +264,19 @@ export async function readComments(
171
264
  }
172
265
 
173
266
  const commentKey = await deriveCommentKey(decodeKey(state.key));
174
- const comments: CommentRecord[] = [];
175
- let unreadable = 0;
267
+ interface DecryptedCommentEntry {
268
+ comment_id: string;
269
+ author: string;
270
+ created_at: string;
271
+ display_name: string | null;
272
+ body: string | null;
273
+ anchor: CommentAnchor | null;
274
+ addresses: string | null;
275
+ version: number | null;
276
+ readable: boolean;
277
+ unreadable_reason: string | null;
278
+ }
279
+ const entries: DecryptedCommentEntry[] = [];
176
280
 
177
281
  for (const [index, entry] of listed.entries()) {
178
282
  const row =
@@ -188,16 +292,18 @@ export async function readComments(
188
292
  const createdAt =
189
293
  typeof row['created_at'] === 'string' ? row['created_at'] : 'unknown';
190
294
  const ciphertext = row['ciphertext'];
295
+ const version = typeof row['version'] === 'number' ? row['version'] : null;
191
296
 
192
297
  if (typeof ciphertext !== 'string') {
193
- unreadable += 1;
194
- comments.push({
298
+ entries.push({
195
299
  comment_id: commentId,
196
300
  author,
197
301
  created_at: createdAt,
198
302
  display_name: null,
199
303
  body: null,
200
304
  anchor: null,
305
+ addresses: null,
306
+ version,
201
307
  readable: false,
202
308
  unreadable_reason: 'the row carried no ciphertext',
203
309
  });
@@ -206,7 +312,7 @@ export async function readComments(
206
312
 
207
313
  try {
208
314
  const plaintext = await decryptComment(commentKey, ciphertext);
209
- comments.push({
315
+ entries.push({
210
316
  comment_id: commentId,
211
317
  author,
212
318
  created_at: createdAt,
@@ -215,20 +321,23 @@ export async function readComments(
215
321
  // Absent and null both mean freeform. One shape crosses to the agent,
216
322
  // so a caller never has to distinguish two ways of saying no mark.
217
323
  anchor: plaintext.anchor ?? null,
324
+ addresses: plaintext.addresses ?? null,
325
+ version,
218
326
  readable: true,
219
327
  unreadable_reason: null,
220
328
  });
221
329
  } catch (error) {
222
330
  // One comment that will not open must not hide the ones that will, and
223
331
  // it must not vanish either. It comes back named, with the reason.
224
- unreadable += 1;
225
- comments.push({
332
+ entries.push({
226
333
  comment_id: commentId,
227
334
  author,
228
335
  created_at: createdAt,
229
336
  display_name: null,
230
337
  body: null,
231
338
  anchor: null,
339
+ addresses: null,
340
+ version,
232
341
  readable: false,
233
342
  unreadable_reason: `it did not decrypt under this relic's comment key: ${
234
343
  (error as Error).message
@@ -236,12 +345,74 @@ export async function readComments(
236
345
  });
237
346
  }
238
347
  }
348
+
349
+ // The sealed copy is authoritative. It sits inside the AEAD. A caller MAY
350
+ // also pass the same id to the service in the clear so the service can route
351
+ // a notification, and that clear copy is a routing hint the operator can see
352
+ // and could forge. Nothing user-facing may resolve "addressed" from the clear
353
+ // copy. We only inspect plaintext.addresses from the decrypted ciphertext.
354
+ const addressedByMap = new Map<
355
+ string,
356
+ { comment_id: string; version: number | null }
357
+ >();
358
+
359
+ for (const entry of entries) {
360
+ if (
361
+ entry.readable &&
362
+ entry.addresses !== null &&
363
+ entry.addresses !== entry.comment_id
364
+ ) {
365
+ if (!addressedByMap.has(entry.addresses)) {
366
+ addressedByMap.set(entry.addresses, {
367
+ comment_id: entry.comment_id,
368
+ version: entry.version,
369
+ });
370
+ }
371
+ }
372
+ }
373
+
374
+ const comments: CommentRecord[] = entries.map((entry) => {
375
+ const addressedBy = addressedByMap.get(entry.comment_id) ?? null;
376
+ const addressed = addressedBy !== null;
377
+ return {
378
+ comment_id: entry.comment_id,
379
+ author: entry.author,
380
+ created_at: entry.created_at,
381
+ display_name: entry.display_name,
382
+ body: entry.body,
383
+ anchor: entry.anchor,
384
+ addresses: entry.addresses,
385
+ readable: entry.readable,
386
+ unreadable_reason: entry.unreadable_reason,
387
+ addressed,
388
+ addressed_by: addressedBy,
389
+ addressed_by_comment_id: addressedBy?.comment_id ?? null,
390
+ addressed_by_version: addressedBy?.version ?? null,
391
+ };
392
+ });
393
+
394
+ const addressedCount = comments.filter((c) => c.addressed).length;
395
+ const unreadableCount = comments.filter((c) => !c.readable).length;
396
+ // A comment is open when it is readable, has not been addressed by another
397
+ // comment, and is not itself a reply or update acknowledgement.
398
+ const openCount = comments.filter(
399
+ (c) => c.readable && !c.addressed && c.addresses === null
400
+ ).length;
401
+
402
+ const summary: CommentsSummary = {
403
+ total: comments.length,
404
+ addressed: addressedCount,
405
+ open: openCount,
406
+ unreadable: unreadableCount,
407
+ };
408
+
239
409
  if (options?.resolve_anchors === true) {
240
410
  const resolved = await resolveAnchors(relicId, comments, deps);
241
411
  return {
242
412
  relic_id: relicId,
243
413
  count: comments.length,
244
- unreadable_count: unreadable,
414
+ unreadable_count: unreadableCount,
415
+ summary,
245
416
  comments: resolved.comments,
246
417
  content_blocks: resolved.imageBlocks,
247
418
  };
@@ -250,7 +421,8 @@ export async function readComments(
250
421
  return {
251
422
  relic_id: relicId,
252
423
  count: comments.length,
253
- unreadable_count: unreadable,
424
+ unreadable_count: unreadableCount,
425
+ summary,
254
426
  comments,
255
427
  };
256
428
  }
@@ -295,6 +467,31 @@ export async function postComment(
295
467
  }
296
468
  }
297
469
 
470
+ let addresses: string | null = null;
471
+ if (input.addresses !== undefined && input.addresses !== null) {
472
+ if (
473
+ typeof input.addresses !== 'string' ||
474
+ input.addresses.trim().length === 0
475
+ ) {
476
+ throw new PublishError(
477
+ 'local_comment_addresses_invalid',
478
+ 'addresses must be a non-empty string naming the comment being answered.'
479
+ );
480
+ }
481
+ const addressesBytes = new TextEncoder().encode(input.addresses).length;
482
+ if (addressesBytes > COMMENT_ADDRESSES_LIMIT_BYTES) {
483
+ throw new PublishError(
484
+ 'local_comment_addresses_too_long',
485
+ `addresses is ${addressesBytes} bytes of UTF-8 and the limit is ${COMMENT_ADDRESSES_LIMIT_BYTES}.`,
486
+ {
487
+ addresses_bytes: addressesBytes,
488
+ limit_bytes: COMMENT_ADDRESSES_LIMIT_BYTES,
489
+ }
490
+ );
491
+ }
492
+ addresses = input.addresses;
493
+ }
494
+
298
495
  const anchor = validateAndNormalizeAnchor(input.anchor);
299
496
 
300
497
  const commentKey = await deriveCommentKey(decodeKey(state.key));
@@ -302,17 +499,23 @@ export async function postComment(
302
499
  body: input.body,
303
500
  display_name: displayName,
304
501
  anchor,
502
+ ...(addresses == null ? {} : { addresses }),
305
503
  });
306
504
 
307
505
  // The token travels in the body, where the republish grant already puts it,
308
506
  // so the two write paths authorize the same way and neither invents a
309
507
  // header the service has to learn.
508
+ // When addressing a comment, the pointer is also sent in the clear for
509
+ // notification routing.
310
510
  const posted = await postJson(
311
511
  deps,
312
512
  `${deps.serviceOrigin}/api/relics/${input.relic_id}/comments`,
313
- { publish_token: state.publish_token, ciphertext }
513
+ {
514
+ publish_token: state.publish_token,
515
+ ciphertext,
516
+ ...(addresses == null ? {} : { addresses }),
517
+ }
314
518
  );
315
-
316
519
  return {
317
520
  relic_id: input.relic_id,
318
521
  comment_id: String(posted['comment_id']),
package/src/publish.ts CHANGED
@@ -78,8 +78,16 @@ export type ClientCode =
78
78
  | 'local_comment_anchor_time_span_invalid'
79
79
  | 'local_comment_anchor_page_missing'
80
80
  | 'local_comment_anchor_page_out_of_range'
81
+ | 'local_comment_addresses_invalid'
82
+ | 'local_comment_addresses_too_long'
83
+ | 'unaddressed_comments'
84
+ | 'empty_acknowledgement_note'
85
+ | 'invalid_acknowledgement_comment_id'
86
+ | 'duplicate_acknowledgement'
87
+ | 'unknown_comment_id'
88
+ | 'unreadable_comment_cannot_be_addressed'
89
+ | 'invalid_acknowledgements'
81
90
  | 'app_response_unusable';
82
-
83
91
  export class PublishError extends Error {
84
92
  override readonly name = 'PublishError';
85
93
  constructor(
package/src/republish.ts CHANGED
@@ -19,6 +19,12 @@ import {
19
19
  type RendererClass,
20
20
  } from '@relic/format';
21
21
  import { keyToMnemonic } from '@relic/format/mnemonic';
22
+ import {
23
+ type CommentResult,
24
+ formatUnaddressedRefusal,
25
+ postComment,
26
+ readComments,
27
+ } from './comments.ts';
22
28
  import {
23
29
  guessMimetype,
24
30
  type PublishDeps,
@@ -34,6 +40,11 @@ import {
34
40
  savePublishState,
35
41
  } from './state.ts';
36
42
 
43
+ export interface RepublishAddressEntry {
44
+ readonly comment_id: string;
45
+ readonly note: string;
46
+ }
47
+
37
48
  export interface RepublishInput {
38
49
  readonly relic_id: string;
39
50
  readonly path: string;
@@ -51,6 +62,13 @@ export interface RepublishInput {
51
62
  * change if that ever shifts.
52
63
  */
53
64
  readonly ttl_days?: number | undefined;
65
+ /**
66
+ * Optional acknowledgement notes for open comments being addressed by this
67
+ * update. Each entry posts an acknowledgement comment stamped with the new
68
+ * version after it lands. Every open comment on the relic must be addressed
69
+ * either by an entry here or by a prior reply.
70
+ */
71
+ readonly addresses?: readonly RepublishAddressEntry[] | undefined;
54
72
  }
55
73
 
56
74
  export interface RepublishResult {
@@ -74,6 +92,10 @@ export interface RepublishResult {
74
92
  readonly report_url: string;
75
93
  readonly disclosure_url: string;
76
94
  readonly key_phrase: string;
95
+ /**
96
+ * Acknowledgement comments posted for comments addressed by this update.
97
+ */
98
+ readonly acknowledgements?: readonly CommentResult[] | undefined;
77
99
  /**
78
100
  * Deliberately no `url` member. The share URL is unchanged by a new
79
101
  * version, and reprinting it would reprint the key for no new benefit;
@@ -118,6 +140,107 @@ export async function republish(
118
140
  if (error instanceof PublishError) throw error;
119
141
  throw new PublishError('local_state_unreadable', (error as Error).message);
120
142
  }
143
+ // Validate addresses entries up front before any network or file reads.
144
+ const addressesEntries = input.addresses;
145
+ if (addressesEntries !== undefined) {
146
+ if (!Array.isArray(addressesEntries)) {
147
+ throw new PublishError(
148
+ 'invalid_acknowledgements',
149
+ 'addresses must be an array of { comment_id, note } entries.'
150
+ );
151
+ }
152
+ const seen = new Set<string>();
153
+ for (const entry of addressesEntries) {
154
+ if (
155
+ typeof entry !== 'object' ||
156
+ entry === null ||
157
+ typeof entry.comment_id !== 'string' ||
158
+ entry.comment_id.trim().length === 0
159
+ ) {
160
+ throw new PublishError(
161
+ 'invalid_acknowledgement_comment_id',
162
+ 'acknowledgement comment_id must be a non-empty string.'
163
+ );
164
+ }
165
+ if (typeof entry.note !== 'string' || entry.note.trim().length === 0) {
166
+ throw new PublishError(
167
+ 'empty_acknowledgement_note',
168
+ `acknowledgement note for comment ${entry.comment_id} cannot be empty or whitespace-only: it must say what was done to address the comment.`
169
+ );
170
+ }
171
+ if (seen.has(entry.comment_id)) {
172
+ throw new PublishError(
173
+ 'duplicate_acknowledgement',
174
+ `duplicate acknowledgement entry for comment ${entry.comment_id}.`
175
+ );
176
+ }
177
+ seen.add(entry.comment_id);
178
+ }
179
+ }
180
+
181
+ // The comment gate: before publishing a new version, read the relic's
182
+ // comments, decrypt them locally, and verify every comment has been
183
+ // addressed. Unaddressed or unreadable comments refuse the republish.
184
+ const commentResult = await readComments(input.relic_id, deps);
185
+ const unreadableComments = commentResult.comments.filter((c) => !c.readable);
186
+ const openComments = commentResult.comments.filter(
187
+ (c) => c.readable && !c.addressed && c.addresses === null
188
+ );
189
+
190
+ if (addressesEntries && addressesEntries.length > 0) {
191
+ for (const entry of addressesEntries) {
192
+ const match = commentResult.comments.find(
193
+ (c) => c.comment_id === entry.comment_id
194
+ );
195
+ if (!match) {
196
+ throw new PublishError(
197
+ 'unknown_comment_id',
198
+ `comment ${entry.comment_id} does not exist on relic ${input.relic_id}. The comment ids can only come from having read the relic's comments.`
199
+ );
200
+ }
201
+ if (!match.readable) {
202
+ throw new PublishError(
203
+ 'unreadable_comment_cannot_be_addressed',
204
+ `comment ${entry.comment_id} cannot be addressed because it could not be decrypted.`
205
+ );
206
+ }
207
+ }
208
+ }
209
+
210
+ const addressesSet = new Set(
211
+ addressesEntries?.map((e) => e.comment_id) ?? []
212
+ );
213
+ const remainingOpen = openComments.filter(
214
+ (c) => !addressesSet.has(c.comment_id)
215
+ );
216
+
217
+ if (unreadableComments.length > 0 || remainingOpen.length > 0) {
218
+ throw new PublishError(
219
+ 'unaddressed_comments',
220
+ formatUnaddressedRefusal(
221
+ input.relic_id,
222
+ remainingOpen,
223
+ unreadableComments
224
+ ),
225
+ {
226
+ relic_id: input.relic_id,
227
+ open_count: remainingOpen.length,
228
+ unreadable_count: unreadableComments.length,
229
+ open_comments: remainingOpen.map((c) => ({
230
+ comment_id: c.comment_id,
231
+ author: c.author,
232
+ created_at: c.created_at,
233
+ body: c.body,
234
+ })),
235
+ unreadable_comments: unreadableComments.map((c) => ({
236
+ comment_id: c.comment_id,
237
+ author: c.author,
238
+ created_at: c.created_at,
239
+ unreadable_reason: c.unreadable_reason,
240
+ })),
241
+ }
242
+ );
243
+ }
121
244
 
122
245
  const source = await readSource(input.path, deps.files);
123
246
  const filename = input.filename ?? source.basename;
@@ -189,6 +312,23 @@ export async function republish(
189
312
  );
190
313
  }
191
314
 
315
+ // After the new version lands, post an acknowledgement comment for each
316
+ // addressed entry, so the acknowledgement is stamped with the new version.
317
+ const acknowledgements: CommentResult[] = [];
318
+ if (addressesEntries && addressesEntries.length > 0) {
319
+ for (const entry of addressesEntries) {
320
+ const ack = await postComment(
321
+ {
322
+ relic_id: input.relic_id,
323
+ body: entry.note,
324
+ addresses: entry.comment_id,
325
+ },
326
+ deps
327
+ );
328
+ acknowledgements.push(ack);
329
+ }
330
+ }
331
+
192
332
  return {
193
333
  relic_id: input.relic_id,
194
334
  version,
@@ -203,5 +343,6 @@ export async function republish(
203
343
  report_url: String(grant['report_url']),
204
344
  disclosure_url: String(grant['disclosure_url']),
205
345
  key_phrase: keyToMnemonic(decodeKey(state.key)).join(' '),
346
+ ...(acknowledgements.length > 0 ? { acknowledgements } : {}),
206
347
  };
207
348
  }
package/src/server.ts CHANGED
@@ -24,6 +24,7 @@ import { MNEMONIC_WORDS } from '@relic/format/mnemonic';
24
24
  import {
25
25
  type CommentAnchorInput,
26
26
  type CommentRecord,
27
+ type CommentsSummary,
27
28
  describeAnchor,
28
29
  formatTimecode,
29
30
  postComment,
@@ -57,7 +58,7 @@ import {
57
58
  republishToolCall,
58
59
  ServerRefusal,
59
60
  } from './publish.ts';
60
- import { republish } from './republish.ts';
61
+ import { type RepublishAddressEntry, republish } from './republish.ts';
61
62
 
62
63
  export type { JsonRpcRequest, JsonRpcResponse };
63
64
  export {
@@ -197,6 +198,14 @@ const MAX_TTL_DAYS = 3650;
197
198
  const VERSION_HISTORY_DISCLOSURE =
198
199
  "Anyone holding a relic's link can fetch every version it has ever held, " +
199
200
  'so republishing does not withdraw earlier content.';
201
+ export const REPUBLISH_DISCIPLINE_DISCLOSURE =
202
+ " Before publishing, it reads the relic's comments and refuses if any are " +
203
+ 'unaddressed. Address open comments either by replying with `relic_comment` ' +
204
+ 'or by passing `addresses: [{ comment_id, note }]` here to acknowledge them ' +
205
+ 'in the new version. Unreadable comments block republishing. This is workflow ' +
206
+ 'discipline in the client, not a boundary: the machine holding the publish ' +
207
+ 'token can call the HTTP API directly, and the service cannot enforce this ' +
208
+ 'because it cannot read comments.';
200
209
 
201
210
  export const TOOL_DEFINITION = {
202
211
  name: TOOL_NAME,
@@ -300,7 +309,8 @@ export const REPUBLISH_TOOL_DEFINITION = {
300
309
  'encrypting under the same key so the existing share URL keeps working. ' +
301
310
  VERSION_HISTORY_DISCLOSURE +
302
311
  " Only possible from the machine that holds the relic's key and publish " +
303
- 'token; a relic that was taken down can never be revived.',
312
+ 'token; a relic that was taken down can never be revived.' +
313
+ REPUBLISH_DISCIPLINE_DISCLOSURE,
304
314
  inputSchema: {
305
315
  type: 'object',
306
316
  properties: {
@@ -334,6 +344,30 @@ export const REPUBLISH_TOOL_DEFINITION = {
334
344
  "request. The service fixes a relic's lifetime at its first " +
335
345
  'publish, so treat this as reserved.',
336
346
  },
347
+ addresses: {
348
+ type: 'array',
349
+ description:
350
+ 'Optional. Acknowledgement notes for open comments being addressed by this update. ' +
351
+ 'Each entry posts an acknowledgement comment stamped with the new version after it lands. ' +
352
+ 'Every open comment on the relic must be addressed either by an entry here or by a prior reply.',
353
+ items: {
354
+ type: 'object',
355
+ properties: {
356
+ comment_id: {
357
+ type: 'string',
358
+ description: 'The id of the open comment being addressed.',
359
+ },
360
+ note: {
361
+ type: 'string',
362
+ description:
363
+ 'Required explanation of what changed in this version to address the comment. ' +
364
+ 'Cannot be empty or whitespace-only.',
365
+ },
366
+ },
367
+ required: ['comment_id', 'note'],
368
+ additionalProperties: false,
369
+ },
370
+ },
337
371
  },
338
372
  required: ['relic_id', 'path'],
339
373
  additionalProperties: false,
@@ -736,6 +770,10 @@ export const READ_COMMENTS_TOOL_DEFINITION = {
736
770
  'on this machine. Use it before changing content somebody was asked to ' +
737
771
  'review, and after sharing a link, because a comment is the only way a ' +
738
772
  'reader can answer back. ' +
773
+ 'Each comment reports whether it has been addressed, and a compact summary ' +
774
+ 'gives total, addressed, open, and unreadable counts. Its output is the input ' +
775
+ 'to `relic_republish`, which requires every open comment to be addressed before ' +
776
+ 'a new version can land. ' +
739
777
  'Each comment can be a remark on the relic as a whole or an exact mark: ' +
740
778
  'a text quote with surrounding context, a stage pin, an artifact region, ' +
741
779
  'a timestamp or span in audio or video, or a page in a document. ' +
@@ -780,6 +818,18 @@ export const READ_COMMENTS_TOOL_DEFINITION = {
780
818
  'How many of `count` did not decrypt. Above zero means part of the ' +
781
819
  'conversation is unread, not absent.',
782
820
  },
821
+ summary: {
822
+ type: 'object',
823
+ description: 'Compact counts of comment states on this relic.',
824
+ properties: {
825
+ total: { type: 'integer', minimum: 0 },
826
+ addressed: { type: 'integer', minimum: 0 },
827
+ open: { type: 'integer', minimum: 0 },
828
+ unreadable: { type: 'integer', minimum: 0 },
829
+ },
830
+ required: ['total', 'addressed', 'open', 'unreadable'],
831
+ additionalProperties: false,
832
+ },
783
833
  comments: {
784
834
  type: 'array',
785
835
  items: {
@@ -843,6 +893,25 @@ export const READ_COMMENTS_TOOL_DEFINITION = {
843
893
  },
844
894
  readable: { type: 'boolean' },
845
895
  unreadable_reason: { type: ['string', 'null'] },
896
+ addresses: {
897
+ type: ['string', 'null'],
898
+ description:
899
+ 'The comment id this comment replies to or acknowledges, if any.',
900
+ },
901
+ addressed: {
902
+ type: 'boolean',
903
+ description:
904
+ 'Whether this comment has been addressed by a reply or update acknowledgement.',
905
+ },
906
+ addressed_by: {
907
+ type: ['object', 'null'],
908
+ description: 'The comment that addressed this one, if addressed.',
909
+ properties: {
910
+ comment_id: { type: 'string' },
911
+ version: { type: ['integer', 'null'] },
912
+ },
913
+ required: ['comment_id'],
914
+ },
846
915
  resolved: {
847
916
  type: ['object', 'null'],
848
917
  description:
@@ -858,12 +927,15 @@ export const READ_COMMENTS_TOOL_DEFINITION = {
858
927
  'anchor',
859
928
  'readable',
860
929
  'unreadable_reason',
930
+ 'addresses',
931
+ 'addressed',
932
+ 'addressed_by',
861
933
  ],
862
934
  additionalProperties: false,
863
935
  },
864
936
  },
865
937
  },
866
- required: ['relic_id', 'count', 'unreadable_count', 'comments'],
938
+ required: ['relic_id', 'count', 'unreadable_count', 'summary', 'comments'],
867
939
  additionalProperties: false,
868
940
  },
869
941
  } as const;
@@ -905,6 +977,13 @@ export const COMMENT_TOOL_DEFINITION = {
905
977
  'UTF-8. It aliases the attribution for presentation and never ' +
906
978
  'replaces it.',
907
979
  },
980
+ addresses: {
981
+ type: 'string',
982
+ description:
983
+ 'Optional. The service-minted id of the comment this one answers or acknowledges. ' +
984
+ 'Writing this marks that comment addressed so the relic can be republished. ' +
985
+ 'The pointer is sealed into the encrypted comment and also passed to the service in the clear for notification routing.',
986
+ },
908
987
  anchor: {
909
988
  type: 'object',
910
989
  description:
@@ -1710,10 +1789,29 @@ async function callRepublish(
1710
1789
  'omitted to leave the lifetime as the first publish set it'
1711
1790
  );
1712
1791
  }
1792
+ const rawAddresses = args['addresses'];
1793
+ let addresses: readonly RepublishAddressEntry[] | undefined;
1794
+ if (rawAddresses !== undefined) {
1795
+ if (!Array.isArray(rawAddresses)) {
1796
+ return errorResponse(
1797
+ id,
1798
+ ERROR_CODES.invalidParams,
1799
+ '`addresses` must be an array of { comment_id, note } objects or omitted'
1800
+ );
1801
+ }
1802
+ addresses = rawAddresses as readonly RepublishAddressEntry[];
1803
+ }
1713
1804
 
1714
1805
  try {
1715
1806
  const result = await republish(
1716
- { relic_id: relicId, path, filename, title, ttl_days: ttlDays.days },
1807
+ {
1808
+ relic_id: relicId,
1809
+ path,
1810
+ filename,
1811
+ title,
1812
+ ttl_days: ttlDays.days,
1813
+ addresses,
1814
+ },
1717
1815
  deps
1718
1816
  );
1719
1817
  return {
@@ -1856,6 +1954,16 @@ async function callComment(
1856
1954
  );
1857
1955
  }
1858
1956
 
1957
+ const rawAddresses = args['addresses'];
1958
+ if (rawAddresses !== undefined && typeof rawAddresses !== 'string') {
1959
+ return errorResponse(
1960
+ id,
1961
+ ERROR_CODES.invalidParams,
1962
+ '`addresses` must be a string or omitted'
1963
+ );
1964
+ }
1965
+ const addresses = typeof rawAddresses === 'string' ? rawAddresses : undefined;
1966
+
1859
1967
  try {
1860
1968
  const result = await postComment(
1861
1969
  {
@@ -1863,6 +1971,7 @@ async function callComment(
1863
1971
  body,
1864
1972
  display_name: displayName,
1865
1973
  anchor: rawAnchor as CommentAnchorInput | null | undefined,
1974
+ addresses,
1866
1975
  },
1867
1976
  deps
1868
1977
  );
@@ -1970,6 +2079,7 @@ function commentTranscript(result: {
1970
2079
  readonly relic_id: string;
1971
2080
  readonly count: number;
1972
2081
  readonly unreadable_count: number;
2082
+ readonly summary: CommentsSummary;
1973
2083
  readonly comments: readonly CommentRecord[];
1974
2084
  }): string {
1975
2085
  if (result.count === 0) {
@@ -1989,7 +2099,18 @@ function commentTranscript(result: {
1989
2099
  // would be the same defect this fixed, one layer up.
1990
2100
  const mark = markLine(comment.anchor);
1991
2101
  const resolution = resolutionLine(comment.resolved);
1992
- return `${comment.created_at} ${who}:\n${mark}${resolution}${comment.body}`;
2102
+ const replyNote =
2103
+ comment.addresses !== null
2104
+ ? `[answers comment ${comment.addresses}]\n`
2105
+ : '';
2106
+ const addressedNote = comment.addressed
2107
+ ? `[addressed by comment ${comment.addressed_by?.comment_id ?? 'unknown'}${
2108
+ comment.addressed_by?.version != null
2109
+ ? ` in v${comment.addressed_by.version}`
2110
+ : ''
2111
+ }]\n`
2112
+ : '';
2113
+ return `${comment.created_at} ${who}:\n${mark}${resolution}${replyNote}${addressedNote}${comment.body}`;
1993
2114
  });
1994
2115
 
1995
2116
  const header =
@@ -2000,7 +2121,9 @@ function commentTranscript(result: {
2000
2121
  'unreadable rather than dropped, so treat this conversation as ' +
2001
2122
  'partially unread.';
2002
2123
 
2003
- return `${header}\n\n${lines.join('\n\n')}`;
2124
+ const summaryLine = `Summary: ${result.summary.total} total, ${result.summary.addressed} addressed, ${result.summary.open} open, ${result.summary.unreadable} unreadable.`;
2125
+
2126
+ return `${header}\n${summaryLine}\n\n${lines.join('\n\n')}`;
2004
2127
  }
2005
2128
 
2006
2129
  /**