@mnemom/mnemom 0.17.34 → 0.17.36
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/dist/commands/agent-config.js +3 -0
- package/dist/commands/agent-goal.d.ts +8 -3
- package/dist/commands/agent-goal.js +22 -8
- package/dist/commands/agent-run.d.ts +14 -2
- package/dist/commands/agent-run.js +28 -7
- package/dist/commands/agent-share-render.js +1 -1
- package/dist/commands/agent-slack.js +32 -0
- package/dist/commands/durable-inbox.d.ts +51 -0
- package/dist/commands/durable-inbox.js +134 -0
- package/dist/commands/durable-questions.d.ts +64 -0
- package/dist/commands/durable-questions.js +161 -0
- package/dist/commands/scan.d.ts +13 -3
- package/dist/commands/scan.js +48 -10
- package/dist/durable/seat-proxy.d.ts +12 -1
- package/dist/durable/seat-proxy.js +55 -9
- package/dist/durable/seat-socket.d.ts +20 -2
- package/dist/durable/seat-socket.js +58 -0
- package/dist/durable/turn-chain.d.ts +8 -0
- package/dist/durable/turn-chain.js +25 -4
- package/dist/host/client.d.ts +41 -0
- package/dist/host/client.js +7 -0
- package/dist/host/control.d.ts +7 -0
- package/dist/host/control.js +15 -0
- package/dist/host/daemon.d.ts +20 -0
- package/dist/host/daemon.js +74 -1
- package/dist/host/inbox-delivery.d.ts +174 -0
- package/dist/host/inbox-delivery.js +453 -0
- package/dist/host/run.d.ts +18 -0
- package/dist/host/run.js +61 -0
- package/dist/host/s1-names.d.ts +2 -0
- package/dist/host/s1-names.js +2 -0
- package/dist/host/tmux.d.ts +7 -0
- package/dist/host/tmux.js +19 -0
- package/dist/index.js +62 -3
- package/dist/lib/api.d.ts +5 -0
- package/dist/lib/api.js +16 -0
- package/dist/lib/config.d.ts +7 -0
- package/dist/lib/config.js +21 -0
- package/dist/lib/durable/api.d.ts +11 -0
- package/dist/lib/durable/api.js +28 -3
- package/dist/lib/durable/gate.d.ts +1 -1
- package/dist/lib/durable/gate.js +8 -1
- package/dist/lib/durable/live.d.ts +5 -0
- package/dist/lib/durable/live.js +19 -0
- package/dist/lib/experimental.js +3 -0
- package/dist/lib/goal-api.d.ts +11 -4
- package/dist/lib/goal-api.js +4 -3
- package/dist/lib/launcher-config.d.ts +5 -0
- package/dist/lib/launcher-config.js +17 -2
- package/dist/lib/prompt.d.ts +4 -2
- package/dist/lib/prompt.js +15 -5
- package/dist/lib/runner/job.d.ts +2 -2
- package/dist/lib/runner/job.js +16 -3
- package/dist/lib/runner/pr-body.d.ts +17 -0
- package/dist/lib/runner/pr-body.js +42 -0
- package/dist/lib/runner/proxy.d.ts +18 -3
- package/dist/lib/runner/proxy.js +24 -5
- package/dist/lib/scan/pr-source.js +2 -0
- package/dist/lib/scan/record.d.ts +60 -9
- package/dist/lib/scan/record.js +157 -39
- package/dist/lib/scan/repo-picker.d.ts +32 -0
- package/dist/lib/scan/repo-picker.js +80 -0
- package/dist/lib/scan/report.d.ts +6 -8
- package/dist/lib/scan/report.js +21 -7
- package/dist/lib/scan/share.d.ts +5 -1
- package/dist/lib/scan/share.js +15 -5
- package/dist/lib/slack-durable.d.ts +123 -0
- package/dist/lib/slack-durable.js +237 -0
- package/dist/lib/slack-router.d.ts +7 -0
- package/dist/lib/slack-router.js +50 -4
- package/dist/vendor/durable-contract/index.js +992 -34
- package/dist/vendor/shared/durable-contract/admission.d.ts +81 -2
- package/dist/vendor/shared/durable-contract/admission.js +319 -2
- package/dist/vendor/shared/durable-contract/cli-surface.js +21 -0
- package/dist/vendor/shared/durable-contract/codes.d.ts +60 -0
- package/dist/vendor/shared/durable-contract/codes.js +22 -0
- package/dist/vendor/shared/durable-contract/events.d.ts +4 -2
- package/dist/vendor/shared/durable-contract/events.js +55 -1
- package/dist/vendor/shared/durable-contract/freeze-record.d.ts +14 -0
- package/dist/vendor/shared/durable-contract/inbox.d.ts +299 -0
- package/dist/vendor/shared/durable-contract/inbox.js +462 -0
- package/dist/vendor/shared/durable-contract/index.d.ts +2 -0
- package/dist/vendor/shared/durable-contract/index.js +2 -0
- package/dist/vendor/shared/durable-contract/question.d.ts +218 -0
- package/dist/vendor/shared/durable-contract/question.js +373 -0
- package/dist/vendor/shared/durable-contract/routes.d.ts +44 -2
- package/dist/vendor/shared/durable-contract/routes.js +43 -1
- package/dist/vendor/shared/durable-contract/turn.d.ts +15 -1
- package/dist/vendor/shared/durable-contract/turn.js +33 -1
- package/dist/vendor/shared/durable-contract/vectors.d.ts +45 -0
- package/dist/vendor/shared/durable-contract/vectors.js +49 -0
- package/node_modules/@mnemom/aip-verifier/LICENSE +190 -0
- package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.cts +478 -0
- package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.ts +478 -0
- package/node_modules/@mnemom/aip-verifier/dist/index.cjs +971 -0
- package/node_modules/@mnemom/aip-verifier/dist/index.cjs.map +1 -0
- package/node_modules/@mnemom/aip-verifier/dist/index.d.cts +330 -0
- package/node_modules/@mnemom/aip-verifier/dist/index.d.ts +330 -0
- package/node_modules/@mnemom/aip-verifier/dist/index.js +933 -0
- package/node_modules/@mnemom/aip-verifier/dist/index.js.map +1 -0
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs +879 -0
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs.map +1 -0
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.cts +96 -0
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.ts +96 -0
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.js +875 -0
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.js.map +1 -0
- package/node_modules/@mnemom/aip-verifier/package.json +86 -0
- package/node_modules/@noble/ed25519/LICENSE +21 -0
- package/node_modules/@noble/ed25519/README.md +371 -0
- package/node_modules/@noble/ed25519/index.d.ts +391 -0
- package/node_modules/@noble/ed25519/index.js +933 -0
- package/node_modules/@noble/ed25519/index.ts +1134 -0
- package/node_modules/@noble/ed25519/package.json +54 -0
- package/node_modules/@noble/hashes/LICENSE +21 -0
- package/node_modules/@noble/hashes/README.md +478 -0
- package/node_modules/@noble/hashes/_blake.d.ts +20 -0
- package/node_modules/@noble/hashes/_blake.js +59 -0
- package/node_modules/@noble/hashes/_md.d.ts +95 -0
- package/node_modules/@noble/hashes/_md.js +209 -0
- package/node_modules/@noble/hashes/_u64.d.ts +36 -0
- package/node_modules/@noble/hashes/_u64.js +77 -0
- package/node_modules/@noble/hashes/argon2.d.ts +148 -0
- package/node_modules/@noble/hashes/argon2.js +577 -0
- package/node_modules/@noble/hashes/blake1.d.ts +162 -0
- package/node_modules/@noble/hashes/blake1.js +630 -0
- package/node_modules/@noble/hashes/blake2.d.ts +203 -0
- package/node_modules/@noble/hashes/blake2.js +573 -0
- package/node_modules/@noble/hashes/blake3.d.ts +68 -0
- package/node_modules/@noble/hashes/blake3.js +310 -0
- package/node_modules/@noble/hashes/eskdf.d.ts +91 -0
- package/node_modules/@noble/hashes/eskdf.js +225 -0
- package/node_modules/@noble/hashes/hkdf.d.ts +70 -0
- package/node_modules/@noble/hashes/hkdf.js +152 -0
- package/node_modules/@noble/hashes/hmac.d.ts +50 -0
- package/node_modules/@noble/hashes/hmac.js +93 -0
- package/node_modules/@noble/hashes/index.d.ts +1 -0
- package/node_modules/@noble/hashes/index.js +34 -0
- package/node_modules/@noble/hashes/legacy.d.ts +107 -0
- package/node_modules/@noble/hashes/legacy.js +348 -0
- package/node_modules/@noble/hashes/package.json +91 -0
- package/node_modules/@noble/hashes/pbkdf2.d.ts +66 -0
- package/node_modules/@noble/hashes/pbkdf2.js +176 -0
- package/node_modules/@noble/hashes/scrypt.d.ts +98 -0
- package/node_modules/@noble/hashes/scrypt.js +304 -0
- package/node_modules/@noble/hashes/sha2.d.ts +176 -0
- package/node_modules/@noble/hashes/sha2.js +446 -0
- package/node_modules/@noble/hashes/sha3-addons.d.ts +533 -0
- package/node_modules/@noble/hashes/sha3-addons.js +973 -0
- package/node_modules/@noble/hashes/sha3.d.ts +208 -0
- package/node_modules/@noble/hashes/sha3.js +475 -0
- package/node_modules/@noble/hashes/src/_blake.ts +67 -0
- package/node_modules/@noble/hashes/src/_md.ts +227 -0
- package/node_modules/@noble/hashes/src/_u64.ts +111 -0
- package/node_modules/@noble/hashes/src/argon2.ts +693 -0
- package/node_modules/@noble/hashes/src/blake1.ts +677 -0
- package/node_modules/@noble/hashes/src/blake2.ts +648 -0
- package/node_modules/@noble/hashes/src/blake3.ts +344 -0
- package/node_modules/@noble/hashes/src/eskdf.ts +265 -0
- package/node_modules/@noble/hashes/src/hkdf.ts +161 -0
- package/node_modules/@noble/hashes/src/hmac.ts +130 -0
- package/node_modules/@noble/hashes/src/index.ts +33 -0
- package/node_modules/@noble/hashes/src/legacy.ts +361 -0
- package/node_modules/@noble/hashes/src/pbkdf2.ts +219 -0
- package/node_modules/@noble/hashes/src/scrypt.ts +375 -0
- package/node_modules/@noble/hashes/src/sha2.ts +486 -0
- package/node_modules/@noble/hashes/src/sha3-addons.ts +1157 -0
- package/node_modules/@noble/hashes/src/sha3.ts +525 -0
- package/node_modules/@noble/hashes/src/utils.ts +978 -0
- package/node_modules/@noble/hashes/src/webcrypto.ts +283 -0
- package/node_modules/@noble/hashes/utils.d.ts +560 -0
- package/node_modules/@noble/hashes/utils.js +687 -0
- package/node_modules/@noble/hashes/webcrypto.d.ts +121 -0
- package/node_modules/@noble/hashes/webcrypto.js +228 -0
- package/package.json +5 -2
- package/plugins/integrity-manifest.json +6 -2
- package/plugins/mnemom/hooks/hooks.json +12 -0
- package/plugins/mnemom/hooks/seat-ask.mjs +221 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable questions (S1 amendment 61, signed off by Wassim 2026-11-02): a locked seat's question to its
|
|
3
|
+
* owner is a journaled fact, answerable from any surface the owner authenticates on, and the answer
|
|
4
|
+
* reaches the seat without anyone looking at its screen.
|
|
5
|
+
*
|
|
6
|
+
* The model (ruling 34.06.1.1 kept as it is): a question IS an owner_mutation approval with subject
|
|
7
|
+
* 'question' (schema.ts SUBJECT_SHAPES.question, subject hash over the session only, so a session
|
|
8
|
+
* has at most one open question). Asking the same question again (same question_sha: same
|
|
9
|
+
* tool_use_id and questions) returns it; asking a different one while it is open, handed_off
|
|
10
|
+
* included, is refused 409 durable_question_busy and stores nothing, so one question's answer can
|
|
11
|
+
* never be handed to another tool call. Amendment 61 adds what M1 left out:
|
|
12
|
+
*
|
|
13
|
+
* 1. The seat. A PreToolUse command hook on AskUserQuestion (cli plugin seat-ask.mjs) sends the
|
|
14
|
+
* tool's `questions` over the per-seat socket; the seat proxy relays it to the host daemon,
|
|
15
|
+
* which holds the executor key and calls ROUTES.exec_question. The api opens (or returns) the
|
|
16
|
+
* question approval, stores the question encrypted (durable_approvals.question_ciphertext) and
|
|
17
|
+
* journals approval.requested and question.asked {approval_id, question_sha}.
|
|
18
|
+
* 2. The owner. ROUTES.own_questions lists the session's questions (decrypted, for the owner);
|
|
19
|
+
* ROUTES.own_answer answers one ({approval_id, answers, via}) or dismisses it
|
|
20
|
+
* ({approval_id, dismiss: true, via}). The api resolves the approval (attestation via = the
|
|
21
|
+
* surface), stores the answer encrypted, and journals approval.resolved, approval.consumed and
|
|
22
|
+
* question.answered {approval_id, outcome, via}.
|
|
23
|
+
* 3. Back to the seat, two paths, never both:
|
|
24
|
+
* - live: the hook polls ROUTES.exec_question_poll (through the socket and the daemon) while
|
|
25
|
+
* the turn is held; on an answer the hook returns permissionDecision "allow" with
|
|
26
|
+
* updatedInput {questions, answers}, so the dialog never draws and the tool result carries
|
|
27
|
+
* the owner's answer. The poll that hands the answer over marks it taken.
|
|
28
|
+
* - hand-off: when the hook's window ends first it calls ROUTES.exec_question_release and
|
|
29
|
+
* denies the tool call with QUESTION_HANDOFF_REASON (end the turn; the answer comes as the
|
|
30
|
+
* next message). From then on the answer, when it comes (or at release, when it already
|
|
31
|
+
* came and was not taken), is delivered as an amendment 60 inbox message from the owner with
|
|
32
|
+
* in_reply_to = approval_id, so the seat resumes on it unattended under §60 (e).
|
|
33
|
+
*
|
|
34
|
+
* Nothing here carries question or answer text into the journal: refs are ids, shas and enums.
|
|
35
|
+
* Both question.* kinds stay api-written (writerOf); no executor appends them.
|
|
36
|
+
*/
|
|
37
|
+
/** An AskUserQuestion call carries 1..this many questions. */
|
|
38
|
+
export declare const QUESTION_MAX_QUESTIONS = 4;
|
|
39
|
+
/** Each question offers this many options, min and max (AskUserQuestion's own bounds). */
|
|
40
|
+
export declare const QUESTION_MIN_OPTIONS = 2;
|
|
41
|
+
export declare const QUESTION_MAX_OPTIONS = 4;
|
|
42
|
+
/** A question's text: 1..this many UTF-8 bytes. */
|
|
43
|
+
export declare const QUESTION_TEXT_MAX_BYTES = 2048;
|
|
44
|
+
/** A header chip: 1..this many UTF-8 bytes. */
|
|
45
|
+
export declare const QUESTION_HEADER_MAX_BYTES = 64;
|
|
46
|
+
/** An option label: 1..this many UTF-8 bytes. */
|
|
47
|
+
export declare const QUESTION_LABEL_MAX_BYTES = 256;
|
|
48
|
+
/** An option description: 0..this many UTF-8 bytes. */
|
|
49
|
+
export declare const QUESTION_DESCRIPTION_MAX_BYTES = 2048;
|
|
50
|
+
/** One answer value (a label, labels joined by ", ", or free text): 1..this many UTF-8 bytes. */
|
|
51
|
+
export declare const QUESTION_ANSWER_MAX_BYTES = 4096;
|
|
52
|
+
/** The whole canonical question body: at most this many UTF-8 bytes (it is stored encrypted). */
|
|
53
|
+
export declare const QUESTION_BODY_MAX_BYTES = 16384;
|
|
54
|
+
/**
|
|
55
|
+
* A question approval's lifetime. Far longer than OWNER_MUTATION_APPROVAL_TTL_S (a device step is
|
|
56
|
+
* answered now; a question may be answered hours later); an expired question journals
|
|
57
|
+
* approval.expired and the seat's hook, if still waiting, hands off.
|
|
58
|
+
*/
|
|
59
|
+
export declare const QUESTION_APPROVAL_TTL_S: number;
|
|
60
|
+
/** own_questions answers at most this many questions, newest first. */
|
|
61
|
+
export declare const QUESTION_LIST_LIMIT = 20;
|
|
62
|
+
/** tool_use_id: Claude Code's id of the AskUserQuestion call (opaque, 1..128 of these). */
|
|
63
|
+
export declare const QUESTION_TOOL_USE_ID_RE: RegExp;
|
|
64
|
+
/** A label ending in this marks the option the seat recommends (the AskUserQuestion convention). */
|
|
65
|
+
export declare const QUESTION_RECOMMENDED_SUFFIX = "(Recommended)";
|
|
66
|
+
/**
|
|
67
|
+
* The surface an answer came from, recorded as the approval attestation's `via` and in
|
|
68
|
+
* question.answered. The attestation vocabulary of durable_approval_attestations.via, unchanged:
|
|
69
|
+
* coding_agent (the CLI), slack, webhook, mcp, device (the web device page).
|
|
70
|
+
*/
|
|
71
|
+
export declare const QUESTION_ANSWER_VIAS: readonly ["coding_agent", "slack", "webhook", "mcp", "device"];
|
|
72
|
+
export type QuestionAnswerVia = (typeof QUESTION_ANSWER_VIAS)[number];
|
|
73
|
+
/** question.answered refs.outcome. */
|
|
74
|
+
export declare const QUESTION_OUTCOMES: readonly ["answered", "dismissed"];
|
|
75
|
+
export type QuestionOutcome = (typeof QUESTION_OUTCOMES)[number];
|
|
76
|
+
/**
|
|
77
|
+
* A question's state as the seat and the owner see it:
|
|
78
|
+
* - open: waiting for the owner;
|
|
79
|
+
* - answered: the owner answered (answers present);
|
|
80
|
+
* - dismissed: the owner dismissed it (no answers);
|
|
81
|
+
* - expired: QUESTION_APPROVAL_TTL_S passed unanswered;
|
|
82
|
+
* - handed_off: the seat stopped waiting; the answer (if any) goes to the inbox instead.
|
|
83
|
+
* answered and handed_off can both hold; the poll reports handed_off once the seat released it.
|
|
84
|
+
*/
|
|
85
|
+
export declare const QUESTION_STATES: readonly ["open", "answered", "dismissed", "expired", "handed_off"];
|
|
86
|
+
export type QuestionState = (typeof QUESTION_STATES)[number];
|
|
87
|
+
/** The kinds amendment 61 gives an emitter (they stay in the frozen NO_M1_EMITTER_KINDS M1 baseline). */
|
|
88
|
+
export declare const A61_EMITTED_KINDS: readonly ["question.asked", "question.answered"];
|
|
89
|
+
/** What the hook tells the model when it stops waiting (the tool call is denied with this reason). */
|
|
90
|
+
export declare const QUESTION_HANDOFF_REASON = "This question is now with your owner. End your turn now without guessing; the owner's answer will arrive as your next message.";
|
|
91
|
+
/** What the hook tells the model when the owner dismissed the question. */
|
|
92
|
+
export declare const QUESTION_DISMISSED_REASON = "Your owner dismissed this question without answering. Decide without it, or ask differently.";
|
|
93
|
+
export interface QuestionOption {
|
|
94
|
+
readonly label: string;
|
|
95
|
+
readonly description: string;
|
|
96
|
+
}
|
|
97
|
+
export interface QuestionItem {
|
|
98
|
+
readonly question: string;
|
|
99
|
+
readonly header: string;
|
|
100
|
+
readonly options: readonly QuestionOption[];
|
|
101
|
+
readonly multiSelect: boolean;
|
|
102
|
+
}
|
|
103
|
+
/** One question item: exactly {question, header, options, multiSelect}, labels unique within it. */
|
|
104
|
+
export declare function questionItemOk(v: unknown): v is QuestionItem;
|
|
105
|
+
/**
|
|
106
|
+
* The questions array as AskUserQuestion takes it: 1..QUESTION_MAX_QUESTIONS items, question texts
|
|
107
|
+
* unique (they key the answers), the canonical whole within QUESTION_BODY_MAX_BYTES.
|
|
108
|
+
*/
|
|
109
|
+
export declare function questionsOk(v: unknown): v is readonly QuestionItem[];
|
|
110
|
+
/**
|
|
111
|
+
* Normalise a hook's AskUserQuestion tool_input.questions into the closed shape: keeps exactly the
|
|
112
|
+
* four keys, fills a missing description with "" and a missing multiSelect with false. Null when
|
|
113
|
+
* the result is not questionsOk (the hook then leaves the dialog to the screen).
|
|
114
|
+
*/
|
|
115
|
+
export declare function normalizeQuestions(v: unknown): QuestionItem[] | null;
|
|
116
|
+
/** The index of the option the seat recommends (label ends in QUESTION_RECOMMENDED_SUFFIX), else null. */
|
|
117
|
+
export declare function recommendedOption(q: QuestionItem): number | null;
|
|
118
|
+
/** question_sha: sha256 of canonicalJson({v: 1, session_id, tool_use_id, questions}), lowercase hex. */
|
|
119
|
+
export declare function questionShaPreimage(m: {
|
|
120
|
+
readonly session_id: string;
|
|
121
|
+
readonly tool_use_id: string;
|
|
122
|
+
readonly questions: readonly QuestionItem[];
|
|
123
|
+
}): string;
|
|
124
|
+
export declare function questionSha(m: {
|
|
125
|
+
readonly session_id: string;
|
|
126
|
+
readonly tool_use_id: string;
|
|
127
|
+
readonly questions: readonly QuestionItem[];
|
|
128
|
+
}): Promise<string>;
|
|
129
|
+
/**
|
|
130
|
+
* The answers map AskUserQuestion's updatedInput takes: exactly one key per question text, each a
|
|
131
|
+
* 1..QUESTION_ANSWER_MAX_BYTES string (a label, several labels joined by ", ", or free text).
|
|
132
|
+
*/
|
|
133
|
+
export declare function answersOk(answers: unknown, questions: readonly QuestionItem[]): answers is Record<string, string>;
|
|
134
|
+
/** The answers map's shape without the questions (the owner route checks it against the stored ones). */
|
|
135
|
+
export declare function answersShapeOk(answers: unknown): answers is Record<string, string>;
|
|
136
|
+
/**
|
|
137
|
+
* Resolve one owner choice against a question: a 1-based option number (or several, comma
|
|
138
|
+
* separated, for a multiSelect question), an option label (case-insensitive, with or without the
|
|
139
|
+
* recommended suffix), or else free text taken as written. Returns the answer string.
|
|
140
|
+
*/
|
|
141
|
+
export declare function answerFromChoice(q: QuestionItem, choice: string): string;
|
|
142
|
+
/**
|
|
143
|
+
* The inbox body of a handed-off answer (§61 (3)): the api renders it, the inbox delivery line
|
|
144
|
+
* (inboxDeliveryText) names the approval, so the body only restates question and answer.
|
|
145
|
+
*/
|
|
146
|
+
export declare function questionAnswerInboxBody(questions: readonly QuestionItem[], answers: Readonly<Record<string, string>>): string;
|
|
147
|
+
/** POST exec_question body: exactly {epoch, nonce, tool_use_id, questions}. */
|
|
148
|
+
export declare const QUESTION_ASK_BODY_KEYS: readonly ["epoch", "nonce", "tool_use_id", "questions"];
|
|
149
|
+
export declare function questionAskBodyOk(v: unknown): boolean;
|
|
150
|
+
/**
|
|
151
|
+
* POST exec_question answer: exactly {approval_id, question_sha, created, expires_at}. created
|
|
152
|
+
* false: the session's open question was returned, which the api does only when its question_sha
|
|
153
|
+
* equals this ask's (a different open question is 409 durable_question_busy). The seat checks it
|
|
154
|
+
* anyway (questionAskMatches) before it polls.
|
|
155
|
+
*/
|
|
156
|
+
export declare function questionAskResponseOk(v: unknown): boolean;
|
|
157
|
+
/**
|
|
158
|
+
* The seat's own check of an exec_question answer: it is about THIS ask (question_sha equal to the
|
|
159
|
+
* sha of {session_id, tool_use_id, questions}). The seat polls only a matching approval; otherwise
|
|
160
|
+
* it denies the tool call without claiming the owner has the question.
|
|
161
|
+
*/
|
|
162
|
+
export declare function questionAskMatches(answer: {
|
|
163
|
+
readonly question_sha: string;
|
|
164
|
+
}, ask: {
|
|
165
|
+
readonly session_id: string;
|
|
166
|
+
readonly tool_use_id: string;
|
|
167
|
+
readonly questions: readonly QuestionItem[];
|
|
168
|
+
}): Promise<boolean>;
|
|
169
|
+
/** POST exec_question_poll / exec_question_release body: exactly {epoch, nonce, approval_id}. */
|
|
170
|
+
export declare const QUESTION_POLL_BODY_KEYS: readonly ["epoch", "nonce", "approval_id"];
|
|
171
|
+
export declare function questionPollBodyOk(v: unknown): boolean;
|
|
172
|
+
/**
|
|
173
|
+
* POST exec_question_poll answer: exactly {approval_id, state, answers}. answers is the answers map
|
|
174
|
+
* iff state is answered (the poll that returns it marks it taken: the hand-off path never delivers
|
|
175
|
+
* it again), else null.
|
|
176
|
+
*/
|
|
177
|
+
export declare function questionPollResponseOk(v: unknown): boolean;
|
|
178
|
+
/**
|
|
179
|
+
* POST exec_question_release answer: exactly {approval_id, state, inbox_id}. state is handed_off,
|
|
180
|
+
* or the final state the question already reached; inbox_id names the inbox message the answer was
|
|
181
|
+
* queued as (released after the owner answered and before the seat took it), else null.
|
|
182
|
+
*/
|
|
183
|
+
export declare function questionReleaseResponseOk(v: unknown): boolean;
|
|
184
|
+
/**
|
|
185
|
+
* One own_questions item, exactly these keys; the owner reads the question text (decrypted by the
|
|
186
|
+
* api). recommended: per question, the index recommendedOption returns (null when none).
|
|
187
|
+
*/
|
|
188
|
+
export declare const QUESTION_LIST_ITEM: Readonly<{
|
|
189
|
+
approval_id: "uuid";
|
|
190
|
+
state: "question_state";
|
|
191
|
+
questions: "questions";
|
|
192
|
+
recommended: "(integer|null)[]";
|
|
193
|
+
answers: "answers|null";
|
|
194
|
+
answered_via: "question_answer_via|null";
|
|
195
|
+
created_at: "timestamp";
|
|
196
|
+
expires_at: "timestamp";
|
|
197
|
+
answered_at: "timestamp|null";
|
|
198
|
+
}>;
|
|
199
|
+
export declare function questionListItemOk(v: unknown): boolean;
|
|
200
|
+
/** GET own_questions: exactly {questions: [item]}, newest first, at most QUESTION_LIST_LIMIT. */
|
|
201
|
+
export declare function questionListResponseOk(v: unknown): boolean;
|
|
202
|
+
/** POST own_answer body: {approval_id, via} plus exactly one of answers or dismiss: true. */
|
|
203
|
+
export declare const QUESTION_ANSWER_BODY_KEYS: readonly ["approval_id", "via", "answers", "dismiss"];
|
|
204
|
+
export declare function questionAnswerBodyOk(v: unknown): boolean;
|
|
205
|
+
/**
|
|
206
|
+
* POST own_answer answer: exactly {approval_id, outcome, delivery, inbox_id}. delivery says how the
|
|
207
|
+
* answer reaches the seat: `live` (the seat's hook is waiting and takes it on its next poll),
|
|
208
|
+
* `inbox` (handed off: inbox_id names the message), or `none` (dismissed).
|
|
209
|
+
*/
|
|
210
|
+
export declare const QUESTION_DELIVERIES: readonly ["live", "inbox", "none"];
|
|
211
|
+
export declare function questionAnswerResponseOk(v: unknown): boolean;
|
|
212
|
+
/** The journal idempotency keys the api uses for question events. */
|
|
213
|
+
export declare const questionIdempotencyKeys: {
|
|
214
|
+
readonly asked: (approvalId: string) => string;
|
|
215
|
+
readonly answered: (approvalId: string) => string;
|
|
216
|
+
};
|
|
217
|
+
/** A uuid as the routes take it (re-exported for the CLI's qid argument). */
|
|
218
|
+
export declare const QUESTION_ID_RE: RegExp;
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// GENERATED from shared/durable-contract/src/question.ts by cli/scripts/sync-durable-contract.mjs; do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* Durable questions (S1 amendment 61, signed off by Wassim 2026-11-02): a locked seat's question to its
|
|
4
|
+
* owner is a journaled fact, answerable from any surface the owner authenticates on, and the answer
|
|
5
|
+
* reaches the seat without anyone looking at its screen.
|
|
6
|
+
*
|
|
7
|
+
* The model (ruling 34.06.1.1 kept as it is): a question IS an owner_mutation approval with subject
|
|
8
|
+
* 'question' (schema.ts SUBJECT_SHAPES.question, subject hash over the session only, so a session
|
|
9
|
+
* has at most one open question). Asking the same question again (same question_sha: same
|
|
10
|
+
* tool_use_id and questions) returns it; asking a different one while it is open, handed_off
|
|
11
|
+
* included, is refused 409 durable_question_busy and stores nothing, so one question's answer can
|
|
12
|
+
* never be handed to another tool call. Amendment 61 adds what M1 left out:
|
|
13
|
+
*
|
|
14
|
+
* 1. The seat. A PreToolUse command hook on AskUserQuestion (cli plugin seat-ask.mjs) sends the
|
|
15
|
+
* tool's `questions` over the per-seat socket; the seat proxy relays it to the host daemon,
|
|
16
|
+
* which holds the executor key and calls ROUTES.exec_question. The api opens (or returns) the
|
|
17
|
+
* question approval, stores the question encrypted (durable_approvals.question_ciphertext) and
|
|
18
|
+
* journals approval.requested and question.asked {approval_id, question_sha}.
|
|
19
|
+
* 2. The owner. ROUTES.own_questions lists the session's questions (decrypted, for the owner);
|
|
20
|
+
* ROUTES.own_answer answers one ({approval_id, answers, via}) or dismisses it
|
|
21
|
+
* ({approval_id, dismiss: true, via}). The api resolves the approval (attestation via = the
|
|
22
|
+
* surface), stores the answer encrypted, and journals approval.resolved, approval.consumed and
|
|
23
|
+
* question.answered {approval_id, outcome, via}.
|
|
24
|
+
* 3. Back to the seat, two paths, never both:
|
|
25
|
+
* - live: the hook polls ROUTES.exec_question_poll (through the socket and the daemon) while
|
|
26
|
+
* the turn is held; on an answer the hook returns permissionDecision "allow" with
|
|
27
|
+
* updatedInput {questions, answers}, so the dialog never draws and the tool result carries
|
|
28
|
+
* the owner's answer. The poll that hands the answer over marks it taken.
|
|
29
|
+
* - hand-off: when the hook's window ends first it calls ROUTES.exec_question_release and
|
|
30
|
+
* denies the tool call with QUESTION_HANDOFF_REASON (end the turn; the answer comes as the
|
|
31
|
+
* next message). From then on the answer, when it comes (or at release, when it already
|
|
32
|
+
* came and was not taken), is delivered as an amendment 60 inbox message from the owner with
|
|
33
|
+
* in_reply_to = approval_id, so the seat resumes on it unattended under §60 (e).
|
|
34
|
+
*
|
|
35
|
+
* Nothing here carries question or answer text into the journal: refs are ids, shas and enums.
|
|
36
|
+
* Both question.* kinds stay api-written (writerOf); no executor appends them.
|
|
37
|
+
*/
|
|
38
|
+
import { fieldOk } from "./admission.js";
|
|
39
|
+
import { canonicalJson, HEX64_RE, sha256Hex, utf8, UUID_RE } from "./canonical.js";
|
|
40
|
+
// ---- limits ---------------------------------------------------------------------------------
|
|
41
|
+
/** An AskUserQuestion call carries 1..this many questions. */
|
|
42
|
+
export const QUESTION_MAX_QUESTIONS = 4;
|
|
43
|
+
/** Each question offers this many options, min and max (AskUserQuestion's own bounds). */
|
|
44
|
+
export const QUESTION_MIN_OPTIONS = 2;
|
|
45
|
+
export const QUESTION_MAX_OPTIONS = 4;
|
|
46
|
+
/** A question's text: 1..this many UTF-8 bytes. */
|
|
47
|
+
export const QUESTION_TEXT_MAX_BYTES = 2048;
|
|
48
|
+
/** A header chip: 1..this many UTF-8 bytes. */
|
|
49
|
+
export const QUESTION_HEADER_MAX_BYTES = 64;
|
|
50
|
+
/** An option label: 1..this many UTF-8 bytes. */
|
|
51
|
+
export const QUESTION_LABEL_MAX_BYTES = 256;
|
|
52
|
+
/** An option description: 0..this many UTF-8 bytes. */
|
|
53
|
+
export const QUESTION_DESCRIPTION_MAX_BYTES = 2048;
|
|
54
|
+
/** One answer value (a label, labels joined by ", ", or free text): 1..this many UTF-8 bytes. */
|
|
55
|
+
export const QUESTION_ANSWER_MAX_BYTES = 4096;
|
|
56
|
+
/** The whole canonical question body: at most this many UTF-8 bytes (it is stored encrypted). */
|
|
57
|
+
export const QUESTION_BODY_MAX_BYTES = 16384;
|
|
58
|
+
/**
|
|
59
|
+
* A question approval's lifetime. Far longer than OWNER_MUTATION_APPROVAL_TTL_S (a device step is
|
|
60
|
+
* answered now; a question may be answered hours later); an expired question journals
|
|
61
|
+
* approval.expired and the seat's hook, if still waiting, hands off.
|
|
62
|
+
*/
|
|
63
|
+
export const QUESTION_APPROVAL_TTL_S = 24 * 60 * 60;
|
|
64
|
+
/** own_questions answers at most this many questions, newest first. */
|
|
65
|
+
export const QUESTION_LIST_LIMIT = 20;
|
|
66
|
+
/** tool_use_id: Claude Code's id of the AskUserQuestion call (opaque, 1..128 of these). */
|
|
67
|
+
export const QUESTION_TOOL_USE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
68
|
+
/** A label ending in this marks the option the seat recommends (the AskUserQuestion convention). */
|
|
69
|
+
export const QUESTION_RECOMMENDED_SUFFIX = "(Recommended)";
|
|
70
|
+
// ---- vocabularies ---------------------------------------------------------------------------
|
|
71
|
+
/**
|
|
72
|
+
* The surface an answer came from, recorded as the approval attestation's `via` and in
|
|
73
|
+
* question.answered. The attestation vocabulary of durable_approval_attestations.via, unchanged:
|
|
74
|
+
* coding_agent (the CLI), slack, webhook, mcp, device (the web device page).
|
|
75
|
+
*/
|
|
76
|
+
export const QUESTION_ANSWER_VIAS = ["coding_agent", "slack", "webhook", "mcp", "device"];
|
|
77
|
+
/** question.answered refs.outcome. */
|
|
78
|
+
export const QUESTION_OUTCOMES = ["answered", "dismissed"];
|
|
79
|
+
/**
|
|
80
|
+
* A question's state as the seat and the owner see it:
|
|
81
|
+
* - open: waiting for the owner;
|
|
82
|
+
* - answered: the owner answered (answers present);
|
|
83
|
+
* - dismissed: the owner dismissed it (no answers);
|
|
84
|
+
* - expired: QUESTION_APPROVAL_TTL_S passed unanswered;
|
|
85
|
+
* - handed_off: the seat stopped waiting; the answer (if any) goes to the inbox instead.
|
|
86
|
+
* answered and handed_off can both hold; the poll reports handed_off once the seat released it.
|
|
87
|
+
*/
|
|
88
|
+
export const QUESTION_STATES = ["open", "answered", "dismissed", "expired", "handed_off"];
|
|
89
|
+
/** The kinds amendment 61 gives an emitter (they stay in the frozen NO_M1_EMITTER_KINDS M1 baseline). */
|
|
90
|
+
export const A61_EMITTED_KINDS = ["question.asked", "question.answered"];
|
|
91
|
+
/** What the hook tells the model when it stops waiting (the tool call is denied with this reason). */
|
|
92
|
+
export const QUESTION_HANDOFF_REASON = "This question is now with your owner. End your turn now without guessing; the owner's answer will arrive as your next message.";
|
|
93
|
+
/** What the hook tells the model when the owner dismissed the question. */
|
|
94
|
+
export const QUESTION_DISMISSED_REASON = "Your owner dismissed this question without answering. Decide without it, or ask differently.";
|
|
95
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
96
|
+
const keysExactly = (o, keys) => Object.keys(o).length === keys.length && keys.every((k) => k in o);
|
|
97
|
+
const keysWithin = (o, required, optional) => required.every((k) => k in o) &&
|
|
98
|
+
Object.keys(o).every((k) => required.includes(k) || optional.includes(k));
|
|
99
|
+
const text = (v, min, max) => typeof v === "string" &&
|
|
100
|
+
!v.includes("\u0000") &&
|
|
101
|
+
utf8(v).length >= min &&
|
|
102
|
+
utf8(v).length <= max;
|
|
103
|
+
const uuid = (v, nullable = false) => fieldOk({ type: "uuid", nullable }, v);
|
|
104
|
+
const ts = (v, nullable = false) => fieldOk({ type: "timestamp", nullable }, v);
|
|
105
|
+
const int = (v, min = 0) => Number.isSafeInteger(v) && v >= min;
|
|
106
|
+
const nonEmpty = (v) => typeof v === "string" && v.length > 0;
|
|
107
|
+
const oneOf = (v, values) => typeof v === "string" && values.includes(v);
|
|
108
|
+
function optionOk(v) {
|
|
109
|
+
return (isObj(v) &&
|
|
110
|
+
keysExactly(v, ["label", "description"]) &&
|
|
111
|
+
text(v.label, 1, QUESTION_LABEL_MAX_BYTES) &&
|
|
112
|
+
text(v.description, 0, QUESTION_DESCRIPTION_MAX_BYTES));
|
|
113
|
+
}
|
|
114
|
+
/** One question item: exactly {question, header, options, multiSelect}, labels unique within it. */
|
|
115
|
+
export function questionItemOk(v) {
|
|
116
|
+
if (!isObj(v) ||
|
|
117
|
+
!keysExactly(v, ["question", "header", "options", "multiSelect"]) ||
|
|
118
|
+
!text(v.question, 1, QUESTION_TEXT_MAX_BYTES) ||
|
|
119
|
+
!text(v.header, 1, QUESTION_HEADER_MAX_BYTES) ||
|
|
120
|
+
typeof v.multiSelect !== "boolean" ||
|
|
121
|
+
!Array.isArray(v.options) ||
|
|
122
|
+
v.options.length < QUESTION_MIN_OPTIONS ||
|
|
123
|
+
v.options.length > QUESTION_MAX_OPTIONS ||
|
|
124
|
+
!v.options.every(optionOk))
|
|
125
|
+
return false;
|
|
126
|
+
const labels = v.options.map((o) => o.label);
|
|
127
|
+
return new Set(labels).size === labels.length;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The questions array as AskUserQuestion takes it: 1..QUESTION_MAX_QUESTIONS items, question texts
|
|
131
|
+
* unique (they key the answers), the canonical whole within QUESTION_BODY_MAX_BYTES.
|
|
132
|
+
*/
|
|
133
|
+
export function questionsOk(v) {
|
|
134
|
+
if (!Array.isArray(v) || v.length < 1 || v.length > QUESTION_MAX_QUESTIONS)
|
|
135
|
+
return false;
|
|
136
|
+
if (!v.every(questionItemOk))
|
|
137
|
+
return false;
|
|
138
|
+
const texts = v.map((q) => q.question);
|
|
139
|
+
if (new Set(texts).size !== texts.length)
|
|
140
|
+
return false;
|
|
141
|
+
return utf8(canonicalJson(v)).length <= QUESTION_BODY_MAX_BYTES;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Normalise a hook's AskUserQuestion tool_input.questions into the closed shape: keeps exactly the
|
|
145
|
+
* four keys, fills a missing description with "" and a missing multiSelect with false. Null when
|
|
146
|
+
* the result is not questionsOk (the hook then leaves the dialog to the screen).
|
|
147
|
+
*/
|
|
148
|
+
export function normalizeQuestions(v) {
|
|
149
|
+
if (!Array.isArray(v))
|
|
150
|
+
return null;
|
|
151
|
+
const out = v.map((q) => {
|
|
152
|
+
const r = isObj(q) ? q : {};
|
|
153
|
+
const opts = Array.isArray(r.options) ? r.options : [];
|
|
154
|
+
return {
|
|
155
|
+
question: r.question,
|
|
156
|
+
header: r.header,
|
|
157
|
+
options: opts.map((o) => {
|
|
158
|
+
const x = isObj(o) ? o : {};
|
|
159
|
+
return { label: x.label, description: x.description ?? "" };
|
|
160
|
+
}),
|
|
161
|
+
multiSelect: r.multiSelect ?? false,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
return questionsOk(out) ? out : null;
|
|
165
|
+
}
|
|
166
|
+
/** The index of the option the seat recommends (label ends in QUESTION_RECOMMENDED_SUFFIX), else null. */
|
|
167
|
+
export function recommendedOption(q) {
|
|
168
|
+
const i = q.options.findIndex((o) => o.label.trimEnd().endsWith(QUESTION_RECOMMENDED_SUFFIX));
|
|
169
|
+
return i < 0 ? null : i;
|
|
170
|
+
}
|
|
171
|
+
/** question_sha: sha256 of canonicalJson({v: 1, session_id, tool_use_id, questions}), lowercase hex. */
|
|
172
|
+
export function questionShaPreimage(m) {
|
|
173
|
+
return canonicalJson({
|
|
174
|
+
v: 1,
|
|
175
|
+
session_id: m.session_id,
|
|
176
|
+
tool_use_id: m.tool_use_id,
|
|
177
|
+
questions: m.questions,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
export async function questionSha(m) {
|
|
181
|
+
return sha256Hex(questionShaPreimage(m));
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The answers map AskUserQuestion's updatedInput takes: exactly one key per question text, each a
|
|
185
|
+
* 1..QUESTION_ANSWER_MAX_BYTES string (a label, several labels joined by ", ", or free text).
|
|
186
|
+
*/
|
|
187
|
+
export function answersOk(answers, questions) {
|
|
188
|
+
return (isObj(answers) &&
|
|
189
|
+
keysExactly(answers, questions.map((q) => q.question)) &&
|
|
190
|
+
Object.values(answers).every((a) => text(a, 1, QUESTION_ANSWER_MAX_BYTES)));
|
|
191
|
+
}
|
|
192
|
+
/** The answers map's shape without the questions (the owner route checks it against the stored ones). */
|
|
193
|
+
export function answersShapeOk(answers) {
|
|
194
|
+
return (isObj(answers) &&
|
|
195
|
+
Object.keys(answers).length >= 1 &&
|
|
196
|
+
Object.keys(answers).length <= QUESTION_MAX_QUESTIONS &&
|
|
197
|
+
Object.keys(answers).every((k) => text(k, 1, QUESTION_TEXT_MAX_BYTES)) &&
|
|
198
|
+
Object.values(answers).every((a) => text(a, 1, QUESTION_ANSWER_MAX_BYTES)));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Resolve one owner choice against a question: a 1-based option number (or several, comma
|
|
202
|
+
* separated, for a multiSelect question), an option label (case-insensitive, with or without the
|
|
203
|
+
* recommended suffix), or else free text taken as written. Returns the answer string.
|
|
204
|
+
*/
|
|
205
|
+
export function answerFromChoice(q, choice) {
|
|
206
|
+
const c = choice.trim();
|
|
207
|
+
const byNumber = (s) => {
|
|
208
|
+
if (!/^[1-9][0-9]*$/.test(s))
|
|
209
|
+
return null;
|
|
210
|
+
const o = q.options[Number(s) - 1];
|
|
211
|
+
return o ? o.label : null;
|
|
212
|
+
};
|
|
213
|
+
const parts = q.multiSelect ? c.split(",").map((p) => p.trim()) : [c];
|
|
214
|
+
if (parts.length > 0 && parts.every((p) => byNumber(p) !== null))
|
|
215
|
+
return parts.map((p) => byNumber(p)).join(", ");
|
|
216
|
+
const norm = (s) => s.trim().replace(QUESTION_RECOMMENDED_SUFFIX, "").trim().toLowerCase();
|
|
217
|
+
const hit = q.options.find((o) => norm(o.label) === norm(c));
|
|
218
|
+
return hit ? hit.label : c;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The inbox body of a handed-off answer (§61 (3)): the api renders it, the inbox delivery line
|
|
222
|
+
* (inboxDeliveryText) names the approval, so the body only restates question and answer.
|
|
223
|
+
*/
|
|
224
|
+
export function questionAnswerInboxBody(questions, answers) {
|
|
225
|
+
const lines = ["Your owner answered your question:"];
|
|
226
|
+
for (const q of questions) {
|
|
227
|
+
lines.push("", `Q: ${q.question}`, `A: ${answers[q.question] ?? ""}`);
|
|
228
|
+
}
|
|
229
|
+
return lines.join("\n");
|
|
230
|
+
}
|
|
231
|
+
// ---- wire shapes ----------------------------------------------------------------------------
|
|
232
|
+
/** POST exec_question body: exactly {epoch, nonce, tool_use_id, questions}. */
|
|
233
|
+
export const QUESTION_ASK_BODY_KEYS = ["epoch", "nonce", "tool_use_id", "questions"];
|
|
234
|
+
export function questionAskBodyOk(v) {
|
|
235
|
+
return (isObj(v) &&
|
|
236
|
+
keysExactly(v, QUESTION_ASK_BODY_KEYS) &&
|
|
237
|
+
int(v.epoch) &&
|
|
238
|
+
nonEmpty(v.nonce) &&
|
|
239
|
+
typeof v.tool_use_id === "string" &&
|
|
240
|
+
QUESTION_TOOL_USE_ID_RE.test(v.tool_use_id) &&
|
|
241
|
+
questionsOk(v.questions));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* POST exec_question answer: exactly {approval_id, question_sha, created, expires_at}. created
|
|
245
|
+
* false: the session's open question was returned, which the api does only when its question_sha
|
|
246
|
+
* equals this ask's (a different open question is 409 durable_question_busy). The seat checks it
|
|
247
|
+
* anyway (questionAskMatches) before it polls.
|
|
248
|
+
*/
|
|
249
|
+
export function questionAskResponseOk(v) {
|
|
250
|
+
return (isObj(v) &&
|
|
251
|
+
keysExactly(v, ["approval_id", "question_sha", "created", "expires_at"]) &&
|
|
252
|
+
uuid(v.approval_id) &&
|
|
253
|
+
typeof v.question_sha === "string" &&
|
|
254
|
+
HEX64_RE.test(v.question_sha) &&
|
|
255
|
+
typeof v.created === "boolean" &&
|
|
256
|
+
ts(v.expires_at));
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The seat's own check of an exec_question answer: it is about THIS ask (question_sha equal to the
|
|
260
|
+
* sha of {session_id, tool_use_id, questions}). The seat polls only a matching approval; otherwise
|
|
261
|
+
* it denies the tool call without claiming the owner has the question.
|
|
262
|
+
*/
|
|
263
|
+
export async function questionAskMatches(answer, ask) {
|
|
264
|
+
return answer.question_sha === (await questionSha(ask));
|
|
265
|
+
}
|
|
266
|
+
/** POST exec_question_poll / exec_question_release body: exactly {epoch, nonce, approval_id}. */
|
|
267
|
+
export const QUESTION_POLL_BODY_KEYS = ["epoch", "nonce", "approval_id"];
|
|
268
|
+
export function questionPollBodyOk(v) {
|
|
269
|
+
return (isObj(v) &&
|
|
270
|
+
keysExactly(v, QUESTION_POLL_BODY_KEYS) &&
|
|
271
|
+
int(v.epoch) &&
|
|
272
|
+
nonEmpty(v.nonce) &&
|
|
273
|
+
uuid(v.approval_id));
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* POST exec_question_poll answer: exactly {approval_id, state, answers}. answers is the answers map
|
|
277
|
+
* iff state is answered (the poll that returns it marks it taken: the hand-off path never delivers
|
|
278
|
+
* it again), else null.
|
|
279
|
+
*/
|
|
280
|
+
export function questionPollResponseOk(v) {
|
|
281
|
+
return (isObj(v) &&
|
|
282
|
+
keysExactly(v, ["approval_id", "state", "answers"]) &&
|
|
283
|
+
uuid(v.approval_id) &&
|
|
284
|
+
oneOf(v.state, QUESTION_STATES) &&
|
|
285
|
+
(v.state === "answered" ? answersShapeOk(v.answers) : v.answers === null));
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* POST exec_question_release answer: exactly {approval_id, state, inbox_id}. state is handed_off,
|
|
289
|
+
* or the final state the question already reached; inbox_id names the inbox message the answer was
|
|
290
|
+
* queued as (released after the owner answered and before the seat took it), else null.
|
|
291
|
+
*/
|
|
292
|
+
export function questionReleaseResponseOk(v) {
|
|
293
|
+
return (isObj(v) &&
|
|
294
|
+
keysExactly(v, ["approval_id", "state", "inbox_id"]) &&
|
|
295
|
+
uuid(v.approval_id) &&
|
|
296
|
+
oneOf(v.state, QUESTION_STATES) &&
|
|
297
|
+
uuid(v.inbox_id, true));
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* One own_questions item, exactly these keys; the owner reads the question text (decrypted by the
|
|
301
|
+
* api). recommended: per question, the index recommendedOption returns (null when none).
|
|
302
|
+
*/
|
|
303
|
+
export const QUESTION_LIST_ITEM = Object.freeze({
|
|
304
|
+
approval_id: "uuid",
|
|
305
|
+
state: "question_state",
|
|
306
|
+
questions: "questions",
|
|
307
|
+
recommended: "(integer|null)[]",
|
|
308
|
+
answers: "answers|null",
|
|
309
|
+
answered_via: "question_answer_via|null",
|
|
310
|
+
created_at: "timestamp",
|
|
311
|
+
expires_at: "timestamp",
|
|
312
|
+
answered_at: "timestamp|null",
|
|
313
|
+
});
|
|
314
|
+
export function questionListItemOk(v) {
|
|
315
|
+
if (!isObj(v) || !keysExactly(v, Object.keys(QUESTION_LIST_ITEM)))
|
|
316
|
+
return false;
|
|
317
|
+
if (!uuid(v.approval_id) || !oneOf(v.state, QUESTION_STATES) || !questionsOk(v.questions))
|
|
318
|
+
return false;
|
|
319
|
+
const qs = v.questions;
|
|
320
|
+
if (!Array.isArray(v.recommended) ||
|
|
321
|
+
v.recommended.length !== qs.length ||
|
|
322
|
+
!v.recommended.every((r, i) => r === recommendedOption(qs[i])))
|
|
323
|
+
return false;
|
|
324
|
+
const answered = v.answers !== null;
|
|
325
|
+
if (answered && !answersOk(v.answers, qs))
|
|
326
|
+
return false;
|
|
327
|
+
if (answered !== (v.answered_via !== null))
|
|
328
|
+
return false;
|
|
329
|
+
if (answered && !oneOf(v.answered_via, QUESTION_ANSWER_VIAS))
|
|
330
|
+
return false;
|
|
331
|
+
return ts(v.created_at) && ts(v.expires_at) && ts(v.answered_at, true);
|
|
332
|
+
}
|
|
333
|
+
/** GET own_questions: exactly {questions: [item]}, newest first, at most QUESTION_LIST_LIMIT. */
|
|
334
|
+
export function questionListResponseOk(v) {
|
|
335
|
+
return (isObj(v) &&
|
|
336
|
+
keysExactly(v, ["questions"]) &&
|
|
337
|
+
Array.isArray(v.questions) &&
|
|
338
|
+
v.questions.length <= QUESTION_LIST_LIMIT &&
|
|
339
|
+
v.questions.every(questionListItemOk));
|
|
340
|
+
}
|
|
341
|
+
/** POST own_answer body: {approval_id, via} plus exactly one of answers or dismiss: true. */
|
|
342
|
+
export const QUESTION_ANSWER_BODY_KEYS = ["approval_id", "via", "answers", "dismiss"];
|
|
343
|
+
export function questionAnswerBodyOk(v) {
|
|
344
|
+
if (!isObj(v) || !keysWithin(v, ["approval_id", "via"], ["answers", "dismiss"]))
|
|
345
|
+
return false;
|
|
346
|
+
if (!uuid(v.approval_id) || !oneOf(v.via, QUESTION_ANSWER_VIAS))
|
|
347
|
+
return false;
|
|
348
|
+
if ("answers" in v === "dismiss" in v)
|
|
349
|
+
return false;
|
|
350
|
+
return "dismiss" in v ? v.dismiss === true : answersShapeOk(v.answers);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* POST own_answer answer: exactly {approval_id, outcome, delivery, inbox_id}. delivery says how the
|
|
354
|
+
* answer reaches the seat: `live` (the seat's hook is waiting and takes it on its next poll),
|
|
355
|
+
* `inbox` (handed off: inbox_id names the message), or `none` (dismissed).
|
|
356
|
+
*/
|
|
357
|
+
export const QUESTION_DELIVERIES = ["live", "inbox", "none"];
|
|
358
|
+
export function questionAnswerResponseOk(v) {
|
|
359
|
+
return (isObj(v) &&
|
|
360
|
+
keysExactly(v, ["approval_id", "outcome", "delivery", "inbox_id"]) &&
|
|
361
|
+
uuid(v.approval_id) &&
|
|
362
|
+
oneOf(v.outcome, QUESTION_OUTCOMES) &&
|
|
363
|
+
oneOf(v.delivery, QUESTION_DELIVERIES) &&
|
|
364
|
+
(v.delivery === "inbox" ? uuid(v.inbox_id) : v.inbox_id === null) &&
|
|
365
|
+
(v.outcome === "dismissed" ? v.delivery === "none" : v.delivery !== "none"));
|
|
366
|
+
}
|
|
367
|
+
/** The journal idempotency keys the api uses for question events. */
|
|
368
|
+
export const questionIdempotencyKeys = {
|
|
369
|
+
asked: (approvalId) => `question:${approvalId}:asked`,
|
|
370
|
+
answered: (approvalId) => `question:${approvalId}:answered`,
|
|
371
|
+
};
|
|
372
|
+
/** A uuid as the routes take it (re-exported for the CLI's qid argument). */
|
|
373
|
+
export const QUESTION_ID_RE = UUID_RE;
|