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,459 @@
|
|
|
1
|
+
// A client for agents and integrations. It finds the inbox in this order:
|
|
2
|
+
// 1. YOUR_ATTENTION_URL and YOUR_ATTENTION_TOKEN
|
|
3
|
+
// 2. ~/.your-attention/client.json, written by `your-attention pair`
|
|
4
|
+
// 3. the inbox running on this machine, with its local agent token
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { bodyProblemsMessage, checkDiagrams, findDiagrams } from './core/diagrams.js';
|
|
8
|
+
import { InboxError } from './core/inbox.js';
|
|
9
|
+
import { isSealed } from './core/sealed.js';
|
|
10
|
+
|
|
11
|
+
import { homeDir } from './node/home.js';
|
|
12
|
+
import { questionLines } from '../web/answers.js';
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
/** The inbox and token for an extension: YOUR_ATTENTION_URL with YOUR_ATTENTION_TOKEN, or the local extension token. */
|
|
24
|
+
export function loadExtensionConfig(home = homeDir()) {
|
|
25
|
+
const url = process.env['YOUR_ATTENTION_URL'];
|
|
26
|
+
const token = process.env['YOUR_ATTENTION_TOKEN'];
|
|
27
|
+
if (url && token) return { url, token };
|
|
28
|
+
try {
|
|
29
|
+
const server = JSON.parse(fs.readFileSync(path.join(home, 'server.json'), 'utf8')) ;
|
|
30
|
+
return { url: server.url, token: fs.readFileSync(path.join(home, 'extension-token'), 'utf8').trim() };
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadConfig(home = homeDir()) {
|
|
37
|
+
const url = process.env['YOUR_ATTENTION_URL'];
|
|
38
|
+
const token = process.env['YOUR_ATTENTION_TOKEN'];
|
|
39
|
+
if (url && token) return { url, token };
|
|
40
|
+
const read = (f ) => {
|
|
41
|
+
try { return fs.readFileSync(path.join(home, f), 'utf8').trim(); } catch { return undefined; }
|
|
42
|
+
};
|
|
43
|
+
// A file that does not parse is a file this machine cannot use, the same as a missing one: `status` reports it as
|
|
44
|
+
// unreadable, and every other caller answers "no inbox" rather than a stack trace.
|
|
45
|
+
const parse = (text ) => {
|
|
46
|
+
if (!text) return undefined;
|
|
47
|
+
try { return JSON.parse(text); } catch { return undefined; }
|
|
48
|
+
};
|
|
49
|
+
// A file that parses to anything but a pair of strings is a pairing this machine cannot use, the same as a missing
|
|
50
|
+
// one: `status` reports it as unreadable or addressless, and every other caller answers "no inbox" rather than
|
|
51
|
+
// failing on `undefined.replace` or on a URL of 42.
|
|
52
|
+
const usable = (value ) => {
|
|
53
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
54
|
+
const { url, token, urls, id } = value ;
|
|
55
|
+
if (typeof url !== 'string' || url === '' || typeof token !== 'string' || token === '') return undefined;
|
|
56
|
+
// `urls` reaches `Client`, which calls `.replace` on each entry, so anything but a list of strings is dropped here
|
|
57
|
+
// rather than crashing there. So is every other field a file may carry.
|
|
58
|
+
const mirrors = Array.isArray(urls) ? urls.filter((u) => typeof u === 'string' && u !== '') : [];
|
|
59
|
+
return { url, token, ...(mirrors.length ? { urls: mirrors } : {}), ...(typeof id === 'string' ? { id } : {}) };
|
|
60
|
+
};
|
|
61
|
+
const paired = usable(read('client.json') === undefined ? undefined : parse(read('client.json')));
|
|
62
|
+
if (paired) return paired;
|
|
63
|
+
const server = usable({ url: (parse(read('server.json')) )?.url, token: read('agent-token') });
|
|
64
|
+
if (server) return server;
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The inbox this machine paired with as owner, for the commands that act as the person: owner.json, written by
|
|
69
|
+
* `your-attention pair --role owner`, or an owner pairing that an older version saved in client.json. */
|
|
70
|
+
export function ownerPairing(home = homeDir()) {
|
|
71
|
+
for (const f of ['owner.json', 'client.json']) {
|
|
72
|
+
try {
|
|
73
|
+
const c = JSON.parse(fs.readFileSync(path.join(home, f), 'utf8')) ;
|
|
74
|
+
if (c.url && c.token) return { ...c, url: c.url.replace(/\/$/, ''), source: path.join(home, f) };
|
|
75
|
+
} catch { /* no such pairing */ }
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What to do on a machine whose only pairing with `url` is an agent's. */
|
|
81
|
+
export function notOwnerAdvice(url ) {
|
|
82
|
+
return `This machine is paired with ${url} as an agent, so it can't act as you. Pair it as owner with \`your-attention pair ${url} --role owner\`, which keeps the agent pairing. Or open the inbox on your phone from the phone button of a page that is signed in.`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
export class Client {
|
|
88
|
+
/** The link in use. */
|
|
89
|
+
url ;
|
|
90
|
+
/** Every link to the same inbox, tried in order. */
|
|
91
|
+
urls ;
|
|
92
|
+
token ;
|
|
93
|
+
inboxId ;
|
|
94
|
+
/** Answers already handed to the agent, so one that arrives over two links counts once. */
|
|
95
|
+
seen = new Set ();
|
|
96
|
+
/** This process, the same across reconnects, so the inbox sends a restarted agent the answers its earlier process never read. */
|
|
97
|
+
instance = crypto.randomUUID();
|
|
98
|
+
/** The newest guidance version each agent of this process has seen, from a hello or a guidance event. */
|
|
99
|
+
guidanceSeen = new Map ();
|
|
100
|
+
/** What the inbox checks itself, from GET /v1/info. An inbox from before the field checks everything. */
|
|
101
|
+
inboxChecks ;
|
|
102
|
+
|
|
103
|
+
constructor(config ) {
|
|
104
|
+
this.urls = [config.url, ...(config.urls ?? [])].map((u) => u.replace(/\/$/, ''));
|
|
105
|
+
this.url = this.urls[0] ?? '';
|
|
106
|
+
this.token = config.token;
|
|
107
|
+
this.inboxId = config.id;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Check that a link leads to the same inbox as the first link. A link to another inbox is skipped,
|
|
111
|
+
* and so is every other link while the client does not know which inbox the first one leads to. */
|
|
112
|
+
async sameInbox(url ) {
|
|
113
|
+
const idOf = async (u ) =>
|
|
114
|
+
((await (await fetch(`${u}/v1/info`, { signal: AbortSignal.timeout(5000) })).json()) ).id;
|
|
115
|
+
if (!this.inboxId) {
|
|
116
|
+
try { this.inboxId = await idOf(this.urls[0] ?? url); } catch { /* the first link is down */ }
|
|
117
|
+
}
|
|
118
|
+
if (url === this.urls[0]) return true;
|
|
119
|
+
if (!this.inboxId) return false;
|
|
120
|
+
return (await idOf(url)) === this.inboxId;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async call (method , route , body ) {
|
|
124
|
+
// The same message id on every try, so the inbox applies the message once whichever link brings it.
|
|
125
|
+
const headers = { authorization: `Bearer ${this.token}`, 'content-type': 'application/json' };
|
|
126
|
+
if (method !== 'GET') headers['x-message-id'] = crypto.randomUUID();
|
|
127
|
+
let lastError ;
|
|
128
|
+
for (const url of [this.url, ...this.urls.filter((u) => u !== this.url)]) {
|
|
129
|
+
let res ;
|
|
130
|
+
try {
|
|
131
|
+
if (url !== this.url && !(await this.sameInbox(url))) continue;
|
|
132
|
+
res = await fetch(`${url}${route}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
133
|
+
} catch (e) {
|
|
134
|
+
lastError = e;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
this.url = url;
|
|
138
|
+
const data = (await res.json().catch(() => ({}))) ;
|
|
139
|
+
if (!res.ok) throw new Error(`${method} ${route}: ${res.status} ${data.error ?? ''}`.trim());
|
|
140
|
+
return data;
|
|
141
|
+
}
|
|
142
|
+
throw new Error(`${method} ${route}: no link answered (${(lastError )?.message ?? 'none tried'})`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** True the first time an answer is seen, false for a copy from another link or a replay. */
|
|
146
|
+
fresh(ev ) {
|
|
147
|
+
if (ev.type !== 'answer') return true;
|
|
148
|
+
const key = `${ev.card.id}:${ev.answer.savedAt}:${ev.answer.forcedAt ?? 0}`;
|
|
149
|
+
if (this.seen.has(key)) return false;
|
|
150
|
+
this.seen.add(key);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The bytes of a file on a card this client posted, such as an image the person pasted into an answer. */
|
|
155
|
+
async attachment(id ) {
|
|
156
|
+
let lastError ;
|
|
157
|
+
for (const url of [this.url, ...this.urls.filter((u) => u !== this.url)]) {
|
|
158
|
+
try {
|
|
159
|
+
if (url !== this.url && !(await this.sameInbox(url))) continue;
|
|
160
|
+
const res = await fetch(`${url}/v1/attachments/${encodeURIComponent(id)}`, { headers: { authorization: `Bearer ${this.token}` } });
|
|
161
|
+
if (!res.ok) throw new Error(`GET /v1/attachments/${id}: ${res.status}`);
|
|
162
|
+
this.url = url;
|
|
163
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
164
|
+
} catch (e) {
|
|
165
|
+
lastError = e;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
throw new Error(`no link answered (${(lastError )?.message ?? 'none tried'})`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The posted card. A revision the person won't see carries `warning`, and `inheritedTags` names the keys the card
|
|
172
|
+
* took from the agent's default tags. */
|
|
173
|
+
async post(card ) {
|
|
174
|
+
await this.checkFirst('POST /v1/cards', { card: { ...card, attachments: undefined } });
|
|
175
|
+
return this.call('POST', '/v1/cards', card);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The default tags this token keeps for one agent id: every card that agent posts inherits them. */
|
|
179
|
+
defaultTags(agent ) {
|
|
180
|
+
return this.call('GET', `/v1/tags?agent=${encodeURIComponent(agent)}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Set, merge, remove or clear the default tags this token keeps for one agent id. */
|
|
184
|
+
setDefaultTags(agent , write ) {
|
|
185
|
+
return this.call('PUT', '/v1/tags', { agent, ...write });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** An inbox that does not check diagrams or bodies itself, such as the hosted one, takes them unchecked. So the client
|
|
189
|
+
* reads what the inbox would store, after templates and widgets, and checks it as a laptop inbox would, before it sends the card.
|
|
190
|
+
* It fails with the laptop inbox's own words. */
|
|
191
|
+
async checkFirst(route , req ) {
|
|
192
|
+
this.inboxChecks ??= this.call ('GET', '/v1/info')
|
|
193
|
+
.then((i) => i.checks ?? { diagrams: true, body: true }, (e ) => { this.inboxChecks = undefined; throw e; });
|
|
194
|
+
const checks = await this.inboxChecks;
|
|
195
|
+
if (checks.diagrams && checks.body) return;
|
|
196
|
+
const fields = await this.call ('POST', '/v1/preview', req);
|
|
197
|
+
try {
|
|
198
|
+
// Mermaid loads on the first diagram, and the OpenUI parser on the first body.
|
|
199
|
+
const diagrams = findDiagrams(fields);
|
|
200
|
+
if (!checks.diagrams && diagrams.length) await checkDiagrams(diagrams, (await import('./adapters/mermaid.js')).checkMermaid);
|
|
201
|
+
const problems = !checks.body && fields.body !== undefined ? (await import('./adapters/openui-body.js')).bodyErrors(fields.body) : [];
|
|
202
|
+
if (problems.length) throw new InboxError(400, bodyProblemsMessage(problems));
|
|
203
|
+
} catch (e) {
|
|
204
|
+
if (e instanceof InboxError) throw new Error(`${route}: ${e.status} ${e.message}`);
|
|
205
|
+
throw e;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
list() { return this.call('GET', '/v1/cards'); }
|
|
210
|
+
get(id ) { return this.call('GET', `/v1/cards/${encodeURIComponent(id)}`); }
|
|
211
|
+
close(id , kind , outcome , ifUnanswered = false) {
|
|
212
|
+
return this.call('POST', `/v1/cards/${encodeURIComponent(id)}/close`, { kind, outcome, ifUnanswered });
|
|
213
|
+
}
|
|
214
|
+
async patch(id , edits , field ) {
|
|
215
|
+
const route = `/v1/cards/${encodeURIComponent(id)}/patch`;
|
|
216
|
+
await this.checkFirst(`POST ${route}`, { id, edits, field });
|
|
217
|
+
return this.call('POST', route, { edits, field });
|
|
218
|
+
}
|
|
219
|
+
status(id , status ) { return this.call('POST', `/v1/cards/${encodeURIComponent(id)}/status`, { status }); }
|
|
220
|
+
saveTemplate(name , source , feedback ) { return this.call('POST', '/v1/templates', { name, source, feedback }); }
|
|
221
|
+
saveWidget(name , source , feedback , extra = {}) { return this.call('POST', '/v1/widgets', { name, source, feedback, ...extra }); }
|
|
222
|
+
/** The card guidance, and the catalog of widgets an agent can place. */
|
|
223
|
+
guidance() { return this.call('GET', '/v1/guidance'); }
|
|
224
|
+
/** Propose new guidance as `agent`, which receives the person's answer and comment on the card. */
|
|
225
|
+
proposeGuidance(text , opts ) { return this.call('POST', '/v1/guidance', { text, ...opts }); }
|
|
226
|
+
ack(id ) { return this.call('POST', `/v1/cards/${encodeURIComponent(id)}/ack`, {}); }
|
|
227
|
+
claim(id , agent ) { return this.call('POST', `/v1/cards/${encodeURIComponent(id)}/claim`, { agent }); }
|
|
228
|
+
search(q , opts = {}) {
|
|
229
|
+
return this.call('GET', `/v1/search?q=${encodeURIComponent(q)}&limit=${opts.limit ?? 20}${opts.answered ? '&answered=1' : ''}`);
|
|
230
|
+
}
|
|
231
|
+
deadLetters() { return this.call('GET', '/v1/dead-letters'); }
|
|
232
|
+
/** The answers this client has not acknowledged, with their card ids. Every field is checked: an address that answers
|
|
233
|
+
* with JSON of its own can leave `cards` out, and a caller that trusts it reads `.length` of nothing. */
|
|
234
|
+
async unread(agent ) {
|
|
235
|
+
const reply = await this.call ('GET', `/v1/unread${agent ? `?agent=${encodeURIComponent(agent)}` : ''}`);
|
|
236
|
+
const cards = Array.isArray(reply?.cards) ? reply.cards.filter((c) => typeof c === 'string') : [];
|
|
237
|
+
const unread = typeof reply?.unread === 'number' && Number.isFinite(reply.unread) ? reply.unread : cards.length;
|
|
238
|
+
return { unread, cards };
|
|
239
|
+
}
|
|
240
|
+
/** Ask the inbox to send the answers to this agent's cards to a webhook. The owner approves it on a system card, and the
|
|
241
|
+
* secret that signs each call comes back here once. */
|
|
242
|
+
subscribe(agent , webhook ) {
|
|
243
|
+
return this.call('POST', '/v1/cards', { agent, kind: 'system', title: `Send the answers for ${agent} to a webhook`, system: { type: 'subscribe', webhook, events: ['answer'] } });
|
|
244
|
+
}
|
|
245
|
+
unsubscribe(agent ) {
|
|
246
|
+
return this.call('POST', '/v1/cards', { agent, kind: 'system', title: `Stop sending the answers for ${agent} to a webhook`, system: { type: 'unsubscribe' } });
|
|
247
|
+
}
|
|
248
|
+
subscriptions() { return this.call('GET', '/v1/subscriptions'); }
|
|
249
|
+
|
|
250
|
+
/** Receive answers for one agent until `signal` aborts. Reconnects after a drop, over the next link when one fails.
|
|
251
|
+
* `auto`, the default, uses a WebSocket and falls back to server-sent events on a link that cannot carry one.
|
|
252
|
+
* `poll` long-polls, for clients that cannot keep a connection. `channelOff` tells the inbox that this session
|
|
253
|
+
* discards channel notifications, so the person sees why an answer does not arrive. */
|
|
254
|
+
async stream(agent , onEvent , signal , transport = 'auto', opts = {}) {
|
|
255
|
+
const channel = opts.channelOff ? '&channel=off' : '';
|
|
256
|
+
let link = 0;
|
|
257
|
+
const noSocket = new Set ();
|
|
258
|
+
// A link that answered 410 for its event stream serves events over its socket only. Asking it again every
|
|
259
|
+
// second is what woke a hosted inbox's Durable Object ~35 times a second, so it is never asked again.
|
|
260
|
+
const noStream = new Set ();
|
|
261
|
+
const deliver = async (ev ) => {
|
|
262
|
+
if (!this.fresh(ev)) return;
|
|
263
|
+
// Record a guidance version before the handler runs, so a fetch that a hello started never hands over older text.
|
|
264
|
+
if (ev.type === 'guidance') this.guidanceSeen.set(agent, Math.max(this.guidanceSeen.get(agent) ?? 0, ev.guidance.version));
|
|
265
|
+
await onEvent(ev);
|
|
266
|
+
// The inbox sends guidance events only to agents online at the approval. A hello on a reconnect carries the
|
|
267
|
+
// current version, so an agent that was away when the person approved a newer one fetches it and gets it here.
|
|
268
|
+
if (ev.type !== 'hello') return;
|
|
269
|
+
const known = this.guidanceSeen.get(agent);
|
|
270
|
+
// The first hello of this process sets the version it starts from.
|
|
271
|
+
if (known === undefined) { this.guidanceSeen.set(agent, ev.guidance); return; }
|
|
272
|
+
if (ev.guidance <= known) return;
|
|
273
|
+
try {
|
|
274
|
+
const g = await this.guidance();
|
|
275
|
+
// A guidance event may have arrived while the fetch ran: hand over the fetched text only when it is newer still.
|
|
276
|
+
if (g.version <= (this.guidanceSeen.get(agent) ?? 0)) return;
|
|
277
|
+
this.guidanceSeen.set(agent, g.version);
|
|
278
|
+
await onEvent({ type: 'guidance', id: `hello-${g.version}`, guidance: { text: g.text, version: g.version, at: g.at } });
|
|
279
|
+
} catch { /* the next hello tries again, and the agent can read the guidance with card_guidance */ }
|
|
280
|
+
};
|
|
281
|
+
// A retry after a failure waits longer each time, so an inbox that is down, or that refuses a transport,
|
|
282
|
+
// is not woken once a second forever. A transport that ran to its end resets the count.
|
|
283
|
+
let failures = 0;
|
|
284
|
+
const backoff = () => {
|
|
285
|
+
const base = Math.min(30_000, 1000 * 2 ** Math.min(Math.max(failures, 1) - 1, 5));
|
|
286
|
+
return Math.round(base * (0.8 + Math.random() * 0.4));
|
|
287
|
+
};
|
|
288
|
+
while (!signal.aborted) {
|
|
289
|
+
const url = this.urls[link % this.urls.length] ?? this.url;
|
|
290
|
+
let wait = 1000;
|
|
291
|
+
try {
|
|
292
|
+
if (this.urls.length > 1 && !(await this.sameInbox(url))) throw new Error('another inbox');
|
|
293
|
+
this.url = url;
|
|
294
|
+
// The event stream is a fallback for a link whose socket does not open, and only until it refuses.
|
|
295
|
+
const overStream = transport === 'sse' || (transport === 'auto' && noSocket.has(url) && !noStream.has(url));
|
|
296
|
+
let ran = true;
|
|
297
|
+
if (transport === 'poll') await this.overPoll(url, agent, deliver, signal, channel);
|
|
298
|
+
else if (overStream) await this.overSse(url, agent, deliver, signal, channel);
|
|
299
|
+
else {
|
|
300
|
+
const opened = await this.overSocket(url, agent, deliver, signal, channel);
|
|
301
|
+
// A socket that never opened on a link that answers means the link cannot carry one. A link that does not answer is down: retry it.
|
|
302
|
+
if (!opened && transport === 'auto') {
|
|
303
|
+
if (!(await this.answers(url))) throw new Error('the link is down');
|
|
304
|
+
noSocket.add(url);
|
|
305
|
+
ran = false;
|
|
306
|
+
// Try the stream once when the link has not refused it; otherwise wait before the next socket.
|
|
307
|
+
wait = noStream.has(url) ? (failures++, backoff()) : 0;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (ran) failures = 0;
|
|
311
|
+
} catch (e) {
|
|
312
|
+
if (signal.aborted) return;
|
|
313
|
+
const status = (e ).status;
|
|
314
|
+
if (status === 410) {
|
|
315
|
+
// The inbox serves events over its socket only, as a hosted inbox does. An explicit transport asked for
|
|
316
|
+
// a stream that this link will never serve, so it reports instead of retrying.
|
|
317
|
+
if (transport !== 'auto') throw e;
|
|
318
|
+
noStream.add(url);
|
|
319
|
+
failures++;
|
|
320
|
+
wait = backoff();
|
|
321
|
+
} else if (status === 401 || status === 403 || status === 404) {
|
|
322
|
+
// A token or address the inbox refuses stays refused: report it instead of retrying forever.
|
|
323
|
+
throw e;
|
|
324
|
+
} else {
|
|
325
|
+
link++;
|
|
326
|
+
failures++;
|
|
327
|
+
wait = backoff();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (wait === 0) continue;
|
|
331
|
+
if (!signal.aborted) await new Promise((r) => setTimeout(r, wait));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async answers(url ) {
|
|
336
|
+
try { return (await fetch(`${url}/v1/info`, { signal: AbortSignal.timeout(5000) })).ok; } catch { return false; }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async overSse(url , agent , deliver , signal , channel ) {
|
|
340
|
+
const res = await fetch(`${url}/v1/stream?agent=${encodeURIComponent(agent)}&instance=${this.instance}${channel}`, {
|
|
341
|
+
headers: { authorization: `Bearer ${this.token}`, accept: 'text/event-stream' },
|
|
342
|
+
signal,
|
|
343
|
+
});
|
|
344
|
+
if (!res.ok || !res.body) throw new TransportRefused('stream', res.status);
|
|
345
|
+
const reader = res.body.getReader();
|
|
346
|
+
const dec = new TextDecoder();
|
|
347
|
+
let buf = '';
|
|
348
|
+
for (;;) {
|
|
349
|
+
const { value, done } = await reader.read();
|
|
350
|
+
if (done) return;
|
|
351
|
+
buf += dec.decode(value, { stream: true });
|
|
352
|
+
let end ;
|
|
353
|
+
while ((end = buf.indexOf('\n\n')) >= 0) {
|
|
354
|
+
const data = buf.slice(0, end).split('\n').find((l) => l.startsWith('data: '));
|
|
355
|
+
buf = buf.slice(end + 2);
|
|
356
|
+
if (!data) continue;
|
|
357
|
+
const ev = JSON.parse(data.slice(6)) ;
|
|
358
|
+
if (ev.type !== 'beat') await deliver(ev);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Resolves when the socket closes: true if it opened first, false if it never did. */
|
|
364
|
+
overSocket(url , agent , deliver , signal , channel ) {
|
|
365
|
+
return new Promise((resolve) => {
|
|
366
|
+
let opened = false;
|
|
367
|
+
const ws = new WebSocket(`${url.replace(/^http/, 'ws')}/v1/ws?agent=${encodeURIComponent(agent)}&instance=${this.instance}${channel}`);
|
|
368
|
+
// An abort ends the stream now: a socket close waits for the far end's answer, and an idle inbox may not send
|
|
369
|
+
// one for as long as the process lives, which would leave a stopped client unable to exit.
|
|
370
|
+
const stop = () => { ws.close(); done(); };
|
|
371
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
372
|
+
// The global WebSocket cannot send headers, so the token goes in the first frame.
|
|
373
|
+
// The client pings every 20 seconds. A Durable Object answers without waking, and closes a socket that stops.
|
|
374
|
+
let beat ;
|
|
375
|
+
ws.addEventListener('open', () => {
|
|
376
|
+
opened = true;
|
|
377
|
+
ws.send(JSON.stringify({ type: 'auth', token: this.token }));
|
|
378
|
+
beat = setInterval(() => { if (ws.readyState === WebSocket.OPEN) ws.send('{"type":"ping"}'); }, 20_000);
|
|
379
|
+
});
|
|
380
|
+
ws.addEventListener('message', (m) => {
|
|
381
|
+
let ev ;
|
|
382
|
+
try { ev = JSON.parse(String(m.data)) ; } catch { return ws.close(); }
|
|
383
|
+
// A beat only says the stream is alive, as it does over the event stream and the long-poll, so it never reaches
|
|
384
|
+
// the caller as an agent event.
|
|
385
|
+
if (ev && typeof ev === 'object' && ev.type !== 'pong' && ev.type !== 'error' && ev.type !== 'beat') deliver(ev ).catch(() => ws.close());
|
|
386
|
+
});
|
|
387
|
+
let ended = false;
|
|
388
|
+
const done = () => {
|
|
389
|
+
if (ended) return;
|
|
390
|
+
ended = true;
|
|
391
|
+
if (beat !== undefined) clearInterval(beat);
|
|
392
|
+
signal.removeEventListener('abort', stop);
|
|
393
|
+
ws.close();
|
|
394
|
+
resolve(opened);
|
|
395
|
+
};
|
|
396
|
+
ws.addEventListener('close', done);
|
|
397
|
+
// A socket that fails to connect fires error and may never fire close.
|
|
398
|
+
ws.addEventListener('error', done);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async overPoll(url , agent , deliver , signal , channel ) {
|
|
403
|
+
while (!signal.aborted) {
|
|
404
|
+
const res = await fetch(`${url}/v1/poll?agent=${encodeURIComponent(agent)}&instance=${this.instance}${channel}&wait=25`, { headers: { authorization: `Bearer ${this.token}` }, signal });
|
|
405
|
+
if (!res.ok) throw new TransportRefused('poll', res.status);
|
|
406
|
+
for (const ev of ((await res.json()) ).events) await deliver(ev);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** A transport the inbox answered with an HTTP status instead of a stream, so the loop can tell permanent from transient. */
|
|
412
|
+
class TransportRefused extends Error {
|
|
413
|
+
status ;
|
|
414
|
+
constructor(name , status ) {
|
|
415
|
+
super(`${name}: ${status}`);
|
|
416
|
+
this.status = status;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Plain text for an answer, as an agent reads it. */
|
|
421
|
+
/** What an integration tells the agent about a card that triage or the inbox page sent back. */
|
|
422
|
+
export function describeReturned(ev ) {
|
|
423
|
+
return `${ev.by ?? 'Triage'} sent back card ${ev.card.id} ("${ev.card.title}"): ${ev.reason} Revise it with card_patch or post a better card.`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function describeAnswer(card , answer , imagePaths ) {
|
|
427
|
+
const lines = [];
|
|
428
|
+
if (card.questions?.length) lines.push(...questionLines(card, answer.values));
|
|
429
|
+
card.approvals?.forEach((a, i) => {
|
|
430
|
+
const r = answer.approvalResults?.[i];
|
|
431
|
+
// Only a recorded send reads as sent: an answer with no result entry says nothing about the pane.
|
|
432
|
+
const done = !answer.values[`approval_${i}`] ? 'not approved'
|
|
433
|
+
: r && 'sentAt' in r ? 'approved, and the inbox typed it into the pane as the person\'s message'
|
|
434
|
+
: r && 'error' in r ? `approved, but not sent: ${r.error}`
|
|
435
|
+
: 'approved, but the inbox recorded no send';
|
|
436
|
+
lines.push(`Approval ${i + 1} for pane ${a.pane}, "${a.text}": ${done}`);
|
|
437
|
+
});
|
|
438
|
+
for (const [k, v] of Object.entries(answer.values)) {
|
|
439
|
+
if (card.questions?.length && /^q\d+(_comment)?$/.test(k)) continue;
|
|
440
|
+
if (card.approvals?.length && /^approval_\d+$/.test(k)) continue;
|
|
441
|
+
// A sealed secret is never written out: the integration opens it into a file and names the file.
|
|
442
|
+
if (isSealed(v)) { lines.push(`${k}: (sealed)`); continue; }
|
|
443
|
+
// A check box or switch group answers with each item on or off, by the item names the agent gave.
|
|
444
|
+
const on = v && typeof v === 'object' && !Array.isArray(v) ? Object.keys(v).filter((x) => (v )[x]) : undefined;
|
|
445
|
+
const label = on ? on.join(', ') || '(none turned on)'
|
|
446
|
+
: k === 'choice' && card.options
|
|
447
|
+
? [v].flat().map((x) => card.options?.find((o) => (o.id ?? o.label) === x)?.label ?? String(x)).join(', ')
|
|
448
|
+
: [v].flat().join(', ');
|
|
449
|
+
lines.push(`${k}: ${label}`);
|
|
450
|
+
}
|
|
451
|
+
if (answer.comment) lines.push(`Comment: ${answer.comment}`);
|
|
452
|
+
// Pasted images are files: name each path, or say how many came when the integration has not saved them.
|
|
453
|
+
const images = answer.images ?? [];
|
|
454
|
+
if (imagePaths?.length) images.forEach((f, i) => lines.push(`Image${f.field ? ` pasted in ${f.field}` : ''}: ${imagePaths[i] ?? f.name}`));
|
|
455
|
+
else if (images.length) lines.push(`${images.length} pasted image${images.length === 1 ? '' : 's'}`);
|
|
456
|
+
// Each widget on the card can add text for the agent, filled from this answer, such as what to do next.
|
|
457
|
+
for (const r of answer.responses ?? []) lines.push('', r);
|
|
458
|
+
return lines.join('\n') || 'An empty answer: the person sent nothing more.';
|
|
459
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The usage collector: a small Worker apart from the inbox Worker. A laptop inbox posts one
|
|
2
|
+
// report a day here, and the collector writes it to the Analytics Engine dataset that hosted
|
|
3
|
+
// inboxes write directly. It checks each report against the fixed fields, and keeps no address.
|
|
4
|
+
import { dataPoint, parseReport } from '../core/telemetry.js';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
/** A report is about 300 bytes. */
|
|
13
|
+
const MAX_BODY = 2048;
|
|
14
|
+
|
|
15
|
+
const json = (data , status ) => Response.json(data, { status, headers: { 'cache-control': 'no-store' } });
|
|
16
|
+
|
|
17
|
+
/** Read at most MAX_BODY bytes, counting what arrives, so a wrong Content-Length cannot make the Worker read more. */
|
|
18
|
+
async function readBody(req ) {
|
|
19
|
+
if (!req.body) return '';
|
|
20
|
+
const reader = req.body.getReader();
|
|
21
|
+
const chunks = [];
|
|
22
|
+
let size = 0;
|
|
23
|
+
for (;;) {
|
|
24
|
+
const { done, value } = await reader.read();
|
|
25
|
+
if (done) break;
|
|
26
|
+
size += value.byteLength;
|
|
27
|
+
if (size > MAX_BODY) { await reader.cancel(); return undefined; }
|
|
28
|
+
chunks.push(value);
|
|
29
|
+
}
|
|
30
|
+
const all = new Uint8Array(size);
|
|
31
|
+
let at = 0;
|
|
32
|
+
for (const c of chunks) { all.set(c, at); at += c.byteLength; }
|
|
33
|
+
return new TextDecoder().decode(all);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default {
|
|
37
|
+
async fetch(req , env ) {
|
|
38
|
+
const url = new URL(req.url);
|
|
39
|
+
if (req.method === 'GET' && url.pathname === '/v1/health') return json({ ok: true }, 200);
|
|
40
|
+
if (req.method !== 'POST' || url.pathname !== '/v1/report') return json({ error: 'not found' }, 404);
|
|
41
|
+
if (env.LIMITER && !(await env.LIMITER.limit({ key: req.headers.get('cf-connecting-ip') ?? 'unknown' })).success) return json({ error: 'too many reports, try again tomorrow' }, 429);
|
|
42
|
+
const text = await readBody(req);
|
|
43
|
+
if (text === undefined) return json({ error: `the body is over ${MAX_BODY} bytes` }, 413);
|
|
44
|
+
let body ;
|
|
45
|
+
try { body = JSON.parse(text); } catch { return json({ error: 'the body is not JSON' }, 400); }
|
|
46
|
+
const report = parseReport(body, Date.now());
|
|
47
|
+
if (typeof report === 'string') return json({ error: report }, 400);
|
|
48
|
+
// Hosted inboxes write their own data points. A report here that claims an invite is not from a laptop.
|
|
49
|
+
if (report.source !== 'laptop' || report.invite !== '') return json({ error: 'only laptop reports come here' }, 400);
|
|
50
|
+
env.USAGE.writeDataPoint(dataPoint(report));
|
|
51
|
+
return new Response(null, { status: 204 });
|
|
52
|
+
},
|
|
53
|
+
};
|