@parley-im/parley 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -2
- package/bin/parley +15 -0
- package/dist/account.js +79 -0
- package/dist/agents/claude.js +134 -0
- package/dist/agents/codex.js +128 -0
- package/dist/agents/process.js +47 -0
- package/dist/agents/types.js +1 -0
- package/dist/api.js +180 -0
- package/dist/charter.js +57 -0
- package/dist/cli.js +272 -0
- package/dist/config.js +97 -0
- package/dist/control.js +72 -0
- package/dist/daemon.js +42 -0
- package/dist/db.js +243 -0
- package/dist/mcp.js +153 -0
- package/dist/models.js +10 -0
- package/dist/relay.js +304 -0
- package/dist/runner.js +198 -0
- package/dist/seal.js +51 -0
- package/dist/server.js +308 -0
- package/dist/state.js +41 -0
- package/dist/transcript.js +53 -0
- package/package.json +46 -4
- package/web/app.css +404 -0
- package/web/chat.html +81 -0
- package/web/chat.js +901 -0
- package/web/ended.html +20 -0
- package/web/home.js +552 -0
- package/web/icon-180.png +0 -0
- package/web/icon.png +0 -0
- package/web/icon.svg +1 -0
- package/web/markdown.js +87 -0
- package/web/missing.html +20 -0
- package/web/panel.html +76 -0
- package/web/panel.js +224 -0
- package/web/seal.js +44 -0
package/web/ended.html
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="robots" content="noindex, nofollow">
|
|
7
|
+
<title>This parley has ended</title>
|
|
8
|
+
<link rel="icon" href="/static/icon.svg">
|
|
9
|
+
<link rel="stylesheet" href="/static/app.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div class="lost">
|
|
13
|
+
<div>
|
|
14
|
+
<span class="mark" aria-hidden="true"><svg viewBox="4 4 24 24"><g fill="currentColor" stroke="none"><circle cx="8" cy="10" r="2.2"/><rect x="12" y="8" width="12" height="4" rx="2"/><rect x="6" y="14" width="20" height="4" rx="2" opacity=".7"/><rect x="6" y="20" width="13" height="4" rx="2" opacity=".7"/></g></svg></span>
|
|
15
|
+
<h1>This parley has ended</h1>
|
|
16
|
+
<p>Thanks for the feedback. The conversation isn't available here anymore.</p>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
package/web/home.js
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// parley.im's front page, without a word on it. An agent in the middle checks
|
|
2
|
+
// in with people, one and then another, hears them out and writes down what
|
|
3
|
+
// it hears. When the page fills up it boils it down, sends it off and starts
|
|
4
|
+
// again, forever. Every line is a line of text with the letters taken out.
|
|
5
|
+
(() => {
|
|
6
|
+
const canvas = document.getElementById("field");
|
|
7
|
+
const ctx = canvas && canvas.getContext("2d");
|
|
8
|
+
if (!ctx) return;
|
|
9
|
+
|
|
10
|
+
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
11
|
+
const rand = (a, b) => a + Math.random() * (b - a);
|
|
12
|
+
const pick = (list) => list[Math.floor(Math.random() * list.length)];
|
|
13
|
+
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
14
|
+
const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
|
|
15
|
+
const toward = (v, to, rate, dt) => v + (to - v) * (1 - Math.exp(-rate * dt));
|
|
16
|
+
|
|
17
|
+
// Word lengths in characters, about as often as English uses them.
|
|
18
|
+
const LENGTHS = [1, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 6, 6, 7, 8, 9, 11];
|
|
19
|
+
function sentence(max, fill) {
|
|
20
|
+
const limit = Math.max(3, Math.round(max * fill));
|
|
21
|
+
const ws = [];
|
|
22
|
+
for (let n = 0; ; ) {
|
|
23
|
+
const w = pick(LENGTHS);
|
|
24
|
+
if (n + w > limit) break;
|
|
25
|
+
ws.push(w);
|
|
26
|
+
n += w + 1;
|
|
27
|
+
}
|
|
28
|
+
return ws.length ? ws : [3];
|
|
29
|
+
}
|
|
30
|
+
const span = (ws) => ws.reduce((n, w) => n + w + 1, -1);
|
|
31
|
+
|
|
32
|
+
const VISIBLE = 5; // lines a person's pane shows
|
|
33
|
+
let W = 0, H = 0, S = 1, CW = 4, BAR = 3, GAP = 10, PAD = 10;
|
|
34
|
+
let narrow = null, clock = 0, color = {};
|
|
35
|
+
let cx = 0, cy = 0, doc = null, people = [], packets = [], ripples = [], lifts = [], nextAsk = 0;
|
|
36
|
+
|
|
37
|
+
function readColors() {
|
|
38
|
+
const css = getComputedStyle(document.documentElement);
|
|
39
|
+
const v = (name) => css.getPropertyValue(name).trim();
|
|
40
|
+
color = { bg: v("--bg"), text: v("--text"), faint: v("--faint"), line: v("--line-strong"), accent: v("--accent") };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------- the cast ----------
|
|
44
|
+
|
|
45
|
+
function build() {
|
|
46
|
+
const count = narrow ? 6 : W < 1000 ? 8 : 10;
|
|
47
|
+
people = Array.from({ length: count }, (_, i) => ({
|
|
48
|
+
angle: -Math.PI / 2 + ((i + (narrow ? 0 : 0.5)) / count) * Math.PI * 2 + rand(-0.14, 0.14),
|
|
49
|
+
reach: rand(0.82, 1),
|
|
50
|
+
bend: (i % 2 ? 1 : -1) * rand(0.1, 0.22),
|
|
51
|
+
phase: rand(0, 100),
|
|
52
|
+
chars: narrow ? 18 : 24,
|
|
53
|
+
lines: [],
|
|
54
|
+
scroll: 0,
|
|
55
|
+
state: "idle",
|
|
56
|
+
wait: rand(0.2, 3),
|
|
57
|
+
speed: rand(14, 30),
|
|
58
|
+
pause: 0,
|
|
59
|
+
plan: [],
|
|
60
|
+
busy: 0,
|
|
61
|
+
}));
|
|
62
|
+
doc = { chars: narrow ? 28 : 42, max: narrow ? 7 : 9, phase: "gather", t: 0, lines: [], queue: [], wait: 0, h: 0, titleY: null, title: { w: [], tw: [] } };
|
|
63
|
+
morph(doc.title, sentence(doc.chars * 0.55, rand(0.6, 1)));
|
|
64
|
+
doc.title.w = doc.title.tw.slice();
|
|
65
|
+
packets = [];
|
|
66
|
+
ripples = [];
|
|
67
|
+
lifts = [];
|
|
68
|
+
nextAsk = 0.4;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function place() {
|
|
72
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
73
|
+
W = window.innerWidth;
|
|
74
|
+
H = window.innerHeight;
|
|
75
|
+
canvas.width = Math.round(W * dpr);
|
|
76
|
+
canvas.height = Math.round(H * dpr);
|
|
77
|
+
canvas.style.width = `${W}px`;
|
|
78
|
+
canvas.style.height = `${H}px`;
|
|
79
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
80
|
+
|
|
81
|
+
const top = (document.querySelector(".topbar")?.getBoundingClientRect().bottom ?? 0) + 6;
|
|
82
|
+
const wasNarrow = narrow;
|
|
83
|
+
narrow = W < 600;
|
|
84
|
+
S = narrow ? clamp(W / 400, 0.82, 1.05) : clamp(Math.min(W, H - top) / 720, 0.82, 1.3);
|
|
85
|
+
CW = 4 * S;
|
|
86
|
+
BAR = 3.2 * S;
|
|
87
|
+
GAP = 10 * S;
|
|
88
|
+
PAD = 10 * S;
|
|
89
|
+
if (narrow !== wasNarrow || people.length !== (narrow ? 6 : W < 1000 ? 8 : 10)) build();
|
|
90
|
+
|
|
91
|
+
const x0 = 12, x1 = W - 12, y0 = top, y1 = H - 18;
|
|
92
|
+
cx = W / 2;
|
|
93
|
+
cy = (y0 + y1) / 2;
|
|
94
|
+
doc.w = doc.chars * CW + PAD * 2 + 9 * S;
|
|
95
|
+
const docBox = { x: cx, y: cy, w: doc.w, h: (doc.max + 1.7) * GAP + PAD * 2 };
|
|
96
|
+
const pw = people[0].chars * CW + PAD * 2 + 9 * S;
|
|
97
|
+
const ph = VISIBLE * GAP + PAD * 1.2;
|
|
98
|
+
const ry = (y1 - y0 - ph) / 2;
|
|
99
|
+
const rx = Math.min((x1 - x0 - pw) / 2, ry * 1.9);
|
|
100
|
+
for (const p of people) {
|
|
101
|
+
p.w = pw;
|
|
102
|
+
p.h = ph;
|
|
103
|
+
p.x = cx + Math.cos(p.angle) * rx * p.reach;
|
|
104
|
+
p.y = cy + Math.sin(p.angle) * ry * p.reach;
|
|
105
|
+
}
|
|
106
|
+
// Nudge panes apart where they'd overlap each other or the notes in the middle.
|
|
107
|
+
const margin = 12 * S;
|
|
108
|
+
for (let k = 0; k < 80; k++) {
|
|
109
|
+
for (const p of people) {
|
|
110
|
+
shove(p, docBox, margin, 1);
|
|
111
|
+
for (const q of people) if (q !== p) shove(p, q, margin, 0.5);
|
|
112
|
+
p.x = clamp(p.x, x0 + pw / 2, x1 - pw / 2);
|
|
113
|
+
p.y = clamp(p.y, y0 + ph / 2, y1 - ph / 2);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function shove(p, o, margin, share) {
|
|
119
|
+
const dx = p.x - o.x, dy = p.y - o.y;
|
|
120
|
+
const ox = (p.w + o.w) / 2 + margin - Math.abs(dx);
|
|
121
|
+
const oy = (p.h + o.h) / 2 + margin - Math.abs(dy);
|
|
122
|
+
if (ox <= 0 || oy <= 0) return;
|
|
123
|
+
if (ox < oy) p.x += (Math.sign(dx) || 1) * ox * share;
|
|
124
|
+
else p.y += (Math.sign(dy) || 1) * oy * share;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A line that can be rewritten in place: its words ease toward new lengths.
|
|
128
|
+
function morph(line, ws) {
|
|
129
|
+
const n = Math.max(line.w.length, ws.length);
|
|
130
|
+
line.w = Array.from({ length: n }, (_, i) => line.w[i] ?? 0);
|
|
131
|
+
line.tw = Array.from({ length: n }, (_, i) => ws[i] ?? 0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------- what happens ----------
|
|
135
|
+
|
|
136
|
+
function ask(p) {
|
|
137
|
+
p.state = "asked";
|
|
138
|
+
const question = sentence(Math.round(p.chars * 0.85), rand(0.45, 1));
|
|
139
|
+
ripples.push({ t: 0 });
|
|
140
|
+
send(p, "out", [question], () => {
|
|
141
|
+
const count = pick([1, 2, 2, 3, 3, 4]);
|
|
142
|
+
p.plan = Array.from({ length: count }, (_, i) => sentence(p.chars, i === count - 1 ? rand(0.3, 0.8) : rand(0.8, 1)));
|
|
143
|
+
p.lines.push({ ws: question, shown: 0, kind: "q", sent: false, dim: 0 });
|
|
144
|
+
p.lines.push({ ws: p.plan.shift(), shown: 0, kind: "a", sent: false, dim: 0 });
|
|
145
|
+
p.state = "reading";
|
|
146
|
+
p.wait = rand(0.5, 1.8);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function send(p, dir, payload, arrive) {
|
|
151
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
152
|
+
packets.push({ p, dir, payload, arrive, t: 0, dur: clamp(d / (230 * S), 0.8, 2.2) });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function stepPerson(p, dt) {
|
|
156
|
+
for (const l of p.lines) {
|
|
157
|
+
if (l.kind === "q") l.shown += 80 * dt;
|
|
158
|
+
l.dim = toward(l.dim, l.sent ? 1 : 0, 2.5, dt);
|
|
159
|
+
}
|
|
160
|
+
if (p.state === "idle") p.wait -= dt;
|
|
161
|
+
else if (p.state === "reading") {
|
|
162
|
+
p.wait -= dt;
|
|
163
|
+
if (p.wait <= 0) p.state = "typing";
|
|
164
|
+
} else if (p.state === "typing") {
|
|
165
|
+
const l = p.lines[p.lines.length - 1];
|
|
166
|
+
if (p.pause > 0) p.pause -= dt;
|
|
167
|
+
else {
|
|
168
|
+
l.shown += p.speed * dt;
|
|
169
|
+
if (Math.random() < dt * 0.8) p.pause = rand(0.15, 0.9);
|
|
170
|
+
}
|
|
171
|
+
if (l.shown >= span(l.ws)) {
|
|
172
|
+
l.shown = span(l.ws);
|
|
173
|
+
if (p.plan.length) p.lines.push({ ws: p.plan.shift(), shown: 0, kind: "a", sent: false, dim: 0 });
|
|
174
|
+
else {
|
|
175
|
+
p.state = "sending";
|
|
176
|
+
p.wait = 0.4;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} else if (p.state === "sending") {
|
|
180
|
+
p.wait -= dt;
|
|
181
|
+
if (p.wait <= 0) {
|
|
182
|
+
const mine = p.lines.filter((l) => !l.sent);
|
|
183
|
+
for (const l of mine) l.sent = true;
|
|
184
|
+
p.state = "idle";
|
|
185
|
+
p.wait = rand(1.5, 5);
|
|
186
|
+
send(p, "in", mine.filter((l) => l.kind === "a").map((l) => l.ws), (payload) => hear(payload));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const talking = p.state !== "idle" || packets.some((k) => k.p === p);
|
|
190
|
+
p.busy = toward(p.busy, talking ? 1 : 0, 4, dt);
|
|
191
|
+
// Scroll so the newest line shows, and forget lines long gone.
|
|
192
|
+
p.scroll = toward(p.scroll, Math.max(0, p.lines.length - VISIBLE), 6, dt);
|
|
193
|
+
while (p.lines.length > VISIBLE + 3 && p.scroll >= 3) {
|
|
194
|
+
p.lines.shift();
|
|
195
|
+
p.scroll -= 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// An answer reaches the middle: a line or two goes into the notes, and
|
|
200
|
+
// something already there may get rewritten in light of it.
|
|
201
|
+
function hear(payload) {
|
|
202
|
+
const d = doc;
|
|
203
|
+
if (d.phase !== "gather") return d.queue.push(payload);
|
|
204
|
+
const adds = Math.max(1, Math.round(payload.length * rand(0.4, 0.8)));
|
|
205
|
+
for (let i = 0; i < adds; i++) {
|
|
206
|
+
const ws = sentence(d.chars, rand(0.5, 1));
|
|
207
|
+
const at = Math.random() < 0.55 ? d.lines.length : Math.floor(rand(0, d.lines.length + 1));
|
|
208
|
+
d.lines.splice(at, 0, { w: ws.slice(), tw: ws, shown: 0, a: 0, heat: 1, y: null, keep: false, into: null });
|
|
209
|
+
}
|
|
210
|
+
const settled = d.lines.filter((l) => l.heat < 0.3);
|
|
211
|
+
if (settled.length && Math.random() < 0.7) {
|
|
212
|
+
const l = pick(settled);
|
|
213
|
+
morph(l, sentence(d.chars, rand(0.5, 1)));
|
|
214
|
+
l.heat = 0.6;
|
|
215
|
+
}
|
|
216
|
+
if (d.lines.length > d.max) condense();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Too much to hold: everything folds into three lines.
|
|
220
|
+
function condense() {
|
|
221
|
+
const d = doc;
|
|
222
|
+
const n = d.lines.length;
|
|
223
|
+
const keys = [0, Math.floor((n - 1) / 2), n - 1];
|
|
224
|
+
keys.forEach((i) => {
|
|
225
|
+
const l = d.lines[i];
|
|
226
|
+
morph(l, sentence(d.chars, rand(0.85, 1)));
|
|
227
|
+
l.keep = true;
|
|
228
|
+
l.heat = 1;
|
|
229
|
+
l.shown = Infinity;
|
|
230
|
+
});
|
|
231
|
+
d.lines.forEach((l, i) => {
|
|
232
|
+
if (l.keep) return;
|
|
233
|
+
const nearest = keys.reduce((best, k) => (Math.abs(k - i) < Math.abs(best - i) ? k : best));
|
|
234
|
+
l.into = d.lines[nearest];
|
|
235
|
+
morph(l, []);
|
|
236
|
+
});
|
|
237
|
+
d.phase = "condense";
|
|
238
|
+
d.t = 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function stepDoc(dt) {
|
|
242
|
+
const d = doc;
|
|
243
|
+
d.t += dt;
|
|
244
|
+
for (const l of [d.title, ...d.lines]) for (let i = 0; i < l.w.length; i++) l.w[i] = toward(l.w[i], l.tw[i], 5, dt);
|
|
245
|
+
for (const l of d.lines) {
|
|
246
|
+
l.shown += 90 * dt;
|
|
247
|
+
l.a = toward(l.a, l.into ? 0 : 1, l.into ? 2.5 : 6, dt);
|
|
248
|
+
if (!l.keep) l.heat = toward(l.heat, 0, 0.8, dt);
|
|
249
|
+
}
|
|
250
|
+
if (d.phase === "gather") {
|
|
251
|
+
d.wait -= dt;
|
|
252
|
+
if (d.queue.length && d.wait <= 0) {
|
|
253
|
+
hear(d.queue.shift());
|
|
254
|
+
d.wait = 0.5;
|
|
255
|
+
}
|
|
256
|
+
} else if (d.phase === "condense" && d.t > 1.6) {
|
|
257
|
+
d.lines = d.lines.filter((l) => !l.into);
|
|
258
|
+
d.phase = "hold";
|
|
259
|
+
d.t = 0;
|
|
260
|
+
} else if (d.phase === "hold" && d.t > 1.2) {
|
|
261
|
+
// Sent off: the summary rises out of the notes, and they start over.
|
|
262
|
+
lifts.push({ lines: d.lines.map((l) => ({ ws: l.tw.slice(), y: l.y })), t: 0 });
|
|
263
|
+
d.lines = [];
|
|
264
|
+
morph(d.title, []);
|
|
265
|
+
d.phase = "rest";
|
|
266
|
+
d.t = 0;
|
|
267
|
+
} else if (d.phase === "rest" && d.t > 1.4) {
|
|
268
|
+
morph(d.title, sentence(d.chars * 0.55, rand(0.6, 1)));
|
|
269
|
+
d.phase = "gather";
|
|
270
|
+
d.t = 0;
|
|
271
|
+
}
|
|
272
|
+
const live = d.lines.filter((l) => !l.into);
|
|
273
|
+
const block = 1.7 * GAP + live.length * GAP;
|
|
274
|
+
const top = cy - block / 2;
|
|
275
|
+
d.titleY = d.titleY == null ? top + GAP / 2 : toward(d.titleY, top + GAP / 2, 6, dt);
|
|
276
|
+
live.forEach((l, i) => {
|
|
277
|
+
const ty = top + 1.7 * GAP + GAP / 2 + i * GAP;
|
|
278
|
+
l.y = l.y == null ? ty : toward(l.y, ty, 7, dt);
|
|
279
|
+
});
|
|
280
|
+
for (const l of d.lines) if (l.into) l.y = toward(l.y, l.into.y, 5, dt);
|
|
281
|
+
d.h = d.h ? toward(d.h, block, 6, dt) : block;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function step(dt) {
|
|
285
|
+
clock += dt;
|
|
286
|
+
nextAsk -= dt;
|
|
287
|
+
if (nextAsk <= 0) {
|
|
288
|
+
nextAsk = rand(0.5, 1.6);
|
|
289
|
+
const free = people.filter((p) => p.state === "idle" && p.wait <= 0);
|
|
290
|
+
const busy = people.filter((p) => p.state !== "idle").length;
|
|
291
|
+
if (doc.phase === "gather" && free.length && busy < Math.ceil(people.length * 0.6)) ask(pick(free));
|
|
292
|
+
}
|
|
293
|
+
for (const p of people) stepPerson(p, dt);
|
|
294
|
+
for (const k of packets) {
|
|
295
|
+
k.t += dt / k.dur;
|
|
296
|
+
if (k.t >= 1) k.arrive(k.payload);
|
|
297
|
+
}
|
|
298
|
+
packets = packets.filter((k) => k.t < 1);
|
|
299
|
+
for (const r of ripples) r.t += dt;
|
|
300
|
+
ripples = ripples.filter((r) => r.t < 1);
|
|
301
|
+
for (const l of lifts) l.t += dt;
|
|
302
|
+
lifts = lifts.filter((l) => l.t < 2.6);
|
|
303
|
+
stepDoc(dt);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------- drawing ----------
|
|
307
|
+
|
|
308
|
+
function bar(x, y, w, h) {
|
|
309
|
+
if (ctx.roundRect) ctx.roundRect(x, y, w, h, h / 2);
|
|
310
|
+
else ctx.rect(x, y, w, h);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// A line of words from (x, y), the first `shown` characters of it. Returns
|
|
314
|
+
// where the typing has got to.
|
|
315
|
+
function words(ws, x, y, shown = Infinity, k = 1, tall = 1) {
|
|
316
|
+
const c = CW * k, h = BAR * k * tall;
|
|
317
|
+
let left = shown, end = x;
|
|
318
|
+
ctx.beginPath();
|
|
319
|
+
for (const w of ws) {
|
|
320
|
+
if (left <= 0) break;
|
|
321
|
+
const len = Math.min(w, left);
|
|
322
|
+
if (len > 0.05) bar(x, y - h / 2, len * c, h);
|
|
323
|
+
end = x + len * c;
|
|
324
|
+
x += (w + Math.min(1, w)) * c;
|
|
325
|
+
left -= w + 1;
|
|
326
|
+
}
|
|
327
|
+
ctx.fill();
|
|
328
|
+
return end;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function curve(p) {
|
|
332
|
+
const px = p.x + Math.sin(clock * 0.23 + p.phase) * 3 * S;
|
|
333
|
+
const py = p.y + Math.cos(clock * 0.19 + p.phase * 1.3) * 3 * S;
|
|
334
|
+
const dx = px - cx, dy = py - cy;
|
|
335
|
+
return { ax: cx, ay: cy, bx: (cx + px) / 2 - dy * p.bend, by: (cy + py) / 2 + dx * p.bend, px, py };
|
|
336
|
+
}
|
|
337
|
+
const at = (c, t) => {
|
|
338
|
+
const u = 1 - t;
|
|
339
|
+
return [u * u * c.ax + 2 * u * t * c.bx + t * t * c.px, u * u * c.ay + 2 * u * t * c.by + t * t * c.py];
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
function draw() {
|
|
343
|
+
ctx.clearRect(0, 0, W, H);
|
|
344
|
+
const curves = new Map(people.map((p) => [p, curve(p)]));
|
|
345
|
+
|
|
346
|
+
// Threads from the middle to everyone.
|
|
347
|
+
ctx.lineWidth = Math.max(1, S);
|
|
348
|
+
for (const p of people) {
|
|
349
|
+
const c = curves.get(p);
|
|
350
|
+
ctx.beginPath();
|
|
351
|
+
ctx.moveTo(c.ax, c.ay);
|
|
352
|
+
ctx.quadraticCurveTo(c.bx, c.by, c.px, c.py);
|
|
353
|
+
ctx.setLineDash([1.2 * S, 5 * S]);
|
|
354
|
+
ctx.globalAlpha = 0.9;
|
|
355
|
+
ctx.strokeStyle = color.line;
|
|
356
|
+
ctx.stroke();
|
|
357
|
+
if (p.busy > 0.02) {
|
|
358
|
+
ctx.setLineDash([]);
|
|
359
|
+
ctx.globalAlpha = p.busy * 0.4;
|
|
360
|
+
ctx.strokeStyle = color.faint;
|
|
361
|
+
ctx.stroke();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
ctx.setLineDash([]);
|
|
365
|
+
|
|
366
|
+
// Panes, cut out of the grid.
|
|
367
|
+
ctx.fillStyle = color.bg;
|
|
368
|
+
ctx.globalAlpha = 1;
|
|
369
|
+
ctx.beginPath();
|
|
370
|
+
for (const p of people) {
|
|
371
|
+
const c = curves.get(p);
|
|
372
|
+
bar(c.px - p.w / 2, c.py - p.h / 2, p.w, p.h);
|
|
373
|
+
}
|
|
374
|
+
ctx.fill();
|
|
375
|
+
ctx.beginPath();
|
|
376
|
+
ctx.roundRect ? ctx.roundRect(cx - doc.w / 2, cy - doc.h / 2 - PAD, doc.w, doc.h + PAD * 2, 8 * S) : ctx.rect(cx - doc.w / 2, cy - doc.h / 2 - PAD, doc.w, doc.h + PAD * 2);
|
|
377
|
+
ctx.fill();
|
|
378
|
+
|
|
379
|
+
drawDoc();
|
|
380
|
+
for (const p of people) drawPerson(p, curves.get(p));
|
|
381
|
+
for (const k of packets) drawPacket(k, curves.get(k.p));
|
|
382
|
+
for (const l of lifts) drawLift(l);
|
|
383
|
+
ctx.globalAlpha = 1;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function drawDoc() {
|
|
387
|
+
const d = doc;
|
|
388
|
+
const left = cx - d.w / 2 + PAD;
|
|
389
|
+
const x = left + 9 * S;
|
|
390
|
+
// The agent: a dot that pulses while it's thinking things over.
|
|
391
|
+
const thinking = d.phase === "condense" || d.phase === "hold";
|
|
392
|
+
const r = 2.8 * S * (thinking ? 1 + 0.25 * Math.sin(clock * 7) : 1);
|
|
393
|
+
ctx.globalAlpha = 1;
|
|
394
|
+
ctx.fillStyle = color.accent;
|
|
395
|
+
ctx.beginPath();
|
|
396
|
+
ctx.arc(left + 2.5 * S, d.titleY, r, 0, Math.PI * 2);
|
|
397
|
+
ctx.fill();
|
|
398
|
+
for (const ring of ripples) {
|
|
399
|
+
ctx.globalAlpha = (1 - ring.t) * 0.55;
|
|
400
|
+
ctx.strokeStyle = color.accent;
|
|
401
|
+
ctx.lineWidth = S;
|
|
402
|
+
ctx.beginPath();
|
|
403
|
+
ctx.arc(left + 2.5 * S, d.titleY, 3 * S + ease(ring.t) * 16 * S, 0, Math.PI * 2);
|
|
404
|
+
ctx.stroke();
|
|
405
|
+
}
|
|
406
|
+
ctx.globalAlpha = 0.95;
|
|
407
|
+
words(d.title.w, x, d.titleY, Infinity, 1, 1.35);
|
|
408
|
+
|
|
409
|
+
for (const l of d.lines) {
|
|
410
|
+
if (l.y == null) continue;
|
|
411
|
+
if (l.heat < 0.99) {
|
|
412
|
+
ctx.globalAlpha = l.a * (1 - l.heat) * 0.82;
|
|
413
|
+
ctx.fillStyle = color.text;
|
|
414
|
+
words(l.w, x, l.y, l.shown);
|
|
415
|
+
}
|
|
416
|
+
if (l.heat > 0.01) {
|
|
417
|
+
ctx.globalAlpha = l.a * l.heat;
|
|
418
|
+
ctx.fillStyle = color.accent;
|
|
419
|
+
words(l.w, x, l.y, l.shown);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function drawPerson(p, c) {
|
|
425
|
+
const x = c.px - p.w / 2, y = c.py - p.h / 2;
|
|
426
|
+
const top = y + PAD * 0.6 + GAP / 2;
|
|
427
|
+
// Someone: a ring that fills in while they're talking with the agent.
|
|
428
|
+
const dot = [x + PAD + 2.5 * S, top];
|
|
429
|
+
ctx.globalAlpha = 1;
|
|
430
|
+
ctx.strokeStyle = color.faint;
|
|
431
|
+
ctx.lineWidth = Math.max(1, S);
|
|
432
|
+
ctx.beginPath();
|
|
433
|
+
ctx.arc(dot[0], dot[1], 2.4 * S, 0, Math.PI * 2);
|
|
434
|
+
ctx.stroke();
|
|
435
|
+
if (p.busy > 0.02) {
|
|
436
|
+
ctx.globalAlpha = p.busy;
|
|
437
|
+
ctx.fillStyle = color.text;
|
|
438
|
+
ctx.fill();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
ctx.save();
|
|
442
|
+
ctx.beginPath();
|
|
443
|
+
ctx.rect(x, y + PAD * 0.25, p.w, p.h - PAD * 0.5);
|
|
444
|
+
ctx.clip();
|
|
445
|
+
const lx = x + PAD + 9 * S;
|
|
446
|
+
p.lines.forEach((l, j) => {
|
|
447
|
+
const v = j - p.scroll;
|
|
448
|
+
if (v < -1 || v > VISIBLE) return;
|
|
449
|
+
const ly = top + v * GAP;
|
|
450
|
+
const fade = clamp(v + 1, 0, 1);
|
|
451
|
+
if (l.kind === "q") {
|
|
452
|
+
ctx.globalAlpha = fade * (0.9 - 0.45 * l.dim);
|
|
453
|
+
ctx.fillStyle = color.accent;
|
|
454
|
+
} else {
|
|
455
|
+
ctx.globalAlpha = fade * (0.78 - 0.45 * l.dim);
|
|
456
|
+
ctx.fillStyle = color.text;
|
|
457
|
+
}
|
|
458
|
+
const end = words(l.ws, lx, ly, l.shown);
|
|
459
|
+
const last = j === p.lines.length - 1;
|
|
460
|
+
if (last && (p.state === "reading" || p.state === "typing")) {
|
|
461
|
+
const blink = p.state === "reading" || p.pause > 0 ? (Math.sin(clock * 8) > -0.3 ? 0.85 : 0.1) : 0.85;
|
|
462
|
+
ctx.globalAlpha = fade * blink;
|
|
463
|
+
ctx.fillStyle = color.text;
|
|
464
|
+
ctx.fillRect(l.shown > 0 ? end + 1.5 * S : lx, ly - BAR * 1.15, 1.4 * S, BAR * 2.3);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
ctx.restore();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function drawPacket(k, c) {
|
|
471
|
+
const e = ease(clamp(k.t, 0, 1));
|
|
472
|
+
const out = k.dir === "out";
|
|
473
|
+
const head = out ? e : 1 - e;
|
|
474
|
+
const tail = out ? Math.max(0, head - 0.24) : Math.min(1, head + 0.24);
|
|
475
|
+
const fade = Math.min(1, k.t / 0.1, (1 - k.t) / 0.1);
|
|
476
|
+
const ink = out ? color.accent : color.text;
|
|
477
|
+
ctx.strokeStyle = ink;
|
|
478
|
+
ctx.lineWidth = 1.3 * S;
|
|
479
|
+
const steps = 12;
|
|
480
|
+
for (let i = 0; i < steps; i++) {
|
|
481
|
+
const [x0, y0] = at(c, tail + ((head - tail) * i) / steps);
|
|
482
|
+
const [x1, y1] = at(c, tail + ((head - tail) * (i + 1)) / steps);
|
|
483
|
+
ctx.globalAlpha = fade * ((i + 1) / steps) * 0.8;
|
|
484
|
+
ctx.beginPath();
|
|
485
|
+
ctx.moveTo(x0, y0);
|
|
486
|
+
ctx.lineTo(x1, y1);
|
|
487
|
+
ctx.stroke();
|
|
488
|
+
}
|
|
489
|
+
// What's being carried, in miniature.
|
|
490
|
+
const [hx, hy] = at(c, head);
|
|
491
|
+
const k2 = 0.55;
|
|
492
|
+
const wide = Math.max(...k.payload.map(span)) * CW * k2;
|
|
493
|
+
const tallness = k.payload.length * GAP * k2 * 0.8;
|
|
494
|
+
const bx = hx - wide / 2, by = hy - tallness / 2;
|
|
495
|
+
ctx.globalAlpha = fade * 0.9;
|
|
496
|
+
ctx.fillStyle = color.bg;
|
|
497
|
+
ctx.beginPath();
|
|
498
|
+
bar(bx - 3 * S, by - 2.5 * S, wide + 6 * S, tallness + 5 * S);
|
|
499
|
+
ctx.fill();
|
|
500
|
+
ctx.globalAlpha = fade;
|
|
501
|
+
ctx.fillStyle = ink;
|
|
502
|
+
k.payload.forEach((ws, i) => words(ws, bx, by + (i + 0.5) * GAP * k2 * 0.8, Infinity, k2));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function drawLift(l) {
|
|
506
|
+
const t = clamp(l.t / 2.6, 0, 1);
|
|
507
|
+
const rise = ease(t) * 54 * S;
|
|
508
|
+
const x = cx - doc.w / 2 + PAD + 9 * S;
|
|
509
|
+
ctx.fillStyle = color.accent;
|
|
510
|
+
ctx.globalAlpha = (1 - t) ** 1.6;
|
|
511
|
+
for (const line of l.lines) words(line.ws, x, line.y - rise);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------- running ----------
|
|
515
|
+
|
|
516
|
+
let last = 0;
|
|
517
|
+
function frame(now) {
|
|
518
|
+
const dt = Math.min(0.05, last ? (now - last) / 1000 : 1 / 60);
|
|
519
|
+
last = now;
|
|
520
|
+
step(dt);
|
|
521
|
+
draw();
|
|
522
|
+
requestAnimationFrame(frame);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Without motion: run it for a while and show one moment of it.
|
|
526
|
+
function snapshot() {
|
|
527
|
+
for (let i = 0; i < 30 * 24; i++) step(1 / 30);
|
|
528
|
+
draw();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
readColors();
|
|
532
|
+
place();
|
|
533
|
+
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
|
534
|
+
readColors();
|
|
535
|
+
if (still) draw();
|
|
536
|
+
});
|
|
537
|
+
let pending = false;
|
|
538
|
+
window.addEventListener("resize", () => {
|
|
539
|
+
if (pending) return;
|
|
540
|
+
pending = true;
|
|
541
|
+
requestAnimationFrame(() => {
|
|
542
|
+
pending = false;
|
|
543
|
+
place();
|
|
544
|
+
if (still) snapshot();
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
if (still) snapshot();
|
|
548
|
+
else {
|
|
549
|
+
for (let i = 0; i < 45; i++) step(1 / 30); // start with something already moving
|
|
550
|
+
requestAnimationFrame(frame);
|
|
551
|
+
}
|
|
552
|
+
})();
|
package/web/icon-180.png
ADDED
|
Binary file
|
package/web/icon.png
ADDED
|
Binary file
|
package/web/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#5b4cdb"/><circle cx="8" cy="10" r="2.2" fill="#fff"/><rect x="12" y="8" width="12" height="4" rx="2" fill="#fff"/><rect x="6" y="14" width="20" height="4" rx="2" fill="#fff" fill-opacity=".7"/><rect x="6" y="20" width="13" height="4" rx="2" fill="#fff" fill-opacity=".7"/></svg>
|
package/web/markdown.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A small Markdown renderer: paragraphs, headings, lists, code, links,
|
|
2
|
+
// emphasis, blockquotes and tables. Everything is HTML-escaped first, so only
|
|
3
|
+
// the tags built here can appear; raw HTML shows as text.
|
|
4
|
+
(function () {
|
|
5
|
+
const escape = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
6
|
+
|
|
7
|
+
function inline(s) {
|
|
8
|
+
const codes = [];
|
|
9
|
+
s = s.replace(/`([^`\n]+)`/g, (_, code) => `\u0000${codes.push(code) - 1}\u0000`);
|
|
10
|
+
s = escape(s);
|
|
11
|
+
s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, (_, text, href) => `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`);
|
|
12
|
+
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+[^\s<).,!?:;'"])/g, (_, pre, href) => `${pre}<a href="${href}" target="_blank" rel="noopener noreferrer">${href}</a>`);
|
|
13
|
+
s = s.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
|
|
14
|
+
s = s.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, "$1<em>$2</em>");
|
|
15
|
+
s = s.replace(/(^|[^_\w])_([^_\n]+)_(?!\w)/g, "$1<em>$2</em>");
|
|
16
|
+
s = s.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${escape(codes[Number(i)])}</code>`);
|
|
17
|
+
return s;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const LIST = /^\s*([-*•]|\d+[.)])\s+/;
|
|
21
|
+
const QUOTE = /^\s*>\s?/;
|
|
22
|
+
const DIVIDER = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/;
|
|
23
|
+
const isTableStart = (lines, i) => lines[i].includes("|") && i + 1 < lines.length && DIVIDER.test(lines[i + 1]) && lines[i + 1].includes("-");
|
|
24
|
+
const cells = (line) => line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
|
|
25
|
+
const startsBlock = (lines, i) => /^```/.test(lines[i]) || /^#{1,6}\s+/.test(lines[i]) || LIST.test(lines[i]) || QUOTE.test(lines[i]) || isTableStart(lines, i);
|
|
26
|
+
|
|
27
|
+
function render(text) {
|
|
28
|
+
const out = [];
|
|
29
|
+
const lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n");
|
|
30
|
+
let i = 0;
|
|
31
|
+
while (i < lines.length) {
|
|
32
|
+
const line = lines[i];
|
|
33
|
+
if (/^```/.test(line)) {
|
|
34
|
+
const body = [];
|
|
35
|
+
i++;
|
|
36
|
+
while (i < lines.length && !/^```/.test(lines[i])) body.push(lines[i++]);
|
|
37
|
+
i++;
|
|
38
|
+
out.push(`<pre><code>${escape(body.join("\n"))}</code></pre>`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (isTableStart(lines, i)) {
|
|
42
|
+
const head = cells(line);
|
|
43
|
+
const align = cells(lines[i + 1]).map((c) => (/^:-+:$/.test(c) ? "center" : /-+:$/.test(c) ? "right" : ""));
|
|
44
|
+
const style = (k) => (align[k] ? ` style="text-align:${align[k]}"` : "");
|
|
45
|
+
i += 2;
|
|
46
|
+
const rows = [];
|
|
47
|
+
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) rows.push(cells(lines[i++]));
|
|
48
|
+
const th = head.map((c, k) => `<th${style(k)}>${inline(c)}</th>`).join("");
|
|
49
|
+
const tb = rows.map((r) => `<tr>${head.map((_, k) => `<td${style(k)}>${inline(r[k] ?? "")}</td>`).join("")}</tr>`).join("");
|
|
50
|
+
out.push(`<div class="table-wrap"><table><thead><tr>${th}</tr></thead><tbody>${tb}</tbody></table></div>`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (QUOTE.test(line)) {
|
|
54
|
+
const body = [];
|
|
55
|
+
while (i < lines.length && QUOTE.test(lines[i])) body.push(lines[i++].replace(QUOTE, ""));
|
|
56
|
+
out.push(`<blockquote>${render(body.join("\n"))}</blockquote>`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (LIST.test(line)) {
|
|
60
|
+
const ordered = /^\s*\d+[.)]\s+/.test(line);
|
|
61
|
+
const items = [];
|
|
62
|
+
while (i < lines.length && LIST.test(lines[i])) {
|
|
63
|
+
let item = lines[i++].replace(LIST, "");
|
|
64
|
+
while (i < lines.length && /^\s{2,}\S/.test(lines[i]) && !LIST.test(lines[i])) item += " " + lines[i++].trim();
|
|
65
|
+
items.push(`<li>${inline(item)}</li>`);
|
|
66
|
+
}
|
|
67
|
+
out.push(ordered ? `<ol>${items.join("")}</ol>` : `<ul>${items.join("")}</ul>`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (/^#{1,6}\s+/.test(line)) {
|
|
71
|
+
out.push(`<h4>${inline(line.replace(/^#{1,6}\s+/, ""))}</h4>`);
|
|
72
|
+
i++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!line.trim()) {
|
|
76
|
+
i++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const para = [];
|
|
80
|
+
while (i < lines.length && lines[i].trim() && !startsBlock(lines, i)) para.push(lines[i++]);
|
|
81
|
+
out.push(`<p>${para.map(inline).join("<br>")}</p>`);
|
|
82
|
+
}
|
|
83
|
+
return out.join("");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
window.renderMarkdown = render;
|
|
87
|
+
})();
|
package/web/missing.html
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="robots" content="noindex, nofollow">
|
|
7
|
+
<title>Link not found</title>
|
|
8
|
+
<link rel="icon" href="/static/icon.svg">
|
|
9
|
+
<link rel="stylesheet" href="/static/app.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div class="lost">
|
|
13
|
+
<div>
|
|
14
|
+
<span class="mark" aria-hidden="true"><svg viewBox="4 4 24 24"><g fill="currentColor" stroke="none"><circle cx="8" cy="10" r="2.2"/><rect x="12" y="8" width="12" height="4" rx="2"/><rect x="6" y="14" width="20" height="4" rx="2" opacity=".7"/><rect x="6" y="20" width="13" height="4" rx="2" opacity=".7"/></g></svg></span>
|
|
15
|
+
<h1>This link doesn't lead anywhere</h1>
|
|
16
|
+
<p>Check that you have the whole link, or ask whoever sent it for a fresh one.</p>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|