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,189 @@
|
|
|
1
|
+
// The Cloudflare build. MODE "single" serves one inbox, for a person who
|
|
2
|
+
// deploys their own. MODE "hosted" serves many: each inbox is created with an
|
|
3
|
+
// invite code, lives at an unguessable /i/<id>/ address, and is one Durable
|
|
4
|
+
// Object that hibernates when nobody uses it.
|
|
5
|
+
import { PIN_ITERATIONS, pinKeyError } from '../core/pin.js';
|
|
6
|
+
import { inviteId } from '../core/telemetry.js';
|
|
7
|
+
import { InboxObject, RegistryObject } from './inbox-object.js';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
/** Invite codes shorter than this are refused before any lookup, so guessing one takes too long to try. */
|
|
37
|
+
const MIN_INVITE_LENGTH = 20;
|
|
38
|
+
|
|
39
|
+
const json = (data , status = 200) => Response.json(data, { status, headers: { 'cache-control': 'no-store' } });
|
|
40
|
+
|
|
41
|
+
function hex(bytes ) {
|
|
42
|
+
return [...crypto.getRandomValues(new Uint8Array(bytes))].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tooMany = () => json({ error: 'too many requests, try again in a minute' }, 429);
|
|
46
|
+
|
|
47
|
+
/** The routes a hosted inbox never serves: it sends events over the WebSocket only. The Worker answers them itself, so a client that retries one does not wake the inbox object. */
|
|
48
|
+
const SOCKET_ONLY = new Set(['/v1/events', '/v1/stream', '/v1/poll']);
|
|
49
|
+
|
|
50
|
+
/** When each client that keeps asking for a stream was last named in the log, so a retry loop costs one line a minute. */
|
|
51
|
+
const refusedAt = new Map ();
|
|
52
|
+
|
|
53
|
+
/** Refuse a stream route, and name the client behind it once a minute: a client that ignores the refusal is the only way it is found. */
|
|
54
|
+
function refuseStream(req , url , id ) {
|
|
55
|
+
const agent = url.searchParams.get('agent') ?? '';
|
|
56
|
+
const instance = url.searchParams.get('instance') ?? '';
|
|
57
|
+
const key = `${id}:${url.pathname}:${agent}:${instance}`;
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const last = refusedAt.get(key);
|
|
60
|
+
if (last === undefined || now - last >= 60_000) {
|
|
61
|
+
if (refusedAt.size >= 1000) refusedAt.clear();
|
|
62
|
+
refusedAt.set(key, now);
|
|
63
|
+
console.log(JSON.stringify({ event: 'stream_refused', inbox: id.slice(0, 8), path: url.pathname, agent, instance, ip: clientAddress(req) }));
|
|
64
|
+
}
|
|
65
|
+
return json({ error: 'this inbox serves events over the WebSocket at /v1/ws only' }, 410);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether a request carries a token. The inbox checks the token; the Worker only picks the limiter. */
|
|
69
|
+
const signedIn = (req ) => /^Bearer /.test(req.headers.get('authorization') ?? '') || /(^|;\s*)ya_token=/.test(req.headers.get('cookie') ?? '');
|
|
70
|
+
const clientAddress = (req ) => req.headers.get('cf-connecting-ip') ?? 'unknown';
|
|
71
|
+
|
|
72
|
+
/** Count a request before it costs anything else. Signed-in calls count per inbox and address, so a stranger cannot use up the owner's budget. */
|
|
73
|
+
async function allowed(env , req , inbox ) {
|
|
74
|
+
const anon = !signedIn(req);
|
|
75
|
+
const limiter = anon ? env.ANON_LIMITER : env.LIMITER;
|
|
76
|
+
return !limiter || (await limiter.limit({ key: anon ? `anon:${clientAddress(req)}` : `${inbox}:${clientAddress(req)}` })).success;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Send a request to the inbox object, with the path the inbox expects. `prefix` is the inbox's own address, which its sign-in cookie is scoped to. */
|
|
80
|
+
async function toInbox(env , name , req , path , search , prefix = '/') {
|
|
81
|
+
const stub = env.INBOX.get(env.INBOX.idFromName(name));
|
|
82
|
+
const res = await stub.fetch(new Request(`https://inbox${path}${search}`, req));
|
|
83
|
+
const set = res.headers.get('set-cookie');
|
|
84
|
+
if (prefix === '/' || !set) return res;
|
|
85
|
+
const scoped = new Response(res.body, res);
|
|
86
|
+
scoped.headers.set('set-cookie', set.replace(/;\s*Path=\/(?=;|$)/i, `; Path=${prefix}`));
|
|
87
|
+
return scoped;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Hosted inbox addresses are 20 random hex digits and 12 digits of an HMAC over them. */
|
|
91
|
+
async function addressTag(secret , random ) {
|
|
92
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
93
|
+
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(random)));
|
|
94
|
+
return [...mac.slice(0, 6)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function newAddress(secret ) {
|
|
98
|
+
const random = hex(10);
|
|
99
|
+
return random + (await addressTag(secret, random));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function validAddress(secret , id ) {
|
|
103
|
+
if (!/^[0-9a-f]{32}$/.test(id)) return false;
|
|
104
|
+
const want = await addressTag(secret, id.slice(0, 20));
|
|
105
|
+
let diff = 0;
|
|
106
|
+
for (let i = 0; i < 12; i++) diff |= want.charCodeAt(i) ^ id.charCodeAt(20 + i);
|
|
107
|
+
return diff === 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The owner token this isolate has given its inbox, so single mode sends it once per isolate instead of once per request. */
|
|
111
|
+
let adoptedOwner ;
|
|
112
|
+
|
|
113
|
+
/** Serve the built-in page and its files. Assets serve index.html at "/", and answer "/index.html" with a redirect to "/". */
|
|
114
|
+
function asset(env , req , path ) {
|
|
115
|
+
const url = new URL(req.url);
|
|
116
|
+
return env.ASSETS.fetch(new Request(new URL(path, url.origin), req));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const LANDING = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
120
|
+
<title>Your Attention</title><style>body{font:16px system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 16px;background:#f6f6f4;color:#1d1d1b}
|
|
121
|
+
@media (prefers-color-scheme: dark){body{background:#141413;color:#ececea}}input,button{font:inherit;padding:8px 12px}</style></head>
|
|
122
|
+
<body><h1>Your Attention</h1><p>One inbox for the questions your coding agents ask you. Create an inbox with an invite code. This browser stays signed in to it. Bookmark the address it opens: in another browser, the address shows how to sign in, and it does not sign anyone in by itself.</p>
|
|
123
|
+
<form id="f"><input id="code" placeholder="Invite code" autocomplete="off" required> <button>Create my inbox</button>
|
|
124
|
+
<p><label>PIN <input id="pin" type="password" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" autocomplete="new-password" placeholder="4 digits" size="6"></label> <label>Again <input id="pin2" type="password" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" autocomplete="new-password" placeholder="4 digits" size="6"></label></p>
|
|
125
|
+
<p>The inbox page asks for the PIN in each new tab, so someone who gets the address or a sign-in link still cannot answer your agents' cards. Leave it empty to skip, and the page asks again when it opens. <label><input type="checkbox" id="never"> Never ask me for a PIN</label></p>
|
|
126
|
+
<p>Usage counts: a hosted inbox always sends the operator of this service, once a day, how many cards were posted and answered, pairing requests, page opens, and how many agents, agent processes and tokens connected, with an id made from your invite code. There is no off switch. It never sends card text, answers, agent names or addresses. To keep even these counts to yourself, run the inbox on your laptop. <a href="https://github.com/elecnix/your-attention#usage-counts">What the counts contain</a>.</p></form><p id="out"></p>
|
|
127
|
+
<script type="module">import{newPinKey}from'./pin-key.js';document.getElementById('f').onsubmit=async(e)=>{e.preventDefault();const v=(id)=>document.getElementById(id).value,out=document.getElementById('out');
|
|
128
|
+
if(v('pin')!==v('pin2')){out.textContent='The two PINs differ.';return}if(v('pin')&&!/^[0-9]{4}$/.test(v('pin'))){out.textContent='A PIN is 4 digits.';return}
|
|
129
|
+
const pin=v('pin')?{pin:await newPinKey(v('pin'),${PIN_ITERATIONS})}:{neverAsk:document.getElementById('never').checked};
|
|
130
|
+
const r=await fetch('new',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({invite:v('code'),...pin})});const d=await r.json();
|
|
131
|
+
if(d.unlock){try{sessionStorage.setItem('ya-unlock:'+new URL(d.url).pathname,d.unlock)}catch{}}if(d.login){location.href=d.login}else{out.textContent=d.error}}</script></body></html>`;
|
|
132
|
+
|
|
133
|
+
export default {
|
|
134
|
+
async fetch(req , env ) {
|
|
135
|
+
const url = new URL(req.url);
|
|
136
|
+
if (env.MODE !== 'hosted') {
|
|
137
|
+
if (url.pathname.startsWith('/v1/')) {
|
|
138
|
+
if (!(await allowed(env, req, 'main'))) return tooMany();
|
|
139
|
+
if (env.OWNER_TOKEN && adoptedOwner !== env.OWNER_TOKEN) {
|
|
140
|
+
const init = await env.INBOX.get(env.INBOX.idFromName('main')).fetch('https://inbox/_init', { method: 'POST', body: JSON.stringify({ ownerToken: env.OWNER_TOKEN }) });
|
|
141
|
+
if (!init.ok) return json(await init.json().catch(() => ({ error: 'the inbox did not start' })), init.status);
|
|
142
|
+
adoptedOwner = env.OWNER_TOKEN;
|
|
143
|
+
}
|
|
144
|
+
return toInbox(env, 'main', req, url.pathname, url.search);
|
|
145
|
+
}
|
|
146
|
+
return asset(env, req, url.pathname);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (url.pathname === '/') return new Response(LANDING, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
150
|
+
// The landing page derives the PIN's key with the module the inbox page uses, so the PIN never leaves the browser.
|
|
151
|
+
if (url.pathname === '/pin-key.js') return asset(env, req, url.pathname);
|
|
152
|
+
const secret = env.INBOX_ID_SECRET;
|
|
153
|
+
if (!secret) return json({ error: 'hosted mode needs the INBOX_ID_SECRET secret' }, 500);
|
|
154
|
+
if (url.pathname === '/new' && req.method === 'POST') {
|
|
155
|
+
if (!(await allowed(env, req, ''))) return tooMany();
|
|
156
|
+
if (Number(req.headers.get('content-length') ?? 0) > 1024) return json({ error: 'body too large' }, 413);
|
|
157
|
+
const { invite, pin, neverAsk } = (await req.json().catch(() => ({}))) ;
|
|
158
|
+
const pinError = pin === undefined ? undefined : pinKeyError(pin);
|
|
159
|
+
if (pinError) return json({ error: `pin: ${pinError}` }, 400);
|
|
160
|
+
const code = String(invite ?? '').trim().slice(0, 200);
|
|
161
|
+
const redeemed = code.length >= MIN_INVITE_LENGTH ? (await (await env.REGISTRY.get(env.REGISTRY.idFromName('invites')).fetch('https://registry/redeem', { method: 'POST', body: JSON.stringify({ code }) })).json()) : { ok: false };
|
|
162
|
+
if (!redeemed.ok) return json({ error: 'That invite code is not valid.' }, 403);
|
|
163
|
+
const id = await newAddress(secret);
|
|
164
|
+
await env.INVITES.put(`inbox:${id}`, String(Date.now()));
|
|
165
|
+
const init = { usage: { invite: await inviteId(code) }, pin, neverAsk: neverAsk === true };
|
|
166
|
+
const res = await env.INBOX.get(env.INBOX.idFromName(id)).fetch('https://inbox/_init', { method: 'POST', body: JSON.stringify(init) });
|
|
167
|
+
const { owner, unlock } = (await res.json()) ;
|
|
168
|
+
const base = `${url.origin}/i/${id}/`;
|
|
169
|
+
// The landing page keeps `unlock` in the tab's session storage, so the inbox opens unlocked in that tab only.
|
|
170
|
+
return json({ url: base, login: `${base}#token=${owner}`, ...(unlock ? { unlock } : {}) }, 201);
|
|
171
|
+
}
|
|
172
|
+
const m = /^\/i\/([0-9a-f]{32})(\/.*)?$/.exec(url.pathname);
|
|
173
|
+
if (!m) return json({ error: 'not found' }, 404);
|
|
174
|
+
const id = m[1] ?? '';
|
|
175
|
+
// The page finds the API next to its own address, so the address must end with a slash.
|
|
176
|
+
if (m[2] === undefined) return Response.redirect(`${url.origin}/i/${id}/${url.search}`, 301);
|
|
177
|
+
const rest = m[2];
|
|
178
|
+
// Only the Worker can sign an address, so a guessed one reaches no Durable Object and no KV read.
|
|
179
|
+
if (!(await validAddress(secret, id))) return json({ error: 'no inbox at this address' }, 404);
|
|
180
|
+
if (rest.startsWith('/v1/')) {
|
|
181
|
+
if (!(await allowed(env, req, id))) return tooMany();
|
|
182
|
+
if (req.method === 'GET' && SOCKET_ONLY.has(rest)) return refuseStream(req, url, id);
|
|
183
|
+
return toInbox(env, id, req, rest, url.search, `/i/${id}/`);
|
|
184
|
+
}
|
|
185
|
+
return asset(env, req, rest);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export { InboxObject, RegistryObject };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Ids, secrets and hashes, with Web Crypto only.
|
|
2
|
+
|
|
3
|
+
const hex = (bytes ) => [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
4
|
+
|
|
5
|
+
export function randomId(bytes = 8) {
|
|
6
|
+
return hex(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A six-digit code the person compares on both screens when pairing. */
|
|
10
|
+
export function pairingCode() {
|
|
11
|
+
const n = crypto.getRandomValues(new Uint32Array(1))[0] ?? 0;
|
|
12
|
+
return String(n % 1_000_000).padStart(6, '0');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function sha256(text ) {
|
|
16
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
17
|
+
return hex(new Uint8Array(digest));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A token secret: the token id, a dot, and 32 random bytes. */
|
|
21
|
+
export function newSecret(id ) {
|
|
22
|
+
return `${id}.${randomId(32)}`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Mermaid diagrams on a card: a ```mermaid block in its text or recommendation, or a
|
|
2
|
+
// Mermaid("...") statement in its body. The inbox checks each one with the platform's
|
|
3
|
+
// checker before it takes the card, so the person never sees a diagram the page cannot draw.
|
|
4
|
+
import { InboxError } from './inbox.js';
|
|
5
|
+
|
|
6
|
+
/** Resolves to mermaid's error for a diagram it cannot draw, or to undefined when it can. */
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
/** The errors of the diagrams a card shows, by source. */
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
export const MAX_DIAGRAMS = 20;
|
|
23
|
+
/** A fence line that opens a Mermaid block. web/markdown.js draws the same blocks. */
|
|
24
|
+
export const MERMAID_FENCE = /^```\s*mermaid(\s|$)/i;
|
|
25
|
+
|
|
26
|
+
function markdownDiagrams(field , src ) {
|
|
27
|
+
if (!src) return [];
|
|
28
|
+
// The page's Markdown reader opens a fence at any line that starts with ``` and closes it at the next one.
|
|
29
|
+
const lines = String(src).replace(/\r\n/g, '\n').split('\n');
|
|
30
|
+
const found = [];
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
if (!/^```/.test(lines[i] )) continue;
|
|
33
|
+
const start = i;
|
|
34
|
+
const code = [];
|
|
35
|
+
for (i++; i < lines.length && !/^```/.test(lines[i] ); i++) code.push(lines[i] );
|
|
36
|
+
if (MERMAID_FENCE.test(lines[start] )) found.push({ where: `${field}, the diagram on line ${start + 1}`, source: code.join('\n') });
|
|
37
|
+
}
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The page draws a body with OpenUI, so the inbox reads the body as @openuidev/browser-bundle 0.2.1 does:
|
|
42
|
+
// it takes the code out of ``` fences when the body has them, drops // and # comments, then reads tokens.
|
|
43
|
+
// Each function below follows the bundle function its comment names. A Mermaid call they miss draws as its source.
|
|
44
|
+
|
|
45
|
+
/** The end of a double-quoted string that starts at i, or i. (bQ) */
|
|
46
|
+
function quoted(e , i ) {
|
|
47
|
+
if (e[i] !== '"') return i;
|
|
48
|
+
let r = i + 1;
|
|
49
|
+
while (r < e.length) {
|
|
50
|
+
if (e[r] === '\\') r += 2;
|
|
51
|
+
else if (e[r] === '"') return r + 1;
|
|
52
|
+
else r++;
|
|
53
|
+
}
|
|
54
|
+
return r;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The code inside ``` fences, or the body when it has none. (qHt) */
|
|
58
|
+
function unfence(e ) {
|
|
59
|
+
const fence = (i ) => e[i] === '`' && e[i + 1] === '`' && e[i + 2] === '`';
|
|
60
|
+
const blocks = [];
|
|
61
|
+
let r = 0;
|
|
62
|
+
while (r < e.length) {
|
|
63
|
+
let open = -1;
|
|
64
|
+
while (r < e.length) {
|
|
65
|
+
const q = quoted(e, r);
|
|
66
|
+
if (q > r) { r = q; continue; }
|
|
67
|
+
if (fence(r)) { open = r; break; }
|
|
68
|
+
r++;
|
|
69
|
+
}
|
|
70
|
+
if (open === -1) break;
|
|
71
|
+
let n = open + 3;
|
|
72
|
+
while (n < e.length && e[n] !== '\n') n++;
|
|
73
|
+
if (n >= e.length) { blocks.push(e.slice(open + 3).replace(/^[^\n]*\n?/, '')); break; }
|
|
74
|
+
n++;
|
|
75
|
+
let close = -1;
|
|
76
|
+
let i = n;
|
|
77
|
+
while (i < e.length) {
|
|
78
|
+
const q = quoted(e, i);
|
|
79
|
+
if (q > i) { i = q; continue; }
|
|
80
|
+
if (fence(i)) { close = i; break; }
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
if (close !== -1) { blocks.push(e.slice(n, close)); r = close + 3; } else { blocks.push(e.slice(n)); r = e.length; }
|
|
84
|
+
}
|
|
85
|
+
if (blocks.length) return blocks.join('\n');
|
|
86
|
+
if (!e.startsWith('```')) return e;
|
|
87
|
+
let a = 3;
|
|
88
|
+
while (a < e.length && e[a] !== '\n') a++;
|
|
89
|
+
const rest = e.slice(a < e.length ? a + 1 : 3);
|
|
90
|
+
const last = rest.lastIndexOf('```');
|
|
91
|
+
return last !== -1 ? rest.slice(0, last) : rest;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The body without // and # comments, which end at the line's end outside a string. (VHt) */
|
|
95
|
+
function uncomment(e ) {
|
|
96
|
+
let inside = false;
|
|
97
|
+
return e.split('\n').map((line) => {
|
|
98
|
+
for (let a = 0; a < line.length; a++) {
|
|
99
|
+
const c = line[a];
|
|
100
|
+
if (inside) {
|
|
101
|
+
if (c === '\\' && a + 1 < line.length) { a++; continue; }
|
|
102
|
+
if (c === inside) inside = false;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (c === '"' || c === "'") { inside = c; continue; }
|
|
106
|
+
if ((c === '/' && line[a + 1] === '/') || c === '#') return line.slice(0, a).trimEnd();
|
|
107
|
+
}
|
|
108
|
+
return line;
|
|
109
|
+
}).join('\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
/** The tokens the statements read: strings with OpenUI's escapes, names, and the punctuation around calls. (pQ) */
|
|
115
|
+
function tokens(e ) {
|
|
116
|
+
const out = [];
|
|
117
|
+
const word = (c ) => !!c && /[A-Za-z0-9_]/.test(c);
|
|
118
|
+
let r = 0;
|
|
119
|
+
while (r < e.length) {
|
|
120
|
+
const c = e[r] ;
|
|
121
|
+
if (c === ' ' || c === '\t' || c === '\r') { r++; continue; }
|
|
122
|
+
if (c === '\n') { out.push({ t: 'nl' }); r++; continue; }
|
|
123
|
+
if (c === '(' || c === ')' || c === ',') { out.push({ t: c }); r++; continue; }
|
|
124
|
+
if (c === '=') {
|
|
125
|
+
if (e[r + 1] === '=') { out.push({ t: 'other' }); r += 2; } else { out.push({ t: '=' }); r++; }
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (c === '"') {
|
|
129
|
+
// A string JSON cannot read keeps its text as written, and one with no closing quote gets one.
|
|
130
|
+
const at = r;
|
|
131
|
+
let closed = false;
|
|
132
|
+
for (r++; r < e.length;) {
|
|
133
|
+
if (e[r] === '\\') r += 2;
|
|
134
|
+
else if (e[r] === '"') { r++; closed = true; break; } else r++;
|
|
135
|
+
}
|
|
136
|
+
const raw = e.slice(at, r);
|
|
137
|
+
let v ;
|
|
138
|
+
try { v = String(JSON.parse(closed ? raw : `${raw}"`)); } catch { v = raw.replace(/^"|"$/g, ''); }
|
|
139
|
+
out.push({ t: 'str', v });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (c === "'") {
|
|
143
|
+
let v = '';
|
|
144
|
+
for (r++; r < e.length;) {
|
|
145
|
+
if (e[r] === '\\') {
|
|
146
|
+
r++;
|
|
147
|
+
if (r < e.length) { const x = e[r] ; v += x === 'n' ? '\n' : x === 't' ? '\t' : x; r++; }
|
|
148
|
+
} else if (e[r] === "'") { r++; break; } else v += e[r++];
|
|
149
|
+
}
|
|
150
|
+
out.push({ t: 'str', v });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if ((c === '$' || c === '@') && /[A-Za-z_]/.test(e[r + 1] ?? '')) {
|
|
154
|
+
for (r++; word(e[r]); r++);
|
|
155
|
+
out.push({ t: 'other' });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (/[A-Za-z_]/.test(c)) {
|
|
159
|
+
const at = r;
|
|
160
|
+
for (; word(e[r]); r++);
|
|
161
|
+
out.push({ t: 'name', v: e.slice(at, r) });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
out.push({ t: 'other' });
|
|
165
|
+
r++;
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function bodyDiagrams(body ) {
|
|
171
|
+
if (!body) return [];
|
|
172
|
+
const list = tokens(uncomment(unfence(String(body).trim())).trim());
|
|
173
|
+
const found = [];
|
|
174
|
+
let statement ;
|
|
175
|
+
let depth = 0;
|
|
176
|
+
for (let i = 0; i < list.length; i++) {
|
|
177
|
+
const k = list[i] ;
|
|
178
|
+
const at = (j ) => list[j]?.t;
|
|
179
|
+
if (k.t === '(') depth++;
|
|
180
|
+
else if (k.t === ')') depth = Math.max(0, depth - 1);
|
|
181
|
+
// A statement starts a line with its name and =, outside any open call.
|
|
182
|
+
if (depth === 0 && k.t === 'name' && (i === 0 || at(i - 1) === 'nl') && at(i + 1) === '=') statement = k.v;
|
|
183
|
+
if (k.t !== 'name' || k.v !== 'Mermaid' || at(i + 1) !== '(') continue;
|
|
184
|
+
const where = statement ? `body, statement ${statement}` : `body, diagram ${found.length + 1}`;
|
|
185
|
+
const arg = list[i + 2];
|
|
186
|
+
if (arg?.t !== 'str' || (at(i + 3) !== ')' && at(i + 3) !== ',')) throw new InboxError(400, `${where}: Mermaid takes the diagram source as a string, such as Mermaid("flowchart TD\\n A --> B")`);
|
|
187
|
+
found.push({ where, source: arg.v });
|
|
188
|
+
}
|
|
189
|
+
return found;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Every diagram the page draws for these fields, in the order the card shows them. */
|
|
193
|
+
export function findDiagrams(fields ) {
|
|
194
|
+
return [...markdownDiagrams('text', fields.text), ...markdownDiagrams('recommendation', fields.recommendation), ...bodyDiagrams(fields.body)];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Check each diagram once, and reject the card with mermaid's own words for each one it cannot draw. */
|
|
198
|
+
export async function checkDiagrams(found , check ) {
|
|
199
|
+
const checked = new Map();
|
|
200
|
+
if (!found.length) return checked;
|
|
201
|
+
if (!check) throw new InboxError(400, 'this inbox cannot check Mermaid diagrams, so it takes no card with one');
|
|
202
|
+
if (found.length > MAX_DIAGRAMS) throw new InboxError(400, `a card shows at most ${MAX_DIAGRAMS} diagrams`);
|
|
203
|
+
for (const d of found) if (!checked.has(d.source)) checked.set(d.source, await check(d.source));
|
|
204
|
+
const bad = found.filter((d) => checked.get(d.source) !== undefined);
|
|
205
|
+
if (bad.length) throw new InboxError(400, bad.map((d) => diagramMessage(d.where, check.version, checked.get(d.source) ?? '', 'the inbox did not take the card')).join('\n\n'));
|
|
206
|
+
return checked;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** What the agent reads about one diagram that does not draw: where it is, what happened to the card, and mermaid's own words. */
|
|
210
|
+
export function diagramMessage(where , version , error , outcome ) {
|
|
211
|
+
return `The Mermaid diagram in ${where} does not parse, so ${outcome}. Mermaid ${version} says:\n${error}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** What the agent reads about a body the page would draw only in part, one fault a line. */
|
|
215
|
+
export function bodyProblemsMessage(problems , outcome ) {
|
|
216
|
+
return `the page cannot draw all of this body${outcome ? `, ${outcome}` : ''}:\n- ${problems.join('\n- ')}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The card commits with no await after its check, so a diagram missing from the check means the card changed meanwhile. */
|
|
220
|
+
export function requireChecked(found , checked ) {
|
|
221
|
+
for (const d of found) {
|
|
222
|
+
if (!checked?.has(d.source)) throw new InboxError(409, `${d.where}: the inbox did not check this diagram before the card changed. Post the card again`);
|
|
223
|
+
if (checked.get(d.source) !== undefined) throw new InboxError(400, `${d.where}: mermaid cannot parse this diagram`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// A small expression language for priority rules, such as
|
|
2
|
+
// kind == "blocked" || waitMinutes > 30 && tags.topic == "release"
|
|
3
|
+
// It parses its own grammar and never calls eval, so a rule can read card
|
|
4
|
+
// properties and nothing else, on every platform, including Workers.
|
|
5
|
+
//
|
|
6
|
+
// or := and ("||" and)*
|
|
7
|
+
// and := not ("&&" not)*
|
|
8
|
+
// not := "!" not | compare
|
|
9
|
+
// compare := sum (("==" | "!=" | "<" | "<=" | ">" | ">=" | "in") sum)?
|
|
10
|
+
// sum := product (("+" | "-") product)*
|
|
11
|
+
// product := unary (("*" | "/") unary)*
|
|
12
|
+
// unary := "-" unary | primary
|
|
13
|
+
// primary := number | string | true | false | null | name ("." name)* | "(" or ")"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
const MAX_LENGTH = 500;
|
|
23
|
+
const TOKEN = /\s*(?:(\d+(?:\.\d+)?)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(\|\||&&|==|!=|<=|>=|[<>!()+\-*/.])|([A-Za-z_][A-Za-z0-9_]*))/y;
|
|
24
|
+
|
|
25
|
+
function tokenize(src ) {
|
|
26
|
+
if (src.length > MAX_LENGTH) throw new Error(`a rule is at most ${MAX_LENGTH} characters`);
|
|
27
|
+
const out = [];
|
|
28
|
+
TOKEN.lastIndex = 0;
|
|
29
|
+
while (TOKEN.lastIndex < src.length) {
|
|
30
|
+
if (/^\s*$/.test(src.slice(TOKEN.lastIndex))) break;
|
|
31
|
+
// A failed sticky match resets lastIndex, so keep the position first.
|
|
32
|
+
const at = TOKEN.lastIndex;
|
|
33
|
+
const m = TOKEN.exec(src);
|
|
34
|
+
if (!m) throw new Error(`unexpected character at ${at + (/^\s*/.exec(src.slice(at))?.[0].length ?? 0) + 1}`);
|
|
35
|
+
out.push(m[1] ?? m[2] ?? m[3] ?? m[4] ?? '');
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parse(src ) {
|
|
41
|
+
const toks = tokenize(src);
|
|
42
|
+
let i = 0;
|
|
43
|
+
const peek = () => toks[i];
|
|
44
|
+
const take = (t ) => {
|
|
45
|
+
const v = toks[i];
|
|
46
|
+
if (v === undefined || (t !== undefined && v !== t)) throw new Error(t ? `expected ${t}` : 'unexpected end of rule');
|
|
47
|
+
i++;
|
|
48
|
+
return v;
|
|
49
|
+
};
|
|
50
|
+
const left = (next , ops ) => {
|
|
51
|
+
let a = next();
|
|
52
|
+
while (ops.includes(peek() ?? '')) { const op = take(); a = { t: 'bin', op, a, b: next() }; }
|
|
53
|
+
return a;
|
|
54
|
+
};
|
|
55
|
+
const primary = () => {
|
|
56
|
+
const v = take();
|
|
57
|
+
if (v === '(') { const e = or(); take(')'); return e; }
|
|
58
|
+
if (/^\d/.test(v)) return { t: 'lit', v: Number(v) };
|
|
59
|
+
if (v[0] === '"' || v[0] === "'") return { t: 'lit', v: v.slice(1, -1).replace(/\\(.)/g, '$1') };
|
|
60
|
+
if (v === 'true' || v === 'false') return { t: 'lit', v: v === 'true' };
|
|
61
|
+
if (v === 'null') return { t: 'lit', v: null };
|
|
62
|
+
if (!/^[A-Za-z_]/.test(v) || v === 'in') throw new Error(`unexpected ${v}`);
|
|
63
|
+
const p = [v];
|
|
64
|
+
while (peek() === '.') { take('.'); const n = take(); if (!/^[A-Za-z_]/.test(n)) throw new Error(`unexpected ${n}`); p.push(n); }
|
|
65
|
+
return { t: 'path', p };
|
|
66
|
+
};
|
|
67
|
+
const unary = () => (peek() === '-' ? (take(), { t: 'neg', a: unary() }) : primary());
|
|
68
|
+
const product = () => left(unary, ['*', '/']);
|
|
69
|
+
const sum = () => left(product, ['+', '-']);
|
|
70
|
+
const compare = () => {
|
|
71
|
+
const a = sum();
|
|
72
|
+
const op = peek();
|
|
73
|
+
if (op && ['==', '!=', '<', '<=', '>', '>=', 'in'].includes(op)) { take(); return { t: 'bin', op, a, b: sum() }; }
|
|
74
|
+
return a;
|
|
75
|
+
};
|
|
76
|
+
const not = () => (peek() === '!' ? (take(), { t: 'not', a: not() }) : compare());
|
|
77
|
+
const and = () => left(not, ['&&']);
|
|
78
|
+
function or() { return left(and, ['||']); }
|
|
79
|
+
const tree = or();
|
|
80
|
+
if (i < toks.length) throw new Error(`unexpected ${toks[i]}`);
|
|
81
|
+
return tree;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function get(scope , path ) {
|
|
85
|
+
let v = scope;
|
|
86
|
+
for (const k of path) {
|
|
87
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v) || !Object.hasOwn(v, k)) return undefined;
|
|
88
|
+
v = (v )[k];
|
|
89
|
+
}
|
|
90
|
+
return v;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function run(n , scope ) {
|
|
94
|
+
switch (n.t) {
|
|
95
|
+
case 'lit': return n.v;
|
|
96
|
+
case 'path': return get(scope, n.p);
|
|
97
|
+
case 'not': return !run(n.a, scope);
|
|
98
|
+
case 'neg': return -Number(run(n.a, scope));
|
|
99
|
+
case 'bin': {
|
|
100
|
+
if (n.op === '&&') return Boolean(run(n.a, scope)) && Boolean(run(n.b, scope));
|
|
101
|
+
if (n.op === '||') return Boolean(run(n.a, scope)) || Boolean(run(n.b, scope));
|
|
102
|
+
const a = run(n.a, scope);
|
|
103
|
+
const b = run(n.b, scope);
|
|
104
|
+
switch (n.op) {
|
|
105
|
+
case '==': return a === b;
|
|
106
|
+
case '!=': return a !== b;
|
|
107
|
+
case '<': return Number(a) < Number(b);
|
|
108
|
+
case '<=': return Number(a) <= Number(b);
|
|
109
|
+
case '>': return Number(a) > Number(b);
|
|
110
|
+
case '>=': return Number(a) >= Number(b);
|
|
111
|
+
case '+': return typeof a === 'string' || typeof b === 'string' ? `${a ?? ''}${b ?? ''}` : Number(a) + Number(b);
|
|
112
|
+
case '-': return Number(a) - Number(b);
|
|
113
|
+
case '*': return Number(a) * Number(b);
|
|
114
|
+
case '/': return Number(a) / Number(b);
|
|
115
|
+
case 'in': return Array.isArray(b) ? b.includes(a) : b !== null && typeof b === 'object' ? Object.hasOwn(b, String(a)) : typeof b === 'string' && b.includes(String(a));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw new Error('unknown expression');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether `rule` holds for `scope`. A rule that fails to parse throws. */
|
|
123
|
+
export function test(rule , scope ) {
|
|
124
|
+
return Boolean(run(parse(rule), scope));
|
|
125
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The file: addresses a card names. The laptop inbox serves a file to the owner's page only when its card names it,
|
|
2
|
+
// so the agent that posted the card chooses what is exposed. web/markdown.js links the same addresses.
|
|
3
|
+
const FILE_URL = /file:\/\/[^\s<>()[\]`]+/g;
|
|
4
|
+
const URL_TAIL = /[.,;:!?'"*_]+$/;
|
|
5
|
+
|
|
6
|
+
/** The decoded path of a file: address on this machine, or undefined for another host or an address that is not one. */
|
|
7
|
+
export function fileLinkPath(url ) {
|
|
8
|
+
if (!/^file:\/\//.test(url)) return undefined;
|
|
9
|
+
try {
|
|
10
|
+
const u = new URL(url);
|
|
11
|
+
return u.host ? undefined : decodeURIComponent(u.pathname);
|
|
12
|
+
} catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Every path the text names with a file: address, anywhere in it. A bare address ends before the punctuation that may close
|
|
18
|
+
* a sentence, and the target of a Markdown link ends at its parenthesis, so before one the address is kept whole as well. */
|
|
19
|
+
export function fileLinkPaths(text ) {
|
|
20
|
+
const paths = new Set ();
|
|
21
|
+
for (const m of text.matchAll(FILE_URL)) {
|
|
22
|
+
const urls = [m[0].replace(URL_TAIL, '')];
|
|
23
|
+
if (text[m.index + m[0].length] === ')') urls.push(m[0]);
|
|
24
|
+
for (const url of urls) {
|
|
25
|
+
const p = fileLinkPath(url);
|
|
26
|
+
if (p !== undefined) paths.add(p);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return [...paths];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The path that the rest of a folder ticket's address names under `folder`, or undefined when it would leave the folder.
|
|
33
|
+
* Each segment decodes on its own, so an encoded slash or backslash never splits one. A segment that is empty, starts with
|
|
34
|
+
* a dot, or contains a slash, a backslash or a NUL is refused. That covers `..`, an absolute path, and hidden files such as `.env`. */
|
|
35
|
+
export function folderFilePath(folder , rest ) {
|
|
36
|
+
const names = [];
|
|
37
|
+
for (const segment of rest.split('/')) {
|
|
38
|
+
let name ;
|
|
39
|
+
try {
|
|
40
|
+
name = decodeURIComponent(segment);
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
if (!name || name.startsWith('.') || /[/\\\0]/.test(name)) return undefined;
|
|
45
|
+
names.push(name);
|
|
46
|
+
}
|
|
47
|
+
return `${folder.replace(/\/+$/, '')}/${names.join('/')}`;
|
|
48
|
+
}
|