pi-roundtable-webchat 0.19.2 → 0.21.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/CHANGELOG.md +14 -0
- package/README.md +17 -3
- package/package.json +3 -3
- package/src/chat.ts +25 -2
- package/src/connections.ts +20 -4
- package/src/prompts.ts +222 -26
- package/src/protocol.ts +97 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.21.0] - 2026-10-20
|
|
9
|
+
|
|
10
|
+
- Release in lockstep with pi-roundtable 0.21.0. The core peer range moves to `>=0.21.0 <0.22.0`. The web chat honours `ApprovalOptions.speakerOnly` without further change: it shows prompts to the conversation's person only, and no owner is on it.
|
|
11
|
+
|
|
12
|
+
## [0.20.0] - 2026-10-18
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Questionnaires: `ready.prompts` advertises them, a client that sees it may send `prompt_features`, and a `questionnaire` prompt is sent only to connections that declared support covering it. The client answers with a new `answers` frame (`bad_frame` for a malformed or non-fitting answer, `forbidden` from another person). The protocol name and `ready.protocol` stay v1, so old clients and old servers keep working; a person whose tabs have not declared is asked one question at a time. A questionnaire that no open connection can show (the declaring tab closed or narrowed its declaration, or none was connected) waits 60 seconds for one to declare, then expires so the model is told it went unanswered and a re-ask uses the one-question fallback. The public `ClientFrame`, `ServerFrame` and `PromptFrame` types gain `answers`, `prompt_features`, `kind: "questionnaire"` and `ready.prompts`, so a TypeScript client that switches exhaustively on `kind` needs a new case. This needs `pi-roundtable` 0.20.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- An `answer` is checked by the core's shared `checkAnswer`; its behaviour is unchanged.
|
|
21
|
+
|
|
8
22
|
## [0.19.2] - 2026-10-18
|
|
9
23
|
|
|
10
24
|
- Release in lockstep with pi-roundtable 0.19.2. The core peer range stays `>=0.19.0 <0.20.0`.
|
package/README.md
CHANGED
|
@@ -224,6 +224,8 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
|
|
|
224
224
|
| `{ type: "stop", conversation }` | Stops the conversation's running turn. |
|
|
225
225
|
| `{ type: "approval", prompt, approved }` | Approves or declines an approval prompt. |
|
|
226
226
|
| `{ type: "answer", prompt, choices, text? }` | Answers a question prompt: the chosen options' labels, and your own text where the question allows it. |
|
|
227
|
+
| `{ type: "answers", prompt, answers, note? }` | Answers a `questionnaire` prompt: `answers` has one entry per question in order, each `{ choices, text? }` as for `answer`, or `null` for a question left blank (at least one answer or a `note` is needed); `note` is a note on the whole set. See [Questionnaires](#questionnaires). |
|
|
228
|
+
| `{ type: "prompt_features", ask }` | Declares what this connection can show of a questionnaire: `ask` is `{ questions, previews, note }` (`questions` 1 to 4). Send it only after a `ready` that has `prompts`; a server before 0.20 would refuse it as `bad_frame`. A connection that never declares is never sent a `questionnaire` prompt. |
|
|
227
229
|
| `{ type: "auth", token }` | A fresh token for the same person, sent after `reauth`. |
|
|
228
230
|
| `{ type: "client_hello", version: 1, tools }` | Declares the functions this connection can run for the assistant's tools, such as reading the page the person has open: up to 64 names, each lowercase words joined by underscores. Send it after each `ready`. See [Client calls](#client-calls). |
|
|
229
231
|
| `{ type: "client_result", call, ok: true, value, parts? }` or `{ type: "client_result", call, ok: false, code, message? }` | The answer to a `client_call`: the JSON `value` and how many binary `parts` were uploaded first, or a failure `code` (`refused`, `failed`, `timeout`, or `stopped`) with a short `message`. |
|
|
@@ -232,14 +234,14 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
|
|
|
232
234
|
|
|
233
235
|
| Frame | Meaning |
|
|
234
236
|
|---|---|
|
|
235
|
-
| `{ type: "ready", protocol, speaker, personas, attachments, clientCalls, expiresAt }` | Sent first, and again after a fresh token: who you are (`{ id, name, tier, principalId }`), the personas you may open (`{ kind, label }`), what an upload may be (`attachments`: `{ maxBytes, perMessage, types }`), what a client call may take (`clientCalls`, below; absent from a server before 0.9.11, which never sends calls), and when the token expires. Open prompts follow it
|
|
237
|
+
| `{ type: "ready", protocol, speaker, personas, attachments, clientCalls, prompts?, expiresAt }` | Sent first, and again after a fresh token: who you are (`{ id, name, tier, principalId }`), the personas you may open (`{ kind, label }`), what an upload may be (`attachments`: `{ maxBytes, perMessage, types }`), what a client call may take (`clientCalls`, below; absent from a server before 0.9.11, which never sends calls), what questionnaires it can send (`prompts`: `{ ask: { questions, previews, note } }`; absent from a server before 0.20, so send no `prompt_features` then), and when the token expires. Open prompts follow it; a `questionnaire` follows only to a connection that already declared (on a reauth `ready`), and a fresh connection gets it after its `prompt_features`. |
|
|
236
238
|
| `{ type: "accepted", id, conversation }` | Your message `id` was taken into `conversation`, a new one when you named none. |
|
|
237
239
|
| `{ type: "typing", conversation, on }` | The assistant is, or is no longer, working in the conversation. |
|
|
238
240
|
| `{ type: "stoppable", conversation, on }` | A stop applies, or no longer applies. |
|
|
239
241
|
| `{ type: "progress", conversation, event }` | What the running turn writes and which tools it runs: `{ type: "text", delta }`, `{ type: "tool_start", id, tool, preview? }`, or `{ type: "tool_end", id, tool, ok, refused? }`. Never the thinking, never a tool's full arguments. A `tool_end` that a hold refused (not a real failure) has `ok: false` and `refused`: `declined` (turned down on its approval card), `expired` (a card was shown and went unanswered; the call is held for the next message), `pending` (the card is still open), `withdrawn` (the plugin that held the call took it back) or `held` (no card could be shown: the person's tier is below the call's, or it could not be posted; the call is held for the next message). `refused` is absent for a success and for a real failure. |
|
|
240
242
|
| `{ type: "reply", conversation, text, thinking?, files? }` | The turn's answer in full markdown, with its files inline as `{ name, data }` (base64). |
|
|
241
243
|
| `{ type: "failed", conversation, stopped }` | The turn ended without an answer: it failed, the host refused it before it ran (for example when its conversation could not be recorded), or it was stopped. The cause stays in the host's log. |
|
|
242
|
-
| `{ type: "prompt", conversation, prompt }` | The turn asks you: `{ id, kind: "approval", title, message, approval? }`,
|
|
244
|
+
| `{ type: "prompt", conversation, prompt }` | The turn asks you: `{ id, kind: "approval", title, message, approval? }`, `{ id, kind: "ask", title, question, options, multi, allowOther }`, or `{ id, kind: "questionnaire", title, questions }` (only to connections that declared `prompt_features`; see [Questionnaires](#questionnaires)). |
|
|
243
245
|
| `{ type: "prompt_closed", conversation, prompt, outcome, note? }` | The prompt closed: `approved`, `declined`, `answered`, `expired`, `cancelled` (the turn stopped), or `withdrawn` (the plugin that asked took the approval back; `note` may carry its reason in a few words). |
|
|
244
246
|
| `{ type: "notice", notice: { id, text, createdAt, readAt } }` | A durable private inbox entry; `readAt` is `null` until read. Fetch the REST inbox to recover entries missed while offline. |
|
|
245
247
|
| `{ type: "reauth", expiresAt }` | Your token expires soon: send `auth` with a fresh one. |
|
|
@@ -263,7 +265,7 @@ approval?: {
|
|
|
263
265
|
|
|
264
266
|
The frame's size is bounded: when `approval` serialized as UTF-8 JSON would pass 256 KiB (`APPROVAL_MAX_BYTES` in `protocol.ts`), every string value of `input` longer than what fits is cut to its start plus `… [N chars]` (N counts code points; a cut never splits a surrogate pair) and `approval.truncated` is `true`. Then `input` is no longer whole: show `message`, or tell the person it is cut. If even the shortest cut cannot fit, `input` is `{}` and `files` are left out.
|
|
265
267
|
|
|
266
|
-
Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an answer the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), `unknown_attachment` (a `send` named a file that is not waiting for you in that conversation: never uploaded, uploaded to another conversation, already used by a message, or deleted after its time; the whole message is refused and nothing is run), `attachment_quota` (the files would take what your messages keep past `usedAttachmentBytesPerPrincipal`; the whole message is refused and the files stay waiting), `unknown_call` (a `client_result` named a call that is not open on this connection: it ended, was answered already, or was sent to another connection), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
|
|
268
|
+
Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an `answer` or `answers` the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), `unknown_attachment` (a `send` named a file that is not waiting for you in that conversation: never uploaded, uploaded to another conversation, already used by a message, or deleted after its time; the whole message is refused and nothing is run), `attachment_quota` (the files would take what your messages keep past `usedAttachmentBytesPerPrincipal`; the whole message is refused and the files stay waiting), `unknown_call` (a `client_result` named a call that is not open on this connection: it ended, was answered already, or was sent to another connection), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
|
|
267
269
|
|
|
268
270
|
Close codes: `4401` when the token expired without a fresh `auth`, or a fresh one was refused; `4403` when the person is no longer admitted, or a fresh token names someone else.
|
|
269
271
|
The host's own limits close with `1008` (too many frames), `1009` (a frame too big), and `1006` (a client that stopped reading).
|
|
@@ -336,6 +338,18 @@ This needs a pi-roundtable release that has `attachments.retention`; on an earli
|
|
|
336
338
|
Uploads are as private as the transcript: files sit in the host's data directory under the private conversation, where the owner and the operator can read them, as they can the transcript.
|
|
337
339
|
Only the person who uploaded a file can use it, and only in the conversation it was uploaded to; a shared conversation (one recorded for no one in particular) takes no uploads.
|
|
338
340
|
|
|
341
|
+
## Questionnaires
|
|
342
|
+
|
|
343
|
+
A turn may ask up to four related questions on one card. The protocol name and `ready.protocol` stay `roundtable.webchat.v1`; the change is additive:
|
|
344
|
+
|
|
345
|
+
1. A server from 0.20 adds `prompts: { ask: { questions: 4, previews: true, note: true } }` to `ready`. A client that sees it may send `prompt_features` with what it can show. A client that does not see it sends nothing new.
|
|
346
|
+
2. The server sends a `prompt` with `kind: "questionnaire"`, `title`, and `questions` (each `{ question, header?, options, multi, allowOther }`, an option being `{ label, description?, preview? }`), **only to connections that declared support covering it**. A client that never declared never receives one, while connected, after a reconnect, or beside a tab that did declare. On a reconnect the open questionnaire is sent after the declaration, not with the other open prompts.
|
|
347
|
+
3. What the assistant may ask is the least of the person's declaring tabs. When no tab declared, the assistant asks one question at a time through the `ask` prompt, as before.
|
|
348
|
+
4. The client answers with `answers`. `bad_frame` for a frame that does not parse or an answer the questions do not allow, `forbidden` from someone other than the conversation's person.
|
|
349
|
+
5. A questionnaire that no open connection can show settles `expired` (a `prompt_closed` to the connections it was sent to) after 60 seconds without a covering declaration: it settles unanswered, the model is told, and if it asks again it gets the one-at-a-time fallback.
|
|
350
|
+
|
|
351
|
+
A client must ignore a prompt `kind` it does not know.
|
|
352
|
+
|
|
339
353
|
## Client calls
|
|
340
354
|
|
|
341
355
|
A tool may need something only the person's own client can do, such as a browser extension reading the page they have open.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-webchat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "A WebSocket chat adapter for pi-roundtable: private web conversations for people an OpenID Connect provider signs in, with live progress and approval cards",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,14 +38,14 @@
|
|
|
38
38
|
"jose": "6.2.12"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"pi-roundtable": ">=0.
|
|
41
|
+
"pi-roundtable": ">=0.21.0 <0.22.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@biomejs/biome": "2.5.15",
|
|
45
45
|
"@earendil-works/pi-ai": ">=1.0.0 <2",
|
|
46
46
|
"@earendil-works/pi-coding-agent": ">=1.0.0 <2",
|
|
47
47
|
"@types/bun": "1.4.3",
|
|
48
|
-
"pi-roundtable": "0.
|
|
48
|
+
"pi-roundtable": "0.21.0",
|
|
49
49
|
"typebox": "1.3.37",
|
|
50
50
|
"typescript": "7.0.2"
|
|
51
51
|
}
|
package/src/chat.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
} from "./oidc.ts";
|
|
33
33
|
import { PromptDesk } from "./prompts.ts";
|
|
34
34
|
import {
|
|
35
|
+
ADVERTISED_ASK,
|
|
35
36
|
CLOSE_CODES,
|
|
36
37
|
type ClientFrame,
|
|
37
38
|
type ErrorCode,
|
|
@@ -85,6 +86,8 @@ export interface WebChatLimits extends UploadLimits {
|
|
|
85
86
|
|
|
86
87
|
export interface WebChatDeps {
|
|
87
88
|
surface: string;
|
|
89
|
+
/** How long a questionnaire no connection can show waits for one before it expires; default 60 seconds. */
|
|
90
|
+
promptGraceMs?: number;
|
|
88
91
|
verifier: TokenVerifier;
|
|
89
92
|
identity(): IdentityService;
|
|
90
93
|
personas: readonly WebPersona[];
|
|
@@ -240,7 +243,14 @@ export class WebChat {
|
|
|
240
243
|
this.connections.sendTo(principal, frame);
|
|
241
244
|
this.desk = new PromptDesk({
|
|
242
245
|
send,
|
|
246
|
+
sendWhere: (principal, frame, where) =>
|
|
247
|
+
this.connections.sendTo(principal, frame, where),
|
|
248
|
+
supports: (principal) => this.connections.askSupports(principal),
|
|
249
|
+
sendOne: (connection, frame) => this.connections.send(connection, frame),
|
|
243
250
|
timeoutMs: deps.limits.promptTimeoutMs,
|
|
251
|
+
...(deps.promptGraceMs === undefined
|
|
252
|
+
? {}
|
|
253
|
+
: { graceMs: deps.promptGraceMs }),
|
|
244
254
|
});
|
|
245
255
|
this.clients = new ClientDesk({
|
|
246
256
|
limits: deps.limits.clientCalls,
|
|
@@ -436,9 +446,13 @@ export class WebChat {
|
|
|
436
446
|
personas: this.personasFor(speaker),
|
|
437
447
|
attachments: this.uploads.advertised,
|
|
438
448
|
clientCalls: this.clients.advertised,
|
|
449
|
+
prompts: { ask: ADVERTISED_ASK },
|
|
439
450
|
expiresAt: identity.expiresAt.toISOString(),
|
|
440
451
|
});
|
|
441
|
-
for (const frame of this.desk.openFor(
|
|
452
|
+
for (const frame of this.desk.openFor(
|
|
453
|
+
speaker.principalId,
|
|
454
|
+
connection.askSupport,
|
|
455
|
+
))
|
|
442
456
|
this.connections.send(connection, frame);
|
|
443
457
|
this.#watchToken(connection);
|
|
444
458
|
}
|
|
@@ -477,6 +491,7 @@ export class WebChat {
|
|
|
477
491
|
closed(socket: RouteSocket<Connection>): void {
|
|
478
492
|
this.clients.closed(socket.data);
|
|
479
493
|
this.connections.closed(socket.data);
|
|
494
|
+
this.desk.closed(socket.data.speaker.principalId);
|
|
480
495
|
}
|
|
481
496
|
|
|
482
497
|
/** One frame from a socket; a refused frame is answered with `error`, never thrown. */
|
|
@@ -497,7 +512,9 @@ export class WebChat {
|
|
|
497
512
|
const ref =
|
|
498
513
|
frame.type === "send"
|
|
499
514
|
? frame.id
|
|
500
|
-
: frame.type === "approval" ||
|
|
515
|
+
: frame.type === "approval" ||
|
|
516
|
+
frame.type === "answer" ||
|
|
517
|
+
frame.type === "answers"
|
|
501
518
|
? frame.prompt
|
|
502
519
|
: frame.type === "client_result"
|
|
503
520
|
? frame.call
|
|
@@ -550,6 +567,12 @@ export class WebChat {
|
|
|
550
567
|
if (refused) throw new Refusal(refused);
|
|
551
568
|
return;
|
|
552
569
|
}
|
|
570
|
+
case "prompt_features":
|
|
571
|
+
case "answers": {
|
|
572
|
+
const refused = this.desk.receive(connection, speaker, frame);
|
|
573
|
+
if (refused) throw new Refusal(refused);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
553
576
|
case "client_hello":
|
|
554
577
|
connection.clientTools = frame.tools;
|
|
555
578
|
return;
|
package/src/connections.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Logger, RouteSocket, Speaker } from "pi-roundtable";
|
|
1
|
+
import type { AskSupport, Logger, RouteSocket, Speaker } from "pi-roundtable";
|
|
2
2
|
import type { WebIdentity } from "./oidc.ts";
|
|
3
3
|
import type { ServerFrame } from "./protocol.ts";
|
|
4
4
|
|
|
@@ -18,6 +18,11 @@ export interface Connection {
|
|
|
18
18
|
* never asked to run anything.
|
|
19
19
|
*/
|
|
20
20
|
clientTools?: readonly string[];
|
|
21
|
+
/**
|
|
22
|
+
* What the client declared with `prompt_features` it can show of a questionnaire; undefined
|
|
23
|
+
* until it declares. Such a connection is never sent a `questionnaire` prompt.
|
|
24
|
+
*/
|
|
25
|
+
askSupport?: AskSupport;
|
|
21
26
|
socket?: RouteSocket<Connection>;
|
|
22
27
|
/** Asks for a fresh token, then closes the socket when the token expires. */
|
|
23
28
|
timers: ReturnType<typeof setTimeout>[];
|
|
@@ -103,9 +108,20 @@ export class Connections {
|
|
|
103
108
|
);
|
|
104
109
|
}
|
|
105
110
|
|
|
106
|
-
/** Sends a frame to every connection of a person; none open is not an error. */
|
|
107
|
-
sendTo(
|
|
111
|
+
/** Sends a frame to every connection of a person, or those `where` accepts; none open is not an error. */
|
|
112
|
+
sendTo(
|
|
113
|
+
principal: string,
|
|
114
|
+
frame: ServerFrame,
|
|
115
|
+
where?: (connection: Connection) => boolean,
|
|
116
|
+
): void {
|
|
108
117
|
for (const connection of this.#byPrincipal.get(principal) ?? [])
|
|
109
|
-
this.send(connection, frame);
|
|
118
|
+
if (!where || where(connection)) this.send(connection, frame);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What each open connection of a person declared it can show of a questionnaire; connections that declared nothing are left out. */
|
|
122
|
+
askSupports(principal: string): AskSupport[] {
|
|
123
|
+
return [...(this.#byPrincipal.get(principal) ?? [])].flatMap((c) =>
|
|
124
|
+
c.askSupport ? [c.askSupport] : [],
|
|
125
|
+
);
|
|
110
126
|
}
|
|
111
127
|
}
|
package/src/prompts.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type Approval,
|
|
3
3
|
type ApprovalDetails,
|
|
4
|
+
type AskSupport,
|
|
5
|
+
checkAnswer,
|
|
6
|
+
checkQuestionnaireAnswer,
|
|
4
7
|
type OwnerAnswer,
|
|
5
8
|
type OwnerQuestion,
|
|
6
9
|
type PromptScope,
|
|
7
10
|
type Prompts,
|
|
11
|
+
type Questionnaire,
|
|
12
|
+
type QuestionnaireAnswer,
|
|
8
13
|
TIERS,
|
|
9
14
|
type Tier,
|
|
10
15
|
withdrawalNote,
|
|
11
16
|
} from "pi-roundtable";
|
|
17
|
+
import type { Connection } from "./connections.ts";
|
|
12
18
|
import {
|
|
13
19
|
APPROVAL_MAX_BYTES,
|
|
20
|
+
type ClientFrame,
|
|
14
21
|
type ErrorCode,
|
|
15
22
|
type PromptFrame,
|
|
16
23
|
type PromptOutcome,
|
|
@@ -29,16 +36,38 @@ interface Open {
|
|
|
29
36
|
frame: PromptFrame;
|
|
30
37
|
/** The lowest tier that may approve; questions have none. */
|
|
31
38
|
minTier?: Tier;
|
|
32
|
-
|
|
39
|
+
/** A questionnaire: the connections it was sent to, which are the only ones told it closed. */
|
|
40
|
+
sentTo?: Set<Connection>;
|
|
41
|
+
/** A questionnaire no connection can show waits this long for one to, then expires. */
|
|
42
|
+
grace?: ReturnType<typeof setTimeout>;
|
|
43
|
+
settle(
|
|
44
|
+
outcome: PromptOutcome,
|
|
45
|
+
answer?: OwnerAnswer | QuestionnaireAnswer,
|
|
46
|
+
note?: string,
|
|
47
|
+
): void;
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
export interface PromptDeskOptions {
|
|
36
51
|
/** Sends a frame to every connection of a person. */
|
|
37
52
|
send(principal: string, frame: ServerFrame): void;
|
|
53
|
+
/** Sends a frame to the connections of a person that `where` accepts. */
|
|
54
|
+
sendWhere(
|
|
55
|
+
principal: string,
|
|
56
|
+
frame: ServerFrame,
|
|
57
|
+
where: (connection: Connection) => boolean,
|
|
58
|
+
): void;
|
|
59
|
+
/** What the open connections of a person declared they can show of a questionnaire. */
|
|
60
|
+
supports(principal: string): AskSupport[];
|
|
61
|
+
/** Sends a frame on one connection. */
|
|
62
|
+
sendOne(connection: Connection, frame: ServerFrame): void;
|
|
38
63
|
/** An unanswered prompt expires after this long. */
|
|
39
64
|
timeoutMs: number;
|
|
65
|
+
/** How long a questionnaire that no connection can show waits for one (default 60 seconds). */
|
|
66
|
+
graceMs?: number;
|
|
40
67
|
}
|
|
41
68
|
|
|
69
|
+
const GRACE_MS = 60_000;
|
|
70
|
+
|
|
42
71
|
const atLeast = (tier: Tier, least: Tier) =>
|
|
43
72
|
TIERS.indexOf(tier) >= TIERS.indexOf(least);
|
|
44
73
|
|
|
@@ -62,6 +91,7 @@ export class PromptDesk {
|
|
|
62
91
|
* a private conversation, so the call stays held, as an unanswered card leaves it.
|
|
63
92
|
*/
|
|
64
93
|
prompts(conversation: string, scope: PromptScope): Prompts {
|
|
94
|
+
const desk = this;
|
|
65
95
|
return {
|
|
66
96
|
confirm: async (
|
|
67
97
|
title,
|
|
@@ -91,7 +121,7 @@ export class PromptDesk {
|
|
|
91
121
|
return toApproval(outcome.outcome);
|
|
92
122
|
},
|
|
93
123
|
ask: async (title, question, signal) => {
|
|
94
|
-
const { outcome, answer } = await this.#ask(
|
|
124
|
+
const { outcome, answer } = await this.#ask<OwnerAnswer>(
|
|
95
125
|
conversation,
|
|
96
126
|
scope.principalId,
|
|
97
127
|
(id) => questionFrame(id, title, question),
|
|
@@ -99,13 +129,63 @@ export class PromptDesk {
|
|
|
99
129
|
);
|
|
100
130
|
return outcome === "answered" ? answer : undefined;
|
|
101
131
|
},
|
|
132
|
+
// The minimum of what the person's declaring panels show; read when asked, so a panel
|
|
133
|
+
// that connects mid-turn counts. None declared means the 0.19 contract.
|
|
134
|
+
get asks() {
|
|
135
|
+
return desk.#asksFor(scope.principalId);
|
|
136
|
+
},
|
|
137
|
+
get questionnaire() {
|
|
138
|
+
const asks = desk.#asksFor(scope.principalId);
|
|
139
|
+
if (!asks || (asks.questions < 2 && !asks.previews)) return undefined;
|
|
140
|
+
return async (
|
|
141
|
+
title: string,
|
|
142
|
+
questionnaire: Questionnaire,
|
|
143
|
+
signal?: AbortSignal,
|
|
144
|
+
) => {
|
|
145
|
+
const { outcome, answer } = await desk.#ask<QuestionnaireAnswer>(
|
|
146
|
+
conversation,
|
|
147
|
+
scope.principalId,
|
|
148
|
+
(id) => ({
|
|
149
|
+
id,
|
|
150
|
+
kind: "questionnaire",
|
|
151
|
+
title,
|
|
152
|
+
questions: questionnaire.questions.map((q) => ({
|
|
153
|
+
...q,
|
|
154
|
+
options: q.options.map((option) => ({ ...option })),
|
|
155
|
+
})),
|
|
156
|
+
}),
|
|
157
|
+
signal,
|
|
158
|
+
);
|
|
159
|
+
return outcome === "answered" ? answer : undefined;
|
|
160
|
+
};
|
|
161
|
+
},
|
|
102
162
|
};
|
|
103
163
|
}
|
|
104
164
|
|
|
105
|
-
/** The
|
|
106
|
-
|
|
165
|
+
/** The field-wise minimum of the person's declared supports; undefined when none declared. */
|
|
166
|
+
#asksFor(principal: string): AskSupport | undefined {
|
|
167
|
+
const supports = this.#options.supports(principal);
|
|
168
|
+
const [first, ...rest] = supports;
|
|
169
|
+
if (!first) return undefined;
|
|
170
|
+
return rest.reduce<AskSupport>(
|
|
171
|
+
(least, s) => ({
|
|
172
|
+
questions: Math.min(least.questions, s.questions),
|
|
173
|
+
previews: least.previews && s.previews,
|
|
174
|
+
note: least.note && s.note,
|
|
175
|
+
}),
|
|
176
|
+
first,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The open prompts of a person, as frames to send again when they connect. A questionnaire is
|
|
182
|
+
* left out unless the connection declared `support` that covers it.
|
|
183
|
+
*/
|
|
184
|
+
openFor(principal: string, support?: AskSupport): ServerFrame[] {
|
|
107
185
|
return [...this.#open.values()]
|
|
108
|
-
.filter(
|
|
186
|
+
.filter(
|
|
187
|
+
(open) => open.principal === principal && showable(open.frame, support),
|
|
188
|
+
)
|
|
109
189
|
.map((open) => ({
|
|
110
190
|
type: "prompt",
|
|
111
191
|
conversation: open.conversation,
|
|
@@ -113,6 +193,34 @@ export class PromptDesk {
|
|
|
113
193
|
}));
|
|
114
194
|
}
|
|
115
195
|
|
|
196
|
+
/**
|
|
197
|
+
* A connection of the person closed. A questionnaire that no remaining connection can show
|
|
198
|
+
* would leave the turn waiting for an answer no one can give, so it gets a grace period for a
|
|
199
|
+
* panel to reconnect and declare, then expires and the model is told it went unanswered.
|
|
200
|
+
*/
|
|
201
|
+
closed(principal: string): void {
|
|
202
|
+
this.#regrace(principal);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Starts or cancels the grace timer of each open questionnaire of a person. */
|
|
206
|
+
#regrace(principal: string): void {
|
|
207
|
+
const supports = this.#options.supports(principal);
|
|
208
|
+
for (const open of this.#open.values()) {
|
|
209
|
+
if (open.principal !== principal || open.frame.kind !== "questionnaire")
|
|
210
|
+
continue;
|
|
211
|
+
const frame = open.frame;
|
|
212
|
+
if (supports.some((s) => showable(frame, s))) {
|
|
213
|
+
clearTimeout(open.grace);
|
|
214
|
+
open.grace = undefined;
|
|
215
|
+
} else if (!open.grace) {
|
|
216
|
+
open.grace = setTimeout(
|
|
217
|
+
() => open.settle("expired"),
|
|
218
|
+
this.#options.graceMs ?? GRACE_MS,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
116
224
|
/** Approves or declines; an error code when the prompt is not an approval this person may answer. */
|
|
117
225
|
approve(
|
|
118
226
|
from: Answerer,
|
|
@@ -136,12 +244,74 @@ export class PromptDesk {
|
|
|
136
244
|
const open = this.#open.get(prompt);
|
|
137
245
|
if (open?.frame.kind !== "ask") return "unknown_prompt";
|
|
138
246
|
if (open.principal !== from.principalId) return "forbidden";
|
|
139
|
-
if (!
|
|
247
|
+
if (!checkAnswer(open.frame, answer)) return "bad_frame";
|
|
248
|
+
open.settle("answered", answer);
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Takes the frames that concern questionnaires: `prompt_features` declares what the connection
|
|
254
|
+
* shows and sends it the open questionnaires it can now show, and `answers` answers one.
|
|
255
|
+
* An error code when the frame is refused.
|
|
256
|
+
*/
|
|
257
|
+
receive(
|
|
258
|
+
connection: Connection,
|
|
259
|
+
from: Answerer,
|
|
260
|
+
frame: Extract<ClientFrame, { type: "prompt_features" | "answers" }>,
|
|
261
|
+
): ErrorCode | undefined {
|
|
262
|
+
if (frame.type === "answers") return this.#answers(from, frame);
|
|
263
|
+
const before = connection.askSupport;
|
|
264
|
+
connection.askSupport = frame.ask;
|
|
265
|
+
const changed =
|
|
266
|
+
!before ||
|
|
267
|
+
before.questions !== frame.ask.questions ||
|
|
268
|
+
before.previews !== frame.ask.previews ||
|
|
269
|
+
before.note !== frame.ask.note;
|
|
270
|
+
// A repeated declaration (each `ready` asks for one) does not send what is already shown.
|
|
271
|
+
if (changed)
|
|
272
|
+
for (const open of this.#open.values())
|
|
273
|
+
if (
|
|
274
|
+
open.principal === from.principalId &&
|
|
275
|
+
open.sentTo &&
|
|
276
|
+
showable(open.frame, frame.ask) &&
|
|
277
|
+
!(open.sentTo.has(connection) && showable(open.frame, before))
|
|
278
|
+
) {
|
|
279
|
+
open.sentTo.add(connection);
|
|
280
|
+
this.#options.sendOne(connection, {
|
|
281
|
+
type: "prompt",
|
|
282
|
+
conversation: open.conversation,
|
|
283
|
+
prompt: open.frame,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
this.#regrace(from.principalId);
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#answers(
|
|
291
|
+
from: Answerer,
|
|
292
|
+
frame: Extract<ClientFrame, { type: "answers" }>,
|
|
293
|
+
): ErrorCode | undefined {
|
|
294
|
+
const open = this.#open.get(frame.prompt);
|
|
295
|
+
if (open?.frame.kind !== "questionnaire") return "unknown_prompt";
|
|
296
|
+
if (open.principal !== from.principalId) return "forbidden";
|
|
297
|
+
const answer: QuestionnaireAnswer = {
|
|
298
|
+
answers: frame.answers.map((a) =>
|
|
299
|
+
a === null || !(a.choices.length > 0 || a.text?.trim())
|
|
300
|
+
? undefined
|
|
301
|
+
: {
|
|
302
|
+
choices: a.choices,
|
|
303
|
+
...(a.text?.trim() ? { text: a.text.trim() } : {}),
|
|
304
|
+
},
|
|
305
|
+
),
|
|
306
|
+
...(frame.note?.trim() ? { note: frame.note.trim() } : {}),
|
|
307
|
+
};
|
|
308
|
+
if (!checkQuestionnaireAnswer({ questions: open.frame.questions }, answer))
|
|
309
|
+
return "bad_frame";
|
|
140
310
|
open.settle("answered", answer);
|
|
141
311
|
return undefined;
|
|
142
312
|
}
|
|
143
313
|
|
|
144
|
-
#ask(
|
|
314
|
+
#ask<A extends OwnerAnswer | QuestionnaireAnswer = OwnerAnswer>(
|
|
145
315
|
conversation: string,
|
|
146
316
|
principal: string,
|
|
147
317
|
frameOf: (id: string) => PromptFrame,
|
|
@@ -149,13 +319,22 @@ export class PromptDesk {
|
|
|
149
319
|
minTier?: Tier,
|
|
150
320
|
supersede?: AbortSignal,
|
|
151
321
|
approvedPrompt?: (prompt: string) => void,
|
|
152
|
-
): Promise<{ outcome: PromptOutcome; answer?:
|
|
322
|
+
): Promise<{ outcome: PromptOutcome; answer?: A }> {
|
|
153
323
|
const { send, timeoutMs } = this.#options;
|
|
154
324
|
if (signal?.aborted || supersede?.aborted)
|
|
155
325
|
return Promise.resolve({ outcome: "cancelled" });
|
|
156
326
|
return new Promise((resolve) => {
|
|
157
327
|
const id = crypto.randomUUID();
|
|
158
328
|
const frame = frameOf(id);
|
|
329
|
+
// A questionnaire reaches only the connections that declared support covering it.
|
|
330
|
+
const sentTo = new Set<Connection>();
|
|
331
|
+
const sendQuestionnaire = (out: ServerFrame) =>
|
|
332
|
+
this.#options.sendWhere(principal, out, (connection) => {
|
|
333
|
+
if (out.type === "prompt_closed") return sentTo.has(connection);
|
|
334
|
+
const shows = showable(frame, connection.askSupport);
|
|
335
|
+
if (shows) sentTo.add(connection);
|
|
336
|
+
return shows;
|
|
337
|
+
});
|
|
159
338
|
const onAbort = () => settle("cancelled");
|
|
160
339
|
const timer = setTimeout(() => settle("expired"), timeoutMs);
|
|
161
340
|
const onSupersede = () =>
|
|
@@ -163,35 +342,50 @@ export class PromptDesk {
|
|
|
163
342
|
settle("withdrawn", undefined, withdrawalNote(supersede, ""));
|
|
164
343
|
const settle = (
|
|
165
344
|
outcome: PromptOutcome,
|
|
166
|
-
answer?: OwnerAnswer,
|
|
345
|
+
answer?: OwnerAnswer | QuestionnaireAnswer,
|
|
167
346
|
note?: string,
|
|
168
347
|
) => {
|
|
169
348
|
// Whichever of an answer and a withdrawal comes first settles the prompt.
|
|
170
|
-
|
|
349
|
+
const open = this.#open.get(id);
|
|
350
|
+
if (!open || !this.#open.delete(id)) return;
|
|
351
|
+
clearTimeout(open.grace);
|
|
171
352
|
// Heard before the approval resolves, so the call that runs on it can say which card it was.
|
|
172
353
|
if (outcome === "approved") approvedPrompt?.(id);
|
|
173
354
|
clearTimeout(timer);
|
|
174
355
|
signal?.removeEventListener("abort", onAbort);
|
|
175
356
|
supersede?.removeEventListener("abort", onSupersede);
|
|
176
|
-
|
|
357
|
+
const closed: ServerFrame = {
|
|
177
358
|
type: "prompt_closed",
|
|
178
359
|
conversation,
|
|
179
360
|
prompt: id,
|
|
180
361
|
outcome,
|
|
181
362
|
...(note ? { note } : {}),
|
|
182
|
-
}
|
|
183
|
-
|
|
363
|
+
};
|
|
364
|
+
if (frame.kind === "questionnaire") sendQuestionnaire(closed);
|
|
365
|
+
else send(principal, closed);
|
|
366
|
+
resolve(answer ? { outcome, answer: answer as A } : { outcome });
|
|
184
367
|
};
|
|
185
368
|
this.#open.set(id, {
|
|
186
369
|
conversation,
|
|
187
370
|
principal,
|
|
188
371
|
frame,
|
|
189
372
|
...(minTier ? { minTier } : {}),
|
|
373
|
+
...(frame.kind === "questionnaire" ? { sentTo } : {}),
|
|
190
374
|
settle,
|
|
191
375
|
});
|
|
192
376
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
193
377
|
supersede?.addEventListener("abort", onSupersede, { once: true });
|
|
194
|
-
|
|
378
|
+
const shown: ServerFrame = {
|
|
379
|
+
type: "prompt",
|
|
380
|
+
conversation,
|
|
381
|
+
prompt: frame,
|
|
382
|
+
};
|
|
383
|
+
if (frame.kind === "questionnaire") {
|
|
384
|
+
sendQuestionnaire(shown);
|
|
385
|
+
// Sent to no one: wait the grace for a panel that can show it, never the full timeout.
|
|
386
|
+
// With no tab connected at all this waits too, so a reconnect can still rescue it.
|
|
387
|
+
this.#regrace(principal);
|
|
388
|
+
} else send(principal, shown);
|
|
195
389
|
});
|
|
196
390
|
}
|
|
197
391
|
}
|
|
@@ -308,17 +502,19 @@ function questionFrame(
|
|
|
308
502
|
};
|
|
309
503
|
}
|
|
310
504
|
|
|
311
|
-
/**
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
505
|
+
/**
|
|
506
|
+
* Whether a connection with `support` may be sent the prompt: every prompt but a questionnaire,
|
|
507
|
+
* which only a connection that declared support covering its questions and previews may have.
|
|
508
|
+
*/
|
|
509
|
+
function showable(
|
|
510
|
+
frame: PromptFrame,
|
|
511
|
+
support: AskSupport | undefined,
|
|
315
512
|
): boolean {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
return answer.choices.length > 0 || Boolean(text);
|
|
513
|
+
if (frame.kind !== "questionnaire") return true;
|
|
514
|
+
if (!support) return false;
|
|
515
|
+
if (frame.questions.length > support.questions) return false;
|
|
516
|
+
const previews = frame.questions.some((q) =>
|
|
517
|
+
q.options.some((o) => o.preview),
|
|
518
|
+
);
|
|
519
|
+
return support.previews || !previews;
|
|
324
520
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
ApprovalDetails,
|
|
3
|
-
AskOption,
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import {
|
|
2
|
+
type ApprovalDetails,
|
|
3
|
+
type AskOption,
|
|
4
|
+
type AskSupport,
|
|
5
|
+
type OwnerAnswer,
|
|
6
|
+
type OwnerQuestion,
|
|
7
|
+
QUESTIONNAIRE_LIMITS,
|
|
8
|
+
type Tier,
|
|
9
|
+
type TurnProgress,
|
|
6
10
|
} from "pi-roundtable";
|
|
7
11
|
import type { Notice } from "./notices.ts";
|
|
8
12
|
|
|
@@ -70,8 +74,22 @@ export type PromptFrame =
|
|
|
70
74
|
options: readonly AskOption[];
|
|
71
75
|
multi: boolean;
|
|
72
76
|
allowOther: boolean;
|
|
77
|
+
}
|
|
78
|
+
| {
|
|
79
|
+
id: string;
|
|
80
|
+
kind: "questionnaire";
|
|
81
|
+
title: string;
|
|
82
|
+
/** The core's questions, verbatim; sent only to connections that declared `prompt_features` covering them. */
|
|
83
|
+
questions: readonly OwnerQuestion[];
|
|
73
84
|
};
|
|
74
85
|
|
|
86
|
+
/** What a server that takes `prompt_features` advertises in `ready.prompts` and can send. */
|
|
87
|
+
export const ADVERTISED_ASK: AskSupport = Object.freeze({
|
|
88
|
+
questions: QUESTIONNAIRE_LIMITS.questions,
|
|
89
|
+
previews: true,
|
|
90
|
+
note: true,
|
|
91
|
+
});
|
|
92
|
+
|
|
75
93
|
/**
|
|
76
94
|
* How a prompt closed. `withdrawn` is an approval the plugin that asked for it took back; the
|
|
77
95
|
* frame may carry a short `note` saying why.
|
|
@@ -163,6 +181,21 @@ export type ClientFrame =
|
|
|
163
181
|
| { type: "approval"; prompt: string; approved: boolean }
|
|
164
182
|
/** Answers a question prompt: the chosen options' labels and, where allowed, their own text. */
|
|
165
183
|
| { type: "answer"; prompt: string; choices: string[]; text?: string }
|
|
184
|
+
/**
|
|
185
|
+
* Answers a `questionnaire` prompt: one entry per question in order, `null` for a question left
|
|
186
|
+
* blank, and an optional note on the whole set.
|
|
187
|
+
*/
|
|
188
|
+
| {
|
|
189
|
+
type: "answers";
|
|
190
|
+
prompt: string;
|
|
191
|
+
answers: (OwnerAnswer | null)[];
|
|
192
|
+
note?: string;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Declares what this connection can show of a questionnaire. Send it only after a `ready` that
|
|
196
|
+
* has `prompts`; a connection that never declares is never sent a `questionnaire` prompt.
|
|
197
|
+
*/
|
|
198
|
+
| { type: "prompt_features"; ask: AskSupport }
|
|
166
199
|
/**
|
|
167
200
|
* Declares the functions this connection can run for the assistant's tools, such as reading the
|
|
168
201
|
* page the person has open. Send it after each `ready`. A connection that declares none is never
|
|
@@ -214,6 +247,11 @@ export type ServerFrame =
|
|
|
214
247
|
* `client_call`: show no feature that needs the page's help then.
|
|
215
248
|
*/
|
|
216
249
|
clientCalls?: ClientCallsAdvertised;
|
|
250
|
+
/**
|
|
251
|
+
* What questionnaires this server can send. Absent from a server before 0.20: never send
|
|
252
|
+
* `prompt_features` then. A client that sees it may declare what it shows.
|
|
253
|
+
*/
|
|
254
|
+
prompts?: { ask: AskSupport };
|
|
217
255
|
}
|
|
218
256
|
/** The message `id` was taken into `conversation`, a new one when the client named none. */
|
|
219
257
|
| { type: "accepted"; id: string; conversation: string }
|
|
@@ -354,6 +392,10 @@ export function parseClientFrame(
|
|
|
354
392
|
...(text === undefined ? {} : { text }),
|
|
355
393
|
};
|
|
356
394
|
}
|
|
395
|
+
case "answers":
|
|
396
|
+
return parseAnswers(value);
|
|
397
|
+
case "prompt_features":
|
|
398
|
+
return parseFeatures(value);
|
|
357
399
|
case "client_hello":
|
|
358
400
|
return parseHello(value);
|
|
359
401
|
case "client_result":
|
|
@@ -363,6 +405,56 @@ export function parseClientFrame(
|
|
|
363
405
|
}
|
|
364
406
|
}
|
|
365
407
|
|
|
408
|
+
function parseAnswers(value: Record<string, unknown>): ClientFrame | undefined {
|
|
409
|
+
const { prompt, answers, note } = value;
|
|
410
|
+
if (!isId(prompt) || !Array.isArray(answers)) return undefined;
|
|
411
|
+
if (answers.length < 1 || answers.length > QUESTIONNAIRE_LIMITS.questions)
|
|
412
|
+
return undefined;
|
|
413
|
+
if (!optional(note, (n): n is string => typeof n === "string"))
|
|
414
|
+
return undefined;
|
|
415
|
+
const parsed: (OwnerAnswer | null)[] = [];
|
|
416
|
+
for (const answer of answers as unknown[]) {
|
|
417
|
+
if (answer === null) {
|
|
418
|
+
parsed.push(null);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (!isRecord(answer) || !Array.isArray(answer.choices)) return undefined;
|
|
422
|
+
const { choices, text } = answer;
|
|
423
|
+
if (!choices.every((choice) => typeof choice === "string"))
|
|
424
|
+
return undefined;
|
|
425
|
+
if (!optional(text, (t): t is string => typeof t === "string"))
|
|
426
|
+
return undefined;
|
|
427
|
+
parsed.push({
|
|
428
|
+
choices: choices as string[],
|
|
429
|
+
...(text === undefined ? {} : { text }),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
type: "answers",
|
|
434
|
+
prompt,
|
|
435
|
+
answers: parsed,
|
|
436
|
+
...(note === undefined ? {} : { note }),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function parseFeatures(
|
|
441
|
+
value: Record<string, unknown>,
|
|
442
|
+
): ClientFrame | undefined {
|
|
443
|
+
const { ask } = value;
|
|
444
|
+
if (!isRecord(ask)) return undefined;
|
|
445
|
+
const { questions, previews, note } = ask;
|
|
446
|
+
if (
|
|
447
|
+
typeof questions !== "number" ||
|
|
448
|
+
!Number.isInteger(questions) ||
|
|
449
|
+
questions < 1 ||
|
|
450
|
+
questions > QUESTIONNAIRE_LIMITS.questions ||
|
|
451
|
+
typeof previews !== "boolean" ||
|
|
452
|
+
typeof note !== "boolean"
|
|
453
|
+
)
|
|
454
|
+
return undefined;
|
|
455
|
+
return { type: "prompt_features", ask: { questions, previews, note } };
|
|
456
|
+
}
|
|
457
|
+
|
|
366
458
|
function parseHello(value: Record<string, unknown>): ClientFrame | undefined {
|
|
367
459
|
const { version, tools } = value;
|
|
368
460
|
if (version !== 1 || !Array.isArray(tools)) return undefined;
|