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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/relic-mcp.js +269 -20
- package/package.json +1 -1
- package/src/comments.ts +214 -11
- package/src/publish.ts +9 -1
- package/src/republish.ts +141 -0
- package/src/server.ts +129 -6
|
@@ -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.
|
|
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.
|
|
24
|
+
"version": "0.8.0"
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "relic",
|
|
3
|
-
"version": "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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
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({
|
|
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
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|