relic-mcp 0.3.2 → 0.4.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/README.md +42 -0
- package/dist/relic-mcp.js +506 -16
- package/package.json +1 -1
- package/skills/relic/SKILL.md +50 -1
- package/src/comments.ts +286 -0
- package/src/publish.ts +59 -8
- package/src/server.ts +343 -17
package/src/server.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* unusable in most of the agents this product exists to serve.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { type CommentRecord, postComment, readComments } from './comments.ts';
|
|
23
24
|
import {
|
|
24
25
|
ERROR_CODES,
|
|
25
26
|
errorResponse,
|
|
@@ -97,6 +98,32 @@ export const REPUBLISH_TOOL_NAME = 'relic_republish';
|
|
|
97
98
|
*/
|
|
98
99
|
export const LOOKUP_TOOL_NAME = 'relic_lookup_source';
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The comment tools, and why they are two rather than one.
|
|
103
|
+
*
|
|
104
|
+
* Reading is the half that makes comments worth having for an agent: a person
|
|
105
|
+
* leaves a comment, the agent reads it back, and it acts on it. Writing is
|
|
106
|
+
* the half that lets the agent answer. They are separated for the same reason
|
|
107
|
+
* lookup is separate from republish: the read needs no credential and the
|
|
108
|
+
* write spends the publish token, so folding them together would make an
|
|
109
|
+
* agent that only wants to read present a write credential to find out.
|
|
110
|
+
*
|
|
111
|
+
* Both take a relic id, never the share URL. The fragment is the key.
|
|
112
|
+
*/
|
|
113
|
+
export const READ_COMMENTS_TOOL_NAME = 'relic_read_comments';
|
|
114
|
+
|
|
115
|
+
export const COMMENT_TOOL_NAME = 'relic_comment';
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The one sentence about comments an agent has to have before it reads any,
|
|
119
|
+
* carried on both comment tools and in the handshake instructions the way the
|
|
120
|
+
* version-history disclosure is.
|
|
121
|
+
*/
|
|
122
|
+
const COMMENT_MACHINE_BOUNDARY =
|
|
123
|
+
'Only works for a relic this machine published: the comment key is derived ' +
|
|
124
|
+
"from that relic's key, which lives in local publish state and nowhere the " +
|
|
125
|
+
'service can reach.';
|
|
126
|
+
|
|
100
127
|
/**
|
|
101
128
|
* The ceiling on a publisher-supplied lifetime, matching the grant
|
|
102
129
|
* contract's `maxTtlDays`. Refusing here keeps a typo like 36500 from
|
|
@@ -105,6 +132,10 @@ export const LOOKUP_TOOL_NAME = 'relic_lookup_source';
|
|
|
105
132
|
*/
|
|
106
133
|
const MAX_TTL_DAYS = 3650;
|
|
107
134
|
|
|
135
|
+
const VERSION_HISTORY_DISCLOSURE =
|
|
136
|
+
"Anyone holding a relic's link can fetch every version it has ever held, " +
|
|
137
|
+
'so republishing does not withdraw earlier content.';
|
|
138
|
+
|
|
108
139
|
export const TOOL_DEFINITION = {
|
|
109
140
|
name: TOOL_NAME,
|
|
110
141
|
title: 'Publish a relic',
|
|
@@ -112,8 +143,10 @@ export const TOOL_DEFINITION = {
|
|
|
112
143
|
'Encrypt a file on this machine and publish it as a new relic, returning ' +
|
|
113
144
|
'a shareable URL. Publishing an update this way costs a second URL that ' +
|
|
114
145
|
'nobody holding the first one will ever see; use relic_republish instead ' +
|
|
115
|
-
'so the existing URL keeps working.
|
|
116
|
-
|
|
146
|
+
'so the existing URL keeps working. ' +
|
|
147
|
+
VERSION_HISTORY_DISCLOSURE +
|
|
148
|
+
' The encryption key is generated locally and never sent to the service. ' +
|
|
149
|
+
'Takes a filesystem path, never ' +
|
|
117
150
|
'inline content.',
|
|
118
151
|
inputSchema: {
|
|
119
152
|
type: 'object',
|
|
@@ -187,7 +220,8 @@ export const REPUBLISH_TOOL_DEFINITION = {
|
|
|
187
220
|
description:
|
|
188
221
|
'Publish a new version of a relic this machine originally published, ' +
|
|
189
222
|
'encrypting under the same key so the existing share URL keeps working. ' +
|
|
190
|
-
|
|
223
|
+
VERSION_HISTORY_DISCLOSURE +
|
|
224
|
+
" Only possible from the machine that holds the relic's key and publish " +
|
|
191
225
|
'token; a relic that was taken down can never be revived.',
|
|
192
226
|
inputSchema: {
|
|
193
227
|
type: 'object',
|
|
@@ -308,6 +342,127 @@ export const LOOKUP_TOOL_DEFINITION = {
|
|
|
308
342
|
},
|
|
309
343
|
} as const;
|
|
310
344
|
|
|
345
|
+
export const READ_COMMENTS_TOOL_DEFINITION = {
|
|
346
|
+
name: READ_COMMENTS_TOOL_NAME,
|
|
347
|
+
title: "Read a relic's comments",
|
|
348
|
+
description:
|
|
349
|
+
'Read the comments people have left on a relic, oldest first, decrypted ' +
|
|
350
|
+
'on this machine. Use it before changing content somebody was asked to ' +
|
|
351
|
+
'review, and after sharing a link, because a comment is the only way a ' +
|
|
352
|
+
'reader can answer back. ' +
|
|
353
|
+
COMMENT_MACHINE_BOUNDARY +
|
|
354
|
+
' Takes the relic id, never the share URL: the URL carries the key in ' +
|
|
355
|
+
'its fragment. A comment that will not decrypt is returned marked ' +
|
|
356
|
+
'unreadable rather than dropped, so a shortened list never reads as ' +
|
|
357
|
+
'agreement.',
|
|
358
|
+
inputSchema: {
|
|
359
|
+
type: 'object',
|
|
360
|
+
properties: {
|
|
361
|
+
relic_id: {
|
|
362
|
+
type: 'string',
|
|
363
|
+
description: 'The 26-character relic id the original publish returned.',
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
required: ['relic_id'],
|
|
367
|
+
additionalProperties: false,
|
|
368
|
+
},
|
|
369
|
+
outputSchema: {
|
|
370
|
+
type: 'object',
|
|
371
|
+
properties: {
|
|
372
|
+
relic_id: { type: 'string' },
|
|
373
|
+
count: { type: 'integer', minimum: 0 },
|
|
374
|
+
unreadable_count: {
|
|
375
|
+
type: 'integer',
|
|
376
|
+
minimum: 0,
|
|
377
|
+
description:
|
|
378
|
+
'How many of `count` did not decrypt. Above zero means part of the ' +
|
|
379
|
+
'conversation is unread, not absent.',
|
|
380
|
+
},
|
|
381
|
+
comments: {
|
|
382
|
+
type: 'array',
|
|
383
|
+
items: {
|
|
384
|
+
type: 'object',
|
|
385
|
+
properties: {
|
|
386
|
+
comment_id: { type: 'string' },
|
|
387
|
+
author: {
|
|
388
|
+
type: 'string',
|
|
389
|
+
description:
|
|
390
|
+
'The commenter\u2019s verified email address, or "publisher" ' +
|
|
391
|
+
'for a comment written with a publish token.',
|
|
392
|
+
},
|
|
393
|
+
created_at: { type: 'string' },
|
|
394
|
+
display_name: { type: ['string', 'null'] },
|
|
395
|
+
body: { type: ['string', 'null'] },
|
|
396
|
+
readable: { type: 'boolean' },
|
|
397
|
+
unreadable_reason: { type: ['string', 'null'] },
|
|
398
|
+
},
|
|
399
|
+
required: [
|
|
400
|
+
'comment_id',
|
|
401
|
+
'author',
|
|
402
|
+
'created_at',
|
|
403
|
+
'display_name',
|
|
404
|
+
'body',
|
|
405
|
+
'readable',
|
|
406
|
+
'unreadable_reason',
|
|
407
|
+
],
|
|
408
|
+
additionalProperties: false,
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
required: ['relic_id', 'count', 'unreadable_count', 'comments'],
|
|
413
|
+
additionalProperties: false,
|
|
414
|
+
},
|
|
415
|
+
} as const;
|
|
416
|
+
|
|
417
|
+
export const COMMENT_TOOL_DEFINITION = {
|
|
418
|
+
name: COMMENT_TOOL_NAME,
|
|
419
|
+
title: 'Comment on a relic',
|
|
420
|
+
description:
|
|
421
|
+
'Leave a comment on a relic this machine published, encrypted here so ' +
|
|
422
|
+
'the service stores ciphertext it cannot read. Everyone holding the ' +
|
|
423
|
+
'link sees it. Attribution is the publish token, so the comment is ' +
|
|
424
|
+
'attributed to "publisher" rather than to an email address: an agent has ' +
|
|
425
|
+
'no mailbox and cannot verify one. That is attribution and not ' +
|
|
426
|
+
'authorization. ' +
|
|
427
|
+
COMMENT_MACHINE_BOUNDARY +
|
|
428
|
+
' Takes the relic id, never the share URL.',
|
|
429
|
+
inputSchema: {
|
|
430
|
+
type: 'object',
|
|
431
|
+
properties: {
|
|
432
|
+
relic_id: {
|
|
433
|
+
type: 'string',
|
|
434
|
+
description: 'The 26-character relic id the original publish returned.',
|
|
435
|
+
},
|
|
436
|
+
body: {
|
|
437
|
+
type: 'string',
|
|
438
|
+
description:
|
|
439
|
+
'The comment text, up to 4096 bytes of UTF-8. It is encrypted ' +
|
|
440
|
+
'before it leaves this machine.',
|
|
441
|
+
},
|
|
442
|
+
display_name: {
|
|
443
|
+
type: 'string',
|
|
444
|
+
description:
|
|
445
|
+
'Optional. A name shown beside the comment, up to 64 bytes of ' +
|
|
446
|
+
'UTF-8. It aliases the attribution for presentation and never ' +
|
|
447
|
+
'replaces it.',
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
required: ['relic_id', 'body'],
|
|
451
|
+
additionalProperties: false,
|
|
452
|
+
},
|
|
453
|
+
outputSchema: {
|
|
454
|
+
type: 'object',
|
|
455
|
+
properties: {
|
|
456
|
+
relic_id: { type: 'string' },
|
|
457
|
+
comment_id: { type: 'string' },
|
|
458
|
+
author: { type: 'string' },
|
|
459
|
+
created_at: { type: 'string' },
|
|
460
|
+
},
|
|
461
|
+
required: ['relic_id', 'comment_id', 'author', 'created_at'],
|
|
462
|
+
additionalProperties: false,
|
|
463
|
+
},
|
|
464
|
+
} as const;
|
|
465
|
+
|
|
311
466
|
export const DESCRIBE_TOOL_DEFINITION = {
|
|
312
467
|
name: DESCRIBE_TOOL_NAME,
|
|
313
468
|
title: 'Describe the Relic client',
|
|
@@ -349,12 +504,14 @@ export const CAPABILITIES = { tools: {} } as const;
|
|
|
349
504
|
* The plugin ships a skill with the same facts, but a skill only reaches
|
|
350
505
|
* Claude Code, and only when somebody installs the plugin rather than wiring
|
|
351
506
|
* this server directly. Every other client saw tool descriptions and nothing
|
|
352
|
-
* else, which left
|
|
507
|
+
* else, which left six things an agent cannot read off a schema.
|
|
353
508
|
*
|
|
354
|
-
* Item five is the
|
|
355
|
-
*
|
|
356
|
-
* too late for an agent that already linked a stylesheet
|
|
357
|
-
*
|
|
509
|
+
* Item five is one of the two reasons this exists at all rather than living
|
|
510
|
+
* only in a tool result. The publish result arrives after the file is
|
|
511
|
+
* written, which is too late for an agent that already linked a stylesheet
|
|
512
|
+
* from a CDN. Item six is the other: an agent that never learns comments
|
|
513
|
+
* exist never reads one. Both land before the work, which is the only moment
|
|
514
|
+
* either can be acted on.
|
|
358
515
|
*
|
|
359
516
|
* It costs context on every session, so it stays short and it stays true.
|
|
360
517
|
* Anything that needs a paragraph belongs in the skill or the disclosure.
|
|
@@ -363,22 +520,25 @@ export const INSTRUCTIONS = `Relic encrypts a file on this machine and uploads \
|
|
|
363
520
|
only ciphertext. The key lives in the URL fragment, which browsers never send \
|
|
364
521
|
to a server.
|
|
365
522
|
|
|
366
|
-
|
|
523
|
+
Six things that change how you should act:
|
|
367
524
|
|
|
368
525
|
1. The link is the credential. Anyone holding it, fragment included, can read \
|
|
369
526
|
the file. Do not paste it into a tracker, a log, or a public channel.
|
|
370
527
|
2. Publishing puts the key in this transcript. That is structural rather than \
|
|
371
528
|
a defect, and worth saying plainly when you hand the link over.
|
|
372
|
-
3.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
recovers the id, and force_new is only for a deliberate second link.
|
|
529
|
+
3. Check existing sources with relic_lookup_source. Use relic_republish when \
|
|
530
|
+
found; relic_publish otherwise costs a second URL. \
|
|
531
|
+
${VERSION_HISTORY_DISCLOSURE}
|
|
376
532
|
4. A relic can be republished only from the machine that published it, which \
|
|
377
533
|
is where its key and publish token are stored. Anywhere else it refuses, and \
|
|
378
534
|
no retry changes that.
|
|
379
535
|
5. Rendered HTML and JSX run in an isolated frame with no network access. \
|
|
380
536
|
Inline the styles, scripts, fonts, and images a page needs, because a CDN \
|
|
381
|
-
reference renders as nothing. Decide that before you write the file
|
|
537
|
+
reference renders as nothing. Decide that before you write the file.
|
|
538
|
+
6. People can comment on a relic. Read them with relic_read_comments before \
|
|
539
|
+
you change reviewed content, and answer with relic_comment. Both take the \
|
|
540
|
+
relic id, work only on the machine that published, and attribute you as the \
|
|
541
|
+
publisher.`;
|
|
382
542
|
|
|
383
543
|
/**
|
|
384
544
|
* Handle one JSON-RPC message.
|
|
@@ -448,6 +608,8 @@ export async function handleMessage(
|
|
|
448
608
|
TOOL_DEFINITION,
|
|
449
609
|
LOOKUP_TOOL_DEFINITION,
|
|
450
610
|
REPUBLISH_TOOL_DEFINITION,
|
|
611
|
+
READ_COMMENTS_TOOL_DEFINITION,
|
|
612
|
+
COMMENT_TOOL_DEFINITION,
|
|
451
613
|
DESCRIBE_TOOL_DEFINITION,
|
|
452
614
|
],
|
|
453
615
|
},
|
|
@@ -506,6 +668,13 @@ async function callTool(
|
|
|
506
668
|
'relic id, source identity, key, and publish token per relic, ' +
|
|
507
669
|
'written 0600 under the user config directory; key and token ' +
|
|
508
670
|
'are never printed or sent',
|
|
671
|
+
comment_encryption:
|
|
672
|
+
'AES-128-GCM under a key derived from the relic key with a ' +
|
|
673
|
+
'distinct HKDF label, so comment bodies reach the service as ' +
|
|
674
|
+
'ciphertext and the URL fragment is unchanged',
|
|
675
|
+
comment_attribution:
|
|
676
|
+
'the publish token, reported by the service as "publisher"; the ' +
|
|
677
|
+
'operator learns which identity commented on which relic and when',
|
|
509
678
|
service_origin: deps.serviceOrigin,
|
|
510
679
|
},
|
|
511
680
|
isError: false,
|
|
@@ -521,6 +690,14 @@ async function callTool(
|
|
|
521
690
|
return callRepublish(id, params, deps);
|
|
522
691
|
}
|
|
523
692
|
|
|
693
|
+
if (params['name'] === READ_COMMENTS_TOOL_NAME) {
|
|
694
|
+
return callReadComments(id, params, deps);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (params['name'] === COMMENT_TOOL_NAME) {
|
|
698
|
+
return callComment(id, params, deps);
|
|
699
|
+
}
|
|
700
|
+
|
|
524
701
|
if (params['name'] !== TOOL_NAME) {
|
|
525
702
|
return errorResponse(
|
|
526
703
|
id,
|
|
@@ -601,6 +778,7 @@ async function callTool(
|
|
|
601
778
|
isolationNote(result.renderer_class) +
|
|
602
779
|
`What Relic knows: ${result.disclosure_url}`,
|
|
603
780
|
},
|
|
781
|
+
{ type: 'text', text: VERSION_HISTORY_DISCLOSURE },
|
|
604
782
|
],
|
|
605
783
|
structuredContent: result,
|
|
606
784
|
isError: false,
|
|
@@ -732,6 +910,99 @@ async function callRepublish(
|
|
|
732
910
|
'\n' +
|
|
733
911
|
`What Relic knows: ${result.disclosure_url}`,
|
|
734
912
|
},
|
|
913
|
+
{ type: 'text', text: VERSION_HISTORY_DISCLOSURE },
|
|
914
|
+
],
|
|
915
|
+
structuredContent: result,
|
|
916
|
+
isError: false,
|
|
917
|
+
},
|
|
918
|
+
};
|
|
919
|
+
} catch (error) {
|
|
920
|
+
return { jsonrpc: '2.0', id, result: toolError(error) };
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function callReadComments(
|
|
925
|
+
id: string | number | null,
|
|
926
|
+
params: Record<string, unknown>,
|
|
927
|
+
deps: PublishDeps
|
|
928
|
+
): Promise<JsonRpcResponse> {
|
|
929
|
+
const args = (params['arguments'] ?? {}) as Record<string, unknown>;
|
|
930
|
+
const relicId = args['relic_id'];
|
|
931
|
+
if (typeof relicId !== 'string' || relicId.length === 0) {
|
|
932
|
+
return errorResponse(
|
|
933
|
+
id,
|
|
934
|
+
ERROR_CODES.invalidParams,
|
|
935
|
+
'`relic_id` is required and must be a string'
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
try {
|
|
940
|
+
const result = await readComments(relicId, deps);
|
|
941
|
+
return {
|
|
942
|
+
jsonrpc: '2.0',
|
|
943
|
+
id,
|
|
944
|
+
result: {
|
|
945
|
+
content: [{ type: 'text', text: commentTranscript(result) }],
|
|
946
|
+
structuredContent: result,
|
|
947
|
+
isError: false,
|
|
948
|
+
},
|
|
949
|
+
};
|
|
950
|
+
} catch (error) {
|
|
951
|
+
return { jsonrpc: '2.0', id, result: toolError(error) };
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
async function callComment(
|
|
956
|
+
id: string | number | null,
|
|
957
|
+
params: Record<string, unknown>,
|
|
958
|
+
deps: PublishDeps
|
|
959
|
+
): Promise<JsonRpcResponse> {
|
|
960
|
+
const args = (params['arguments'] ?? {}) as Record<string, unknown>;
|
|
961
|
+
const relicId = args['relic_id'];
|
|
962
|
+
if (typeof relicId !== 'string' || relicId.length === 0) {
|
|
963
|
+
return errorResponse(
|
|
964
|
+
id,
|
|
965
|
+
ERROR_CODES.invalidParams,
|
|
966
|
+
'`relic_id` is required and must be a string'
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const body = args['body'];
|
|
971
|
+
if (typeof body !== 'string') {
|
|
972
|
+
return errorResponse(
|
|
973
|
+
id,
|
|
974
|
+
ERROR_CODES.invalidParams,
|
|
975
|
+
'`body` is required and must be a string'
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const displayName = args['display_name'];
|
|
980
|
+
if (displayName !== undefined && typeof displayName !== 'string') {
|
|
981
|
+
return errorResponse(
|
|
982
|
+
id,
|
|
983
|
+
ERROR_CODES.invalidParams,
|
|
984
|
+
'`display_name` must be a string or omitted'
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
try {
|
|
989
|
+
const result = await postComment(
|
|
990
|
+
{ relic_id: relicId, body, display_name: displayName },
|
|
991
|
+
deps
|
|
992
|
+
);
|
|
993
|
+
return {
|
|
994
|
+
jsonrpc: '2.0',
|
|
995
|
+
id,
|
|
996
|
+
result: {
|
|
997
|
+
content: [
|
|
998
|
+
{
|
|
999
|
+
type: 'text',
|
|
1000
|
+
text:
|
|
1001
|
+
`Commented on relic ${result.relic_id} as ${result.author}.\n` +
|
|
1002
|
+
'Everyone holding the link sees it. The service stored ' +
|
|
1003
|
+
'ciphertext it cannot read, and it knows that this address ' +
|
|
1004
|
+
'commented on this relic at this time.',
|
|
1005
|
+
},
|
|
735
1006
|
],
|
|
736
1007
|
structuredContent: result,
|
|
737
1008
|
isError: false,
|
|
@@ -742,6 +1013,46 @@ async function callRepublish(
|
|
|
742
1013
|
}
|
|
743
1014
|
}
|
|
744
1015
|
|
|
1016
|
+
/**
|
|
1017
|
+
* The comments as a person would read them, because a JSON array of rows is
|
|
1018
|
+
* not a conversation.
|
|
1019
|
+
*
|
|
1020
|
+
* Unreadable comments are printed in place, in order, with their reason. A
|
|
1021
|
+
* list that quietly closed over a gap would read as the whole conversation,
|
|
1022
|
+
* and an agent acting on "nobody objected" when somebody did is exactly the
|
|
1023
|
+
* failure the count exists to prevent.
|
|
1024
|
+
*/
|
|
1025
|
+
function commentTranscript(result: {
|
|
1026
|
+
readonly relic_id: string;
|
|
1027
|
+
readonly count: number;
|
|
1028
|
+
readonly unreadable_count: number;
|
|
1029
|
+
readonly comments: readonly CommentRecord[];
|
|
1030
|
+
}): string {
|
|
1031
|
+
if (result.count === 0) {
|
|
1032
|
+
return `No comments on relic ${result.relic_id} yet.`;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const lines = result.comments.map((comment) => {
|
|
1036
|
+
const who =
|
|
1037
|
+
comment.display_name === null
|
|
1038
|
+
? comment.author
|
|
1039
|
+
: `${comment.display_name} (${comment.author})`;
|
|
1040
|
+
return comment.readable
|
|
1041
|
+
? `${comment.created_at} ${who}:\n${comment.body}`
|
|
1042
|
+
: `${comment.created_at} ${who}:\n[unreadable: ${comment.unreadable_reason}]`;
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
const header =
|
|
1046
|
+
result.unreadable_count === 0
|
|
1047
|
+
? `${result.count} comment(s) on relic ${result.relic_id}, oldest first.`
|
|
1048
|
+
: `${result.count} comment(s) on relic ${result.relic_id}, oldest ` +
|
|
1049
|
+
`first. ${result.unreadable_count} did not decrypt and are shown as ` +
|
|
1050
|
+
'unreadable rather than dropped, so treat this conversation as ' +
|
|
1051
|
+
'partially unread.';
|
|
1052
|
+
|
|
1053
|
+
return `${header}\n\n${lines.join('\n\n')}`;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
745
1056
|
/**
|
|
746
1057
|
* A lifetime is opt-in: absent or null means no change from the default. A
|
|
747
1058
|
* value that fails the contract is refused rather than dropped, because
|
|
@@ -764,11 +1075,11 @@ function parseTtlDays(
|
|
|
764
1075
|
}
|
|
765
1076
|
|
|
766
1077
|
/**
|
|
767
|
-
*
|
|
1078
|
+
* Refusals a publisher must understand on their own terms.
|
|
768
1079
|
*
|
|
769
1080
|
* The server's problem document carries the code; these sentences carry
|
|
770
1081
|
* what the publisher can still do, because "403" and "410" answer nothing
|
|
771
|
-
* a human would ask. The two must stay distinct: one means this machine
|
|
1082
|
+
* a human would ask. The first two must stay distinct: one means this machine
|
|
772
1083
|
* lost its standing, the other means nobody has any, ever again.
|
|
773
1084
|
*/
|
|
774
1085
|
const REFUSAL_GUIDANCE: Readonly<Record<string, string>> = {
|
|
@@ -782,6 +1093,10 @@ const REFUSAL_GUIDANCE: Readonly<Record<string, string>> = {
|
|
|
782
1093
|
'That relic was taken down. A takedown is permanent: republishing ' +
|
|
783
1094
|
'cannot revive it, whatever token is presented. Publish the content as ' +
|
|
784
1095
|
'a new relic instead.',
|
|
1096
|
+
comment_rate_limited:
|
|
1097
|
+
'The service is rate limiting comments on that relic. The refusal ' +
|
|
1098
|
+
'carries retry_after_seconds; wait it out rather than retrying in a ' +
|
|
1099
|
+
'loop, which only extends the limit.',
|
|
785
1100
|
};
|
|
786
1101
|
|
|
787
1102
|
/**
|
|
@@ -816,7 +1131,7 @@ function toolError(error: unknown): Record<string, unknown> {
|
|
|
816
1131
|
};
|
|
817
1132
|
}
|
|
818
1133
|
return {
|
|
819
|
-
content: [{ type: 'text', text: `
|
|
1134
|
+
content: [{ type: 'text', text: `the call failed: ${String(error)}` }],
|
|
820
1135
|
structuredContent: { code: 'unknown' },
|
|
821
1136
|
isError: true,
|
|
822
1137
|
};
|
|
@@ -861,6 +1176,17 @@ service ever holds, and neither secret is ever printed or logged. Deleting the
|
|
|
861
1176
|
file changes nothing for existing links; it only ends this machine's ability
|
|
862
1177
|
to update those relics.
|
|
863
1178
|
|
|
1179
|
+
What happens with comments: a comment body is encrypted here too, under a key
|
|
1180
|
+
derived from that relic's key with a distinct HKDF label, so the service
|
|
1181
|
+
stores comment ciphertext it cannot read and the URL fragment does not change.
|
|
1182
|
+
Reading comments needs that key, and writing one is authorized by the publish
|
|
1183
|
+
token, so both work only on the machine that published. A comment this client
|
|
1184
|
+
writes is attributed to the publisher rather than an email address, because
|
|
1185
|
+
an agent has no mailbox to verify. What the operator does learn is who
|
|
1186
|
+
commented on which relic and when: for a person that is a verified email
|
|
1187
|
+
address, and that association is a real cost the content's encryption does
|
|
1188
|
+
not cover.
|
|
1189
|
+
|
|
864
1190
|
What this does NOT protect against: the key is returned to your agent in the
|
|
865
1191
|
URL, so it enters the model's context and your session transcript. That is
|
|
866
1192
|
structural, not a defect. Anyone who can read this conversation can open the
|