@inixiative/signet 0.4.6 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/threads.ts ADDED
@@ -0,0 +1,426 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Agent conversations (Kingdom `docs/AGENT-CONVERSATIONS.md`, Foundry `docs/reference/CONVERSATIONS.md`).
5
+ * A member reaches a host Foundry's main thread through a Signet on the Foundry's single `threads`
6
+ * resource. Kingdom admits each call and relays it over the host's Installation socket; the host
7
+ * answers. Nothing here is stored by Kingdom.
8
+ */
9
+ export const threadsResourceKind = 'threads';
10
+
11
+ /**
12
+ * A Foundry id (a thread, an invitation, a held prompt). Thread ids sit inside Kingdom stream names,
13
+ * which are colon-separated, and inside URL paths, so neither `:` nor `/` is allowed. Main threads
14
+ * are `thread_<uuidv7>`.
15
+ */
16
+ export const foundryIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,128}$/);
17
+ export const threadIdSchema = foundryIdSchema;
18
+
19
+ /**
20
+ * Selector forms that are named for standing Signets (design §9) but not built. Each fails to parse
21
+ * on every party until a release adds it to `threadSelectorSchema`, so a new form only takes effect
22
+ * once every side has upgraded and never widens access silently.
23
+ *
24
+ * - `{ form: 'withMember' }`: threads opened with the grant's recipient;
25
+ * - `{ form: 'tag', tag }`: threads carrying a tag.
26
+ */
27
+ export const unbuiltThreadSelectorForms = ['withMember', 'tag'] as const;
28
+
29
+ /**
30
+ * `ids` takes `within` only once a non-`ids` form exists: `within` will hold exactly one such form
31
+ * (never a selector, so it can't nest), set only by approving a request for a selector form narrowed
32
+ * to `ids` (the grant is then `ids ∩ within`), and approval may never drop or widen it. Until then
33
+ * the strict object refuses `within` like any unknown key: structural, and representable in Kingdom's
34
+ * OpenAPI (a `z.never()` placeholder is not).
35
+ */
36
+ export const idsThreadSelectorSchema = z.strictObject({
37
+ form: z.literal('ids'),
38
+ ids: z.array(threadIdSchema).min(1).max(100),
39
+ });
40
+
41
+ /** Which threads a grant reaches. A discriminated union on `form`; v1 builds only `ids`. */
42
+ export const threadSelectorSchema = z.discriminatedUnion('form', [idsThreadSelectorSchema]);
43
+ export type ThreadSelector = z.infer<typeof threadSelectorSchema>;
44
+
45
+ /** `own`: the member's posts and the sends addressed to it. `thread`: the main thread's messages. */
46
+ export const threadViewSchema = z.enum(['own', 'thread']);
47
+ /** `note`: rides the owner's next turn. `turn`: queued as a turn of its own. */
48
+ export const threadDeliverySchema = z.enum(['note', 'turn']);
49
+ /** `review`: the host's sends to this member wait for the owner. `auto`: they leave at once. */
50
+ export const threadOutboundSchema = z.enum(['review', 'auto']);
51
+
52
+ /**
53
+ * A member's terms on a `threads` resource. Every term but the selector defaults to its narrowest
54
+ * value, so a lens that omits one can only be more careful. `threads` has no default: `{}` is never a
55
+ * valid threads lens, which is why a grant's preset must fill it (review R9).
56
+ */
57
+ export const threadsLensSchema = z.strictObject({
58
+ threads: threadSelectorSchema,
59
+ view: threadViewSchema.default('own'),
60
+ delivery: threadDeliverySchema.default('note'),
61
+ outbound: threadOutboundSchema.default('review'),
62
+ });
63
+ export type ThreadsLens = z.infer<typeof threadsLensSchema>;
64
+
65
+ /** Is `threadId` inside the selector? For `ids`, Kingdom answers this at admission. */
66
+ export const selectsThread = (selector: ThreadSelector, threadId: string): boolean => {
67
+ switch (selector.form) {
68
+ case 'ids':
69
+ return selector.ids.includes(threadId);
70
+ }
71
+ };
72
+
73
+ /** `granted` reaches no thread `requested` doesn't: per form, as the design's narrowing rules say. */
74
+ export const narrowsThreadSelector = (
75
+ granted: ThreadSelector,
76
+ requested: ThreadSelector,
77
+ ): boolean => {
78
+ switch (requested.form) {
79
+ case 'ids':
80
+ // When `within` exists, it must also be kept as asked: never dropped, never added.
81
+ return granted.form === 'ids' && granted.ids.every((id) => requested.ids.includes(id));
82
+ }
83
+ };
84
+
85
+ const order = <T extends string>(values: readonly T[]) => {
86
+ const rank = (value: T) => values.indexOf(value);
87
+ return (granted: T, requested: T) => rank(granted) <= rank(requested);
88
+ };
89
+ const narrowsView = order(threadViewSchema.options);
90
+ const narrowsDelivery = order(threadDeliverySchema.options);
91
+ const narrowsOutbound = order(threadOutboundSchema.options);
92
+
93
+ /** Approving a request may only narrow (`own` < `thread`, `note` < `turn`, `review` < `auto`). */
94
+ export const narrowsThreadsLens = (granted: ThreadsLens, requested: ThreadsLens): boolean =>
95
+ narrowsThreadSelector(granted.threads, requested.threads) &&
96
+ narrowsView(granted.view, requested.view) &&
97
+ narrowsDelivery(granted.delivery, requested.delivery) &&
98
+ narrowsOutbound(granted.outbound, requested.outbound);
99
+
100
+ /** The operations a `threads` resource carries. `prompt.resolve` is for owner recipients only. */
101
+ export const threadOperationSchema = z.enum([
102
+ 'thread.read',
103
+ 'thread.prompt',
104
+ 'thread.floor',
105
+ 'prompt.resolve',
106
+ ]);
107
+ export type ThreadOperation = z.infer<typeof threadOperationSchema>;
108
+
109
+ /**
110
+ * A party in a conversation, as Foundry names it (not "participant": that is a Warden contributor).
111
+ * Read paths (entries, views) use plain objects, so a host that adds a field never costs an older
112
+ * member its entries; lenses and request frames stay strict.
113
+ */
114
+ export const conversationMemberSchema = z.object({
115
+ kind: z.enum(['owner', 'local-herald', 'signet']),
116
+ id: z.string().min(1).max(128),
117
+ });
118
+ export type ConversationMember = z.infer<typeof conversationMemberSchema>;
119
+
120
+ /** A post or a read is capped at 16,000 characters, like Archive document relays. */
121
+ export const threadTextLimit = 16_000;
122
+
123
+ export const threadFloorActionSchema = z.discriminatedUnion('action', [
124
+ z.strictObject({ action: z.literal('request') }),
125
+ z.strictObject({ action: z.literal('release') }),
126
+ z.strictObject({ action: z.literal('pass'), to: conversationMemberSchema }),
127
+ ]);
128
+
129
+ /**
130
+ * The input each thread operation takes, as the host receives it. `access/execute` carries the same
131
+ * fields beside `resourceId` (`accessInputSchema`); Kingdom allows exactly these keys per operation.
132
+ */
133
+ export const threadInputSchemas = {
134
+ 'thread.read': z.strictObject({
135
+ threadId: threadIdSchema,
136
+ afterSeq: z.number().int().min(0).optional(),
137
+ }),
138
+ /** `postId` is the member's own id for the post: a retry with a new request id keeps it. */
139
+ 'thread.prompt': z.strictObject({
140
+ threadId: threadIdSchema,
141
+ postId: z.uuid(),
142
+ text: z.string().min(1).max(threadTextLimit),
143
+ /** The invitation a `speak-when-spoken-to` reply answers. */
144
+ inReplyTo: foundryIdSchema.optional(),
145
+ }),
146
+ 'thread.floor': z.strictObject({ threadId: threadIdSchema, floor: threadFloorActionSchema }),
147
+ 'prompt.resolve': z.strictObject({
148
+ threadId: threadIdSchema,
149
+ promptId: foundryIdSchema,
150
+ /** `approved`, `rejected` or an option id, plus the edited text where the prompt takes input. */
151
+ answer: z.strictObject({
152
+ action: z.string().min(1).max(80),
153
+ input: z.string().max(threadTextLimit).optional(),
154
+ }),
155
+ }),
156
+ } as const satisfies Record<ThreadOperation, z.ZodObject>;
157
+ /** The `access/execute` input keys each thread operation takes, besides `resourceId`. */
158
+ export const threadInputKeys = <O extends ThreadOperation>(operation: O) =>
159
+ Object.keys(threadInputSchemas[operation].shape) as (keyof ThreadInput<O>)[];
160
+ export type ThreadInput<O extends ThreadOperation> = z.infer<(typeof threadInputSchemas)[O]>;
161
+
162
+ /**
163
+ * One entry of the main thread's journal as a member sees it: an owner message, a main-agent
164
+ * message, a member's post (`member` = its author) or a released send (`member` = the addressee).
165
+ */
166
+ export const threadEntrySchema = z.object({
167
+ journalSeq: z.number().int().min(0),
168
+ at: z.iso.datetime(),
169
+ kind: z.enum(['owner', 'agent', 'post', 'send']),
170
+ member: conversationMemberSchema.nullable(),
171
+ text: z.string().max(threadTextLimit),
172
+ });
173
+ export type ThreadEntry = z.infer<typeof threadEntrySchema>;
174
+
175
+ /** A member's view, taken at `journalSeq`: the answer to `thread.read` and to `thread.subscribe`. */
176
+ export const threadViewResultSchema = z.object({
177
+ journalSeq: z.number().int().min(0),
178
+ entries: z.array(threadEntrySchema).max(1000),
179
+ /** The view was cut at the character cap; read again after the last entry's `journalSeq`. */
180
+ truncated: z.boolean(),
181
+ });
182
+ export type ThreadViewResult = z.infer<typeof threadViewResultSchema>;
183
+
184
+ /**
185
+ * A view read per entry (`thread.read` results, stream snapshots): an entry this package can't read
186
+ * is dropped and counted, never costing the whole view. Null when the view itself is unreadable.
187
+ */
188
+ export const parseThreadView = (value: unknown) => {
189
+ const record = z
190
+ .object({ entries: z.array(z.unknown()).max(1000) })
191
+ .loose()
192
+ .safeParse(value);
193
+ const view = threadViewResultSchema.omit({ entries: true }).safeParse(value);
194
+ if (!record.success || !view.success) return null;
195
+ const entries = record.data.entries.flatMap((entry) => {
196
+ const parsed = threadEntrySchema.safeParse(entry);
197
+ return parsed.success ? [parsed.data] : [];
198
+ });
199
+ return { ...view.data, entries, dropped: record.data.entries.length - entries.length };
200
+ };
201
+
202
+ export const floorModeSchema = z.enum(['talk', 'speak-when-spoken-to', 'conch']);
203
+
204
+ /** What the host answers per operation (and to `thread.subscribe`). */
205
+ export const threadResultSchemas = {
206
+ 'thread.read': threadViewResultSchema,
207
+ /** `duplicate`: the same `(signetId, postId)` was already admitted; `journalSeq` is the original's. */
208
+ 'thread.prompt': z.strictObject({
209
+ accepted: z.literal(true),
210
+ journalSeq: z.number().int().min(0),
211
+ duplicate: z.boolean(),
212
+ }),
213
+ 'thread.floor': z.strictObject({
214
+ mode: floorModeSchema,
215
+ holder: conversationMemberSchema.nullable(),
216
+ expiresAt: z.iso.datetime().nullable(),
217
+ }),
218
+ 'prompt.resolve': z.strictObject({ resolved: z.literal(true) }),
219
+ 'thread.subscribe': threadViewResultSchema,
220
+ } as const satisfies Record<ThreadOperation | 'thread.subscribe', z.ZodType>;
221
+ export type ThreadResult<O extends keyof typeof threadResultSchemas> = z.infer<
222
+ (typeof threadResultSchemas)[O]
223
+ >;
224
+
225
+ /**
226
+ * Why the host refused. The first six are the design's refusals, which Kingdom records as
227
+ * `AccessExecution.status = 'denied'` with this `errorCode` (§5.4). `invalid_request` is a frame the
228
+ * host could not read for any other reason, and `failed` an error inside the host; Kingdom records
229
+ * both as `failed`.
230
+ */
231
+ export const threadRefusalSchema = z.enum([
232
+ 'unknown_thread',
233
+ 'unknown_selector',
234
+ 'thread_closed',
235
+ 'floor_denied',
236
+ 'not_invited',
237
+ 'conversation_paused',
238
+ 'invalid_request',
239
+ 'failed',
240
+ ]);
241
+ export type ThreadRefusal = z.infer<typeof threadRefusalSchema>;
242
+ export const threadDeniedRefusals = [
243
+ 'unknown_thread',
244
+ 'unknown_selector',
245
+ 'thread_closed',
246
+ 'floor_denied',
247
+ 'not_invited',
248
+ 'conversation_paused',
249
+ ] as const satisfies readonly ThreadRefusal[];
250
+
251
+ /** The member a frame acts for: its Signet and the name Kingdom shows for its recipient. */
252
+ export const threadMemberSchema = z.strictObject({
253
+ signetId: z.uuid(),
254
+ displayName: z.string().min(1).max(200),
255
+ });
256
+
257
+ const relayed = {
258
+ /** The `AccessExecution` id: one per admitted call, and the id the answer carries back. */
259
+ requestId: z.uuid(),
260
+ signetId: z.uuid(),
261
+ /** The Signet revision Kingdom admitted the call under; the lens is that revision's. */
262
+ acceptedRevision: z.number().int().min(1),
263
+ member: threadMemberSchema,
264
+ lens: threadsLensSchema,
265
+ };
266
+ const requestFrame = <O extends ThreadOperation>(operation: O) =>
267
+ z.strictObject({
268
+ type: z.literal('thread.request'),
269
+ ...relayed,
270
+ operation: z.literal(operation),
271
+ input: threadInputSchemas[operation],
272
+ });
273
+
274
+ /**
275
+ * Kingdom → host over the Installation socket: one admitted call. The host trusts it (Kingdom's
276
+ * admission is the membership check) and answers with `thread.response` within 15 s.
277
+ */
278
+ export const threadRequestFrameSchema = z.discriminatedUnion('operation', [
279
+ requestFrame('thread.read'),
280
+ requestFrame('thread.prompt'),
281
+ requestFrame('thread.floor'),
282
+ requestFrame('prompt.resolve'),
283
+ ]);
284
+ export type ThreadRequestFrame = z.infer<typeof threadRequestFrameSchema>;
285
+
286
+ /**
287
+ * Kingdom → host: open a member's live view, atomically. The answer (`thread.response` with
288
+ * `requestId`) is the view and the `journalSeq` it was taken at, and the subscription starts at that
289
+ * same point. `requestId` is also the subscription's id for appends, leases and unsubscribe.
290
+ */
291
+ export const threadSubscribeFrameSchema = z.strictObject({
292
+ type: z.literal('thread.subscribe'),
293
+ ...relayed,
294
+ threadId: threadIdSchema,
295
+ });
296
+ export type ThreadSubscribeFrame = z.infer<typeof threadSubscribeFrameSchema>;
297
+
298
+ /** Kingdom → host: the member's stream closed (leave, revocation, any revision, …). */
299
+ export const threadUnsubscribeFrameSchema = z.strictObject({
300
+ type: z.literal('thread.unsubscribe'),
301
+ subscriptionId: z.uuid(),
302
+ });
303
+
304
+ /**
305
+ * Kingdom → host, on every 45 s re-authorize sweep: the subscriptions it still holds. The host drops
306
+ * any subscription not re-asserted within `threadSubscriptionLeaseMs`, and all of them when its
307
+ * socket reconnects, so a dead Kingdom node leaves nothing pushing.
308
+ */
309
+ export const threadLeaseFrameSchema = z.strictObject({
310
+ type: z.literal('thread.lease'),
311
+ subscriptionIds: z.array(z.uuid()).max(10_000),
312
+ });
313
+ /**
314
+ * Two sweeps from the last re-assertion: a subscription survives one late sweep and is dropped by the
315
+ * next missed one, so at most 45 s after a missed re-assertion (Foundry test 27).
316
+ */
317
+ export const threadSubscriptionLeaseMs = 90_000;
318
+
319
+ /** Every frame Kingdom sends a host for conversations. */
320
+ export const threadHostFrameSchema = z.discriminatedUnion('type', [
321
+ threadRequestFrameSchema,
322
+ threadSubscribeFrameSchema,
323
+ threadUnsubscribeFrameSchema,
324
+ threadLeaseFrameSchema,
325
+ ]);
326
+
327
+ const refusedOutcome = z.strictObject({
328
+ ok: z.literal(false),
329
+ refusal: threadRefusalSchema,
330
+ message: z.string().max(500).optional(),
331
+ });
332
+ const responseFrame = <R extends z.ZodType>(result: R) =>
333
+ z.strictObject({
334
+ action: z.literal('thread.response'),
335
+ requestId: z.uuid(),
336
+ outcome: z.discriminatedUnion('ok', [
337
+ z.strictObject({ ok: z.literal(true), result }),
338
+ refusedOutcome,
339
+ ]),
340
+ });
341
+
342
+ /** Host → Kingdom: the answer to a `thread.request` or `thread.subscribe`, read by what was asked. */
343
+ export const threadResponseFrameSchema = <O extends keyof typeof threadResultSchemas>(
344
+ operation: O,
345
+ ) => responseFrame(threadResultSchemas[operation]);
346
+ /** The response envelope before Kingdom knows which request it answers. */
347
+ export const threadResponseEnvelopeSchema = responseFrame(z.unknown());
348
+ export type ThreadOutcome<O extends keyof typeof threadResultSchemas> =
349
+ | { ok: true; result: ThreadResult<O> }
350
+ | { ok: false; refusal: ThreadRefusal; message?: string };
351
+ export type ThreadResponseFrame<O extends keyof typeof threadResultSchemas> = {
352
+ action: 'thread.response';
353
+ requestId: string;
354
+ outcome: ThreadOutcome<O>;
355
+ };
356
+
357
+ /** A host's answer: the operation's result. */
358
+ export const threadResult = <R>(result: R) => ({ ok: true as const, result });
359
+ /** A host's refusal. */
360
+ export const threadRefused = (refusal: ThreadRefusal, message?: string) => ({
361
+ ok: false as const,
362
+ refusal,
363
+ ...(message ? { message: message.slice(0, 500) } : {}),
364
+ });
365
+
366
+ /**
367
+ * Host → Kingdom: an entry for one subscription, already filtered to that member's view. `seq` counts
368
+ * this subscription's appends densely from 1 (the snapshot is 0): a filtered view skips journal
369
+ * entries, so `journalSeq` jumps on purpose and can't detect loss. Kingdom drops an append whose
370
+ * `journalSeq` is at or below the snapshot's, and sends the member `resync` when `seq` jumps. An
371
+ * entry the host can't send still spends its `seq`, so the member resyncs instead of missing it.
372
+ */
373
+ export const threadAppendFrameSchema = z.strictObject({
374
+ action: z.literal('thread.append'),
375
+ subscriptionId: z.uuid(),
376
+ signetId: z.uuid(),
377
+ threadId: threadIdSchema,
378
+ seq: z.number().int().min(1),
379
+ journalSeq: z.number().int().min(0),
380
+ entry: threadEntrySchema,
381
+ });
382
+ export type ThreadAppendFrame = z.infer<typeof threadAppendFrameSchema>;
383
+
384
+ /**
385
+ * A member's live view of one thread is a Kingdom data stream (design §5.6.1): open, snapshot,
386
+ * append, close, a per-stream `seq`, `resync` on a gap, no replay. Its family is the operationId of
387
+ * Kingdom's snapshot route `GET /api/v1/access/threads/{signetId}/{threadId}`, and a stream's name is
388
+ * that route's channel key: the family, then each path field and value, fields sorted.
389
+ */
390
+ export const accessThreadStreamFamily = 'accessThread';
391
+ export const accessThreadStreamName = (stream: { signetId: string; threadId: string }) =>
392
+ `${accessThreadStreamFamily}:signetId:${z.uuid().parse(stream.signetId)}:threadId:${threadIdSchema.parse(stream.threadId)}`;
393
+ export const parseAccessThreadStreamName = (name: string) => {
394
+ const [family, signetField, signetId, threadField, threadId, ...rest] = name.split(':');
395
+ if (
396
+ family !== accessThreadStreamFamily ||
397
+ signetField !== 'signetId' ||
398
+ threadField !== 'threadId'
399
+ )
400
+ return null;
401
+ const parsed = z
402
+ .object({ signetId: z.uuid(), threadId: threadIdSchema })
403
+ .safeParse({ signetId, threadId });
404
+ return parsed.success && rest.length === 0 ? parsed.data : null;
405
+ };
406
+ /** The snapshot is the route's body: Kingdom's `{ data }` envelope around the member's view. */
407
+ export const accessThreadSnapshotSchema = z.object({ data: threadViewResultSchema });
408
+ /** A `log` stream's one op: each append's payload is one `ThreadEntry`. */
409
+ export const accessThreadAppendType = 'append';
410
+
411
+ /** Why Kingdom closed a member's stream (sent on `closed` and `openRejected`). */
412
+ export const accessThreadCloseReasonSchema = z.enum([
413
+ /** The member closed it. */
414
+ 'left',
415
+ 'revoked',
416
+ /** Any revision closes the stream; the member reopens under the new lens. */
417
+ 'revised',
418
+ 'enrollmentRevoked',
419
+ 'expired',
420
+ 'hostOffline',
421
+ /** The 45 s re-authorize sweep no longer admits the stream. */
422
+ 'unauthorized',
423
+ /** No `renewPresentation` arrived before the socket's access token expired. */
424
+ 'presentationExpired',
425
+ ]);
426
+ export type AccessThreadCloseReason = z.infer<typeof accessThreadCloseReasonSchema>;