your-attention 0.1.0 → 0.1.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/README.md +2 -0
- package/bin/your-attention.js +6 -2
- package/dist/src/adapters/files.js +66 -0
- package/dist/src/adapters/mdns.js +53 -0
- package/dist/src/adapters/mermaid.js +50 -0
- package/dist/src/adapters/openui-body.js +40 -0
- package/dist/src/adapters/openui-library.js +3 -0
- package/dist/src/adapters/sqlite.js +68 -0
- package/dist/src/cli.js +814 -0
- package/dist/src/client.js +459 -0
- package/dist/src/cloudflare/collector.js +53 -0
- package/dist/src/cloudflare/inbox-object.js +338 -0
- package/dist/src/cloudflare/storage.js +151 -0
- package/dist/src/cloudflare/worker.js +189 -0
- package/dist/src/core/crypto.js +23 -0
- package/dist/src/core/diagrams.js +225 -0
- package/dist/src/core/expr.js +125 -0
- package/dist/src/core/file-links.js +48 -0
- package/dist/src/core/hooks.js +74 -0
- package/dist/src/core/http.js +651 -0
- package/dist/src/core/inbox.js +2463 -0
- package/dist/src/core/pin.js +234 -0
- package/dist/src/core/sealed.js +167 -0
- package/dist/src/core/session-link.js +13 -0
- package/dist/src/core/socket.js +88 -0
- package/dist/src/core/storage.js +102 -0
- package/dist/src/core/summary.js +149 -0
- package/dist/src/core/telemetry.js +221 -0
- package/dist/src/core/types.js +470 -0
- package/dist/src/core/version.js +2 -0
- package/dist/src/core/widgets.js +128 -0
- package/dist/src/extensions/triage.js +232 -0
- package/dist/src/integrations/claude/ask-hook.js +84 -0
- package/dist/src/integrations/claude/channel-flag.js +102 -0
- package/dist/src/integrations/claude/channel.js +276 -0
- package/dist/src/integrations/claude/session-name.js +32 -0
- package/dist/src/integrations/claude/turn-end-hook.js +84 -0
- package/dist/src/integrations/herdr/approve.js +71 -0
- package/dist/src/integrations/herdr/blocked.js +210 -0
- package/dist/src/integrations/herdr/dialog.js +64 -0
- package/dist/src/integrations/herdr/focus.js +31 -0
- package/dist/src/integrations/herdr/place.js +22 -0
- package/dist/src/integrations/images.js +43 -0
- package/dist/src/integrations/opencode/install.js +24 -0
- package/dist/src/integrations/opencode/plugin.js +157 -0
- package/dist/src/integrations/pi/extension.js +166 -0
- package/dist/src/integrations/run-tool.js +157 -0
- package/dist/src/integrations/secrets.js +63 -0
- package/dist/src/integrations/tags.js +27 -0
- package/dist/src/integrations/tools.js +147 -0
- package/dist/src/node/home.js +20 -0
- package/dist/src/node/install.js +225 -0
- package/dist/src/node/links.js +249 -0
- package/dist/src/node/pin-prompt.js +125 -0
- package/dist/src/node/server.js +320 -0
- package/dist/src/node/service.js +74 -0
- package/dist/src/node/telemetry.js +127 -0
- package/dist/src/owner-watch.js +49 -0
- package/dist/src/tui/api.js +203 -0
- package/dist/src/tui/app.js +586 -0
- package/dist/src/tui/body.js +727 -0
- package/dist/src/tui/cardview.js +453 -0
- package/dist/src/tui/doc.js +157 -0
- package/dist/src/tui/edit.js +119 -0
- package/dist/src/tui/input.js +192 -0
- package/dist/src/tui/md.js +196 -0
- package/dist/src/tui/screen.js +225 -0
- package/dist/src/tui/terminal.js +130 -0
- package/dist/src/tui/text.js +103 -0
- package/dist/src/tui/theme.js +105 -0
- package/dist/src/tui/view.js +314 -0
- package/dist/src/watch.js +104 -0
- package/dist/web/age.js +16 -0
- package/dist/web/answers.js +485 -0
- package/dist/web/app.js +739 -0
- package/dist/web/body-faults.js +69 -0
- package/dist/web/components.js +923 -0
- package/dist/web/copy.js +118 -0
- package/dist/web/details.js +59 -0
- package/dist/web/env.js +97 -0
- package/dist/web/favicon-waiting.svg +1 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +16 -0
- package/dist/web/library-spec.js +33 -0
- package/dist/web/markdown.js +142 -0
- package/dist/web/paste-images.js +193 -0
- package/dist/web/pin-key.js +125 -0
- package/dist/web/pin.js +126 -0
- package/dist/web/queue.js +97 -0
- package/dist/web/sealed.js +13 -0
- package/dist/web/search.js +70 -0
- package/dist/web/stories/cards.stories.js +144 -0
- package/dist/web/stories/page.stories.js +54 -0
- package/dist/web/stories/render.js +16 -0
- package/dist/web/style.css +417 -0
- package/package.json +4 -2
- package/src/core/version.ts +1 -1
|
@@ -0,0 +1,2463 @@
|
|
|
1
|
+
// The inbox: every change to cards, answers, presence and tokens goes through
|
|
2
|
+
// this one object. Storage calls are synchronous, so each change completes
|
|
3
|
+
// before the next starts. Every change is written to storage before a method
|
|
4
|
+
// returns, and memory holds only the open connections.
|
|
5
|
+
import { newSecret, pairingCode, randomId, sha256 } from './crypto.js';
|
|
6
|
+
import { parse, test as testRule, } from './expr.js';
|
|
7
|
+
import { HOOK_VERSION, HOOKS, deliverWebhook, isHook, postSigned, } from './hooks.js';
|
|
8
|
+
import { isSealed, sealedFields, secretInputs, withoutSealed } from './sealed.js';
|
|
9
|
+
import { MAX_DIAGRAMS, bodyProblemsMessage, checkDiagrams, diagramMessage, findDiagrams, requireChecked, } from './diagrams.js';
|
|
10
|
+
import { BUILTIN_WIDGETS, distinctWidgets, expandWidgets, fill, respond, } from './widgets.js';
|
|
11
|
+
import { PinLock } from './pin.js';
|
|
12
|
+
import { MemoryBlobs, } from './storage.js';
|
|
13
|
+
import { cleanTags, SummarizedStorage, } from './summary.js';
|
|
14
|
+
import { Usage } from './telemetry.js';
|
|
15
|
+
import { MAX_TAGS } from './types.js';
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
export class InboxError extends Error {
|
|
22
|
+
status ;
|
|
23
|
+
constructor(status , message ) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.status = status;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
/** Types an approved sentence into a herdr pane as the person's message, and names a pane's agent and tab. */
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
/** A herdr pane id, such as w6P:p2. It never starts with a dash, so herdr cannot read it as a flag. */
|
|
42
|
+
export const PANE_ID = /^w[A-Za-z0-9]+:p[A-Za-z0-9]+$/;
|
|
43
|
+
export const MAX_APPROVALS = 5;
|
|
44
|
+
export const MAX_APPROVAL_CHARS = 1000;
|
|
45
|
+
const MAX_APPROVAL_LABEL_CHARS = 120;
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
export const SYSTEM_AGENT = 'system';
|
|
75
|
+
/** The inbox itself, as the caller of the system cards it posts. */
|
|
76
|
+
const INBOX = { token: '', role: 'owner' };
|
|
77
|
+
const PAIRING_TTL_MS = 10 * 60 * 1000;
|
|
78
|
+
const MAX_PENDING_PAIRINGS = 5;
|
|
79
|
+
const CLOSE_KINDS = new Set (['retracted', 'self-answered', 'answered-in-terminal', 'resolved']);
|
|
80
|
+
const HISTORY_LIMIT = 20;
|
|
81
|
+
/** How long an agent stays online after its last connection closes, so a reconnect or the next long-poll keeps it online. */
|
|
82
|
+
export const PRESENCE_GRACE_MS = 2000;
|
|
83
|
+
/** How long the inbox remembers a message id, to apply a message once however many links bring it. */
|
|
84
|
+
export const MESSAGE_MEMORY_MS = 24 * 60 * 60 * 1000;
|
|
85
|
+
/** How long a message id stays reserved while its first copy is applied. */
|
|
86
|
+
export const MESSAGE_RESERVATION_MS = 30_000;
|
|
87
|
+
/** An open card whose agent has been offline this long is orphaned. */
|
|
88
|
+
export const ORPHAN_MS = 60 * 60 * 1000;
|
|
89
|
+
export const DEFAULT_PRIORITIES = [
|
|
90
|
+
{ id: 'now', label: 'Decide now', definition: 'The agent cannot go on without the answer.', color: '#e5484d' },
|
|
91
|
+
{ id: 'soon', label: 'Decide soon', definition: 'The agent needs the answer before it finishes.', color: '#c9a100' },
|
|
92
|
+
{ id: 'later', label: 'Can wait', definition: 'The answer refines the work later.' },
|
|
93
|
+
];
|
|
94
|
+
/** The version of `DEFAULT_GUIDANCE`. It moves whenever that text changes, so a client reads the words it has not seen. */
|
|
95
|
+
export const DEFAULT_GUIDANCE_VERSION = 5;
|
|
96
|
+
export const DEFAULT_GUIDANCE = [
|
|
97
|
+
'On a card, "you" is the person who reads it. Write about yourself in the third person, as "this agent" or the name in `from`, never "I" or "we", so the person always knows who acts.',
|
|
98
|
+
'Write each card so the person can answer it without opening the session.',
|
|
99
|
+
'State the goal, the options you weighed, the evidence, and your recommendation.',
|
|
100
|
+
'Shape the answer options to the question. The person may leave any field empty.',
|
|
101
|
+
'Put each question next to the field that answers it. One question uses options. Two or more questions use questions, each with its own text and options, and never one options list after all the text. The page draws each question as its own section with a comment field. A question that needs a table or a widget beside it uses a form in the body.',
|
|
102
|
+
'Explain each option inside it, in its description: what this agent does if the person picks it, such as "This agent reruns the deploy." Never list the options with their explanations in the text.',
|
|
103
|
+
'Mark the option this agent recommends with recommended: true, and put only the reasons in the recommendation. The page shows them inside that option, so the person reads the pick once.',
|
|
104
|
+
'Never list questions in the text with the answers at the bottom. Write what you will check yourself as statements, not questions.',
|
|
105
|
+
'The page adds a comment box, Send answer and Skip at the bottom of every card. Add no general comment box or send button of your own.',
|
|
106
|
+
'When a card body or HTML asks several questions, give each one its own short comment field beside it, so the person can say why for that question alone.',
|
|
107
|
+
'When a card is about another agent, link its session so the person can go there in one click: [label](https://herdr.invalid/v1/pane/<pane id>), from `herdr agent list`. Link another card with [[card-id]].',
|
|
108
|
+
'Put text the person should copy, such as a command or an approval sentence, in inline code or a fenced block, and the page lets the person copy it with a click.',
|
|
109
|
+
'When an action needs the person\'s own words in a session, post the sentence as an approval on the card instead of asking the person to type it there.',
|
|
110
|
+
'A card title is one line of plain text: no Markdown and no links. Name the thing there, as "PR #93 — an empty list no longer looks like a success". Put the link in `text`, or in a field label in the body: both read inline Markdown.',
|
|
111
|
+
'To follow up on a card, post a new card with `follows: "<card id>"`. The inbox links the two cards both ways, and there is no reply to a card.',
|
|
112
|
+
'Never ask for a secret, such as an API key, in a text field. Place the secret widget in the card body, `key = widget secret {"name": "API_KEY"}`, and card_post seals it for this session.',
|
|
113
|
+
'Search earlier answers before you post, and never ask the same thing twice.',
|
|
114
|
+
].join('\n');
|
|
115
|
+
export const MAX_ATTACHMENTS = 10;
|
|
116
|
+
export const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
|
117
|
+
export const MAX_ANSWER_IMAGES = 10;
|
|
118
|
+
/** How many card ids `unread` names before it only counts. A command line has a length. */
|
|
119
|
+
export const UNREAD_ID_LIMIT = 20;
|
|
120
|
+
// The images of one answer share a byte budget, because the route reads a body of 16 MB and base64 costs four thirds:
|
|
121
|
+
// ten images of 8 MB each would make a body no route accepts.
|
|
122
|
+
export const MAX_ANSWER_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
123
|
+
const ANSWER_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
124
|
+
/** Each stored file counts as at least this many bytes toward the upload limit, so tiny files cannot make unlimited blob writes. */
|
|
125
|
+
export const MIN_FILE_CHARGE = 64 * 1024;
|
|
126
|
+
/** The largest card, as stored, without its files. */
|
|
127
|
+
export const MAX_CARD_BYTES = 256 * 1024;
|
|
128
|
+
export const MAX_SOURCE_BYTES = 64 * 1024;
|
|
129
|
+
export const MAX_FROM_CHARS = 80;
|
|
130
|
+
export const MAX_VERSIONS = 50;
|
|
131
|
+
export const MAX_WEBHOOKS = 5;
|
|
132
|
+
export const MAX_SUBSCRIPTIONS = 20;
|
|
133
|
+
const MAX_WEBHOOK_HEADERS = 10;
|
|
134
|
+
/** How long one call to an answer webhook may take. */
|
|
135
|
+
export const ANSWER_WEBHOOK_TIMEOUT_MS = 10_000;
|
|
136
|
+
/** A call that started this long ago and never finished, such as one a crash cut off, goes again. */
|
|
137
|
+
const DELIVERY_LEASE_MS = ANSWER_WEBHOOK_TIMEOUT_MS + 5_000;
|
|
138
|
+
/** The first retry of an answer webhook waits this long, and each later one twice as long, up to an hour. */
|
|
139
|
+
export const RETRY_FIRST_MS = 5_000;
|
|
140
|
+
const RETRY_MAX_MS = 60 * 60 * 1000;
|
|
141
|
+
/** After this long the inbox stops calling a webhook for one answer. The answer still waits for the agent stream. */
|
|
142
|
+
export const RETRY_GIVE_UP_MS = 24 * 60 * 60 * 1000;
|
|
143
|
+
/** Headers the inbox sets on each webhook call itself, or that the platform sets. */
|
|
144
|
+
const RESERVED_HEADER = /^(content-type|content-length|host|transfer-encoding|connection|x-your-attention-.*)$/i;
|
|
145
|
+
/** The pinned tag values one inbox keeps. A tag key or value is cut to 100 characters, as in the card's summary. */
|
|
146
|
+
export const MAX_PINS = 20;
|
|
147
|
+
export const MAX_TAG_CHARS = 100;
|
|
148
|
+
/** How long a sign-in code from the phone dialog works. */
|
|
149
|
+
export const SIGN_IN_CODE_TTL_MS = 5 * 60 * 1000;
|
|
150
|
+
const MAX_SIGN_IN_CODES = 5;
|
|
151
|
+
/** The most tag keys GET /v1/guidance lists, and the most values it lists for each key, so a busy inbox stays short. */
|
|
152
|
+
export const MAX_TAG_KEYS = 20;
|
|
153
|
+
export const MAX_TAG_VALUES = 15;
|
|
154
|
+
/** How long a closed pairing request and its card stay before the sweep deletes them. */
|
|
155
|
+
export const PAIRING_KEEP_MS = 24 * 60 * 60 * 1000;
|
|
156
|
+
/** How long a cleared card, or a card nobody answered, stays after its last change before the purge deletes it. */
|
|
157
|
+
export const PURGE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
|
158
|
+
/** Where the purge keeps the files a delete could not remove, so a laptop retries them on its next pass. */
|
|
159
|
+
const PENDING_FILE_DELETES = 'pending-file-deletes';
|
|
160
|
+
/** The most cards one purge pass deletes, so no alarm does unbounded work. */
|
|
161
|
+
export const PURGE_BATCH = 100;
|
|
162
|
+
/** The share of the storage limit the purge keeps free, by deleting the oldest of those cards sooner. */
|
|
163
|
+
export const STORAGE_HEADROOM = 0.2;
|
|
164
|
+
/** How long the sweep keeps the presence row of an agent after it went offline. */
|
|
165
|
+
export const PRESENCE_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
|
|
166
|
+
|
|
167
|
+
const PATCH_FIELDS = new Set(['title', 'text', 'body', 'html', 'recommendation']);
|
|
168
|
+
const DELIVERIES = new Set (['default', 'steer', 'followUp', 'force']);
|
|
169
|
+
|
|
170
|
+
/** A card's state follows from its closure and its answer. */
|
|
171
|
+
export function cardState(card , answer ) {
|
|
172
|
+
// An answer saved after the agent closed the card still goes to the agent.
|
|
173
|
+
if (card.closed && !(answer && answer.savedAt > card.closed.at)) return 'closed';
|
|
174
|
+
if (!answer && card.triage?.state === 'pending') return 'in-triage';
|
|
175
|
+
if (!answer && card.triage?.state === 'held') return 'held';
|
|
176
|
+
if (!answer) return 'open';
|
|
177
|
+
if (answer.readAt) return 'read';
|
|
178
|
+
if (answer.deliveredAt) return 'delivered';
|
|
179
|
+
return 'answered';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The agent an answer goes to, or null for none. A guidance card closes as the person answers it, and its answer
|
|
183
|
+
* still goes to the agent that proposed the text. */
|
|
184
|
+
function recipient(card , answer ) {
|
|
185
|
+
if (card.kind === 'system' && card.broadcast) return card.broadcast.from && card.token ? card.broadcast.from : null;
|
|
186
|
+
return card.deliver === 'stream' && cardState(card, answer) !== 'closed' ? card.agent : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** One answer waiting for its webhook call, keyed by subscription and card, so a new answer replaces one still waiting. */
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
/** The id of an answer: the same on every webhook call that carries it, and new when the person changes the answer. */
|
|
205
|
+
export function answerId(card , savedAt ) {
|
|
206
|
+
return `${card}@${savedAt}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
/** A token's default tags, by agent id, as the inbox stores them under one `meta` key. */
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The tags a card stores: the agent's default tags under the card's own. A key on the card wins, and a key the card
|
|
221
|
+
* passes with an empty string drops that default for that card alone. Defaults past the tag limit lose, so the card's
|
|
222
|
+
* own tags always fit. Returns the merged tags, and the keys the card took from the defaults.
|
|
223
|
+
*/
|
|
224
|
+
export function mergeDefaultTags(defaults , own ) {
|
|
225
|
+
const tags = {};
|
|
226
|
+
const inherited = [];
|
|
227
|
+
for (const [k, value] of Object.entries(defaults)) {
|
|
228
|
+
// A plain object answers `toString` for every key, so the card's own tags are read by their own key only.
|
|
229
|
+
const mine = own !== undefined && Object.hasOwn(own, k) ? own[k] : undefined;
|
|
230
|
+
if (mine === '') continue;
|
|
231
|
+
if (mine !== undefined) { tags[k] = String(mine); continue; }
|
|
232
|
+
tags[k] = value;
|
|
233
|
+
inherited.push(k);
|
|
234
|
+
}
|
|
235
|
+
for (const [k, value] of Object.entries(own ?? {})) {
|
|
236
|
+
// `in` would see `constructor` and `toString` on any object, so a tag by those names would be dropped.
|
|
237
|
+
if (Object.hasOwn(defaults, k) || value === '') continue;
|
|
238
|
+
tags[k] = String(value);
|
|
239
|
+
}
|
|
240
|
+
// The card's own tags come last in insertion order, so the defaults give way at the limit, and a card that names
|
|
241
|
+
// more than the limit is refused rather than cut.
|
|
242
|
+
while (Object.keys(tags).length > MAX_TAGS && inherited.length) delete tags[inherited.pop() ];
|
|
243
|
+
if (Object.keys(tags).length > MAX_TAGS) throw new InboxError(400, `a card takes at most ${MAX_TAGS} tags, with its agent's defaults`);
|
|
244
|
+
return { tags: Object.keys(tags).length ? tags : undefined, inherited };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export class Inbox {
|
|
248
|
+
store ;
|
|
249
|
+
blobs ;
|
|
250
|
+
uploadLimit ;
|
|
251
|
+
storageLimit ;
|
|
252
|
+
readsPerDay ;
|
|
253
|
+
checkDiagram ;
|
|
254
|
+
checkBody ;
|
|
255
|
+
fallbackBody ;
|
|
256
|
+
clientChecks ;
|
|
257
|
+
shownCardLimit ;
|
|
258
|
+
now ;
|
|
259
|
+
approvalSender ;
|
|
260
|
+
agents = new Map ();
|
|
261
|
+
instances = new WeakMap ();
|
|
262
|
+
pages = new Set ();
|
|
263
|
+
/** The pages that need an unlock token, with the one they opened with and how to close them. */
|
|
264
|
+
guarded = new Map ();
|
|
265
|
+
/** What each open page reports about the person, by page id. */
|
|
266
|
+
viewers = new Map ();
|
|
267
|
+
extensions = new Map ();
|
|
268
|
+
/** Webhook deliveries still running, so tests and shutdown can wait for them. */
|
|
269
|
+
deliveries = new Set ();
|
|
270
|
+
fetcher ;
|
|
271
|
+
/** The usage counts the platform sends once a day, when the person left them on. */
|
|
272
|
+
usage ;
|
|
273
|
+
/** The PIN a browser tab types before it acts as the owner. */
|
|
274
|
+
pin ;
|
|
275
|
+
|
|
276
|
+
/** `keepPresence` is for a platform whose sockets outlive the object, such as a Durable Object that wakes from hibernation.
|
|
277
|
+
* `uploadLimitBytes` caps the bytes of every file this inbox ever stores. Deleting a file gives none back.
|
|
278
|
+
* `storageLimitBytes` caps what the inbox keeps in its storage now. A full inbox takes only writes that free space.
|
|
279
|
+
* `attachmentReadsPerDay` caps how many files the inbox serves in a day. */
|
|
280
|
+
constructor(store , now = Date.now, blobs = new MemoryBlobs(), opts = {}) {
|
|
281
|
+
// Each write to a card or an answer also writes the card's summary, which GET /v1/state reads.
|
|
282
|
+
this.store = new SummarizedStorage(store);
|
|
283
|
+
this.now = now;
|
|
284
|
+
this.blobs = blobs;
|
|
285
|
+
this.uploadLimit = opts.uploadLimitBytes;
|
|
286
|
+
this.storageLimit = opts.storageLimitBytes;
|
|
287
|
+
this.readsPerDay = opts.attachmentReadsPerDay;
|
|
288
|
+
this.checkDiagram = opts.checkDiagram;
|
|
289
|
+
this.checkBody = opts.checkBody;
|
|
290
|
+
this.fallbackBody = opts.fallbackBody;
|
|
291
|
+
this.clientChecks = opts.clientChecks === true;
|
|
292
|
+
this.shownCardLimit = opts.shownCardLimit;
|
|
293
|
+
this.approvalSender = opts.approvals;
|
|
294
|
+
// Workers refuse a fetch called as a method of another object, so the default calls it bare.
|
|
295
|
+
this.fetcher = opts.fetch ?? ((input, init) => fetch(input, init));
|
|
296
|
+
this.usage = new Usage(store, now, { alwaysOn: opts.usageAlwaysOn });
|
|
297
|
+
this.pin = new PinLock(store, now, opts.pinIterations);
|
|
298
|
+
// A page that opened before the PIN changed must unlock again.
|
|
299
|
+
this.pin.onChange(() => this.closeLockedPages());
|
|
300
|
+
markDialogCards(this.store);
|
|
301
|
+
// On a laptop, connections do not survive a restart, so nobody is online at start.
|
|
302
|
+
if (!opts.keepPresence) {
|
|
303
|
+
for (const p of store.list ('presence')) {
|
|
304
|
+
if (p.online) store.put('presence', p.agent, { ...p, online: false });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Store a token whose secret the deployment chose, such as an owner token set as a Worker secret. */
|
|
310
|
+
async adoptToken(secret , name , role ) {
|
|
311
|
+
const id = secret.split('.')[0] ?? '';
|
|
312
|
+
if (!/^[0-9a-f]{12}$/.test(id) || !/^[0-9a-f]{12}\.[0-9a-f]{64}$/.test(secret)) throw new InboxError(400, 'a token is 12 hex digits, a dot, and 64 hex digits');
|
|
313
|
+
const token = { id, hash: await sha256(secret), name, role, createdAt: this.now() };
|
|
314
|
+
this.store.put('tokens', id, token);
|
|
315
|
+
return token;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** When the platform alarm must next call sweep(), or undefined when nothing waits. */
|
|
319
|
+
nextDeadline() {
|
|
320
|
+
const times = [];
|
|
321
|
+
for (const p of this.store.list ('presence')) {
|
|
322
|
+
if (p.online && p.leftAt !== undefined) times.push(p.leftAt + PRESENCE_GRACE_MS);
|
|
323
|
+
else if (!p.online) times.push((p.leftAt ?? p.lastSeen) + PRESENCE_KEEP_MS);
|
|
324
|
+
}
|
|
325
|
+
for (const p of this.store.list ('pairings')) {
|
|
326
|
+
times.push(p.status === 'pending' || p.secret ? p.createdAt + PAIRING_TTL_MS : p.createdAt + PAIRING_KEEP_MS);
|
|
327
|
+
}
|
|
328
|
+
for (const c of this.store.list ('cards')) {
|
|
329
|
+
if (c.triage?.state === 'pending' && c.triage.deadline) times.push(c.triage.deadline);
|
|
330
|
+
if (c.expiresAt && !c.closed && !this.store.get('answers', c.id)) times.push(c.expiresAt);
|
|
331
|
+
}
|
|
332
|
+
const oldest = this.purgeQueue()[0];
|
|
333
|
+
if (oldest) times.push(this.needsRoom() ? this.now() : oldest.since + PURGE_AFTER_MS);
|
|
334
|
+
for (const d of this.store.list ('outbox')) times.push(d.nextAt);
|
|
335
|
+
return times.length ? Math.min(...times) : undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---- tokens and pairing ----
|
|
339
|
+
|
|
340
|
+
/** Create a token and return its secret. The secret is shown once. */
|
|
341
|
+
async createToken(name , role , opts = {}) {
|
|
342
|
+
const id = randomId(6);
|
|
343
|
+
const secret = newSecret(id);
|
|
344
|
+
const token = { id, hash: await sha256(secret), name, role, createdAt: this.now(), ...(opts.pinExempt && role === 'owner' ? { pinExempt: true } : {}) };
|
|
345
|
+
this.store.put('tokens', id, token);
|
|
346
|
+
return { token, secret };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async authenticate(secret ) {
|
|
350
|
+
if (!secret) return undefined;
|
|
351
|
+
const id = secret.split('.')[0] ?? '';
|
|
352
|
+
const token = this.store.get ('tokens', id);
|
|
353
|
+
if (!token) return undefined;
|
|
354
|
+
return (await sha256(secret)) === token.hash ? token : undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
listTokens() {
|
|
358
|
+
return this.store.list ('tokens').map(({ hash: _hash, ...t }) => t);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Whether a token, by id, still exists: nobody signed it out or revoked it. */
|
|
362
|
+
hasToken(id ) {
|
|
363
|
+
return !!this.store.get ('tokens', id);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
revokeToken(id ) {
|
|
367
|
+
if (!this.store.get('tokens', id)) throw new InboxError(404, 'unknown token');
|
|
368
|
+
this.store.delete('tokens', id);
|
|
369
|
+
this.pin.dropUnlocks(id);
|
|
370
|
+
this.removeSubscriptions((s) => s.token === id, 'The token that asked for this webhook was revoked.');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Let an owner token act without the PIN, such as the laptop's owner-token file, which only this machine's user reads. */
|
|
374
|
+
exemptFromPin(id ) {
|
|
375
|
+
const t = this.store.get ('tokens', id);
|
|
376
|
+
if (t && t.role === 'owner' && !t.pinExempt) this.store.put ('tokens', id, { ...t, pinExempt: true });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Whether a caller must unlock with the PIN, and whether it has. Every cookie, and every owner token that can
|
|
380
|
+
* travel as a link, needs the PIN once the inbox has one. `unlock` is the id of the unlock token the caller sent. */
|
|
381
|
+
async pinGate(token , viaCookie , secret ) {
|
|
382
|
+
const gated = token.role === 'owner' && (viaCookie || !token.pinExempt);
|
|
383
|
+
if (!gated) return { gated, locked: false };
|
|
384
|
+
const unlock = await this.pin.verifyUnlock(secret, token.id);
|
|
385
|
+
return { gated, locked: !unlock && this.pin.isSet(), unlock };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
closeLockedPages() {
|
|
389
|
+
if (!this.pin.isSet()) return;
|
|
390
|
+
for (const [send, g] of this.guarded) {
|
|
391
|
+
if (this.pin.live(g.unlock)) continue;
|
|
392
|
+
this.guarded.delete(send);
|
|
393
|
+
g.close();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** The owner's page asks for a code that signs one more device in, such as a phone that scans it. */
|
|
398
|
+
createSignInCode(caller ) {
|
|
399
|
+
if (caller.role !== 'owner') throw new InboxError(403, 'only the owner signs a device in');
|
|
400
|
+
const now = this.now();
|
|
401
|
+
const live = this.signInCodes().filter((c) => {
|
|
402
|
+
if (c.expiresAt > now) return true;
|
|
403
|
+
this.store.delete('meta', c.key);
|
|
404
|
+
return false;
|
|
405
|
+
});
|
|
406
|
+
if (live.length >= MAX_SIGN_IN_CODES) throw new InboxError(429, 'too many sign-in codes are waiting; wait five minutes');
|
|
407
|
+
const code = randomId(16);
|
|
408
|
+
const expiresAt = now + SIGN_IN_CODE_TTL_MS;
|
|
409
|
+
this.store.put('meta', `signin:${code}`, { key: `signin:${code}`, expiresAt });
|
|
410
|
+
return { code, expiresAt };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** A device turns a sign-in code into its own owner token. Each code works once. */
|
|
414
|
+
async redeemSignInCode(code ) {
|
|
415
|
+
const key = `signin:${String(code ?? '')}`;
|
|
416
|
+
const found = /^signin:[0-9a-f]{32}$/.test(key) ? this.store.get ('meta', key) : undefined;
|
|
417
|
+
if (found) this.store.delete('meta', key);
|
|
418
|
+
if (!found || found.expiresAt <= this.now()) throw new InboxError(401, 'this sign-in code is not valid or has expired; scan a new one');
|
|
419
|
+
const { secret } = await this.createToken(`device signed in ${new Date(this.now()).toISOString().slice(0, 16).replace('T', ' ')}`, 'owner');
|
|
420
|
+
return { secret };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
signInCodes() {
|
|
424
|
+
return this.store.list ('meta')
|
|
425
|
+
.filter((m) => typeof m?.key === 'string' && m.key.startsWith('signin:'));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** A client asks to pair. The person sees a system card with the code. */
|
|
429
|
+
requestPairing(name , role ) {
|
|
430
|
+
const clean = String(name || '').trim().slice(0, 80);
|
|
431
|
+
if (!clean) throw new InboxError(400, 'name is required');
|
|
432
|
+
if (role !== 'agent' && role !== 'owner' && role !== 'extension') throw new InboxError(400, 'role must be agent, owner or extension');
|
|
433
|
+
const pending = this.store.list ('pairings').filter((p) => p.status === 'pending');
|
|
434
|
+
if (pending.length >= MAX_PENDING_PAIRINGS) throw new InboxError(429, 'too many pairing requests are waiting');
|
|
435
|
+
const pairing = { id: randomId(8), name: clean, role, code: pairingCode(), status: 'pending', createdAt: this.now() };
|
|
436
|
+
// The request shows as a card, so it needs room before it counts as waiting.
|
|
437
|
+
this.makeRoom(`pair-${pairing.id}`);
|
|
438
|
+
this.store.put('pairings', pairing.id, pairing);
|
|
439
|
+
this.usage.record('pairings');
|
|
440
|
+
this.postCard({ token: '', role: 'owner' }, {
|
|
441
|
+
id: `pair-${pairing.id}`,
|
|
442
|
+
agent: SYSTEM_AGENT,
|
|
443
|
+
kind: 'system',
|
|
444
|
+
title: `Pair ${role === 'owner' ? 'a front-end' : role === 'extension' ? 'an extension' : 'an agent client'}: ${clean}`,
|
|
445
|
+
text: `A client asks for ${role === 'owner' ? 'full access, as a front-end' : role === 'extension' ? 'extension access: it reads every card and can hold or return them' : 'agent access'}. It shows the code ${pairing.code}. Approve only if the client shows the same code.`,
|
|
446
|
+
options: [
|
|
447
|
+
{ id: 'approve', label: 'Approve', description: `The client shows ${pairing.code}` },
|
|
448
|
+
{ id: 'deny', label: 'Deny' },
|
|
449
|
+
],
|
|
450
|
+
priority: 'now',
|
|
451
|
+
deliver: 'none',
|
|
452
|
+
});
|
|
453
|
+
return pairing;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The client collects its token. The secret is returned once. */
|
|
457
|
+
collectPairing(id ) {
|
|
458
|
+
const p = this.store.get ('pairings', id);
|
|
459
|
+
if (!p) throw new InboxError(404, 'unknown pairing');
|
|
460
|
+
if (p.status !== 'approved' || !p.secret) return { status: p.status };
|
|
461
|
+
const { secret, ...rest } = p;
|
|
462
|
+
this.store.put('pairings', id, rest);
|
|
463
|
+
return { status: p.status, token: secret };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async resolvePairing(card , answer ) {
|
|
467
|
+
const id = card.id.slice('pair-'.length);
|
|
468
|
+
const p = this.store.get ('pairings', id);
|
|
469
|
+
if (!p || p.status !== 'pending') return;
|
|
470
|
+
const choice = [answer.values['choice']].flat()[0];
|
|
471
|
+
if (choice === 'approve') {
|
|
472
|
+
// Mark the request first, so a second answer during the await mints nothing.
|
|
473
|
+
this.store.put ('pairings', id, { ...p, status: 'approved' });
|
|
474
|
+
// The person approved this client on an unlocked page, and its token stays on that machine.
|
|
475
|
+
const { secret } = await this.createToken(p.name, p.role, { pinExempt: p.role === 'owner' });
|
|
476
|
+
this.store.put ('pairings', id, { ...p, status: 'approved', secret });
|
|
477
|
+
this.closeAs(card, 'resolved', `Paired ${p.name}.`);
|
|
478
|
+
} else {
|
|
479
|
+
this.store.put ('pairings', id, { ...p, status: 'denied' });
|
|
480
|
+
this.closeAs(card, 'resolved', `Denied ${p.name}.`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ---- cards ----
|
|
485
|
+
|
|
486
|
+
/** `checked` holds what checkCard found for the card's diagrams. A card with a diagram it does not hold is refused.
|
|
487
|
+
* `opts.revise` marks a patch, which edits one field and keeps the tags the card already carries. */
|
|
488
|
+
postCard(caller , input , storedFiles , checked , places , opts = {}) {
|
|
489
|
+
if (!input || typeof input !== 'object') throw new InboxError(400, 'card must be an object');
|
|
490
|
+
const title = String(input.title ?? '').trim();
|
|
491
|
+
if (!title) throw new InboxError(400, 'title is required');
|
|
492
|
+
const agent = String(input.agent ?? '').trim();
|
|
493
|
+
if (!agent) throw new InboxError(400, 'agent is required');
|
|
494
|
+
if (agent.length > 80) throw new InboxError(400, 'agent must be at most 80 characters');
|
|
495
|
+
if (title.length > 300) throw new InboxError(400, 'title must be at most 300 characters');
|
|
496
|
+
if (agent === SYSTEM_AGENT && caller.token !== '') throw new InboxError(403, 'the system agent is reserved');
|
|
497
|
+
this.makeRoom(input.id);
|
|
498
|
+
const levels = this.priorities();
|
|
499
|
+
const priority = input.priority ?? (levels.some((l) => l.id === 'soon') ? 'soon' : levels[0]?.id ?? 'soon');
|
|
500
|
+
if (!levels.some((l) => l.id === priority)) throw new InboxError(400, `priority must be one of ${levels.map((l) => l.id).join(', ')}`);
|
|
501
|
+
if (input.rating !== undefined && !(Number.isInteger(input.rating) && input.rating >= 1 && input.rating <= 5)) throw new InboxError(400, 'rating must be 1 to 5');
|
|
502
|
+
if (!input.multiple && Array.isArray(input.options) && input.options.filter((o) => o?.recommended === true).length > 1) throw new InboxError(400, 'a card without multiple takes at most one recommended option');
|
|
503
|
+
checkQuestions(input.questions);
|
|
504
|
+
const id = input.id ? String(input.id) : randomId(8);
|
|
505
|
+
if (!/^[\w.:-]{1,80}$/.test(id)) throw new InboxError(400, 'id must be 1 to 80 letters, digits, dots, colons, dashes or underscores');
|
|
506
|
+
|
|
507
|
+
const kind = input.kind ?? 'ask';
|
|
508
|
+
if (kind === 'system' && caller.token !== '') throw new InboxError(400, 'a system card from a client asks the inbox for something: send system.type subscribe or unsubscribe');
|
|
509
|
+
// A secret card that an older inbox stored still takes revisions. A new one is refused.
|
|
510
|
+
const before = this.store.get ('cards', id);
|
|
511
|
+
if (kind === 'secret' && before?.kind !== 'secret') throw new InboxError(400, 'the secret card kind was removed. Place the secret widget in the card body instead: key = widget secret {"name": "API_KEY"}. The card_post tool fills its public key. A session that started before this change must restart to load that tool');
|
|
512
|
+
const now = this.now();
|
|
513
|
+
if (before && before.token !== caller.token && caller.role !== 'owner') throw new InboxError(403, 'another client owns this card');
|
|
514
|
+
// A card that carries an answer or a draft already names an option by its label. An id derived now would leave
|
|
515
|
+
// that pick pointing at no option, so the card keeps its options as they are.
|
|
516
|
+
const picked = !!this.store.get ('answers', id) || !!before?.closed?.draft;
|
|
517
|
+
// A revision keeps what herdr answered about a pane it does not change, since only herdr sets a place.
|
|
518
|
+
const approvals = this.cleanApprovals(input.approvals, kind, places)?.map((a, i) => {
|
|
519
|
+
const kept = a.place ?? (before?.approvals?.[i]?.pane === a.pane ? before.approvals[i].place : undefined);
|
|
520
|
+
return kept ? { ...a, place: kept } : a;
|
|
521
|
+
});
|
|
522
|
+
const { body, template, expanded } = this.cardBody(input);
|
|
523
|
+
// The page drops what it cannot read of a body without a word, so the agent hears it here.
|
|
524
|
+
const bodyProblems = body === undefined || !this.checkBody || this.clientChecks ? [] : this.checkBody(String(body));
|
|
525
|
+
if (bodyProblems.length) throw new InboxError(400, bodyProblemsMessage(bodyProblems));
|
|
526
|
+
const secrets = body === undefined ? [] : secretInputs(String(body));
|
|
527
|
+
let widgets = before?.widgets;
|
|
528
|
+
let responses = before?.responses;
|
|
529
|
+
if (expanded) {
|
|
530
|
+
widgets = expanded.used.length ? distinctWidgets(expanded.used) : undefined;
|
|
531
|
+
responses = expanded.responses.length ? expanded.responses : undefined;
|
|
532
|
+
}
|
|
533
|
+
let attachments = before?.attachments;
|
|
534
|
+
if (input.attachments !== undefined) {
|
|
535
|
+
if (!Array.isArray(input.attachments) || input.attachments.length > MAX_ATTACHMENTS) throw new InboxError(400, `a card takes at most ${MAX_ATTACHMENTS} attachments`);
|
|
536
|
+
attachments = input.attachments.map((f, i) => {
|
|
537
|
+
if (!f || typeof f !== 'object') throw new InboxError(400, `attachment ${index(i)}: must be an object with src`);
|
|
538
|
+
if (f.ref) {
|
|
539
|
+
// A reference names a file this inbox stored: one postCardWithFiles just stored, or one the card already has.
|
|
540
|
+
const kept = before?.attachments?.find((a) => a.id !== undefined && a.id === f.ref?.id);
|
|
541
|
+
if (kept) return kept;
|
|
542
|
+
if (f.ref.id && storedFiles?.has(f.ref.id)) return f.ref;
|
|
543
|
+
throw new InboxError(400, `attachment ${index(i)}: ref must name a file this card already has`);
|
|
544
|
+
}
|
|
545
|
+
const src = String(f.src ?? '');
|
|
546
|
+
if (!/^https:\/\//.test(src)) throw new InboxError(400, `attachment ${index(i)}: src must be an https URL, or a data: URL posted through postCardWithFiles`);
|
|
547
|
+
let last ;
|
|
548
|
+
try { last = decodeURIComponent(new URL(src).pathname.split('/').pop() || 'file'); } catch { throw new InboxError(400, `attachment ${index(i)}: src is not a valid URL`); }
|
|
549
|
+
const name = cleanName(f.name) || last;
|
|
550
|
+
return { url: src, name, type: typeFromName(name), alt: f.alt ? String(f.alt).slice(0, 300) : undefined };
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
const history = before
|
|
554
|
+
? [...(before.history ?? []), { version: before.version, title: before.title, text: before.text, recommendation: before.recommendation, body: before.body, html: before.html, at: before.updatedAt }].slice(-HISTORY_LIMIT)
|
|
555
|
+
: [];
|
|
556
|
+
// Every card an agent posts inherits its default tags, under the card's own. The inbox's own system cards keep none.
|
|
557
|
+
const mergedTags = caller.token === '' || kind === 'system' || opts.revise === true
|
|
558
|
+
? { tags: input.tags }
|
|
559
|
+
: mergeDefaultTags(this.tagValues(caller.token, agent), ownTags(input.tags));
|
|
560
|
+
const card = {
|
|
561
|
+
id,
|
|
562
|
+
agent,
|
|
563
|
+
token: before?.token ?? caller.token,
|
|
564
|
+
kind,
|
|
565
|
+
title,
|
|
566
|
+
text: input.text,
|
|
567
|
+
recommendation: input.recommendation,
|
|
568
|
+
body,
|
|
569
|
+
options: picked ? withKeptOptionIds(input.options, before) : withOptionIds(input.options),
|
|
570
|
+
multiple: input.multiple,
|
|
571
|
+
questions: input.questions,
|
|
572
|
+
approvals,
|
|
573
|
+
dialog: input.dialog === true ? true : undefined,
|
|
574
|
+
screen: input.screen,
|
|
575
|
+
secret: kind === 'secret' ? before?.secret : undefined,
|
|
576
|
+
secrets: secrets.length ? secrets : undefined,
|
|
577
|
+
html: input.html,
|
|
578
|
+
attachments: attachments?.length ? attachments : undefined,
|
|
579
|
+
template: template ?? before?.template,
|
|
580
|
+
widgets,
|
|
581
|
+
responses,
|
|
582
|
+
priority,
|
|
583
|
+
status: input.status ?? before?.status,
|
|
584
|
+
expiresAt: input.expiresAt,
|
|
585
|
+
rating: input.rating,
|
|
586
|
+
triage: before?.triage,
|
|
587
|
+
history: history.length ? history : undefined,
|
|
588
|
+
broadcast: before?.broadcast,
|
|
589
|
+
origin: cleanOrigin(input.origin),
|
|
590
|
+
from: input.from === undefined || input.from === null ? undefined : String(input.from).trim().slice(0, MAX_FROM_CHARS) || undefined,
|
|
591
|
+
tags: mergedTags.tags,
|
|
592
|
+
follows: input.follows ?? before?.follows,
|
|
593
|
+
followedBy: before?.followedBy,
|
|
594
|
+
deliver: input.deliver ?? 'stream',
|
|
595
|
+
version: (before?.version ?? 0) + 1,
|
|
596
|
+
createdAt: before?.createdAt ?? now,
|
|
597
|
+
updatedAt: now,
|
|
598
|
+
// A card triage sent back opens again when the agent revises it.
|
|
599
|
+
closed: before?.closed?.kind === 'returned' ? undefined : before?.closed,
|
|
600
|
+
replies: before?.replies ?? [],
|
|
601
|
+
};
|
|
602
|
+
// The inbox's own system cards quote an agent's words, such as proposed guidance, and the page shows a diagram there as its source.
|
|
603
|
+
if (!this.clientChecks && (kind !== 'system' || caller.token !== '')) requireChecked(findDiagrams(card), checked);
|
|
604
|
+
// History has its own cap, and the replies of older cards are stored data the card cap leaves out.
|
|
605
|
+
const size = new TextEncoder().encode(JSON.stringify({ ...card, history: undefined, replies: undefined })).length;
|
|
606
|
+
if (size > MAX_CARD_BYTES) throw new InboxError(413, `the card is ${size} bytes, over the limit of ${MAX_CARD_BYTES}. Attach large content as a file`);
|
|
607
|
+
this.store.put('cards', id, card);
|
|
608
|
+
this.store.index(id, searchText(card));
|
|
609
|
+
if (card.version === 1 && caller.token !== '') {
|
|
610
|
+
this.usage.record('cards');
|
|
611
|
+
this.usage.saw('tokens', caller.token);
|
|
612
|
+
}
|
|
613
|
+
if (card.follows && card.follows !== id) {
|
|
614
|
+
const prev = this.store.get ('cards', card.follows);
|
|
615
|
+
if (prev) {
|
|
616
|
+
this.store.put('cards', prev.id, { ...prev, followedBy: id });
|
|
617
|
+
this.emit({ type: 'change', card: prev.id });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
this.emit({ type: 'change', card: id });
|
|
621
|
+
// Agent cards go through the card.created hook before the person sees them.
|
|
622
|
+
if ((kind === 'ask' || kind === 'secret') && caller.token !== '') return this.requestTriage(card);
|
|
623
|
+
return card;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** An inbox with a limit shows at most that many cards, so its state fits the hosted inbox's CPU time. The limit counts
|
|
627
|
+
* the inbox's own cards too, such as pairing requests, which a client without a token can make. A revision always fits. */
|
|
628
|
+
makeRoom(id ) {
|
|
629
|
+
const limit = this.shownCardLimit;
|
|
630
|
+
if (limit === undefined || (id && this.store.get('cards', String(id)))) return;
|
|
631
|
+
if ((this.store.count?.('summaries') ?? 0) >= limit) {
|
|
632
|
+
throw new InboxError(507, `the inbox shows ${limit} cards, the most it shows. The person must clear answered or closed cards before a new one fits`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** The body a card shows: its template filled, and its widgets placed. */
|
|
637
|
+
cardBody(input ) {
|
|
638
|
+
let body = input.body;
|
|
639
|
+
let template ;
|
|
640
|
+
if (input.template) {
|
|
641
|
+
const t = this.getTemplate(String(input.template.name));
|
|
642
|
+
const v = input.template.version === undefined ? t.versions.at(-1) : t.versions.find((x) => x.version === input.template?.version);
|
|
643
|
+
if (!v) throw new InboxError(404, 'unknown template version');
|
|
644
|
+
body = fill(v.source, input.template.vars ?? {});
|
|
645
|
+
template = { name: t.name, version: v.version };
|
|
646
|
+
}
|
|
647
|
+
// An empty body is no body: a client that fills a missing field with "", or an agent that sends body: "", means a
|
|
648
|
+
// card without widgets, and the body checks would otherwise refuse it for a root statement it has no reason to have.
|
|
649
|
+
if (body === undefined || String(body).trim() === '') return { body: undefined, template };
|
|
650
|
+
const expanded = expandWidgets(String(body), (name, version) => this.widgetVersion(name, version));
|
|
651
|
+
return { body: expanded.body, template, expanded };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Parse every diagram the card would show, before the card commits. An await sits here, so the commit reads everything again. */
|
|
655
|
+
async checkCard(input ) {
|
|
656
|
+
if (this.clientChecks) return new Map();
|
|
657
|
+
return checkDiagrams(findDiagrams({ text: input.text, recommendation: input.recommendation, body: this.cardBody(input).body }), this.checkDiagram);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** What this inbox checks itself before it takes a card. A posting client checks the rest, from GET /v1/info. */
|
|
661
|
+
checks() {
|
|
662
|
+
return { diagrams: !this.clientChecks && !!this.checkDiagram, body: !this.clientChecks && !!this.checkBody };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** The text, recommendation and body a post or a patch would store, after templates and widgets, without storing anything.
|
|
666
|
+
* A client that checks diagrams and bodies itself reads them here. */
|
|
667
|
+
preview(caller , req ) {
|
|
668
|
+
if (caller.role === 'extension') throw new InboxError(403, 'only the client that posts a card can preview it');
|
|
669
|
+
const input = req.id !== undefined ? this.patched(caller, String(req.id), req.edits ?? [], req.field).input : req.card;
|
|
670
|
+
if (!input || typeof input !== 'object') throw new InboxError(400, 'send {card} for a post, or {id, edits, field} for a patch');
|
|
671
|
+
return { text: input.text, recommendation: input.recommendation, body: this.cardBody(input).body };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** The page could not draw a card it shows: a diagram that mermaid cannot parse, or a body the OpenUI parser reads in part.
|
|
675
|
+
* The card goes back to its agent with the errors, as a laptop inbox would have refused the post. Only the owner reports. */
|
|
676
|
+
drawFault(caller , id , sent ) {
|
|
677
|
+
if (caller.role !== 'owner') throw new InboxError(403, 'only the page reports what it cannot draw');
|
|
678
|
+
const report = sent && typeof sent === 'object' ? sent : {};
|
|
679
|
+
const card = this.getCard(id);
|
|
680
|
+
if (card.kind === 'system') throw new InboxError(400, 'a system card shows its diagrams as source');
|
|
681
|
+
if (Number(report.version) !== card.version) throw new InboxError(409, `the card is at version ${card.version}, and the page drew version ${String(report.version)}`);
|
|
682
|
+
if (card.closed || this.store.get ('answers', id)) return card;
|
|
683
|
+
const renderer = String(report.renderer ?? '').replace(/[^\w.-]/g, '').slice(0, 20) || 'on the page';
|
|
684
|
+
const shown = findDiagrams(card);
|
|
685
|
+
const reported = (Array.isArray(report.diagrams) ? report.diagrams : []).slice(0, MAX_DIAGRAMS) ;
|
|
686
|
+
// The page names a diagram by its source. One the card does not show is not this card's fault.
|
|
687
|
+
const diagrams = reported.flatMap((d) => {
|
|
688
|
+
const at = shown.find((x) => x.source === d?.source);
|
|
689
|
+
return at ? [diagramMessage(at.where, renderer, String(d.error ?? 'it does not draw').slice(0, 2000), 'the page could not draw it, and the inbox sent the card back')] : [];
|
|
690
|
+
});
|
|
691
|
+
const faults = card.body === undefined || !Array.isArray(report.body) ? [] : report.body.slice(0, 20).map((f) => String(f).slice(0, 1000)).filter(Boolean);
|
|
692
|
+
const reason = [...diagrams, ...(faults.length ? [bodyProblemsMessage(faults, 'so the inbox sent the card back')] : [])].join('\n\n');
|
|
693
|
+
if (!reason) throw new InboxError(400, 'the report names no diagram this card shows, and no fault in its body');
|
|
694
|
+
return this.sendBack(card, reason, 'The inbox page');
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Close a card as returned and tell its agent why. */
|
|
698
|
+
sendBack(card , reason , by ) {
|
|
699
|
+
this.closeAs(card, 'returned', reason);
|
|
700
|
+
for (const [k, subs] of this.agents) {
|
|
701
|
+
if (k === this.key(card.token ?? '', card.agent)) for (const send of subs) send({ type: 'returned', id: randomId(6), card: publicCard(card), reason, by });
|
|
702
|
+
}
|
|
703
|
+
return this.getCard(card.id);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ---- extensions and hooks ----
|
|
707
|
+
|
|
708
|
+
/** An extension dials in for the hooks it names. A second connection with the same name replaces the first. */
|
|
709
|
+
connectExtension(caller , reg , send ) {
|
|
710
|
+
const entry = { reg: this.checkExtension(caller, reg), token: caller.token, send };
|
|
711
|
+
const name = entry.reg.name;
|
|
712
|
+
this.extensions.set(name, entry);
|
|
713
|
+
// Screen the cards that came in while no extension was there.
|
|
714
|
+
if (entry.reg.hooks.includes('card.created')) {
|
|
715
|
+
for (const card of this.store.list ('cards')) {
|
|
716
|
+
if ((card.kind !== 'ask' && card.kind !== 'secret') || card.closed || this.store.get('answers', card.id)) continue;
|
|
717
|
+
const t = card.triage;
|
|
718
|
+
if (t?.state === 'pending' && t.request && t.deadline && t.waiting?.includes(name)) {
|
|
719
|
+
// A call this extension missed while it reconnected.
|
|
720
|
+
send({ type: 'hook', version: HOOK_VERSION, id: t.request, hook: 'card.created', card: publicCard(card), deadline: t.deadline, person: this.person() });
|
|
721
|
+
} else if (!t || t.state === 'skipped' || (t.state === 'pending' && !t.waiting?.length)) this.requestTriage(card);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return () => { if (this.extensions.get(name) === entry) this.extensions.delete(name); };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Check and complete an extension registration without connecting it. */
|
|
728
|
+
checkExtension(caller , reg ) {
|
|
729
|
+
if (caller.role === 'agent') throw new InboxError(403, 'only an extension or owner token can register hooks');
|
|
730
|
+
const name = String(reg.name ?? '').trim();
|
|
731
|
+
if (!/^[\w.-]{1,40}$/.test(name)) throw new InboxError(400, 'name must be 1 to 40 letters, digits, dots, dashes or underscores');
|
|
732
|
+
const hooks = (reg.hooks ?? []).map(String);
|
|
733
|
+
if (!hooks.length || !hooks.every(isHook)) throw new InboxError(400, `hooks must name some of ${Object.keys(HOOKS).join(', ')}`);
|
|
734
|
+
const policy = reg.policy === 'fail-closed' ? 'fail-closed' : 'fail-open';
|
|
735
|
+
const asked = Number(reg.timeoutMs);
|
|
736
|
+
const timeoutMs = Math.min(Math.max(Number.isFinite(asked) && asked > 0 ? asked : HOOKS['card.created'].defaultTimeoutMs, 1000), 600_000);
|
|
737
|
+
return { name, hooks: hooks , policy, timeoutMs };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
requestTriage(card ) {
|
|
741
|
+
const targets = [...this.extensions.values()].filter((e) => e.reg.hooks.includes('card.created'));
|
|
742
|
+
const now = this.now();
|
|
743
|
+
if (!targets.length) {
|
|
744
|
+
// Fail-open when triage is down: the card goes straight to the person, and a returning extension screens it.
|
|
745
|
+
const next = { ...card, triage: { state: 'skipped' , reason: 'No triage extension was connected.', at: now } };
|
|
746
|
+
this.store.put('cards', card.id, next);
|
|
747
|
+
return next;
|
|
748
|
+
}
|
|
749
|
+
const request = randomId(8);
|
|
750
|
+
const deadline = now + Math.max(...targets.map((t) => t.reg.timeoutMs));
|
|
751
|
+
const policy = targets.some((t) => t.reg.policy === 'fail-closed') ? 'fail-closed' : 'fail-open' ;
|
|
752
|
+
const next = { ...card, triage: { state: 'pending', by: targets.map((t) => t.reg.name).join(', '), request, waiting: targets.map((t) => t.reg.name), deadline, policy, at: now } };
|
|
753
|
+
this.store.put('cards', card.id, next);
|
|
754
|
+
this.emit({ type: 'change', card: card.id });
|
|
755
|
+
const person = this.person();
|
|
756
|
+
for (const t of targets) t.send({ type: 'hook', version: HOOK_VERSION, id: request, hook: 'card.created', card: publicCard(next), deadline, person });
|
|
757
|
+
return next;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** An extension answers a card.created call. */
|
|
761
|
+
hookVerdict(caller , request , verdict , reason ) {
|
|
762
|
+
const card = this.store.list ('cards').find((c) => c.triage?.request === request);
|
|
763
|
+
if (!card?.triage) throw new InboxError(404, 'unknown hook call');
|
|
764
|
+
const ext = [...this.extensions.values()].find((e) => e.token === caller.token && card.triage?.waiting?.includes(e.reg.name));
|
|
765
|
+
if (!ext) throw new InboxError(403, 'this hook call does not wait for this extension');
|
|
766
|
+
if (!(HOOKS['card.created'].verdicts ).includes(verdict)) throw new InboxError(400, 'verdict must be pass, hold or return');
|
|
767
|
+
if (card.triage.state !== 'pending') return card;
|
|
768
|
+
const clean = String(reason ?? '').slice(0, 1000);
|
|
769
|
+
const waiting = (card.triage.waiting ?? []).filter((n) => n !== ext.reg.name);
|
|
770
|
+
const now = this.now();
|
|
771
|
+
if (verdict === 'return') {
|
|
772
|
+
this.store.put('cards', card.id, { ...card, triage: { ...card.triage, state: 'returned', by: ext.reg.name, reason: clean, waiting: [], at: now } });
|
|
773
|
+
return this.sendBack(card, clean || 'Triage sent the card back.', 'Triage');
|
|
774
|
+
}
|
|
775
|
+
const triage = verdict === 'hold'
|
|
776
|
+
? { ...card.triage, state: 'held' , by: ext.reg.name, reason: clean, waiting: [], at: now }
|
|
777
|
+
: { ...card.triage, state: waiting.length ? 'pending' : 'passed' , waiting, reason: clean || card.triage.reason, at: now };
|
|
778
|
+
const next = { ...card, triage };
|
|
779
|
+
this.store.put('cards', card.id, next);
|
|
780
|
+
this.emit({ type: 'change', card: card.id });
|
|
781
|
+
return next;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** The owner lets a held card through. */
|
|
785
|
+
release(id ) {
|
|
786
|
+
const card = this.getCard(id);
|
|
787
|
+
if (!card.triage || card.triage.state !== 'held') return card;
|
|
788
|
+
const next = { ...card, triage: { ...card.triage, state: 'passed' , reason: `Released by you. ${card.triage.reason ?? ''}`.trim(), at: this.now() } };
|
|
789
|
+
this.store.put('cards', id, next);
|
|
790
|
+
this.emit({ type: 'change', card: id });
|
|
791
|
+
return next;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
addWebhook(hook , url , secret ) {
|
|
795
|
+
if (!isHook(hook)) throw new InboxError(400, `hook must be one of ${Object.keys(HOOKS).join(', ')}`);
|
|
796
|
+
if (!/^https:\/\//.test(String(url))) throw new InboxError(400, 'url must be https');
|
|
797
|
+
if (this.webhooks().length >= MAX_WEBHOOKS) throw new InboxError(400, `an inbox takes at most ${MAX_WEBHOOKS} webhooks`);
|
|
798
|
+
const w = { id: randomId(6), hook, url: String(url), secret };
|
|
799
|
+
this.store.put('meta', `webhook:${w.id}`, w);
|
|
800
|
+
return w;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
webhooks() {
|
|
804
|
+
return this.store.list ('meta').filter((m) => typeof m === 'object' && m !== null && 'url' in m && 'secret' in m);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
listWebhooks() {
|
|
808
|
+
return this.webhooks().map(({ secret: _s, ...w }) => w);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
deleteWebhook(id ) {
|
|
812
|
+
this.store.delete('meta', `webhook:${id}`);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
observeAnswer(card , answer ) {
|
|
816
|
+
// A sealed value opens only with the key in its session's memory, so no hook gets it.
|
|
817
|
+
const event = { type: 'hook', version: HOOK_VERSION, id: randomId(8), hook: 'answer.saved', card: publicCard(card), answer: withoutSealed(card, answer) };
|
|
818
|
+
for (const e of this.extensions.values()) if (e.reg.hooks.includes('answer.saved')) e.send(event);
|
|
819
|
+
for (const w of this.webhooks().filter((x) => x.hook === 'answer.saved')) this.track(deliverWebhook(w, event, this.fetcher));
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
track(p ) {
|
|
823
|
+
this.deliveries.add(p);
|
|
824
|
+
void p.then(() => this.deliveries.delete(p), () => this.deliveries.delete(p));
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ---- answer webhooks ----
|
|
828
|
+
|
|
829
|
+
/** A client's system card: a request to send the answers to its cards to a webhook, or to stop. The owner approves each
|
|
830
|
+
* webhook on a system card the inbox posts, and nothing goes out before that. */
|
|
831
|
+
systemRequest(caller , input ) {
|
|
832
|
+
const type = input?.system?.type;
|
|
833
|
+
if (type === 'subscribe') return this.subscribe(caller, input);
|
|
834
|
+
if (type !== 'unsubscribe') throw new InboxError(400, 'a system card from a client carries system.type subscribe or unsubscribe');
|
|
835
|
+
const agent = this.requestingAgent(caller, input);
|
|
836
|
+
return { removed: this.removeSubscriptions((s) => s.token === caller.token && s.agent === agent, `The agent ${agent} unsubscribed.`) };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** The agent a client's system card speaks for. */
|
|
840
|
+
requestingAgent(caller , input ) {
|
|
841
|
+
if (caller.role === 'extension') throw new InboxError(403, 'an extension posts no cards');
|
|
842
|
+
const agent = String(input?.agent ?? '').trim();
|
|
843
|
+
if (!agent || agent.length > 80) throw new InboxError(400, 'agent is required, at most 80 characters');
|
|
844
|
+
if (agent === SYSTEM_AGENT) throw new InboxError(403, 'the system agent is reserved');
|
|
845
|
+
return agent;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** A subscribe request: the webhook waits, pending, for the owner to approve it on its card. The secret is returned once. */
|
|
849
|
+
subscribe(caller , input ) {
|
|
850
|
+
const agent = this.requestingAgent(caller, input);
|
|
851
|
+
const req = input.system;
|
|
852
|
+
if (req?.type !== 'subscribe') throw new InboxError(400, 'a subscribe request carries system.type subscribe');
|
|
853
|
+
const mine = (s ) => s.token === caller.token && s.agent === agent;
|
|
854
|
+
const url = webhookUrl(req.webhook?.url);
|
|
855
|
+
const headers = webhookHeaders(req.webhook?.headers);
|
|
856
|
+
const events = req.events ?? ['answer'];
|
|
857
|
+
if (!Array.isArray(events) || !events.length || events.some((e) => e !== 'answer')) throw new InboxError(400, 'events takes answer, the only event a webhook receives');
|
|
858
|
+
if (this.store.list ('subscriptions').filter((s) => !mine(s)).length >= MAX_SUBSCRIPTIONS) {
|
|
859
|
+
throw new InboxError(429, `an inbox takes at most ${MAX_SUBSCRIPTIONS} answer webhooks`);
|
|
860
|
+
}
|
|
861
|
+
const id = randomId(8);
|
|
862
|
+
const cardId = `subscribe-${id}`;
|
|
863
|
+
this.makeRoom(cardId);
|
|
864
|
+
// One webhook for each agent and token: a new request replaces the one before.
|
|
865
|
+
this.removeSubscriptions(mine, 'The agent sent a new request, which replaces this one.');
|
|
866
|
+
const sub = { id, agent, token: caller.token, url, headers, events: ['answer'], secret: randomId(24), status: 'pending', createdAt: this.now(), delivered: 0 };
|
|
867
|
+
this.store.put('subscriptions', id, sub);
|
|
868
|
+
const host = new URL(url).host;
|
|
869
|
+
// The card is Markdown, so the agent's words go in code spans, which show them as typed.
|
|
870
|
+
const code = (text ) => `\`${text.replace(/`/g, "'")}\``;
|
|
871
|
+
const names = Object.keys(headers).map(code);
|
|
872
|
+
this.postCard(INBOX, {
|
|
873
|
+
id: cardId, agent: SYSTEM_AGENT, kind: 'system', deliver: 'none', priority: this.priorities()[0]?.id,
|
|
874
|
+
title: `Send the answers for ${agent} to a webhook?`,
|
|
875
|
+
text: `The agent ${code(agent)} asks the inbox to send each answer you give to its cards to ${code(url)}${names.length ? `, with the header${names.length === 1 ? '' : 's'} ${names.join(', ')}` : ''}. Whoever runs ${host} then reads those answers. Approve only if you know that address.`,
|
|
876
|
+
options: [
|
|
877
|
+
{ id: 'approve', label: 'Approve', description: `The inbox sends each answer to the cards of ${code(agent)} to ${host}.` },
|
|
878
|
+
{ id: 'deny', label: 'Deny', description: 'Nothing goes out. To stop a webhook you approved, change your answer to this.' },
|
|
879
|
+
],
|
|
880
|
+
});
|
|
881
|
+
const card = { ...this.getCard(cardId), subscription: id };
|
|
882
|
+
this.store.put('cards', cardId, card);
|
|
883
|
+
return { subscription: this.publicSubscription(sub), card: publicCard(card), secret: sub.secret };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
resolveSubscription(card , answer ) {
|
|
887
|
+
const sub = this.store.get ('subscriptions', card.subscription ?? '');
|
|
888
|
+
// The webhook went before this answer, so the card keeps the outcome it closed with.
|
|
889
|
+
if (!sub) { this.closeAs(card, 'resolved', card.closed?.outcome ?? 'This webhook is gone.'); return; }
|
|
890
|
+
const host = new URL(sub.url).host;
|
|
891
|
+
if ([answer.values['choice']].flat()[0] === 'approve') {
|
|
892
|
+
this.store.put ('subscriptions', sub.id, { ...sub, status: 'approved', approvedAt: sub.approvedAt ?? this.now() });
|
|
893
|
+
this.closeAs(card, 'resolved', `Sending the answers for ${sub.agent} to ${host}. To stop, change your answer to Deny.`);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
this.removeSubscriptions((s) => s.id === sub.id, sub.status === 'approved' ? `Stopped: no more answers for ${sub.agent} go to ${host}.` : `Denied: nothing goes to ${host}.`);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** Remove the webhooks that match, with the calls still waiting, and close the card of each with the outcome. */
|
|
900
|
+
removeSubscriptions(match , outcome ) {
|
|
901
|
+
const gone = this.store.list ('subscriptions').filter(match);
|
|
902
|
+
for (const s of gone) {
|
|
903
|
+
this.store.delete('subscriptions', s.id);
|
|
904
|
+
for (const d of this.store.list ('outbox')) if (d.sub === s.id) this.store.delete('outbox', d.key);
|
|
905
|
+
const card = this.store.get ('cards', `subscribe-${s.id}`);
|
|
906
|
+
if (card) this.closeAs(card, 'resolved', outcome);
|
|
907
|
+
}
|
|
908
|
+
return gone.length;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** The owner removes any webhook, and a client the ones it asked for. */
|
|
912
|
+
deleteSubscription(caller , id ) {
|
|
913
|
+
const sub = this.store.get ('subscriptions', id);
|
|
914
|
+
if (!sub) throw new InboxError(404, 'unknown subscription');
|
|
915
|
+
if (caller.role !== 'owner' && sub.token !== caller.token) throw new InboxError(403, 'another client owns this subscription');
|
|
916
|
+
this.removeSubscriptions((s) => s.id === id, sub.token === caller.token ? `The agent ${sub.agent} unsubscribed.` : 'You removed this webhook.');
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Every webhook for the owner, and its own for any other client. */
|
|
920
|
+
listSubscriptions(caller ) {
|
|
921
|
+
if (caller.role === 'extension') throw new InboxError(403, 'an extension reads no webhooks');
|
|
922
|
+
return this.store.list ('subscriptions').filter((s) => caller.role === 'owner' || s.token === caller.token).map((s) => this.publicSubscription(s));
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
publicSubscription(s ) {
|
|
926
|
+
const { token: _t, secret: _s, headers, ...rest } = s;
|
|
927
|
+
return { ...rest, headers: Object.keys(headers), waiting: this.store.list ('outbox').filter((d) => d.sub === s.id).length };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Queue the answer for the webhook of its card's agent and token, and call it now. */
|
|
931
|
+
queueAnswer(card , answer ) {
|
|
932
|
+
if (card.deliver !== 'stream') return;
|
|
933
|
+
const sub = this.store.list ('subscriptions').find((s) => s.status === 'approved' && s.token === (card.token ?? '') && s.agent === card.agent);
|
|
934
|
+
if (!sub) return;
|
|
935
|
+
const now = this.now();
|
|
936
|
+
const key = `${sub.id}:${card.id}`;
|
|
937
|
+
this.store.put ('outbox', key, { key, sub: sub.id, card: card.id, savedAt: answer.savedAt, attempts: 0, firstAt: now, nextAt: now });
|
|
938
|
+
this.track(this.deliverAnswers());
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** Call the webhook of each answer that is due. An answer calls it at once, and the platform alarm calls it after sweep()
|
|
942
|
+
* for each retry. Each call carries the agent stream's answer event, with the answer's id. */
|
|
943
|
+
async deliverAnswers() {
|
|
944
|
+
const now = this.now();
|
|
945
|
+
const calls = [];
|
|
946
|
+
for (const item of this.store.list ('outbox')) {
|
|
947
|
+
if (item.nextAt > now) continue;
|
|
948
|
+
const sub = this.store.get ('subscriptions', item.sub);
|
|
949
|
+
const card = this.store.get ('cards', item.card);
|
|
950
|
+
const answer = this.store.get ('answers', item.card);
|
|
951
|
+
// The card went to another agent, the person changed the answer, or the agent read it already.
|
|
952
|
+
if (!sub || sub.status !== 'approved' || !card || card.agent !== sub.agent || (card.token ?? '') !== sub.token || !answer || answer.savedAt !== item.savedAt || answer.readAt) {
|
|
953
|
+
this.store.delete('outbox', item.key);
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
// Hold the call before the await, so a second run does not make it again. A call that a crash cut off goes after the lease.
|
|
957
|
+
this.store.put ('outbox', item.key, { ...item, nextAt: now + DELIVERY_LEASE_MS, calling: true });
|
|
958
|
+
const id = answerId(card.id, answer.savedAt);
|
|
959
|
+
// A sealed value opens only with the key in its session's memory, so the webhook gets the rest of the answer.
|
|
960
|
+
const ev = { type: 'answer', id, card: publicCard(card), answer: withoutSealed(card, answer) };
|
|
961
|
+
const headers = { ...sub.headers, 'x-your-attention-event': 'answer', 'x-your-attention-delivery': id };
|
|
962
|
+
calls.push(postSigned(sub.url, sub.secret, headers, ev, ANSWER_WEBHOOK_TIMEOUT_MS, this.fetcher).then((r) => this.called(item, r)));
|
|
963
|
+
}
|
|
964
|
+
// A call that fails to record its result, such as on a store that closed, must not stop the alarm's other work.
|
|
965
|
+
await Promise.allSettled(calls);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Record how a webhook call went: a 2xx marks the answer read, and anything else waits for the next retry. */
|
|
969
|
+
called(item , r ) {
|
|
970
|
+
const now = this.now();
|
|
971
|
+
const current = this.store.get ('outbox', item.key);
|
|
972
|
+
const sub = this.store.get ('subscriptions', item.sub);
|
|
973
|
+
// The webhook went, or a newer answer took this call's place while it ran and has its own schedule.
|
|
974
|
+
if (!sub || !current || current.savedAt !== item.savedAt) return;
|
|
975
|
+
if (r.ok) {
|
|
976
|
+
this.store.delete('outbox', item.key);
|
|
977
|
+
const { lastError: _e, ...rest } = sub;
|
|
978
|
+
this.store.put ('subscriptions', sub.id, { ...rest, delivered: sub.delivered + 1 });
|
|
979
|
+
const answer = this.store.get ('answers', item.card);
|
|
980
|
+
// The webhook has the answer, as an agent that acknowledges it on its stream.
|
|
981
|
+
if (answer && answer.savedAt === item.savedAt && !answer.readAt) {
|
|
982
|
+
this.store.put ('answers', item.card, { ...answer, deliveredAt: answer.deliveredAt ?? now, readAt: now });
|
|
983
|
+
this.emit({ type: 'change', card: item.card });
|
|
984
|
+
}
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
const reason = r.reason ?? 'the webhook failed';
|
|
988
|
+
if (now - item.firstAt >= RETRY_GIVE_UP_MS) {
|
|
989
|
+
this.store.delete('outbox', item.key);
|
|
990
|
+
this.store.put ('subscriptions', sub.id, { ...sub, lastError: { at: now, reason: `${reason}; the inbox stopped trying after 24 hours` } });
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
const attempts = item.attempts + 1;
|
|
994
|
+
this.store.put ('subscriptions', sub.id, { ...sub, lastError: { at: now, reason } });
|
|
995
|
+
const { calling: _c, ...waiting } = current;
|
|
996
|
+
this.store.put ('outbox', item.key, { ...waiting, attempts, nextAt: now + Math.min(RETRY_FIRST_MS * 2 ** (attempts - 1), RETRY_MAX_MS) });
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
getCard(id ) {
|
|
1000
|
+
const card = this.store.get ('cards', id);
|
|
1001
|
+
if (!card) throw new InboxError(404, 'unknown card');
|
|
1002
|
+
return card;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Post a card whose attachments include data: URLs. The bytes go to the blob store first,
|
|
1006
|
+
* then the card commits in one synchronous step, and the files it replaced are deleted. */
|
|
1007
|
+
async postCardWithFiles(caller , input ) {
|
|
1008
|
+
if (!input || typeof input !== 'object') throw new InboxError(400, 'card must be an object');
|
|
1009
|
+
// Only herdr names a pane's agent and tab: a place the agent sent is dropped.
|
|
1010
|
+
// Only herdr names a pane's agent and tab: the places go around postCard, so an agent cannot supply one.
|
|
1011
|
+
const approvals = this.cleanApprovals(input.approvals, input.kind ?? 'ask');
|
|
1012
|
+
const places = approvals ? await this.describePanes(approvals) : undefined;
|
|
1013
|
+
const checked = await this.checkCard(input);
|
|
1014
|
+
const files = input.attachments;
|
|
1015
|
+
if (!Array.isArray(files) || !files.some((f) => /^data:/.test(String(f?.src ?? '')))) return this.postCard(caller, input, undefined, checked, places);
|
|
1016
|
+
if (files.length > MAX_ATTACHMENTS) throw new InboxError(400, `a card takes at most ${MAX_ATTACHMENTS} attachments`);
|
|
1017
|
+
const id = input.id ? String(input.id) : randomId(8);
|
|
1018
|
+
// Check the id before it goes into a blob key.
|
|
1019
|
+
if (!/^[\w.:-]{1,80}$/.test(id)) throw new InboxError(400, 'id must be 1 to 80 letters, digits, dots, colons, dashes or underscores');
|
|
1020
|
+
const before = this.store.get ('cards', id);
|
|
1021
|
+
if (before && before.token !== caller.token && caller.role !== 'owner') throw new InboxError(403, 'another client owns this card');
|
|
1022
|
+
if (files.some((f) => !f || typeof f !== 'object')) throw new InboxError(400, 'each attachment must be an object with src');
|
|
1023
|
+
const decoded = files.map((f, i) => (/^data:/.test(String(f?.src ?? '')) ? decodeDataUrl(i, f) : undefined));
|
|
1024
|
+
// Count the bytes before the first await, so two posts at once cannot both fit under the limit.
|
|
1025
|
+
const bytes = decoded.reduce((n, d) => n + (d ? Math.max(d.bytes.length, MIN_FILE_CHARGE) : 0), 0);
|
|
1026
|
+
const { used, limit } = this.uploads();
|
|
1027
|
+
if (limit !== undefined && used + bytes > limit) {
|
|
1028
|
+
throw new InboxError(413, `these files are ${bytes} bytes, and this inbox has stored ${used} of its lifetime limit of ${limit} bytes`);
|
|
1029
|
+
}
|
|
1030
|
+
this.store.put('meta', 'uploaded-bytes', used + bytes);
|
|
1031
|
+
const stored = [];
|
|
1032
|
+
const refund = () => this.refundUploads(stored, bytes);
|
|
1033
|
+
// Wait for every put, so a put that fails leaves none of the others behind. Each key is listed before its put starts.
|
|
1034
|
+
const settled = await Promise.allSettled(files.map(async (f, i) => {
|
|
1035
|
+
const d = decoded[i];
|
|
1036
|
+
if (!d) return f;
|
|
1037
|
+
const key = `${id}.${i}.${randomId(6)}`;
|
|
1038
|
+
const name = cleanName(f.name) || `attachment-${i + 1}`;
|
|
1039
|
+
const type = attachmentType({ name, type: d.type });
|
|
1040
|
+
const ref = { id: key, name, type, size: d.bytes.length, alt: f.alt ? String(f.alt).slice(0, 300) : undefined };
|
|
1041
|
+
stored.push(ref);
|
|
1042
|
+
await this.blobs.put(key, d.bytes, type);
|
|
1043
|
+
return { src: '', ref };
|
|
1044
|
+
}));
|
|
1045
|
+
const failed = settled.find((r) => r.status === 'rejected');
|
|
1046
|
+
if (failed) {
|
|
1047
|
+
await refund();
|
|
1048
|
+
throw failed.reason;
|
|
1049
|
+
}
|
|
1050
|
+
const resolved = settled.map((r) => (r ).value);
|
|
1051
|
+
let card ;
|
|
1052
|
+
let prior ;
|
|
1053
|
+
try {
|
|
1054
|
+
// Read the card again right before the commit, with no await between: another update may have landed meanwhile.
|
|
1055
|
+
prior = this.store.get ('cards', id);
|
|
1056
|
+
card = this.postCard(caller, { ...input, id, attachments: resolved }, new Set(stored.map((f) => f.id ?? '')), checked, places);
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
await refund();
|
|
1059
|
+
throw e;
|
|
1060
|
+
}
|
|
1061
|
+
const kept = new Set(card.attachments?.map((f) => f.id));
|
|
1062
|
+
await Promise.all((prior?.attachments ?? []).filter((f) => f.id && !kept.has(f.id)).map((f) => this.blobs.delete(f.id ?? '')));
|
|
1063
|
+
return card;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** Whether the inbox keeps more than its storage limit. A full inbox takes only writes that free space. */
|
|
1067
|
+
storageFull() {
|
|
1068
|
+
return this.storageLimit !== undefined && (this.store.bytes?.() ?? 0) >= this.storageLimit;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** The bytes of files this inbox has stored since it was created, and its lifetime limit, if it has one. */
|
|
1072
|
+
uploads() {
|
|
1073
|
+
return { used: this.store.get ('meta', 'uploaded-bytes') ?? 0, limit: this.uploadLimit };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** The bytes of a file, for the owner, an extension, or the client that posted its card. */
|
|
1077
|
+
async attachment(caller , id ) {
|
|
1078
|
+
// A key is <card id>.<index>.<random>, and a card id may contain dots.
|
|
1079
|
+
const card = this.getCard(id.split('.').slice(0, -2).join('.') || id);
|
|
1080
|
+
if (caller.role === 'agent' && card.token !== caller.token) throw new InboxError(403, 'another client owns this card');
|
|
1081
|
+
const answer = this.store.get ('answers', card.id);
|
|
1082
|
+
const file = card.attachments?.find((f) => f.id === id)
|
|
1083
|
+
?? [answer, ...(answer?.previous ?? [])].flatMap((a) => a?.images ?? []).find((f) => f.id === id);
|
|
1084
|
+
if (!file) throw new InboxError(404, 'unknown attachment');
|
|
1085
|
+
// Each client has its own daily budget, so an agent's downloads never use up the owner's. A refused download reads no blob.
|
|
1086
|
+
if (this.readsPerDay !== undefined) {
|
|
1087
|
+
const key = `reads:${new Date(this.now()).toISOString().slice(0, 10)}:${caller.token}`;
|
|
1088
|
+
const n = this.store.get ('meta', key)?.n ?? 0;
|
|
1089
|
+
if (n >= this.readsPerDay) throw new InboxError(429, `you downloaded ${this.readsPerDay} files from this inbox today`);
|
|
1090
|
+
this.store.put('meta', key, { key, n: n + 1 });
|
|
1091
|
+
}
|
|
1092
|
+
const blob = await this.blobs.get(id);
|
|
1093
|
+
// A cloud inbox deletes files after a while, and the card keeps listing them.
|
|
1094
|
+
if (!blob) throw new InboxError(410, `${file.name} is no longer stored`);
|
|
1095
|
+
return { file, bytes: blob.bytes };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** Why the person won't see a revision of this card, when they answered it or it is closed. The page keeps such a card in
|
|
1099
|
+
* Sent and never asks again, so the agent hears it with the revision instead. */
|
|
1100
|
+
revisionWarning(id ) {
|
|
1101
|
+
const card = this.store.get ('cards', id);
|
|
1102
|
+
if (!card) return undefined;
|
|
1103
|
+
const s = cardState(card, this.store.get ('answers', id));
|
|
1104
|
+
if (s === 'open' || s === 'in-triage' || s === 'held') return undefined;
|
|
1105
|
+
return `Card ${id} ${s === 'closed' ? 'is closed' : 'was already answered'}, so this revision stays in the person's Sent list: the person is not notified, and won't answer it again. To ask again, post a new card with follows: "${id}".`;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** Edit a card the way a coding agent edits a file: each edit replaces text that occurs exactly once. */
|
|
1109
|
+
patchCard(caller , id , edits , field , checked ) {
|
|
1110
|
+
const { card, input } = this.patched(caller, id, edits, field);
|
|
1111
|
+
const next = this.postCard(caller, input, undefined, checked, undefined, { revise: true });
|
|
1112
|
+
// A patch edits the body the widgets already filled, so the card still shows them and keeps their responses, and any the patch placed.
|
|
1113
|
+
if (!card.widgets) return next;
|
|
1114
|
+
const responses = [...(card.responses ?? []), ...(next.responses ?? [])];
|
|
1115
|
+
const kept = { ...next, widgets: distinctWidgets([...card.widgets, ...(next.widgets ?? [])]), ...(responses.length ? { responses } : {}) };
|
|
1116
|
+
this.store.put('cards', kept.id, kept);
|
|
1117
|
+
return kept;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** patchCard, with the diagrams of the patched card checked first. */
|
|
1121
|
+
async patchCardChecked(caller , id , edits , field ) {
|
|
1122
|
+
const checked = await this.checkCard(this.patched(caller, id, edits, field).input);
|
|
1123
|
+
return this.patchCard(caller, id, edits, field, checked);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** The card a patch would post, and the card it edits. */
|
|
1127
|
+
patched(caller , id , edits , field ) {
|
|
1128
|
+
const card = this.owned(caller, id);
|
|
1129
|
+
const f = field ?? (card.body !== undefined ? 'body' : card.html !== undefined ? 'html' : 'text');
|
|
1130
|
+
if (!PATCH_FIELDS.has(f)) throw new InboxError(400, `field must be one of ${[...PATCH_FIELDS].join(', ')}`);
|
|
1131
|
+
if (!Array.isArray(edits) || !edits.length) throw new InboxError(400, 'edits must be a list of {old, new}');
|
|
1132
|
+
let text = String((card )[f] ?? '');
|
|
1133
|
+
for (const [i, e] of edits.entries()) {
|
|
1134
|
+
const old = String(e?.old ?? '');
|
|
1135
|
+
// An empty old text fills a field that is empty.
|
|
1136
|
+
if (!old && !text) { text = String(e.new ?? ''); continue; }
|
|
1137
|
+
// A patch that replaces text with itself changes nothing, so it is a mistake rather than a revision: accepting it
|
|
1138
|
+
// bumped the version, told the person their card changed, and made a "did this text survive?" probe look like an
|
|
1139
|
+
// edit.
|
|
1140
|
+
if (old && String(e.new ?? '') === old) throw new InboxError(400, `edit ${i + 1}: new is identical to old, so it changes nothing`);
|
|
1141
|
+
const count = old ? text.split(old).length - 1 : 0;
|
|
1142
|
+
if (count !== 1) {
|
|
1143
|
+
// A count alone leaves the agent guessing at text it cannot see. Naming the closest stored lines lets it patch
|
|
1144
|
+
// from the real text instead of from the payload it remembers sending.
|
|
1145
|
+
const near = count === 0 ? nearestLines(text, old) : [];
|
|
1146
|
+
const hint = near.length
|
|
1147
|
+
? `; the closest stored lines are ${near.map((l) => JSON.stringify(l)).join(', ')}`
|
|
1148
|
+
: count === 0 ? '; no stored line shares a word with it, so read the card and take the text from there' : '';
|
|
1149
|
+
throw new InboxError(409, `edit ${i + 1}: the old text occurs ${count} times in ${f}, and it must occur once${hint}`);
|
|
1150
|
+
}
|
|
1151
|
+
text = text.replace(old, () => String(e.new ?? ''));
|
|
1152
|
+
}
|
|
1153
|
+
const { token: _t, version: _v, createdAt: _c, updatedAt: _u, closed: _cl, replies: _r, history: _h, triage: _tr, followedBy: _fb, broadcast: _b, template: _tp, attachments: _a, widgets: _w, responses: _rs, ...rest } = card;
|
|
1154
|
+
return { card, input: { ...rest, [f]: text } };
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** The agent sets a state label on its own card. An empty label clears it. */
|
|
1158
|
+
setStatus(caller , id , status ) {
|
|
1159
|
+
const card = this.owned(caller, id);
|
|
1160
|
+
const clean = String(status ?? '').trim().slice(0, 60);
|
|
1161
|
+
const next = { ...card, status: clean || undefined };
|
|
1162
|
+
this.store.put('cards', id, next);
|
|
1163
|
+
this.emit({ type: 'change', card: id });
|
|
1164
|
+
return next;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// ---- templates ----
|
|
1168
|
+
|
|
1169
|
+
saveTemplate(name , source , feedback ) {
|
|
1170
|
+
return this.saveVersion('templates', 'template', name, source, feedback);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** Templates and widgets keep every version, with the feedback each one answers. */
|
|
1174
|
+
saveVersion(kind , label , name , source , feedback , extra = {}) {
|
|
1175
|
+
if (!/^[\w.-]{1,60}$/.test(String(name ?? ''))) throw new InboxError(400, `${label} name must be 1 to 60 letters, digits, dots, dashes or underscores`);
|
|
1176
|
+
if (!String(source ?? '').trim()) throw new InboxError(400, 'source is required');
|
|
1177
|
+
if (String(source).length > MAX_SOURCE_BYTES) throw new InboxError(413, `source must be at most ${MAX_SOURCE_BYTES} characters`);
|
|
1178
|
+
const before = this.store.get (kind, name);
|
|
1179
|
+
const versions = before?.versions ?? [];
|
|
1180
|
+
if (versions.length >= MAX_VERSIONS) throw new InboxError(400, `${label} ${name} has ${MAX_VERSIONS} versions, the most it keeps. Save it under a new name`);
|
|
1181
|
+
for (const [k, v] of Object.entries(extra)) if (v !== undefined && String(v).length > MAX_SOURCE_BYTES) throw new InboxError(413, `${k} must be at most ${MAX_SOURCE_BYTES} characters`);
|
|
1182
|
+
const when = String(extra.when ?? '').trim() || undefined;
|
|
1183
|
+
const response = String(extra.response ?? '').trim() ? String(extra.response) : undefined;
|
|
1184
|
+
const next = { name, versions: [...versions, { version: versions.length + 1, source: String(source), feedback: feedback ? String(feedback) : undefined, ...(when ? { when } : {}), ...(response ? { response } : {}), at: this.now() }] };
|
|
1185
|
+
this.store.put(kind, name, next);
|
|
1186
|
+
return next;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// ---- widgets ----
|
|
1190
|
+
|
|
1191
|
+
saveWidget(name , source , feedback , extra = {}) {
|
|
1192
|
+
if (!/^\s*root\s*=/m.test(String(source ?? ''))) throw new InboxError(400, 'a widget must define root, the part a card places');
|
|
1193
|
+
if (BUILTIN_WIDGETS.some((w) => w.name === name)) throw new InboxError(400, `${name} is a built-in widget: save yours under another name`);
|
|
1194
|
+
return this.saveVersion('widgets', 'widget', name, source, feedback, extra);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** The widgets an agent can place, each with when to use it and the values it takes, as agents read them in the card guidance. */
|
|
1198
|
+
widgetCatalog() {
|
|
1199
|
+
const lines = this.listWidgets().map((w) => {
|
|
1200
|
+
const v = w.versions.at(-1);
|
|
1201
|
+
if (!v) return '';
|
|
1202
|
+
const values = [...new Set([...v.source.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))];
|
|
1203
|
+
return `- ${w.name}: ${v.when ?? 'No hook: read its source with GET /v1/widgets/' + w.name + ' first.'}${values.length ? ` Its values: ${values.join(', ')}.` : ''}`;
|
|
1204
|
+
}).filter(Boolean);
|
|
1205
|
+
return lines.length ? `Widgets you can place in a card body with a line \`part = widget <name> {json values}\`:\n${lines.join('\n')}` : '';
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
getWidget(name ) {
|
|
1209
|
+
const w = BUILTIN_WIDGETS.find((x) => x.name === name) ?? this.store.get ('widgets', name);
|
|
1210
|
+
if (!w) throw new InboxError(404, `unknown widget ${name}`);
|
|
1211
|
+
return w;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** The built-in widgets first, then the saved ones. */
|
|
1215
|
+
listWidgets() {
|
|
1216
|
+
return [...BUILTIN_WIDGETS, ...this.store.list ('widgets').filter((w) => !BUILTIN_WIDGETS.some((b) => b.name === w.name))];
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
widgetVersion(name , version ) {
|
|
1220
|
+
const w = this.getWidget(name);
|
|
1221
|
+
const v = version === undefined ? w.versions.at(-1) : w.versions.find((x) => x.version === version);
|
|
1222
|
+
if (!v) throw new InboxError(404, `unknown version ${version} of widget ${name}`);
|
|
1223
|
+
return { name, version: v.version, source: v.source, ...(v.response ? { response: v.response } : {}) };
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
getTemplate(name ) {
|
|
1227
|
+
const t = this.store.get ('templates', name);
|
|
1228
|
+
if (!t) throw new InboxError(404, 'unknown template');
|
|
1229
|
+
return t;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
listTemplates() {
|
|
1233
|
+
return this.store.list ('templates');
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// ---- settings, priority levels and guidance ----
|
|
1237
|
+
|
|
1238
|
+
settings() {
|
|
1239
|
+
return this.store.get ('meta', 'settings') ?? { allowHtml: false };
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
setSettings(input ) {
|
|
1243
|
+
const next = { ...this.settings(), ...(typeof input.allowHtml === 'boolean' ? { allowHtml: input.allowHtml } : {}) };
|
|
1244
|
+
if (input.groupBy !== undefined) next.groupBy = this.checkGroupBy(input.groupBy);
|
|
1245
|
+
if (input.pins !== undefined) next.pins = this.checkPins(input.pins);
|
|
1246
|
+
// A pin or an unpin travels as its own change, so two devices that pin at the same moment both keep their value.
|
|
1247
|
+
if (input.pinAdd !== undefined || input.pinRemove !== undefined) {
|
|
1248
|
+
const same = (a , b ) => a[0] === b[0] && a[1] === b[1];
|
|
1249
|
+
const add = input.pinAdd === undefined ? [] : this.checkPins(input.pinAdd);
|
|
1250
|
+
const remove = input.pinRemove === undefined ? [] : this.checkPins(input.pinRemove);
|
|
1251
|
+
const current = next.pins ?? [];
|
|
1252
|
+
const kept = current.filter((p) => !remove.some((r) => same(r, p)));
|
|
1253
|
+
const merged = [...kept, ...add.filter((a) => !kept.some((k) => same(k, a)))];
|
|
1254
|
+
// A pin the inbox cannot keep is refused, so the person hears it rather than losing the value quietly.
|
|
1255
|
+
if (merged.length > MAX_PINS) throw new InboxError(400, `pins takes at most ${MAX_PINS}; unpin a value first`);
|
|
1256
|
+
next.pins = merged;
|
|
1257
|
+
}
|
|
1258
|
+
this.store.put('meta', 'settings', next);
|
|
1259
|
+
this.emit({ type: 'change', card: '' });
|
|
1260
|
+
return next;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
checkGroupBy(groupBy ) {
|
|
1264
|
+
const raw = typeof groupBy === 'string' && groupBy.startsWith('tag:') ? groupBy.slice(4) : '';
|
|
1265
|
+
const key = raw.trim();
|
|
1266
|
+
if (!(groupBy === 'none' || groupBy === 'priority' || groupBy === 'agent' || (key.length >= 1 && key.length <= MAX_TAG_CHARS))) {
|
|
1267
|
+
throw new InboxError(400, 'groupBy must be none, priority, agent or tag:<key>');
|
|
1268
|
+
}
|
|
1269
|
+
// The key is stored the way it is matched, so `tag: topic ` groups the cards tagged `topic`.
|
|
1270
|
+
return key && groupBy !== 'none' ? `tag:${key}` : groupBy ;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** Pins reach the queue the page draws, so a pair that isn't a tag key and its value is refused rather than stored. */
|
|
1274
|
+
checkPins(pins ) {
|
|
1275
|
+
if (!Array.isArray(pins) || pins.length > MAX_PINS) throw new InboxError(400, `pins must be a list of at most ${MAX_PINS} [key, value] pairs`);
|
|
1276
|
+
return pins.map((pair, i) => {
|
|
1277
|
+
if (!Array.isArray(pair) || pair.length !== 2) throw new InboxError(400, `pin ${i + 1}: give a tag key and its value`);
|
|
1278
|
+
const [key, value] = pair.map((side) => (typeof side === 'string' ? side.trim() : ''));
|
|
1279
|
+
if (!key || !value) throw new InboxError(400, `pin ${i + 1}: the tag key and its value are both required`);
|
|
1280
|
+
if (key.length > MAX_TAG_CHARS || value.length > MAX_TAG_CHARS) throw new InboxError(400, `pin ${i + 1}: the tag key and its value are at most ${MAX_TAG_CHARS} characters`);
|
|
1281
|
+
return [key, value];
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
priorities() {
|
|
1286
|
+
const saved = this.store.get ('meta', 'priorities');
|
|
1287
|
+
if (!saved) return DEFAULT_PRIORITIES;
|
|
1288
|
+
// A default level saved without a color keeps its default color.
|
|
1289
|
+
return saved.map((l) => (l.color ? l : { ...l, color: DEFAULT_PRIORITIES.find((d) => d.id === l.id)?.color }));
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/** An agent proposes new priority levels. They reorder the whole queue, so the person approves them on a system card. */
|
|
1293
|
+
proposePriorities(levels ) {
|
|
1294
|
+
const clean = this.checkPriorities(levels);
|
|
1295
|
+
const id = `priorities-${randomId(4)}`;
|
|
1296
|
+
this.postCard({ token: '', role: 'owner' }, {
|
|
1297
|
+
id, agent: SYSTEM_AGENT, kind: 'system', deliver: 'none', priority: this.priorities()[0]?.id,
|
|
1298
|
+
title: 'Change the priority levels?',
|
|
1299
|
+
text: `An agent proposes these levels. They apply to every card in the queue.\n\n${clean.map((l) => `- ${l.label} (${l.id})${l.definition ? `: ${l.definition}` : ''}${l.rule ? `\n rule: ${l.rule}` : ''}`).join('\n')}`,
|
|
1300
|
+
options: [{ id: 'approve', label: 'Apply them' }, { id: 'reject', label: 'Keep the current levels' }],
|
|
1301
|
+
});
|
|
1302
|
+
this.store.put('meta', `proposal:${id}`, clean);
|
|
1303
|
+
return this.getCard(id);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
resolvePriorityProposal(card , answer ) {
|
|
1307
|
+
const levels = this.store.get ('meta', `proposal:${card.id}`);
|
|
1308
|
+
this.store.delete('meta', `proposal:${card.id}`);
|
|
1309
|
+
if (!levels || [answer.values['choice']].flat()[0] !== 'approve') { this.closeAs(card, 'resolved', 'Kept the current priority levels.'); return; }
|
|
1310
|
+
this.setPriorities(levels);
|
|
1311
|
+
this.closeAs(card, 'resolved', `Applied ${levels.length} priority levels.`);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
setPriorities(levels ) {
|
|
1315
|
+
const clean = this.checkPriorities(levels);
|
|
1316
|
+
this.store.put('meta', 'priorities', clean);
|
|
1317
|
+
this.emit({ type: 'change', card: '' });
|
|
1318
|
+
return clean;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
checkPriorities(levels ) {
|
|
1322
|
+
if (!Array.isArray(levels) || !levels.length || levels.length > 10) throw new InboxError(400, 'give 1 to 10 priority levels');
|
|
1323
|
+
const seen = new Set ();
|
|
1324
|
+
const clean = levels.map((l, i) => {
|
|
1325
|
+
const id = String(l?.id ?? '');
|
|
1326
|
+
if (!/^[\w-]{1,30}$/.test(id) || seen.has(id)) throw new InboxError(400, `level ${i + 1}: id must be unique, 1 to 30 letters, digits, dashes or underscores`);
|
|
1327
|
+
seen.add(id);
|
|
1328
|
+
const label = String(l.label ?? '').trim();
|
|
1329
|
+
if (!label) throw new InboxError(400, `level ${i + 1}: label is required`);
|
|
1330
|
+
const rule = l.rule ? String(l.rule) : undefined;
|
|
1331
|
+
if (rule) {
|
|
1332
|
+
try { parse(rule); } catch (e) { throw new InboxError(400, `level ${i + 1}: ${(e ).message}`); }
|
|
1333
|
+
}
|
|
1334
|
+
const color = l.color ? String(l.color).toLowerCase() : undefined;
|
|
1335
|
+
if (color && !/^#[0-9a-f]{6}$/.test(color)) throw new InboxError(400, `level ${i + 1}: color must be #rrggbb`);
|
|
1336
|
+
return { id, label, definition: String(l.definition ?? ''), rule, ...(color ? { color } : {}) };
|
|
1337
|
+
});
|
|
1338
|
+
return clean;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/** The level a card shows: the first level whose rule matches it, or the level the agent gave. */
|
|
1342
|
+
effectivePriority(card , levels = this.priorities(), answer = this.store.get ('answers', card.id)) {
|
|
1343
|
+
const scope = {
|
|
1344
|
+
kind: card.kind, agent: card.agent, priority: card.priority, status: card.status ?? null, rating: card.rating ?? null,
|
|
1345
|
+
tags: card.tags ?? {}, blocked: card.kind === 'blocked', answered: !!answer,
|
|
1346
|
+
waitMinutes: Math.floor((this.now() - card.createdAt) / 60000), harness: card.origin?.harness ?? null,
|
|
1347
|
+
};
|
|
1348
|
+
for (const l of levels) {
|
|
1349
|
+
if (!l.rule) continue;
|
|
1350
|
+
try { if (testRule(l.rule, scope)) return l.id; } catch { /* stored rules always parse */ }
|
|
1351
|
+
}
|
|
1352
|
+
return levels.some((l) => l.id === card.priority) ? card.priority : levels[0]?.id ?? card.priority;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
guidance() {
|
|
1356
|
+
return this.store.get ('meta', 'guidance') ?? { text: DEFAULT_GUIDANCE, version: DEFAULT_GUIDANCE_VERSION, at: 0 };
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/** The tag keys in use with their values and counts, for GET /v1/guidance and card_guidance, so an agent reuses a
|
|
1360
|
+
* value another card set instead of inventing a near miss. It counts the open cards and the cards cleared or closed
|
|
1361
|
+
* in the last PURGE_AFTER_MS, and drops the rest, so a value nobody uses anymore leaves the list. Keys sort by their
|
|
1362
|
+
* total count, then by key, and values by count, then by value, cut to MAX_TAG_KEYS keys and MAX_TAG_VALUES values
|
|
1363
|
+
* each. It reads the summaries the state already reads, and a whole card only for one cleared inside the window. */
|
|
1364
|
+
tagsInUse() {
|
|
1365
|
+
const cutoff = this.now() - PURGE_AFTER_MS;
|
|
1366
|
+
const counts = new Map ();
|
|
1367
|
+
const count = (tags ) => {
|
|
1368
|
+
// A summary written before tags were cleaned still counts the way the print cleans them, so a stored line break
|
|
1369
|
+
// can never forge a line in an agent's card_guidance.
|
|
1370
|
+
for (const [key, value] of Object.entries(cleanTags(tags) ?? {})) {
|
|
1371
|
+
const values = counts.get(key) ?? new Map ();
|
|
1372
|
+
values.set(value, (values.get(value) ?? 0) + 1);
|
|
1373
|
+
counts.set(key, values);
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
for (const s of this.store.list ('summaries')) {
|
|
1377
|
+
// A card that closed before the window still shows until the person clears it, and its tags are no longer in use.
|
|
1378
|
+
if (cardState(s, s.answer) === 'closed' && (s.closed?.at ?? 0) < cutoff) continue;
|
|
1379
|
+
count(s.tags);
|
|
1380
|
+
}
|
|
1381
|
+
// A cleared card keeps no summary, so the list reads the card itself, and only while its clear is inside the window.
|
|
1382
|
+
for (const c of this.store.list ('cleared')) {
|
|
1383
|
+
if (c.archivedAt < cutoff) continue;
|
|
1384
|
+
count(this.store.get ('cards', c.id)?.tags);
|
|
1385
|
+
}
|
|
1386
|
+
const byName = (a , b ) => (a < b ? -1 : a > b ? 1 : 0);
|
|
1387
|
+
return [...counts.entries()]
|
|
1388
|
+
.map(([key, values]) => {
|
|
1389
|
+
const list = [...values.entries()].map(([value, n]) => ({ value, count: n })).sort((a, b) => b.count - a.count || byName(a.value, b.value));
|
|
1390
|
+
return { key, total: list.reduce((n, v) => n + v.count, 0), values: list };
|
|
1391
|
+
})
|
|
1392
|
+
.sort((a, b) => b.total - a.total || byName(a.key, b.key))
|
|
1393
|
+
.slice(0, MAX_TAG_KEYS)
|
|
1394
|
+
.map(({ key, values }) => ({ key, values: values.slice(0, MAX_TAG_VALUES) }));
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/** An agent proposes new card guidance. The person approves it, and picks who receives it, on a system card. With `by`,
|
|
1398
|
+
* the answer and its comment go to the agent that proposed the text, which may acknowledge it or retract the card. */
|
|
1399
|
+
proposeGuidance(text , message , by ) {
|
|
1400
|
+
const clean = String(text ?? '').trim();
|
|
1401
|
+
if (!clean) throw new InboxError(400, 'text is required');
|
|
1402
|
+
// The person reads the message to decide, so a proposal without one is refused rather than shown unexplained.
|
|
1403
|
+
const why = String(message ?? '').trim();
|
|
1404
|
+
if (!why) throw new InboxError(400, 'a guidance proposal needs a message: say what changed and why, for the person who approves it');
|
|
1405
|
+
if (why.length > 2000) throw new InboxError(400, 'message must be at most 2000 characters');
|
|
1406
|
+
// The widget catalog reaches agents beside the guidance text, and never inside it: a proposal carries only the text.
|
|
1407
|
+
// Any complete catalog line counts, so a paste that drops the header is refused too.
|
|
1408
|
+
const bullets = this.widgetCatalog().split('\n').slice(1).filter((l) => l.trim());
|
|
1409
|
+
if (bullets.some((l) => clean.includes(l))) throw new InboxError(400, 'the proposal includes the widget catalog, which the inbox adds beside the guidance text and which is not part of it: propose the text without those lines');
|
|
1410
|
+
const from = by ? String(by.agent ?? '').trim() : '';
|
|
1411
|
+
if (from.length > 80) throw new InboxError(400, 'agent must be at most 80 characters');
|
|
1412
|
+
if (from === SYSTEM_AGENT) throw new InboxError(403, 'the system agent is reserved');
|
|
1413
|
+
const base = this.guidance();
|
|
1414
|
+
const id = `guidance-${randomId(4)}`;
|
|
1415
|
+
const cardText = `${from ? `The agent ${from}` : 'An agent'} proposes this guidance for writing cards. It goes out only if you approve it.\n\n${clean}`;
|
|
1416
|
+
// The card keeps no list of agents: the page lists the online ones from live presence when it draws the card, and
|
|
1417
|
+
// an offline agent reads the new version when it reconnects or calls card_guidance.
|
|
1418
|
+
const broadcast = { text: clean, message: why, ...(from ? { from } : {}), base: { version: base.version, text: base.text } };
|
|
1419
|
+
// The stored card has the proposal in its text and in broadcast, and the guidance it starts from, so it is measured
|
|
1420
|
+
// whole before anything is stored. The margin covers the card's other fields, which stay under a kilobyte.
|
|
1421
|
+
const size = new TextEncoder().encode(JSON.stringify({ text: cardText, broadcast })).length + 2048;
|
|
1422
|
+
if (size > MAX_CARD_BYTES) throw new InboxError(413, `the guidance card would be ${size} bytes, over the limit of ${MAX_CARD_BYTES}: propose a shorter text`);
|
|
1423
|
+
this.postCard({ token: '', role: 'owner' }, {
|
|
1424
|
+
id, agent: SYSTEM_AGENT, kind: 'system', deliver: 'none', priority: this.priorities()[0]?.id,
|
|
1425
|
+
title: 'Send new card guidance to agents?',
|
|
1426
|
+
text: cardText,
|
|
1427
|
+
options: [{ id: 'approve', label: 'Approve and send' }, { id: 'reject', label: 'Reject' }],
|
|
1428
|
+
});
|
|
1429
|
+
const card = {
|
|
1430
|
+
...this.getCard(id),
|
|
1431
|
+
...(from && by?.caller.token ? { token: by.caller.token } : {}),
|
|
1432
|
+
broadcast,
|
|
1433
|
+
};
|
|
1434
|
+
this.store.put('cards', id, card);
|
|
1435
|
+
this.emit({ type: 'change', card: id });
|
|
1436
|
+
return card;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/** Apply or drop the proposed guidance, close the card, and send the answer to the agent that proposed the text. */
|
|
1440
|
+
resolveBroadcast(card , answer ) {
|
|
1441
|
+
if (!card.broadcast) return;
|
|
1442
|
+
this.closeAs(card, 'resolved', this.applyBroadcast(card.broadcast, answer));
|
|
1443
|
+
if (card.broadcast.from) this.flush(card.broadcast.from);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
applyBroadcast(broadcast , answer ) {
|
|
1447
|
+
if ([answer.values['choice']].flat()[0] !== 'approve') return 'Rejected: the guidance did not change.';
|
|
1448
|
+
// The ticked agents, or every connected one when the answer names none. Only connected agents get the event.
|
|
1449
|
+
const picked = Array.isArray(answer.values['agents']) ? new Set((answer.values['agents'] ).map(String)) : undefined;
|
|
1450
|
+
const guidance = { text: broadcast.text, version: this.guidance().version + 1, at: this.now() };
|
|
1451
|
+
this.store.put('meta', 'guidance', guidance);
|
|
1452
|
+
let sent = 0;
|
|
1453
|
+
for (const [k, subs] of this.agents) {
|
|
1454
|
+
const agent = k.slice(k.indexOf('\n') + 1);
|
|
1455
|
+
if (picked && !picked.has(agent)) continue;
|
|
1456
|
+
for (const send of subs) send({ type: 'guidance', id: randomId(6), guidance });
|
|
1457
|
+
sent++;
|
|
1458
|
+
}
|
|
1459
|
+
return `Sent guidance version ${guidance.version} to ${sent} connected agent${sent === 1 ? '' : 's'}. The others get it when they connect.`;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/** The cards a client may read, with their states: all of them for the owner. */
|
|
1463
|
+
listCards(caller ) {
|
|
1464
|
+
return this.store.list ('cards')
|
|
1465
|
+
.filter((c) => caller.role === 'owner' || c.token === caller.token)
|
|
1466
|
+
.map((c) => ({ card: publicCard(c), state: cardState(c, this.store.get ('answers', c.id)) }));
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/** The title, text preview and clear time of each card the caller may read, by id, for the card links on screen.
|
|
1470
|
+
* A cleared card leaves the state but stays stored until the purge, so its links still name it. Ids it never stored are left out. */
|
|
1471
|
+
linkedCards(caller , ids ) {
|
|
1472
|
+
if (ids.length > MAX_LOOKUP) throw new InboxError(400, `name at most ${MAX_LOOKUP} card ids`);
|
|
1473
|
+
const out = [];
|
|
1474
|
+
for (const id of ids) {
|
|
1475
|
+
const card = this.store.get ('cards', id);
|
|
1476
|
+
if (!card || (caller.role !== 'owner' && card.token !== caller.token)) continue;
|
|
1477
|
+
out.push([id, { title: card.title, ...(card.text ? { preview: card.text.slice(0, 300) } : {}), ...(card.archivedAt ? { archivedAt: card.archivedAt } : {}) }]);
|
|
1478
|
+
}
|
|
1479
|
+
// fromEntries makes every id an own key, even one named like an Object property.
|
|
1480
|
+
return Object.fromEntries(out);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
owned(caller , id ) {
|
|
1484
|
+
const card = this.getCard(id);
|
|
1485
|
+
if (caller.role !== 'owner' && card.token !== caller.token) throw new InboxError(403, 'another client owns this card');
|
|
1486
|
+
return card;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
closeAs(card , kind , outcome , draft ) {
|
|
1490
|
+
const current = this.getCard(card.id);
|
|
1491
|
+
// A card closed while triage checks it needs no verdict any more.
|
|
1492
|
+
const triage = current.triage?.state === 'pending' ? { ...current.triage, state: 'skipped' , waiting: [], reason: 'The card closed before triage answered.' } : current.triage;
|
|
1493
|
+
// A draft the person recorded stays with the card, whoever closes it next, so their words are never lost.
|
|
1494
|
+
const kept = draft ?? current.closed?.draft;
|
|
1495
|
+
const closed = { ...current, triage, closed: { kind, outcome, at: this.now(), ...(kept ? { draft: kept } : {}) } };
|
|
1496
|
+
this.store.put('cards', card.id, closed);
|
|
1497
|
+
this.emit({ type: 'change', card: card.id });
|
|
1498
|
+
return closed;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/** The agent retracts its card or answers it itself. */
|
|
1502
|
+
close(caller , id , kind , outcome , ifUnanswered = false) {
|
|
1503
|
+
const card = this.owned(caller, id);
|
|
1504
|
+
if (!CLOSE_KINDS.has(kind)) throw new InboxError(400, 'unknown close kind');
|
|
1505
|
+
const answer = this.store.get ('answers', id);
|
|
1506
|
+
if (ifUnanswered && answer) throw new InboxError(409, 'the person already answered this card');
|
|
1507
|
+
return this.closeAs(card, kind, String(outcome || '').slice(0, 2000) || 'No outcome given.');
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/** The person dismisses a card: it closes without the agent hearing anything, and the form values and the comment on
|
|
1511
|
+
* screen are recorded with the time, even when both are empty. No answer is stored, so no stream receives an event, and
|
|
1512
|
+
* a revision the agent sends later is saved but leaves the card in Sent. Only the owner reads the draft. */
|
|
1513
|
+
dismiss(caller , id , input = {}) {
|
|
1514
|
+
if (caller.role !== 'owner') throw new InboxError(403, 'only the owner dismisses a card');
|
|
1515
|
+
const card = this.getCard(id);
|
|
1516
|
+
if (card.closed) throw new InboxError(409, 'this card is already closed');
|
|
1517
|
+
// A secret field holds the secret itself, and a dismissal is not an answer to seal it, so it stays out of the record.
|
|
1518
|
+
// The other fields and the comment are the person's own words about the card, so they stay.
|
|
1519
|
+
const sealed = sealedFields(card);
|
|
1520
|
+
const values = !input.values || typeof input.values !== 'object' || Array.isArray(input.values)
|
|
1521
|
+
? {} : Object.fromEntries(Object.entries(input.values ).filter(([k]) => !sealed.includes(k)));
|
|
1522
|
+
return this.closeAs(card, 'dismissed', 'Nothing was sent to the agent.', { values, comment: String(input.comment ?? '').trim(), at: this.now() });
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/** A successor agent takes over a card, and with it any answer not yet delivered. */
|
|
1526
|
+
claim(caller , id , agent ) {
|
|
1527
|
+
const card = this.getCard(id);
|
|
1528
|
+
if (caller.role !== 'owner' && card.token !== caller.token) throw new InboxError(403, 'another client owns this card');
|
|
1529
|
+
const token = caller.role === 'owner' ? card.token : caller.token;
|
|
1530
|
+
// A guidance card stays the inbox's system card: the successor takes over as the agent that proposed the text.
|
|
1531
|
+
const next = card.kind === 'system' && card.broadcast
|
|
1532
|
+
? { ...card, token, broadcast: { ...card.broadcast, from: String(agent) } }
|
|
1533
|
+
: { ...card, agent: String(agent), token };
|
|
1534
|
+
this.store.put('cards', id, next);
|
|
1535
|
+
this.emit({ type: 'change', card: id });
|
|
1536
|
+
this.flush(String(agent));
|
|
1537
|
+
return next;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// ---- answers ----
|
|
1541
|
+
|
|
1542
|
+
async answer(id , input ) {
|
|
1543
|
+
const posted = this.getCard(id);
|
|
1544
|
+
const delivery = input.delivery ?? 'default';
|
|
1545
|
+
if (!DELIVERIES.has(delivery)) throw new InboxError(400, 'unknown delivery');
|
|
1546
|
+
const values = input.values && typeof input.values === 'object' ? { ...input.values } : {};
|
|
1547
|
+
if (input.images !== undefined && !Array.isArray(input.images)) throw new InboxError(400, 'images must be a list');
|
|
1548
|
+
// A secret field takes only a sealed value, so plain text never reaches storage. An empty one declines.
|
|
1549
|
+
for (const field of sealedFields(posted)) {
|
|
1550
|
+
// An own property only: a field named like an inherited one, such as constructor, is empty when the answer leaves it out.
|
|
1551
|
+
const v = Object.hasOwn(values, field) ? values[field] : undefined;
|
|
1552
|
+
if (v === undefined || v === null || v === '') delete values[field];
|
|
1553
|
+
else if (!isSealed(v)) throw new InboxError(400, `${field} takes only a sealed value: seal it with the public key on the card`);
|
|
1554
|
+
if (input.images?.some((f) => f?.field === field)) throw new InboxError(400, `${field} takes no images`);
|
|
1555
|
+
}
|
|
1556
|
+
// A pairing request takes a code and its card goes a day later, and the sweep deletes the answer without its
|
|
1557
|
+
// files, so an image here would be stored bytes nothing can name again. The stored pairing decides, not the id:
|
|
1558
|
+
// an agent may post a card of its own called `pair-something`, and that one is neither swept nor a request.
|
|
1559
|
+
const request = posted.id.startsWith('pair-') && this.store.get ('pairings', posted.id.slice('pair-'.length));
|
|
1560
|
+
if (request && input.images?.length) throw new InboxError(400, 'a pairing request takes a code, not images');
|
|
1561
|
+
const { images, refund } = await this.storeAnswerImages(posted, input.images);
|
|
1562
|
+
let card ;
|
|
1563
|
+
try {
|
|
1564
|
+
// Storing images awaited, so another session may have claimed the card while the files were written. Read it
|
|
1565
|
+
// again, and deliver to whoever holds it now: the claim's own flush ran before this answer existed.
|
|
1566
|
+
card = this.getCard(id);
|
|
1567
|
+
} catch (e) {
|
|
1568
|
+
// The card went while the files were written, as `reset` does, so nothing will ever name these files: give the
|
|
1569
|
+
// bytes back rather than leave them charged against the inbox's lifetime upload limit.
|
|
1570
|
+
await refund();
|
|
1571
|
+
throw e;
|
|
1572
|
+
}
|
|
1573
|
+
const before = this.store.get ('answers', id);
|
|
1574
|
+
const answer = {
|
|
1575
|
+
card: id,
|
|
1576
|
+
version: Number(input.version ?? card.version),
|
|
1577
|
+
values,
|
|
1578
|
+
comment: String(input.comment ?? '').trim(),
|
|
1579
|
+
...(images.length ? { images } : {}),
|
|
1580
|
+
delivery,
|
|
1581
|
+
savedAt: this.now(),
|
|
1582
|
+
deliveredAt: null,
|
|
1583
|
+
readAt: null,
|
|
1584
|
+
forcedAt: null,
|
|
1585
|
+
previous: before ? [...before.previous, { ...before, previous: undefined } ] : [],
|
|
1586
|
+
};
|
|
1587
|
+
const responses = (card.responses ?? []).map((r) => respond(r.template, values, r.values)).filter(Boolean);
|
|
1588
|
+
if (responses.length) answer.responses = responses;
|
|
1589
|
+
// An approval sent for this version of the card stays sent, so a second answer never types it again.
|
|
1590
|
+
const kept = before && before.version === answer.version ? (before.approvalResults ?? []).map((r) => (r && !('error' in r) ? r : null)) : [];
|
|
1591
|
+
if (kept.some(Boolean)) answer.approvalResults = kept;
|
|
1592
|
+
for (const p of answer.previous) delete (p ).previous;
|
|
1593
|
+
this.store.put('answers', id, answer);
|
|
1594
|
+
this.store.index(id, `${searchText(card)}\n${answer.comment}`);
|
|
1595
|
+
this.usage.record('answers');
|
|
1596
|
+
this.emit({ type: 'change', card: id });
|
|
1597
|
+
if (card.kind === 'system' && card.id.startsWith('pair-')) await this.resolvePairing(card, answer);
|
|
1598
|
+
else if (card.kind === 'system' && card.broadcast) this.resolveBroadcast(card, answer);
|
|
1599
|
+
else if (card.kind === 'system' && card.id.startsWith('priorities-')) this.resolvePriorityProposal(card, answer);
|
|
1600
|
+
else if (card.kind === 'system' && card.subscription) this.resolveSubscription(card, answer);
|
|
1601
|
+
else this.flush(card.agent);
|
|
1602
|
+
if (card.kind !== 'system') {
|
|
1603
|
+
const saved = this.store.get ('answers', id) ?? answer;
|
|
1604
|
+
this.observeAnswer(card, saved);
|
|
1605
|
+
this.queueAnswer(card, saved);
|
|
1606
|
+
}
|
|
1607
|
+
return this.store.get ('answers', id) ?? answer;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/** Type each approval the owner turned on in the card's answer into its pane, once per card version, and record what
|
|
1611
|
+
* happened on the answer. Only an owner's answer sends: an agent's call returns the answer unchanged. */
|
|
1612
|
+
async sendApprovals(caller , id ) {
|
|
1613
|
+
const answer = this.store.get ('answers', id);
|
|
1614
|
+
const sender = this.approvalSender;
|
|
1615
|
+
const card = this.store.get ('cards', id);
|
|
1616
|
+
if (!sender || caller.role !== 'owner' || !answer || !card?.approvals?.length) return answer;
|
|
1617
|
+
const approvals = card.approvals;
|
|
1618
|
+
const results = approvals.map((_, i) => answer.approvalResults?.[i] ?? null);
|
|
1619
|
+
const claimed = [];
|
|
1620
|
+
approvals.forEach((_, i) => {
|
|
1621
|
+
const r = results[i];
|
|
1622
|
+
if (answer.values[`approval_${i}`] !== true || (r && !('error' in r))) return;
|
|
1623
|
+
// The person read other sentences on the version they answered, so the inbox sends none of the new ones.
|
|
1624
|
+
if (answer.version !== card.version) { results[i] = { error: 'the card changed after you answered it: answer it again to send this' }; return; }
|
|
1625
|
+
results[i] = { sendingAt: this.now() };
|
|
1626
|
+
claimed.push(i);
|
|
1627
|
+
});
|
|
1628
|
+
if (!results.some(Boolean)) return answer;
|
|
1629
|
+
// The claim is written before the first await, so an answer saved meanwhile carries it and never sends again.
|
|
1630
|
+
this.store.put('answers', id, { ...answer, approvalResults: results });
|
|
1631
|
+
this.emit({ type: 'change', card: id });
|
|
1632
|
+
for (const i of claimed) {
|
|
1633
|
+
const a = approvals[i] ;
|
|
1634
|
+
let result ;
|
|
1635
|
+
try {
|
|
1636
|
+
await sender.send(a.pane, a.text);
|
|
1637
|
+
result = { sentAt: this.now() };
|
|
1638
|
+
} catch (e) {
|
|
1639
|
+
result = { error: e instanceof Error ? e.message : String(e) };
|
|
1640
|
+
}
|
|
1641
|
+
this.recordApproval(id, answer.version, i, result);
|
|
1642
|
+
}
|
|
1643
|
+
return this.store.get ('answers', id);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/** Replace the claim on one approval with what the send did, on whichever answer to that card version is current. */
|
|
1647
|
+
recordApproval(id , version , i , result ) {
|
|
1648
|
+
const answer = this.store.get ('answers', id);
|
|
1649
|
+
const claim = answer?.approvalResults?.[i];
|
|
1650
|
+
if (!answer || answer.version !== version || !claim || !('sendingAt' in claim)) return;
|
|
1651
|
+
const results = [...(answer.approvalResults ?? [])];
|
|
1652
|
+
results[i] = result;
|
|
1653
|
+
this.store.put('answers', id, { ...answer, approvalResults: results });
|
|
1654
|
+
this.emit({ type: 'change', card: id });
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/** What herdr says about each approval's pane, one entry per approval, empty when herdr does not know the pane. */
|
|
1658
|
+
async describePanes(approvals ) {
|
|
1659
|
+
const describe = this.approvalSender?.describe?.bind(this.approvalSender);
|
|
1660
|
+
if (!describe) return approvals.map(() => undefined);
|
|
1661
|
+
return Promise.all(approvals.map((a) => describe(a.pane).catch(() => undefined)));
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** A card's approvals, checked. A place comes only from `places`, which herdr answered, so an agent cannot set one. */
|
|
1665
|
+
cleanApprovals(input , kind , places ) {
|
|
1666
|
+
if (input === undefined || input === null) return undefined;
|
|
1667
|
+
if (!this.approvalSender) throw new InboxError(400, 'this inbox does not send approvals: only an inbox on the computer that runs herdr types into a pane');
|
|
1668
|
+
if (kind !== 'ask') throw new InboxError(400, 'only an ask card takes approvals');
|
|
1669
|
+
if (!Array.isArray(input)) throw new InboxError(400, 'approvals must be a list of {pane, text, label}');
|
|
1670
|
+
if (input.length > MAX_APPROVALS) throw new InboxError(400, `a card takes at most ${MAX_APPROVALS} approvals`);
|
|
1671
|
+
if (!input.length) return undefined;
|
|
1672
|
+
return input.map((a , i) => {
|
|
1673
|
+
const at = `approval ${i + 1}`;
|
|
1674
|
+
if (!a || typeof a !== 'object') throw new InboxError(400, `${at}: must be an object with pane and text`);
|
|
1675
|
+
const { pane, text, label } = a ;
|
|
1676
|
+
if (typeof pane !== 'string' || !PANE_ID.test(pane)) throw new InboxError(400, `${at}: pane must be a herdr pane id, such as w6P:p2`);
|
|
1677
|
+
if (typeof text !== 'string' || !text.trim()) throw new InboxError(400, `${at}: text must be the message to send`);
|
|
1678
|
+
if (text.length > MAX_APPROVAL_CHARS) throw new InboxError(400, `${at}: text must be at most 1,000 characters`);
|
|
1679
|
+
// herdr reads an argument that starts with a dash as an option, and takes no -- before the text.
|
|
1680
|
+
if (text.startsWith('-')) throw new InboxError(400, `${at}: text must not start with a dash`);
|
|
1681
|
+
if (label !== undefined && (typeof label !== 'string' || label.length > MAX_APPROVAL_LABEL_CHARS)) throw new InboxError(400, `${at}: label must be at most ${MAX_APPROVAL_LABEL_CHARS} characters`);
|
|
1682
|
+
const kept = cleanPlace(places?.[i]);
|
|
1683
|
+
return { pane, text, ...(label ? { label } : {}), ...(kept ? { place: kept } : {}) };
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/** Delete files a caller wrote and give their bytes back to the lifetime upload limit, for a write that cannot stand. */
|
|
1688
|
+
async refundUploads(stored , bytes ) {
|
|
1689
|
+
await Promise.all(stored.map((f) => this.blobs.delete(f.id ?? '')));
|
|
1690
|
+
this.store.put('meta', 'uploaded-bytes', Math.max(0, this.uploads().used - bytes));
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/** Store the images pasted into an answer, and return their references. Each must be a base64 image of a type the page
|
|
1694
|
+
* shows, within the attachment limit. The files count against the upload limit like a card's files. */
|
|
1695
|
+
async storeAnswerImages(card , input ) {
|
|
1696
|
+
const none = { images: [] , refund: async () => {} };
|
|
1697
|
+
if (input === undefined) return none;
|
|
1698
|
+
if (!Array.isArray(input)) throw new InboxError(400, 'images must be a list');
|
|
1699
|
+
if (input.length > MAX_ANSWER_IMAGES) throw new InboxError(400, `an answer takes at most ${MAX_ANSWER_IMAGES} images`);
|
|
1700
|
+
const decoded = input.map((f, i) => {
|
|
1701
|
+
if (!f || typeof f !== 'object' || !/^data:/.test(String(f.src ?? ''))) throw new InboxError(400, `image ${i + 1}: src must be a base64 data: URL`);
|
|
1702
|
+
const d = decodeDataUrl(i, f);
|
|
1703
|
+
if (!ANSWER_IMAGE_TYPES.has(d.type)) throw new InboxError(400, `image ${i + 1}${f.name ? ` (${cleanName(f.name)})` : ''} is ${d.type}: an answer takes only PNG, JPEG, GIF or WebP images`);
|
|
1704
|
+
return d;
|
|
1705
|
+
});
|
|
1706
|
+
const bytes = decoded.reduce((n, d) => n + Math.max(d.bytes.length, MIN_FILE_CHARGE), 0);
|
|
1707
|
+
const { used, limit } = this.uploads();
|
|
1708
|
+
if (bytes > MAX_ANSWER_IMAGE_BYTES) throw new InboxError(413, `these images are ${bytes} bytes, and an answer takes at most ${MAX_ANSWER_IMAGE_BYTES}`);
|
|
1709
|
+
if (limit !== undefined && used + bytes > limit) throw new InboxError(413, `these images are ${bytes} bytes, and this inbox has stored ${used} of its lifetime limit of ${limit} bytes`);
|
|
1710
|
+
if (bytes) this.store.put('meta', 'uploaded-bytes', used + bytes);
|
|
1711
|
+
const stored = [];
|
|
1712
|
+
// A caller that cannot keep the references deletes the files and gives the bytes back, as a card post does.
|
|
1713
|
+
const refund = () => this.refundUploads(stored, bytes);
|
|
1714
|
+
const settled = await Promise.allSettled(input.map(async (f, i) => {
|
|
1715
|
+
const d = decoded[i] ;
|
|
1716
|
+
const key = `${card.id}.a${i}.${randomId(6)}`;
|
|
1717
|
+
const ref = { id: key, name: cleanName(f.name) || `pasted-${i + 1}.${d.type.slice(6).replace('jpeg', 'jpg')}`, type: d.type, size: d.bytes.length, ...(f.field ? { field: String(f.field).slice(0, 80) } : {}) };
|
|
1718
|
+
stored.push(ref);
|
|
1719
|
+
await this.blobs.put(key, d.bytes, d.type);
|
|
1720
|
+
}));
|
|
1721
|
+
const failed = settled.find((r) => r.status === 'rejected');
|
|
1722
|
+
if (failed) {
|
|
1723
|
+
await refund();
|
|
1724
|
+
throw (failed ).reason;
|
|
1725
|
+
}
|
|
1726
|
+
return { images: stored, refund };
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/** The model has the answer in its context. */
|
|
1730
|
+
ack(caller , id ) {
|
|
1731
|
+
this.owned(caller, id);
|
|
1732
|
+
const answer = this.store.get ('answers', id);
|
|
1733
|
+
if (!answer) throw new InboxError(404, 'no answer for this card');
|
|
1734
|
+
if (!answer.readAt) {
|
|
1735
|
+
const next = { ...answer, readAt: this.now(), deliveredAt: answer.deliveredAt ?? this.now() };
|
|
1736
|
+
this.store.put('answers', id, next);
|
|
1737
|
+
this.emit({ type: 'change', card: id });
|
|
1738
|
+
return next;
|
|
1739
|
+
}
|
|
1740
|
+
return answer;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/** Send an unread answer again, marked to interrupt the agent. */
|
|
1744
|
+
force(id ) {
|
|
1745
|
+
const answer = this.store.get ('answers', id);
|
|
1746
|
+
if (!answer) throw new InboxError(404, 'no answer for this card');
|
|
1747
|
+
if (answer.readAt) return answer;
|
|
1748
|
+
const next = { ...answer, delivery: 'force', deliveredAt: null, forcedAt: this.now() };
|
|
1749
|
+
this.store.put('answers', id, next);
|
|
1750
|
+
this.emit({ type: 'change', card: id });
|
|
1751
|
+
const card = this.getCard(id);
|
|
1752
|
+
this.flush(recipient(card, next) ?? card.agent);
|
|
1753
|
+
// A webhook call that waits for its retry goes now. A call still running keeps its lease, so it never goes twice at once.
|
|
1754
|
+
const now = this.now();
|
|
1755
|
+
for (const d of this.store.list ('outbox')) {
|
|
1756
|
+
if (d.card === id && !(d.calling && d.nextAt > now)) this.store.put ('outbox', d.key, { ...d, nextAt: Math.min(d.nextAt, now) });
|
|
1757
|
+
}
|
|
1758
|
+
this.track(this.deliverAnswers());
|
|
1759
|
+
return this.store.get ('answers', id) ?? next;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/** Answers that wait for an agent that is offline. Agents query and claim them. */
|
|
1763
|
+
deadLetters() {
|
|
1764
|
+
return this.store.list ('answers')
|
|
1765
|
+
.filter((a) => !a.deliveredAt)
|
|
1766
|
+
.map((a) => ({ card: this.getCard(a.card), answer: a }))
|
|
1767
|
+
.filter(({ card, answer }) => { const to = recipient(card, answer); return to !== null && !this.isOnline(to); });
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/** The answers this caller has not consumed: stored, and never acknowledged. `agent` narrows it to one agent, which
|
|
1771
|
+
* is what a watcher asks about. The ids come with the count, so a caller can acknowledge them without a second read. */
|
|
1772
|
+
unread(caller , agent ) {
|
|
1773
|
+
const mine = this.store.list ('answers')
|
|
1774
|
+
.filter((a) => !a.readAt)
|
|
1775
|
+
.filter((a) => {
|
|
1776
|
+
const card = this.store.get ('cards', a.card);
|
|
1777
|
+
if (!card) return false;
|
|
1778
|
+
// A guidance card closes as the person answers it, and its answer waits for the agent that proposed the text.
|
|
1779
|
+
const guidance = card.kind === 'system' && card.broadcast;
|
|
1780
|
+
if (guidance ? recipient(card, a) === null : cardState(card, a) === 'closed') return false;
|
|
1781
|
+
if (caller.role !== 'owner' && card.token !== caller.token) return false;
|
|
1782
|
+
return agent === undefined || (guidance ? card.broadcast?.from : card.agent) === agent;
|
|
1783
|
+
})
|
|
1784
|
+
.map((a) => a.card)
|
|
1785
|
+
.sort();
|
|
1786
|
+
return { unread: mine.length, cards: mine.slice(0, UNREAD_ID_LIMIT) };
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/** Cards that match the words, best first. `answered` keeps only cards the person answered. */
|
|
1790
|
+
search(query , limit = 20, answered = false) {
|
|
1791
|
+
const hits = this.store.search(String(query || ''), answered ? Math.max(limit * 5, 100) : limit);
|
|
1792
|
+
return hits
|
|
1793
|
+
.filter((h) => !answered || this.store.get('answers', h.card))
|
|
1794
|
+
.slice(0, limit)
|
|
1795
|
+
.map((h) => ({ card: this.store.get ('cards', h.card), answer: this.store.get ('answers', h.card) }))
|
|
1796
|
+
.filter((r) => !!r.card)
|
|
1797
|
+
.map(({ card, answer }) => (answer ? { card: publicCard(card), answer } : { card: publicCard(card) }));
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
/** This inbox's stable identity, the same over every link to it. */
|
|
1801
|
+
id() {
|
|
1802
|
+
let id = this.store.get ('meta', 'inbox-id');
|
|
1803
|
+
if (!id) { id = randomId(8); this.store.put('meta', 'inbox-id', id); }
|
|
1804
|
+
return id;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
/** The stored result of a message this client sent before, by its id. */
|
|
1808
|
+
recallMessage(key ) {
|
|
1809
|
+
const m = this.store.get ('messages', key);
|
|
1810
|
+
// A reservation whose request died, such as in a crash, lets the message be applied again.
|
|
1811
|
+
if (m && m.status === 0 && this.now() - m.at > MESSAGE_RESERVATION_MS) return undefined;
|
|
1812
|
+
return m;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
rememberMessage(key , status , body ) {
|
|
1816
|
+
this.store.put('messages', key, { key, status, body, at: this.now() });
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
forgetMessage(key ) {
|
|
1820
|
+
this.store.delete('messages', key);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// ---- default tags ----
|
|
1824
|
+
|
|
1825
|
+
/** The default tags this caller set for one agent id. A token reads only the defaults it set itself. */
|
|
1826
|
+
defaultTags(caller , agent ) {
|
|
1827
|
+
if (caller.role !== 'agent') throw new InboxError(403, 'only an agent reads its own default tags');
|
|
1828
|
+
const id = this.agentId(agent);
|
|
1829
|
+
return { agent: id, tags: this.storedTags(caller.token, id) };
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/** Set, merge, remove or clear the default tags this caller keeps for one agent id. Every card that agent posts
|
|
1833
|
+
* inherits them, under its own tags. */
|
|
1834
|
+
setDefaultTags(caller , input ) {
|
|
1835
|
+
if (caller.role !== 'agent') throw new InboxError(403, 'only an agent sets its own default tags');
|
|
1836
|
+
const agent = this.agentId(input.agent);
|
|
1837
|
+
const from = input.from === undefined ? 'card_tags' : String(input.from);
|
|
1838
|
+
if (!/^[A-Za-z_][\w.-]{0,39}$/.test(from)) throw new InboxError(400, 'from must be 1 to 40 letters, digits, dots, dashes or underscores');
|
|
1839
|
+
const key = `tags:${caller.token}`;
|
|
1840
|
+
const row = this.store.get ('meta', key);
|
|
1841
|
+
const agents = { ...(row?.agents ?? {}) };
|
|
1842
|
+
const tags = input.clear === true ? {} : { ...(agents[agent] ?? {}) };
|
|
1843
|
+
if (input.tags !== undefined) {
|
|
1844
|
+
if (!input.tags || typeof input.tags !== 'object' || Array.isArray(input.tags)) throw new InboxError(400, 'tags must be an object of tag keys and values');
|
|
1845
|
+
for (const [k, v] of Object.entries(input.tags)) {
|
|
1846
|
+
if (!k) throw new InboxError(400, 'each tag needs a key');
|
|
1847
|
+
if (k.length > MAX_TAG_CHARS) throw new InboxError(400, `tag key ${k.slice(0, 20)}… must be at most ${MAX_TAG_CHARS} characters`);
|
|
1848
|
+
const value = String(v ?? '');
|
|
1849
|
+
if (!value) throw new InboxError(400, `tag ${k} has an empty value: leave it out, or remove it`);
|
|
1850
|
+
if (value.length > MAX_TAG_CHARS) throw new InboxError(400, `tag ${k} must be at most ${MAX_TAG_CHARS} characters`);
|
|
1851
|
+
tags[k] = { value, from };
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
if (input.remove !== undefined) {
|
|
1855
|
+
if (!Array.isArray(input.remove)) throw new InboxError(400, 'remove must be a list of tag keys');
|
|
1856
|
+
for (const k of input.remove.map(String)) delete tags[String(k)];
|
|
1857
|
+
}
|
|
1858
|
+
if (Object.keys(tags).length > MAX_TAGS) throw new InboxError(400, `an agent keeps at most ${MAX_TAGS} default tags`);
|
|
1859
|
+
if (Object.keys(tags).length) agents[agent] = tags;
|
|
1860
|
+
else delete agents[agent];
|
|
1861
|
+
if (Object.keys(agents).length) this.store.put ('meta', key, { token: caller.token, agents, at: this.now() });
|
|
1862
|
+
else this.store.delete('meta', key);
|
|
1863
|
+
return { agent, tags };
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/** Every token's default tags, or one agent's, for the owner. */
|
|
1867
|
+
defaultTagsList(caller , agent ) {
|
|
1868
|
+
if (caller.role !== 'owner') throw new InboxError(403, 'only the owner reads every default tag');
|
|
1869
|
+
const out = [];
|
|
1870
|
+
for (const row of this.store.list ('meta')) {
|
|
1871
|
+
if (typeof row?.token !== 'string' || !row.agents) continue;
|
|
1872
|
+
for (const [id, tags] of Object.entries(row.agents)) {
|
|
1873
|
+
if ((agent === undefined || id === agent) && tags && Object.keys(tags).length) out.push({ token: row.token, agent: id, tags });
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
return out;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/** The tag keys a post by this caller takes from its defaults, read after the card commits. The route names them in
|
|
1880
|
+
* the reply, so an agent knows what it inherited without reading the card back. */
|
|
1881
|
+
inheritedTags(caller , input ) {
|
|
1882
|
+
if (caller.token === '' || (input.kind ?? 'ask') === 'system') return [];
|
|
1883
|
+
return mergeDefaultTags(this.tagValues(caller.token, String(input.agent ?? '').trim()), ownTags(input.tags)).inherited;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
/** The defaults this token keeps for one agent id, by value. */
|
|
1887
|
+
tagValues(token , agent ) {
|
|
1888
|
+
return Object.fromEntries(Object.entries(this.storedTags(token, agent)).map(([k, t]) => [k, t.value]));
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
storedTags(token , agent ) {
|
|
1892
|
+
return this.store.get ('meta', `tags:${token}`)?.agents?.[agent] ?? {};
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
agentId(agent ) {
|
|
1896
|
+
const id = String(agent ?? '').trim();
|
|
1897
|
+
if (!id) throw new InboxError(400, 'agent is required');
|
|
1898
|
+
if (id.length > 80) throw new InboxError(400, 'agent must be at most 80 characters');
|
|
1899
|
+
return id;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// ---- connections ----
|
|
1903
|
+
|
|
1904
|
+
key(token , agent ) {
|
|
1905
|
+
return `${token}\n${agent}`;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
isOnline(agent ) {
|
|
1909
|
+
return this.store.get ('presence', agent)?.online ?? false;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
/** An agent connects. `instance` names the agent process, and stays the same across its reconnects. `resumed` marks a
|
|
1913
|
+
* connection the platform rebuilt for a socket that stayed open, such as a Durable Object waking from hibernation:
|
|
1914
|
+
* the agent already got its `hello` on that socket, so it gets none again. `channelOff` says the session discards
|
|
1915
|
+
* channel notifications, and a connection that leaves it out keeps what the last one said. */
|
|
1916
|
+
connect(caller , agent , send , instance , opts = {}) {
|
|
1917
|
+
if (String(agent).length > 80) throw new InboxError(400, 'agent must be at most 80 characters');
|
|
1918
|
+
const k = this.key(caller.token, agent);
|
|
1919
|
+
let set = this.agents.get(k);
|
|
1920
|
+
if (!set) this.agents.set(k, (set = new Set()));
|
|
1921
|
+
set.add(send);
|
|
1922
|
+
if (instance) this.instances.set(send, instance);
|
|
1923
|
+
this.usage.saw('agents', agent);
|
|
1924
|
+
if (instance) this.usage.saw('instances', instance);
|
|
1925
|
+
if (caller.token) this.usage.saw('tokens', caller.token);
|
|
1926
|
+
this.setPresence(agent, true, opts.channelOff);
|
|
1927
|
+
if (!opts.resumed) send({ type: 'hello', agent, version: 1, guidance: this.guidance().version });
|
|
1928
|
+
this.flush(agent);
|
|
1929
|
+
if (instance) this.resend(caller.token, agent, send, instance);
|
|
1930
|
+
return () => {
|
|
1931
|
+
const s = this.agents.get(k);
|
|
1932
|
+
if (!s?.delete(send)) return;
|
|
1933
|
+
if (s.size === 0) this.agents.delete(k);
|
|
1934
|
+
const stillOpen = [...this.agents.keys()].some((x) => x.endsWith(`\n${agent}`));
|
|
1935
|
+
if (!stillOpen) this.putPresence(agent, { online: true, lastSeen: this.now(), leftAt: this.now() });
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/** `channelOff` is undefined for a connection that says nothing about the channel, which keeps what is stored. */
|
|
1940
|
+
setPresence(agent , online , channelOff ) {
|
|
1941
|
+
const before = this.store.get ('presence', agent);
|
|
1942
|
+
const off = channelOff ?? before?.channelOff;
|
|
1943
|
+
const next = { ...before, agent, online, lastSeen: this.now() };
|
|
1944
|
+
delete next.channelOff;
|
|
1945
|
+
if (off) next.channelOff = true;
|
|
1946
|
+
// A live connection has no closing time, and one left from an earlier close would arm the alarm for a past moment.
|
|
1947
|
+
if (online) delete next.leftAt;
|
|
1948
|
+
this.store.put ('presence', agent, next);
|
|
1949
|
+
this.emit({ type: 'presence', agent, online });
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
/** Write a presence change over what is stored, so the fields a change does not name stay. */
|
|
1953
|
+
putPresence(agent , change ) {
|
|
1954
|
+
const before = this.store.get ('presence', agent);
|
|
1955
|
+
this.store.put ('presence', agent, { ...before, agent, online: false, lastSeen: this.now(), ...change });
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/** Send a new agent process each answer an earlier process received but never read, such as one a session got before it restarted. */
|
|
1959
|
+
resend(token , agent , send , instance ) {
|
|
1960
|
+
for (const answer of this.store.list ('answers')) {
|
|
1961
|
+
if (!answer.deliveredAt || answer.readAt || answer.deliveredTo?.includes(instance)) continue;
|
|
1962
|
+
const card = this.store.get ('cards', answer.card);
|
|
1963
|
+
if (!card || recipient(card, answer) !== agent || (card.token ?? '') !== token) continue;
|
|
1964
|
+
const next = { ...answer, deliveredTo: [...(answer.deliveredTo ?? []), instance] };
|
|
1965
|
+
this.store.put('answers', answer.card, next);
|
|
1966
|
+
send({ type: 'answer', id: randomId(6), card: publicCard(card), answer: next });
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
/** Deliver each answer the agent has not received yet. */
|
|
1971
|
+
flush(agent ) {
|
|
1972
|
+
for (const answer of this.store.list ('answers')) {
|
|
1973
|
+
if (answer.deliveredAt) continue;
|
|
1974
|
+
const card = this.store.get ('cards', answer.card);
|
|
1975
|
+
if (!card || recipient(card, answer) !== agent) continue;
|
|
1976
|
+
const subs = this.agents.get(this.key(card.token ?? '', agent));
|
|
1977
|
+
if (!subs || subs.size === 0) continue;
|
|
1978
|
+
const to = [...subs].map((f) => this.instances.get(f)).filter((x) => !!x);
|
|
1979
|
+
const next = { ...answer, deliveredAt: this.now(), ...(to.length ? { deliveredTo: to } : {}) };
|
|
1980
|
+
this.store.put('answers', answer.card, next);
|
|
1981
|
+
const ev = { type: 'answer', id: randomId(6), card: publicCard(card), answer: next };
|
|
1982
|
+
for (const send of subs) send(ev);
|
|
1983
|
+
this.emit({ type: 'change', card: card.id });
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
/** A page opens its event stream. `viewer` is the page's own id, for its presence reports. */
|
|
1988
|
+
subscribePage(send , viewer , guard ) {
|
|
1989
|
+
this.pages.add(send);
|
|
1990
|
+
if (guard) this.guarded.set(send, guard);
|
|
1991
|
+
if (viewer) this.usage.saw('pages', viewer);
|
|
1992
|
+
if (viewer) { this.viewers.set(viewer, this.viewers.get(viewer) ?? { visible: true, activeAt: this.now(), focus: null }); this.personChanged(); }
|
|
1993
|
+
// A socket that wakes after the PIN changed closes at once.
|
|
1994
|
+
if (guard && this.pin.isSet() && !this.pin.live(guard.unlock)) queueMicrotask(() => this.closeLockedPages());
|
|
1995
|
+
return () => {
|
|
1996
|
+
this.pages.delete(send);
|
|
1997
|
+
this.guarded.delete(send);
|
|
1998
|
+
if (viewer && this.viewers.delete(viewer)) this.personChanged();
|
|
1999
|
+
};
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
/** A page reports whether it is visible, that the person did something, and the card on its screen. */
|
|
2003
|
+
reportViewer(viewer , report ) {
|
|
2004
|
+
if (!/^[\w-]{4,64}$/.test(String(viewer))) throw new InboxError(400, 'viewer must be 4 to 64 letters, digits, dashes or underscores');
|
|
2005
|
+
const before = this.viewers.get(viewer) ?? { visible: true, activeAt: null, focus: null };
|
|
2006
|
+
const next = {
|
|
2007
|
+
visible: typeof report.visible === 'boolean' ? report.visible : before.visible,
|
|
2008
|
+
activeAt: report.active ? this.now() : before.activeAt,
|
|
2009
|
+
focus: report.focus === undefined ? before.focus : report.focus ? String(report.focus) : null,
|
|
2010
|
+
};
|
|
2011
|
+
this.viewers.set(viewer, next);
|
|
2012
|
+
// Extensions hear about a change in what the person sees, not about every keypress.
|
|
2013
|
+
if (next.visible !== before.visible || next.focus !== before.focus) return this.personChanged();
|
|
2014
|
+
const person = this.person();
|
|
2015
|
+
this.store.put('meta', 'person', person);
|
|
2016
|
+
return person;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
person() {
|
|
2020
|
+
const views = [...this.viewers.values()];
|
|
2021
|
+
const shown = views.filter((v) => v.visible).sort((a, b) => (b.activeAt ?? 0) - (a.activeAt ?? 0));
|
|
2022
|
+
const activeAt = views.reduce ((m, v) => (v.activeAt !== null && (m === null || v.activeAt > m) ? v.activeAt : m), null);
|
|
2023
|
+
return { pages: views.length, visible: shown.length > 0, activeAt, focus: shown[0]?.focus ?? null, at: this.now() };
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
personChanged() {
|
|
2027
|
+
const person = this.person();
|
|
2028
|
+
this.store.put('meta', 'person', person);
|
|
2029
|
+
const event = { type: 'hook', version: HOOK_VERSION, id: randomId(8), hook: 'person.changed', person };
|
|
2030
|
+
for (const e of this.extensions.values()) if (e.reg.hooks.includes('person.changed')) e.send(event);
|
|
2031
|
+
return person;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/** The page code changed: open pages reload and keep their drafts. */
|
|
2035
|
+
announceCode() {
|
|
2036
|
+
this.emit({ type: 'code' });
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
emit(ev ) {
|
|
2040
|
+
for (const send of this.pages) send(ev);
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
/** Called by the platform alarm: ends presence after its grace, forgets old message ids,
|
|
2044
|
+
* resolves late hook calls, and expires pairing requests and cards. */
|
|
2045
|
+
sweep() {
|
|
2046
|
+
const now = this.now();
|
|
2047
|
+
for (const p of this.store.list ('presence')) {
|
|
2048
|
+
const open = [...this.agents.keys()].some((x) => x.endsWith(`\n${p.agent}`));
|
|
2049
|
+
if (p.online && p.leftAt !== undefined && now - p.leftAt >= PRESENCE_GRACE_MS && !open) {
|
|
2050
|
+
// The agent was last seen when its connection closed, not when the alarm noticed.
|
|
2051
|
+
this.putPresence(p.agent, { online: false, lastSeen: p.leftAt });
|
|
2052
|
+
this.emit({ type: 'presence', agent: p.agent, online: false });
|
|
2053
|
+
} else if (!p.online && now - (p.leftAt ?? p.lastSeen) >= PRESENCE_KEEP_MS) {
|
|
2054
|
+
// The row of an agent gone this long only makes GET /v1/state bigger. A reconnect writes a fresh one.
|
|
2055
|
+
this.store.delete('presence', p.agent);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
// Download counts from earlier days go.
|
|
2059
|
+
const today = `reads:${new Date(now).toISOString().slice(0, 10)}:`;
|
|
2060
|
+
for (const m of this.store.list ('meta')) {
|
|
2061
|
+
const key = (m )?.key;
|
|
2062
|
+
if (typeof key === 'string' && key.startsWith('reads:') && !key.startsWith(today)) this.store.delete('meta', key);
|
|
2063
|
+
const expiresAt = (m )?.expiresAt;
|
|
2064
|
+
if (typeof key === 'string' && key.startsWith('signin:') && typeof expiresAt === 'number' && expiresAt <= now) this.store.delete('meta', key);
|
|
2065
|
+
}
|
|
2066
|
+
this.pin.sweep();
|
|
2067
|
+
for (const m of this.store.list ('messages')) {
|
|
2068
|
+
if (now - m.at > MESSAGE_MEMORY_MS) this.store.delete('messages', m.key);
|
|
2069
|
+
}
|
|
2070
|
+
for (const card of this.store.list ('cards')) {
|
|
2071
|
+
const t = card.triage;
|
|
2072
|
+
if (t?.state === 'pending' && t.deadline && t.deadline <= now) {
|
|
2073
|
+
const late = (t.waiting ?? []).join(', ') || 'triage';
|
|
2074
|
+
const triage = t.policy === 'fail-closed'
|
|
2075
|
+
? { ...t, state: 'held' , reason: `${late} did not answer in time, and its policy holds the card.`, waiting: [], at: now }
|
|
2076
|
+
: { ...t, state: 'passed' , reason: `${late} did not answer in time, so the card went through.`, waiting: [], at: now };
|
|
2077
|
+
this.store.put('cards', card.id, { ...card, triage });
|
|
2078
|
+
this.emit({ type: 'change', card: card.id });
|
|
2079
|
+
}
|
|
2080
|
+
if (card.expiresAt && card.expiresAt <= now && !card.closed && !this.store.get('answers', card.id)) {
|
|
2081
|
+
this.closeAs(card, 'expired', 'The agent stopped waiting for an answer.');
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
for (const p of this.store.list ('pairings')) {
|
|
2085
|
+
if (now - p.createdAt >= PAIRING_KEEP_MS) {
|
|
2086
|
+
// A day later, the request and its card go, so pairing requests cannot pile up.
|
|
2087
|
+
this.store.delete('pairings', p.id);
|
|
2088
|
+
this.store.delete('cards', `pair-${p.id}`);
|
|
2089
|
+
this.store.delete('answers', `pair-${p.id}`);
|
|
2090
|
+
this.store.index(`pair-${p.id}`, '');
|
|
2091
|
+
this.emit({ type: 'change', card: `pair-${p.id}` });
|
|
2092
|
+
continue;
|
|
2093
|
+
}
|
|
2094
|
+
if (now - p.createdAt < PAIRING_TTL_MS) continue;
|
|
2095
|
+
// A token nobody collected in time is dropped, so its secret never stays in storage.
|
|
2096
|
+
if (p.secret) {
|
|
2097
|
+
const { secret: _s, ...rest } = p;
|
|
2098
|
+
this.store.put ('pairings', p.id, rest);
|
|
2099
|
+
continue;
|
|
2100
|
+
}
|
|
2101
|
+
if (p.status !== 'pending') continue;
|
|
2102
|
+
this.store.put ('pairings', p.id, { ...p, status: 'denied' });
|
|
2103
|
+
const card = this.store.get ('cards', `pair-${p.id}`);
|
|
2104
|
+
if (card && !card.closed) this.closeAs(card, 'resolved', 'The pairing request expired.');
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/** Everything a page needs to draw the inbox. */
|
|
2109
|
+
/** Clear the cards you dealt with: answered, retracted or closed. Search still finds them. */
|
|
2110
|
+
archive(caller ) {
|
|
2111
|
+
if (caller.role !== 'owner') throw new InboxError(403, 'only the owner clears the inbox');
|
|
2112
|
+
const now = this.now();
|
|
2113
|
+
let archived = 0;
|
|
2114
|
+
for (const card of this.store.list ('cards')) {
|
|
2115
|
+
if (card.archivedAt) continue;
|
|
2116
|
+
const answer = this.store.get ('answers', card.id);
|
|
2117
|
+
const s = cardState(card, answer);
|
|
2118
|
+
if (!['closed', 'answered', 'delivered', 'read'].includes(s)) continue;
|
|
2119
|
+
this.store.put('cards', card.id, { ...card, archivedAt: now });
|
|
2120
|
+
archived++;
|
|
2121
|
+
}
|
|
2122
|
+
if (archived) this.emit({ type: 'change', card: '' });
|
|
2123
|
+
return { archived };
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
/** Remove every card, answer and attached file. Tokens, templates, widgets, settings and guidance stay. */
|
|
2127
|
+
async reset(caller ) {
|
|
2128
|
+
if (caller.role !== 'owner') throw new InboxError(403, 'only the owner resets the inbox');
|
|
2129
|
+
const cards = this.store.list ('cards');
|
|
2130
|
+
for (const card of cards) await Promise.all(this.drop(card).map((f) => this.blobs.delete(f)));
|
|
2131
|
+
this.emit({ type: 'change', card: '' });
|
|
2132
|
+
return { removed: cards.length };
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
/** Called by the platform alarm after sweep(). Deletes the cards of `purgeQueue` whose 30 days are up, and the oldest
|
|
2136
|
+
* others while the storage is past its headroom, at most PURGE_BATCH a pass. It removes what `reset` removes for each. */
|
|
2137
|
+
async purge() {
|
|
2138
|
+
const now = this.now();
|
|
2139
|
+
const files = [];
|
|
2140
|
+
let removed = 0;
|
|
2141
|
+
for (const { id, since } of this.purgeQueue()) {
|
|
2142
|
+
if (removed >= PURGE_BATCH || (now - since < PURGE_AFTER_MS && !this.needsRoom())) break;
|
|
2143
|
+
const card = this.store.get ('cards', id);
|
|
2144
|
+
if (!card) {
|
|
2145
|
+
// A row whose card is gone would stay due forever, so it goes too.
|
|
2146
|
+
this.store.delete('cleared', id);
|
|
2147
|
+
this.store.delete('summaries', id);
|
|
2148
|
+
continue;
|
|
2149
|
+
}
|
|
2150
|
+
files.push(...this.drop(card));
|
|
2151
|
+
removed++;
|
|
2152
|
+
}
|
|
2153
|
+
// Every record goes before the first await, so no request sees half a batch.
|
|
2154
|
+
if (removed) this.emit({ type: 'change', card: '' });
|
|
2155
|
+
// The records are gone already, so a failed delete must not stop the alarm that called this. R2 deletes the file
|
|
2156
|
+
// 30 days after its upload anyway, and a laptop retries the delete on its next pass.
|
|
2157
|
+
const retry = this.store.get ('meta', PENDING_FILE_DELETES) ?? [];
|
|
2158
|
+
const settled = await Promise.allSettled([...retry, ...files].map((f) => this.blobs.delete(f)));
|
|
2159
|
+
const failed = [...retry, ...files].filter((_, i) => settled[i]?.status === 'rejected').slice(0, PURGE_BATCH * 10);
|
|
2160
|
+
if (failed.length) this.store.put('meta', PENDING_FILE_DELETES, failed);
|
|
2161
|
+
else if (retry.length) this.store.delete('meta', PENDING_FILE_DELETES);
|
|
2162
|
+
return { removed };
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/** The cards the purge may delete, oldest first, each with the time its 30 days start: a cleared card from when the
|
|
2166
|
+
* person cleared it, and a card nobody answered from its last revision. An answered card on show stays.
|
|
2167
|
+
* It reads the summaries and the oldest PURGE_BATCH cleared rows: a pass deletes no more, and the later rows are newer.
|
|
2168
|
+
* So its time follows the cards on show, never the cleared ones or the size of their text. */
|
|
2169
|
+
purgeQueue() {
|
|
2170
|
+
const queue = this.store.first ('cleared', PURGE_BATCH).map((c) => ({ id: c.id, since: c.archivedAt }));
|
|
2171
|
+
for (const s of this.store.list ('summaries')) {
|
|
2172
|
+
if (!s.answer) queue.push({ id: s.id, since: s.updatedAt });
|
|
2173
|
+
}
|
|
2174
|
+
return queue.sort((a, b) => a.since - b.since);
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/** Whether the storage holds more than its limit less the headroom the purge keeps free. */
|
|
2178
|
+
needsRoom() {
|
|
2179
|
+
return this.storageLimit !== undefined && (this.store.bytes?.() ?? 0) > this.storageLimit * (1 - STORAGE_HEADROOM);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/** Delete a card's records and search entry, and return the ids of its files and answer images, for the caller to
|
|
2183
|
+
* delete. The records go first: an answer that lands while the files are deleted then finds no card, and `answer`
|
|
2184
|
+
* refunds the images it had already stored instead of leaving them where this list cannot name them. */
|
|
2185
|
+
drop(card ) {
|
|
2186
|
+
const answer = this.store.get ('answers', card.id);
|
|
2187
|
+
const images = [answer, ...(answer?.previous ?? [])].flatMap((a) => a?.images ?? []);
|
|
2188
|
+
this.store.delete('answers', card.id);
|
|
2189
|
+
this.store.delete('cards', card.id);
|
|
2190
|
+
this.store.index(card.id, '');
|
|
2191
|
+
return [...(card.attachments ?? []), ...images].flatMap((f) => (f.id ? [f.id] : []));
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
/** GET /v1/state: a summary of each card the page shows, with the states, presence and levels. It reads only the
|
|
2195
|
+
* summaries, so its time grows with the cards on show, never with the size of their text or the cleared ones. */
|
|
2196
|
+
summaryState()
|
|
2197
|
+
|
|
2198
|
+
|
|
2199
|
+
{
|
|
2200
|
+
const cards = this.store.list ('summaries').sort((a, b) => a.createdAt - b.createdAt);
|
|
2201
|
+
const answers = {};
|
|
2202
|
+
for (const c of cards) if (c.answer) answers[c.id] = c.answer;
|
|
2203
|
+
const presence = Object.fromEntries(this.store.list ('presence').map((p) => [p.agent, p]));
|
|
2204
|
+
const levels = this.priorities();
|
|
2205
|
+
const states = {};
|
|
2206
|
+
const priorities = {};
|
|
2207
|
+
for (const c of cards) {
|
|
2208
|
+
states[c.id] = this.shownState(c, presence[c.agent], c.answer);
|
|
2209
|
+
priorities[c.id] = this.effectivePriority(c, levels, c.answer);
|
|
2210
|
+
}
|
|
2211
|
+
return { cards, answers, presence, states, priorities, levels, settings: this.settings(), guidance: this.guidance() };
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
/** GET /v1/cards/<id>: the whole card, its answer, its state, and the rev of its summary. `owner` keeps what only the
|
|
2215
|
+
* person reads, such as the draft they left on a card they dismissed. */
|
|
2216
|
+
cardDetail(id , owner = false) {
|
|
2217
|
+
const card = this.getCard(id);
|
|
2218
|
+
const answer = this.store.get ('answers', id);
|
|
2219
|
+
// A body stored before the inbox checked bodies may not draw whole, so the client gets what it can draw.
|
|
2220
|
+
const fallback = card.body !== undefined ? this.fallbackBody?.(card.body) : undefined;
|
|
2221
|
+
const summary = this.store.get ('summaries', id);
|
|
2222
|
+
const shown = owner && card.closed ? { ...publicCard(card), closed: card.closed } : publicCard(card);
|
|
2223
|
+
return {
|
|
2224
|
+
card: fallback ? { ...shown, bodyFallback: fallback } : shown,
|
|
2225
|
+
...(answer ? { answer } : {}),
|
|
2226
|
+
state: this.shownState(card, this.store.get ('presence', card.agent), answer),
|
|
2227
|
+
...(summary ? { rev: summary.rev } : {}),
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
/** Whether an open card's agent has left long ago, so another session can claim it. A row the sweep pruned counts as gone,
|
|
2232
|
+
* and so does a card whose agent never connected, once the card itself is old enough. */
|
|
2233
|
+
orphaned(c , s , p , now ) {
|
|
2234
|
+
if (s !== 'open' || c.deliver !== 'stream') return false;
|
|
2235
|
+
if (p) return !p.online && now - p.lastSeen > ORPHAN_MS;
|
|
2236
|
+
return now - (c.updatedAt ?? c.createdAt) > ORPHAN_MS;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
/** A card's state, with orphaned for an open card whose agent left long ago: another session can claim it. */
|
|
2240
|
+
shownState(c , p , answer ) {
|
|
2241
|
+
return this.orphaned(c, cardState(c, answer), p, this.now()) ? 'orphaned' : cardState(c, answer);
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
state()
|
|
2245
|
+
|
|
2246
|
+
|
|
2247
|
+
{
|
|
2248
|
+
const cards = this.store.list ('cards').filter((c) => !c.archivedAt).map(publicCard).sort((a, b) => a.createdAt - b.createdAt)
|
|
2249
|
+
// A body stored before the inbox checked bodies may not draw whole, so the page gets what it can draw.
|
|
2250
|
+
.map((c) => { const f = c.body !== undefined ? this.fallbackBody?.(c.body) : undefined; return f ? { ...c, bodyFallback: f } : c; });
|
|
2251
|
+
const shown = new Set(cards.map((c) => c.id));
|
|
2252
|
+
const answers = Object.fromEntries(this.store.list ('answers').filter((a) => shown.has(a.card)).map((a) => [a.card, a]));
|
|
2253
|
+
const presence = Object.fromEntries(this.store.list ('presence').map((p) => [p.agent, p]));
|
|
2254
|
+
const now = this.now();
|
|
2255
|
+
const states = Object.fromEntries(cards.map((c) => {
|
|
2256
|
+
const s = cardState(c, answers[c.id]);
|
|
2257
|
+
return [c.id, this.orphaned(c, s, presence[c.agent], now) ? 'orphaned' : s];
|
|
2258
|
+
}));
|
|
2259
|
+
const levels = this.priorities();
|
|
2260
|
+
// The answers are read already, so each card costs no storage read of its own.
|
|
2261
|
+
const priorities = Object.fromEntries(cards.map((c) => [c.id, this.effectivePriority(c, levels, answers[c.id])]));
|
|
2262
|
+
return { cards, answers, presence, states, priorities, levels, settings: this.settings(), guidance: this.guidance() };
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
const TYPES_BY_EXTENSION = {
|
|
2267
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', pdf: 'application/pdf',
|
|
2268
|
+
md: 'text/markdown', markdown: 'text/markdown', txt: 'text/plain', log: 'text/plain', csv: 'text/csv', json: 'application/json',
|
|
2269
|
+
html: 'text/html', htm: 'text/html',
|
|
2270
|
+
};
|
|
2271
|
+
|
|
2272
|
+
/** At most as many ids as a card's text links with [[card-id]]. */
|
|
2273
|
+
const MAX_LOOKUP = 20;
|
|
2274
|
+
const index = (i ) => i + 1;
|
|
2275
|
+
|
|
2276
|
+
/**
|
|
2277
|
+
* Before cards had `dialog`, the Claude Code AskUserQuestion hook was the only poster of `questions`, and the channel and
|
|
2278
|
+
* the herdr sidecar read `questions` as "this card has a terminal dialog". This marks each card that hook stored then, once
|
|
2279
|
+
* per inbox, so an answer on a card still open at the upgrade closes its dialog. The hook posts ids `ask-<8 hex digits>`.
|
|
2280
|
+
*/
|
|
2281
|
+
function markDialogCards(store ) {
|
|
2282
|
+
if (store.get ('meta', 'dialog-marked')) return;
|
|
2283
|
+
for (const c of store.list ('cards')) {
|
|
2284
|
+
if (c.dialog === undefined && c.questions?.length && c.origin?.harness === 'claude-code' && /^ask-[0-9a-f]{8}$/.test(c.id)) store.put('cards', c.id, { ...c, dialog: true });
|
|
2285
|
+
}
|
|
2286
|
+
store.put('meta', 'dialog-marked', true);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
/**
|
|
2290
|
+
* Each option with an id, as a stored card always carries one. `card_post` documents an option as `{label, description,
|
|
2291
|
+
* recommended}` and an agent may also post a plain label, so the inbox derives an id from the label when the post has
|
|
2292
|
+
* none: the label in lowercase, each run of other characters as one dash, and a number when two options share a label.
|
|
2293
|
+
* An id the agent gave stays as it is, and a derived id steps around every id the card already carries, so the ids
|
|
2294
|
+
* are unique. A card that carries an answer or a draft gets none: its pick names a label.
|
|
2295
|
+
*/
|
|
2296
|
+
/**
|
|
2297
|
+
* The same options for a card that carries an answer or a draft, which keeps the identity its pick names. No id is
|
|
2298
|
+
* derived here, and an option whose label a stored option had keeps that option's id, so a re-post that resends the
|
|
2299
|
+
* labels keeps the pick resolving.
|
|
2300
|
+
*/
|
|
2301
|
+
function withKeptOptionIds(options , before ) {
|
|
2302
|
+
if (!Array.isArray(options) || !before?.options) return options;
|
|
2303
|
+
const byLabel = new Map(before.options.filter((o) => o && typeof o.id === 'string' && o.id).map((o) => [o.label, o.id ]));
|
|
2304
|
+
return options.map((o) => {
|
|
2305
|
+
if (!o || typeof o !== 'object' || (typeof o.id === 'string' && o.id)) return o;
|
|
2306
|
+
const id = byLabel.get(o.label);
|
|
2307
|
+
return id ? { ...o, id } : o;
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
function withOptionIds(options ) {
|
|
2312
|
+
if (!Array.isArray(options)) return options;
|
|
2313
|
+
const taken = new Set ();
|
|
2314
|
+
for (const o of options) if (o && typeof o === 'object' && typeof o.id === 'string' && o.id) taken.add(o.id);
|
|
2315
|
+
return options.map((o, i) => {
|
|
2316
|
+
if (!o || typeof o !== 'object' || (typeof o.id === 'string' && o.id)) return o;
|
|
2317
|
+
const label = typeof o.label === 'string' ? o.label : '';
|
|
2318
|
+
const base = (label.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).join('-').slice(0, 60) || `option-${index(i)}`;
|
|
2319
|
+
let id = base;
|
|
2320
|
+
for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
|
|
2321
|
+
taken.add(id);
|
|
2322
|
+
return { ...o, id };
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
/** The page draws each question from these fields, and the answer picks an option by its label, so two options with one label could not be told apart. */
|
|
2327
|
+
function checkQuestions(questions ) {
|
|
2328
|
+
if (questions === undefined) return;
|
|
2329
|
+
if (!Array.isArray(questions)) throw new InboxError(400, 'questions must be a list of {question, text, options, multiSelect}');
|
|
2330
|
+
questions.forEach((q , i) => {
|
|
2331
|
+
const at = `question ${index(i)}`;
|
|
2332
|
+
if (!q || typeof q !== 'object' || typeof q.question !== 'string' || !q.question.trim()) throw new InboxError(400, `${at}: question must be a non-empty string`);
|
|
2333
|
+
if (q.text !== undefined && typeof q.text !== 'string') throw new InboxError(400, `${at}: text must be a string`);
|
|
2334
|
+
if (q.options === undefined) return;
|
|
2335
|
+
if (!Array.isArray(q.options)) throw new InboxError(400, `${at}: options must be a list of {label, description, recommended}`);
|
|
2336
|
+
const labels = new Set ();
|
|
2337
|
+
for (const o of q.options) {
|
|
2338
|
+
if (!o || typeof o !== 'object' || typeof o.label !== 'string') throw new InboxError(400, `${at}: each option needs a label`);
|
|
2339
|
+
if (labels.has(o.label)) throw new InboxError(400, `${at}: two options have the label ${o.label}`);
|
|
2340
|
+
labels.add(o.label);
|
|
2341
|
+
}
|
|
2342
|
+
if (!q.multiSelect && q.options.filter((o) => o.recommended === true).length > 1) throw new InboxError(400, `${at}: a question without multiSelect takes at most one recommended option`);
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
/** The text search matches: the card's words, its tags, and its file names. */
|
|
2347
|
+
function searchText(card ) {
|
|
2348
|
+
// A card stored before this field existed may hold anything the store accepted then, so read it defensively.
|
|
2349
|
+
const questions = (Array.isArray(card.questions) ? card.questions : []).flatMap((q) => [q?.question, q?.text, ...(Array.isArray(q?.options) ? q.options : []).map((o) => o?.label)]);
|
|
2350
|
+
return [card.title, card.text, card.recommendation, card.body, ...questions, card.status, ...Object.entries(card.tags ?? {}).map(([k, v]) => `${k} ${v}`), ...(card.attachments ?? []).map((a) => a.name)]
|
|
2351
|
+
.filter(Boolean).join('\n');
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
const ORIGIN_FIELDS = ['harness', 'session', 'name', 'host', 'cwd', 'herdrPane', 'herdrWorkspace', 'herdrTab'] ;
|
|
2355
|
+
|
|
2356
|
+
/** A post's tags as the merge reads them: an object of values, or nothing. A client that sends anything else keeps the
|
|
2357
|
+
* tags it sent on the card, as it always did. */
|
|
2358
|
+
function ownTags(tags ) {
|
|
2359
|
+
return tags && typeof tags === 'object' && !Array.isArray(tags) ? (tags ) : undefined;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
/** The origin fields the page knows, each a string of at most 200 characters. */
|
|
2363
|
+
function cleanOrigin(origin ) {
|
|
2364
|
+
if (!origin || typeof origin !== 'object') return undefined;
|
|
2365
|
+
const out = {};
|
|
2366
|
+
for (const k of ORIGIN_FIELDS) {
|
|
2367
|
+
const v = (origin )[k];
|
|
2368
|
+
if (v !== undefined && v !== null && v !== '') out[k] = String(v).slice(0, 200);
|
|
2369
|
+
}
|
|
2370
|
+
return Object.keys(out).length ? out : undefined;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
/** What herdr said about a pane, as short strings, or undefined when it said nothing. */
|
|
2374
|
+
function cleanPlace(place ) {
|
|
2375
|
+
if (!place || typeof place !== 'object') return undefined;
|
|
2376
|
+
const out = {};
|
|
2377
|
+
for (const k of ['agent', 'title', 'tab', 'workspace'] ) {
|
|
2378
|
+
const v = (place )[k];
|
|
2379
|
+
if (typeof v === 'string' && v.trim()) out[k] = v.trim().slice(0, 120);
|
|
2380
|
+
}
|
|
2381
|
+
return Object.keys(out).length ? out : undefined;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
function cleanName(name ) {
|
|
2385
|
+
return name ? String(name).replace(/[\\/\x00-\x1f]/g, '_').slice(0, 120) : '';
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
function decodeDataUrl(i , f ) {
|
|
2389
|
+
const m = /^data:([\w.+-]+\/[\w.+-]+)(?:;[\w=.+-]+)*;base64,([A-Za-z0-9+/=\s]+)$/.exec(String(f.src));
|
|
2390
|
+
if (!m) throw new InboxError(400, `attachment ${i + 1}: src must be an https URL or a base64 data: URL`);
|
|
2391
|
+
const data = (m[2] ?? '').replace(/\s/g, '');
|
|
2392
|
+
const size = Math.floor((data.length * 3) / 4);
|
|
2393
|
+
if (size > MAX_ATTACHMENT_BYTES + 2) throw new InboxError(413, `attachment ${i + 1}${f.name ? ` (${cleanName(f.name)})` : ''} is ${size} bytes, over the limit of ${MAX_ATTACHMENT_BYTES}`);
|
|
2394
|
+
let raw ;
|
|
2395
|
+
try { raw = atob(data); } catch { throw new InboxError(400, `attachment ${i + 1}: the base64 data is malformed`); }
|
|
2396
|
+
// A loop, not `Uint8Array.from(raw, ...)`: the callback that form calls once per byte is most of the time this route
|
|
2397
|
+
// spends on a large screenshot.
|
|
2398
|
+
const bytes = new Uint8Array(raw.length);
|
|
2399
|
+
for (let k = 0; k < raw.length; k++) bytes[k] = raw.charCodeAt(k);
|
|
2400
|
+
return { type: (m[1] ?? '').toLowerCase(), bytes };
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
/** The type of a file by its extension, or application/octet-stream for an extension the inbox does not know. */
|
|
2404
|
+
export function typeFromName(name ) {
|
|
2405
|
+
return TYPES_BY_EXTENSION[name.split('.').pop()?.toLowerCase() ?? ''] ?? 'application/octet-stream';
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
/** A file's type, or the type its name gives when an agent sent it as application/octet-stream. */
|
|
2409
|
+
export function attachmentType(file ) {
|
|
2410
|
+
return !file.type || file.type === 'application/octet-stream' ? typeFromName(file.name) : file.type;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
/** A webhook address the inbox calls: https only, as for the owner's webhooks. */
|
|
2414
|
+
function webhookUrl(url ) {
|
|
2415
|
+
const text = String(url ?? '');
|
|
2416
|
+
let parsed ;
|
|
2417
|
+
try { parsed = new URL(text); } catch { throw new InboxError(400, 'webhook.url must be an https URL'); }
|
|
2418
|
+
if (parsed.protocol !== 'https:') throw new InboxError(400, 'webhook.url must be https');
|
|
2419
|
+
if (text.length > 2000) throw new InboxError(400, 'webhook.url must be at most 2,000 characters');
|
|
2420
|
+
// A backtick is the one URL character that ends a code span on the card, and %60 is the same address.
|
|
2421
|
+
return parsed.href.replace(/`/g, '%60');
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
/** The headers a client sends with each call: names a header can have, values on one line, and none the inbox sets itself. */
|
|
2425
|
+
function webhookHeaders(headers ) {
|
|
2426
|
+
if (headers === undefined || headers === null) return {};
|
|
2427
|
+
if (typeof headers !== 'object' || Array.isArray(headers)) throw new InboxError(400, 'webhook.headers must be an object of header names and values');
|
|
2428
|
+
const entries = Object.entries(headers );
|
|
2429
|
+
if (entries.length > MAX_WEBHOOK_HEADERS) throw new InboxError(400, `webhook.headers takes at most ${MAX_WEBHOOK_HEADERS} headers`);
|
|
2430
|
+
const out = {};
|
|
2431
|
+
const seen = new Set ();
|
|
2432
|
+
for (const [name, value] of entries) {
|
|
2433
|
+
if (seen.has(name.toLowerCase())) throw new InboxError(400, `webhook header ${name}: given twice, in two cases`);
|
|
2434
|
+
seen.add(name.toLowerCase());
|
|
2435
|
+
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/.test(name)) throw new InboxError(400, `webhook header ${JSON.stringify(name.slice(0, 64))}: not a header name`);
|
|
2436
|
+
if (RESERVED_HEADER.test(name)) throw new InboxError(400, `webhook header ${name}: the inbox sets it itself`);
|
|
2437
|
+
const v = String(value ?? '');
|
|
2438
|
+
if (/[\r\n\0]/.test(v) || v.length > 2000) throw new InboxError(400, `webhook header ${name}: the value must be one line of at most 2,000 characters`);
|
|
2439
|
+
out[name] = v;
|
|
2440
|
+
}
|
|
2441
|
+
return out;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
/** A card without what only the person reads: the id of the token that posted it, and the draft they left on a card
|
|
2445
|
+
* they dismissed. */
|
|
2446
|
+
export function publicCard(card ) {
|
|
2447
|
+
const { token: _token, ...rest } = card;
|
|
2448
|
+
if (!rest.closed?.draft) return rest;
|
|
2449
|
+
const { kind, outcome, at } = rest.closed;
|
|
2450
|
+
return { ...rest, closed: { kind, outcome, at } };
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
/** The stored lines that share the most words with a text that matched nothing, so an error can name what is there. */
|
|
2454
|
+
function nearestLines(text , old ) {
|
|
2455
|
+
const words = [...new Set(old.toLowerCase().split(/\s+/).filter((w) => w.length >= 3))].slice(0, 8);
|
|
2456
|
+
if (!words.length) return [];
|
|
2457
|
+
return text.split('\n')
|
|
2458
|
+
.map((line) => ({ line: line.trim(), score: words.filter((w) => line.toLowerCase().includes(w)).length }))
|
|
2459
|
+
.filter((c) => c.line !== '' && c.score > 0)
|
|
2460
|
+
.sort((a, b) => b.score - a.score || b.line.length - a.line.length)
|
|
2461
|
+
.slice(0, 3)
|
|
2462
|
+
.map((c) => c.line.slice(0, 180));
|
|
2463
|
+
}
|