samagotchi 0.100.0 → 0.101.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -1
  3. data/bin/chi +1 -0
  4. data/docs/architecture.md +10 -1
  5. data/docs/cli.md +55 -0
  6. data/docs/configuration.md +1 -0
  7. data/docs/hooks.md +2 -0
  8. data/docs/internals/engine.md +1 -0
  9. data/docs/internals/working-set.md +130 -0
  10. data/docs/sessions.md +1 -1
  11. data/docs/testing.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/work-cap/plugin.rb +26 -16
  15. data/lib/samagotchi/config.rb +3 -0
  16. data/lib/samagotchi/engine.rb +30 -0
  17. data/lib/samagotchi/hooks/loader.rb +17 -2
  18. data/lib/samagotchi/host_keys.rb +35 -18
  19. data/lib/samagotchi/testing/fake_executable.rb +6 -16
  20. data/lib/samagotchi/testing/fake_executable_launcher +6 -0
  21. data/lib/samagotchi/tool_response.rb +10 -6
  22. data/lib/samagotchi/tool_runner.rb +25 -6
  23. data/lib/samagotchi/version.rb +1 -1
  24. data/lib/samagotchi/web/app.rb +49 -1
  25. data/lib/samagotchi/web/public/app.js +77 -11
  26. data/lib/samagotchi/web/public/chains.js +24 -0
  27. data/lib/samagotchi/web/public/chip_popover.js +56 -17
  28. data/lib/samagotchi/web/public/data.js +6 -0
  29. data/lib/samagotchi/web/public/index.html +134 -2
  30. data/lib/samagotchi/web/public/info_bar.js +16 -4
  31. data/lib/samagotchi/web/public/info_bar_model.js +7 -3
  32. data/lib/samagotchi/web/public/markdown_place.js +47 -1
  33. data/lib/samagotchi/web/public/refs.js +10 -1
  34. data/lib/samagotchi/web/public/send_turn.js +6 -2
  35. data/lib/samagotchi/web/public/session_actions.js +10 -5
  36. data/lib/samagotchi/web/public/stage_model.js +6 -5
  37. data/lib/samagotchi/web/public/stage_view.js +34 -2
  38. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  39. data/lib/samagotchi/web/public/turn_view.js +37 -11
  40. data/lib/samagotchi/web/public/working_set.js +520 -0
  41. data/lib/samagotchi/web/public/working_set_rail.js +278 -0
  42. data/lib/samagotchi/web/public/working_set_view.js +426 -0
  43. data/lib/samagotchi/web/server.rb +6 -4
  44. data/lib/samagotchi/working_set.rb +200 -0
  45. metadata +7 -1
@@ -0,0 +1,520 @@
1
+ // The working set's pure parts (web.working_set, the stage view): what a
2
+ // turn brought into the model's context, from the tool rows the page
3
+ // already has, and how it folds into a few fixed lines. No DOM, so
4
+ // `node --test` covers the rules; working_set_rail.js and
5
+ // working_set_view.js draw them.
6
+ //
7
+ // An item is {kind, key, label, ops, at, count, state, path?, url?, ref?,
8
+ // preloaded?, muted?}: kind file|memory|url, key what it is (a file's full
9
+ // path, a memory's name, a URL), ops the operations on it (read, edit,
10
+ // write: a write of a file is an edit), at the first row that brought it in
11
+ // as ordinals ({turn, step, row}: the turn's place in the page's turns, the
12
+ // generation's in the turn, the row's in the generation; never a row key,
13
+ // which differs live and reloaded), count the rows that touched it. A later
14
+ // touch unions its ops and bumps its count, never its at: within a turn
15
+ // the set is append-only, which keeps the fold stable. state is "live" from
16
+ // the rows; the server's projection (GET working_set, :working_set_updated:
17
+ // WorkingSet) overlays its state (live, stale, forgotten) and tokens by key
18
+ // (overlay), never its at.
19
+
20
+ export const FLASH_MS = 4000;
21
+ // Labels per form: the rail's four lines, the row's and the footprint's
22
+ // three, the collapsed head's newest one.
23
+ export const BUDGETS = Object.freeze({ rail: 4, row: 3, footprint: 3, head: 1 });
24
+ export const GLYPHS = Object.freeze({ file: "", memory: "◆", url: "↗", muted: "◇" });
25
+ // A directory label longer than this keeps its last two segments.
26
+ const DIR_MAX = 22;
27
+
28
+ const FILE_OPS = { read: "read", edit: "edit", write: "edit" };
29
+ const MEMORY_OPS = { memory_read: "read", memory_write: "write" };
30
+
31
+ // The names a memory row names: memory_read's title can list several
32
+ // ("a,b"; an empty one is an index listing: none), memory_write's is one.
33
+ export function memoryNames(title) {
34
+ return String(title || "").split(",").map((name) => name.trim()).filter(Boolean);
35
+ }
36
+
37
+ // The URL in a web_fetch row's params (url="…", Ruby-inspect quoted, cut at
38
+ // 80 characters with a "…": the cut URL is kept as is), or null.
39
+ export function urlOfParams(params) {
40
+ const m = /(?:^|\s)url="((?:[^"\\]|\\.)*)"?/.exec(String(params || ""));
41
+ if (!m) return null;
42
+ const url = m[1].replace(/\\(.)/g, "$1");
43
+ return url || null;
44
+ }
45
+
46
+ // A URL as a short label: its host, then its last two path segments behind
47
+ // "…/" when there are more ("github.com/…/pull/812").
48
+ export function urlLabel(url) {
49
+ const text = String(url || "");
50
+ const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)([^?#]*)/i.exec(text);
51
+ if (!m) return text;
52
+ const segments = m[2].split("/").filter(Boolean);
53
+ if (segments.length <= 2) return [m[1], ...segments].join("/");
54
+ return `${m[1]}/…/${segments.slice(-2).join("/")}`;
55
+ }
56
+
57
+ function basename(path) {
58
+ const parts = String(path).split("/").filter(Boolean);
59
+ return parts.length ? parts[parts.length - 1] : String(path);
60
+ }
61
+
62
+ function dirOf(path) {
63
+ const parts = String(path).split("/");
64
+ parts.pop();
65
+ return parts.join("/");
66
+ }
67
+
68
+ // A directory as a group's label: its path with a trailing "/" when it
69
+ // fits DIR_MAX, else "…/" and its last two segments ("…/web/public/").
70
+ export function dirLabel(dir) {
71
+ const clean = String(dir).replace(/\/+$/, "");
72
+ const full = `${clean}/`;
73
+ if (full.length <= DIR_MAX) return full;
74
+ const segments = clean.split("/").filter(Boolean);
75
+ return `…/${segments.slice(-2).join("/")}/`;
76
+ }
77
+
78
+ // What one finished row brings into the set: [{kind, key, op, …}], none
79
+ // unless it completed ok.
80
+ export function rowEntries(row) {
81
+ if (!row || row.status !== "ok") return [];
82
+ const tool = row.tool;
83
+ if (FILE_OPS[tool]) {
84
+ const path = row.ref?.kind === "file" && row.ref.path ? row.ref.path : row.title;
85
+ if (!path) return [];
86
+ const ref = row.ref?.kind === "file" ? row.ref : null;
87
+ // Shown (and grouped) by the title when it is the path relative to the
88
+ // session's folder (the ref's path is absolute; a cut title starts "…").
89
+ const title = String(row.title || "");
90
+ const rel = title && !title.startsWith("\u2026") && String(path).endsWith(title) ? title : String(path);
91
+ return [{ kind: "file", key: String(path), op: FILE_OPS[tool], path: rel, ref }];
92
+ }
93
+ if (MEMORY_OPS[tool]) {
94
+ return memoryNames(row.title).map((name) => ({ kind: "memory", key: name, op: MEMORY_OPS[tool] }));
95
+ }
96
+ if (tool === "web_fetch") {
97
+ const url = urlOfParams(row.params);
98
+ return url ? [{ kind: "url", key: url, op: "read", url }] : [];
99
+ }
100
+ return [];
101
+ }
102
+
103
+ // A row's read lines ("1–120") when its ref names them.
104
+ function linesOf(ref) {
105
+ if (!ref?.line) return "";
106
+ return ref.end_line && ref.end_line !== ref.line ? `${ref.line}–${ref.end_line}` : String(ref.line);
107
+ }
108
+
109
+ function newItem(entry, at) {
110
+ const item = { kind: entry.kind, key: entry.key, label: leafText(entry), ops: [entry.op], at, count: 1, state: "live" };
111
+ if (entry.path) item.path = entry.path;
112
+ if (entry.url) item.url = entry.url;
113
+ if (entry.ref) item.ref = entry.ref;
114
+ const lines = entry.op === "read" ? linesOf(entry.ref) : "";
115
+ if (lines) item.lines = lines;
116
+ return item;
117
+ }
118
+
119
+ function touch(item, op) {
120
+ if (!item.ops.includes(op)) item.ops = [...item.ops, op];
121
+ item.count += 1;
122
+ }
123
+
124
+ // A turn's items ({gens: [{tools: rows}]}, the live turn's model or a
125
+ // reloaded turn's steps as gens), in order of first entry; +turnOrdinal+
126
+ // is the turn's place in the page's turns.
127
+ export function itemsOfTurn(turn, turnOrdinal) {
128
+ const items = [];
129
+ const byKey = new Map();
130
+ (turn?.gens || []).forEach((gen, step) => {
131
+ (gen?.tools || []).forEach((row, rowIndex) => {
132
+ for (const entry of rowEntries(row)) {
133
+ const id = `${entry.kind}:${entry.key}`;
134
+ const found = byKey.get(id);
135
+ if (found) touch(found, entry.op);
136
+ else {
137
+ const item = newItem(entry, { turn: turnOrdinal, step, row: rowIndex });
138
+ byKey.set(id, item);
139
+ items.push(item);
140
+ }
141
+ }
142
+ });
143
+ });
144
+ return items;
145
+ }
146
+
147
+ // The session's set: every turn's items merged (the first entry's at kept,
148
+ // ops unioned, counts summed), the preloaded memories first (tag
149
+ // "prompt"), the used memories no row explains (a turn without its saved
150
+ // rows) after them; the muted memories listed apart, never counted.
151
+ // @param turns the finished turns' item lists, in order
152
+ // @param live the running turn's items (or none)
153
+ export function sessionItems({ turns = [], live = [], preloaded = [], muted = [], used = [] } = {}) {
154
+ const items = [];
155
+ const byKey = new Map();
156
+ for (const name of preloaded || []) {
157
+ if (byKey.has(`memory:${name}`)) continue;
158
+ const item = { kind: "memory", key: name, label: name, ops: [], at: null, count: 0, state: "live", preloaded: true };
159
+ byKey.set(`memory:${name}`, item);
160
+ items.push(item);
161
+ }
162
+ for (const list of [...turns, live || []]) {
163
+ for (const it of list) {
164
+ const id = `${it.kind}:${it.key}`;
165
+ const found = byKey.get(id);
166
+ if (!found) {
167
+ const copy = { ...it, ops: [...it.ops] };
168
+ byKey.set(id, copy);
169
+ items.push(copy);
170
+ } else {
171
+ for (const op of it.ops) if (!found.ops.includes(op)) found.ops = [...found.ops, op];
172
+ found.count += it.count;
173
+ if (!found.at) found.at = it.at;
174
+ }
175
+ }
176
+ }
177
+ for (const name of used || []) {
178
+ if (byKey.has(`memory:${name}`)) continue;
179
+ const item = { kind: "memory", key: name, label: name, ops: ["read"], at: null, count: 0, state: "live" };
180
+ byKey.set(`memory:${name}`, item);
181
+ items.push(item);
182
+ }
183
+ const mutedItems = (muted || []).map((name) => ({ kind: "memory", key: name, label: name, ops: [], at: null, count: 0, state: "live", muted: true }));
184
+ return { items, muted: mutedItems };
185
+ }
186
+
187
+ // ── the server's truth ────────────────────────────────────────────────────
188
+
189
+ export const STATES = Object.freeze(["live", "stale", "forgotten"]);
190
+ const ELLIPSIS = "\u2026";
191
+
192
+ // The server's items by "kind:key", and its URLs (a cut row key matches one
193
+ // by prefix).
194
+ function serverIndex(server) {
195
+ const byKey = new Map();
196
+ const urls = [];
197
+ for (const it of server?.items || []) {
198
+ if (!it || !STATES.includes(it.state)) continue;
199
+ byKey.set(`${it.kind}:${it.key}`, it);
200
+ if (it.kind === "url") urls.push(it);
201
+ }
202
+ return { byKey, urls };
203
+ }
204
+
205
+ // The server's item for +item+: files by absolute path (a title fallback
206
+ // won't match), memories by name, URLs by the row's key (its params
207
+ // preview: cut at 80 characters with a "…", whose head must start the
208
+ // server's URL). null when the server doesn't know it (a running call).
209
+ function serverItem(index, item) {
210
+ const exact = index.byKey.get(`${item.kind}:${item.key}`);
211
+ if (exact || item.kind !== "url") return exact || null;
212
+ const key = String(item.key);
213
+ if (!key.endsWith(ELLIPSIS)) return null;
214
+ const head = key.slice(0, -1);
215
+ return index.urls.find((it) => String(it.key).startsWith(head)) || null;
216
+ }
217
+
218
+ // +items+ with the server's state and tokens (+server+: a snapshot {items,
219
+ // at}, or null); an item it doesn't know stays live, without tokens. New
220
+ // objects only where something changed; never touches at, ops or count.
221
+ export function overlay(items, server) {
222
+ if (!server) return items;
223
+ const index = serverIndex(server);
224
+ return items.map((item) => {
225
+ const found = serverItem(index, item);
226
+ const state = found ? found.state : "live";
227
+ const tokens = found && Number.isFinite(found.tokens) ? found.tokens : undefined;
228
+ if (item.state === state && item.tokens === tokens) return item;
229
+ const next = { ...item, state };
230
+ if (tokens === undefined) delete next.tokens;
231
+ else next.tokens = tokens;
232
+ return next;
233
+ });
234
+ }
235
+
236
+ // Which snapshot to keep: +next+ when there is none yet or it is built from
237
+ // as much of the conversation as the kept one (its at), else +kept+. A GET
238
+ // that answered after a newer event never wins over it.
239
+ export function newerSnapshot(kept, next) {
240
+ if (!next || !Array.isArray(next.items) || !Number.isFinite(next.at)) return kept;
241
+ if (!kept || next.at >= kept.at) return next;
242
+ return kept;
243
+ }
244
+
245
+ // Tokens as the popover shows them: "~840", "~1.2k", "~12k".
246
+ export function tokensText(tokens) {
247
+ if (!Number.isFinite(tokens)) return "";
248
+ if (tokens < 1000) return `~${Math.round(tokens)}`;
249
+ const k = tokens / 1000;
250
+ return `~${k < 10 ? String(Math.round(k * 10) / 10).replace(/\.0$/, "") : Math.round(k)}k`;
251
+ }
252
+
253
+ const isForgotten = (item) => item.state === "forgotten";
254
+ const isFaded = (item) => item.state === "stale" || item.state === "forgotten";
255
+
256
+ // A group's state: forgotten when all its members are, stale when all are
257
+ // stale or forgotten, else live (spec §3: struck only when all forgotten,
258
+ // faded when all stale or forgotten).
259
+ export function groupState(members) {
260
+ if (members.length && members.every(isForgotten)) return "forgotten";
261
+ if (members.length && members.every(isFaded)) return "stale";
262
+ return "live";
263
+ }
264
+
265
+ function edited(item) {
266
+ return item.kind === "file" && item.ops.includes("edit");
267
+ }
268
+
269
+ // A leaf's text: a file's name, a memory's name, a URL's short label.
270
+ export function leafText(item) {
271
+ if (item.kind === "file") return basename(item.path || item.key);
272
+ if (item.kind === "url") return urlLabel(item.url || item.key);
273
+ return String(item.key);
274
+ }
275
+
276
+ // ── the fold ──────────────────────────────────────────────────────────────
277
+
278
+ function leafLabel(item) {
279
+ return {
280
+ id: `${item.kind}:${item.key}`, type: "leaf", kind: item.kind, glyph: GLYPHS[item.kind] || "", text: leafText(item),
281
+ n: 1, edited: edited(item) ? 1 : 0, cut: isForgotten(item) ? 1 : 0, state: item.state || "live", members: [item],
282
+ };
283
+ }
284
+
285
+ function plural(n, word) {
286
+ if (n === 1) return `${n} ${word}`;
287
+ return `${n} ${word === "memory" ? "memories" : `${word}s`}`;
288
+ }
289
+
290
+ const KIND_WORDS = { memory: "memory", url: "url", file: "file" };
291
+
292
+ // A group spec as a label over +members+ (items, in entry order).
293
+ function groupLabel(spec, members) {
294
+ const n = members.length;
295
+ const edits = members.filter(edited).length;
296
+ // The state marks (spec §3): ✂n the forgotten members, the group's own
297
+ // state from all of them.
298
+ const marks = { cut: members.filter(isForgotten).length, state: groupState(members) };
299
+ if (spec.type === "dir") {
300
+ return { id: `dir:${spec.dir}`, type: "dir", kind: "file", glyph: "", text: dirLabel(spec.dir), count: plural(n, "file"), n, edited: edits, ...marks, members, dir: spec.dir };
301
+ }
302
+ if (spec.type === "kind") {
303
+ return { id: `kind:${spec.of}`, type: "kind", kind: spec.of, glyph: GLYPHS[spec.of], text: plural(n, KIND_WORDS[spec.of]), n, edited: 0, ...marks, members };
304
+ }
305
+ const dirs = new Set(members.map((m) => dirOf(m.path || m.key)));
306
+ return { id: "files", type: "files", kind: "file", glyph: "", text: `${plural(n, "file")} · ${plural(dirs.size, "dir")}`, n, edited: edits, ...marks, members };
307
+ }
308
+
309
+ // Whether label +l+ belongs to group +spec+.
310
+ function belongs(l, spec) {
311
+ if (spec.type === "dir") return l.type === "leaf" && l.kind === "file" && (l.members[0].path || l.members[0].key).startsWith(`${spec.dir}/`);
312
+ if (spec.type === "kind") return l.type === "leaf" && l.kind === spec.of;
313
+ return l.kind === "file" && (l.type === "leaf" || l.type === "dir");
314
+ }
315
+
316
+ // +labels+ with +spec+'s members folded into one label at the first one's
317
+ // slot, or null when it would hold fewer than +least+.
318
+ function applyGroup(labels, spec, least = 2) {
319
+ const at = [];
320
+ labels.forEach((l, i) => { if (belongs(l, spec)) at.push(i); });
321
+ if (at.length < least) return null;
322
+ const members = at.flatMap((i) => labels[i].members);
323
+ const group = groupLabel(spec, members);
324
+ const out = [];
325
+ labels.forEach((l, i) => {
326
+ if (i === at[0]) out.push(group);
327
+ else if (!at.includes(i)) out.push(l);
328
+ });
329
+ return out;
330
+ }
331
+
332
+ // The folders, in order: each looks at the labels and names the group it
333
+ // would fold, or null. Later rules are new entries here, not loop changes.
334
+ function byDirectory(labels) {
335
+ const counts = new Map();
336
+ for (const l of labels) {
337
+ if (l.type !== "leaf" || l.kind !== "file") continue;
338
+ const parts = String(l.members[0].path || l.members[0].key).split("/");
339
+ parts.pop();
340
+ for (let d = parts.length; d > 0; d -= 1) {
341
+ const dir = parts.slice(0, d).join("/");
342
+ if (!dir) continue;
343
+ counts.set(dir, (counts.get(dir) || 0) + 1);
344
+ }
345
+ }
346
+ // The deepest directory holding two or more (ties: the one holding more,
347
+ // then the earlier), so web/public/ and lib/samagotchi/llm/ fold apart
348
+ // instead of into lib/samagotchi/.
349
+ let best = null;
350
+ for (const [dir, n] of counts) {
351
+ if (n < 2) continue;
352
+ const depth = dir.split("/").length;
353
+ if (!best || depth > best.depth || (depth === best.depth && n > best.n)) best = { dir, depth, n };
354
+ }
355
+ return best ? { type: "dir", dir: best.dir } : null;
356
+ }
357
+
358
+ function byKind(labels) {
359
+ for (const of of ["memory", "url"]) {
360
+ if (labels.filter((l) => l.type === "leaf" && l.kind === of).length >= 2) return { type: "kind", of };
361
+ }
362
+ return null;
363
+ }
364
+
365
+ function lastResort(labels) {
366
+ return labels.filter((l) => l.kind === "file" && (l.type === "leaf" || l.type === "dir")).length >= 2 ? { type: "files" } : null;
367
+ }
368
+
369
+ const FOLDERS = [byDirectory, byKind, lastResort];
370
+
371
+ function specKey(spec) {
372
+ return spec.type === "dir" ? `dir:${spec.dir}` : spec.type === "kind" ? `kind:${spec.of}` : "files";
373
+ }
374
+
375
+ // +items+ (in entry order) as at most +budget+ labels: leaves while they
376
+ // fit, else the folders' groups, each at its first member's slot. +kept+:
377
+ // the groups an earlier fold of the same set made (its +groups+): applied
378
+ // first, so a group once folded stays (the set only grows). +lastResort+:
379
+ // false leaves the "N files · K dirs" rule out (the footprint's tails say
380
+ // the rest instead). @return {labels, groups}
381
+ export function fold(items, budget, { kept = [], lastResort: useLast = true } = {}) {
382
+ let labels = items.map(leafLabel);
383
+ const groups = [];
384
+ const done = new Set();
385
+ for (const spec of kept) {
386
+ if (done.has(specKey(spec))) continue;
387
+ const next = applyGroup(labels, spec, 1);
388
+ if (next) {
389
+ labels = next;
390
+ groups.push(spec);
391
+ done.add(specKey(spec));
392
+ }
393
+ }
394
+ const folders = useLast ? FOLDERS : FOLDERS.filter((f) => f !== lastResort);
395
+ while (labels.length > budget) {
396
+ const spec = folders.map((f) => f(labels)).find(Boolean);
397
+ const next = spec && applyGroup(labels, spec);
398
+ if (!next) break;
399
+ labels = next;
400
+ groups.push(spec);
401
+ }
402
+ return { labels, groups };
403
+ }
404
+
405
+ // The labels a form shows for +budget+: all of them when they fit, else
406
+ // the first budget − 1 and how many members the rest hold (its "+ n more").
407
+ export function shown(labels, budget) {
408
+ if (labels.length <= budget) return { labels, more: 0 };
409
+ const head = labels.slice(0, Math.max(0, budget - 1));
410
+ return { labels: head, more: labels.slice(head.length).reduce((n, l) => n + l.n, 0) };
411
+ }
412
+
413
+ // A label's hover: its members, one per line ("path ✎").
414
+ export function labelTitle(label) {
415
+ return label.members.map((m) => `${m.kind === "file" ? (m.path || m.key) : m.kind === "url" ? (m.url || m.key) : m.key}${edited(m) ? " ✎" : ""}${isFaded(m) ? ` (${m.state})` : ""}`).join("\n");
416
+ }
417
+
418
+ // ── tags, texts ───────────────────────────────────────────────────────────
419
+
420
+ // Where an item entered: "s3" (step 3 of its turn), "t2·s3" in the
421
+ // session's scope, "prompt" for a preloaded memory, "" when unknown.
422
+ export function stepTag(item, { session = false } = {}) {
423
+ if (item?.preloaded) return "prompt";
424
+ const at = item?.at;
425
+ if (!at) return "";
426
+ return session ? `t${at.turn + 1}·s${at.step + 1}` : `s${at.step + 1}`;
427
+ }
428
+
429
+ // A group's steps as a range ("s1–5"), or its one step ("s2").
430
+ export function stepRange(members) {
431
+ const steps = [...new Set(members.filter((m) => m.at).map((m) => m.at.step + 1))].sort((a, b) => a - b);
432
+ if (!steps.length) return "";
433
+ return steps.length === 1 ? `s${steps[0]}` : `s${steps[0]}–${steps[steps.length - 1]}`;
434
+ }
435
+
436
+ // What was done to an item, in the expanded view's words.
437
+ export function opsText(item) {
438
+ if (item.muted) return "muted (--mute)";
439
+ if (item.preloaded) return item.ops.length ? "preloaded, read" : "preloaded";
440
+ const words = [];
441
+ if (item.ops.includes("read")) {
442
+ const reads = item.ops.length === 1 && item.count > 1 ? ` ×${item.count}` : item.lines ? ` ${item.lines}` : "";
443
+ words.push(item.kind === "url" ? "fetched" : `read${reads}`);
444
+ }
445
+ if (item.ops.includes("edit")) words.push(item.ops.length === 1 && item.count > 1 ? `edited ×${item.count}` : "edited");
446
+ if (item.ops.includes("write")) words.push("written");
447
+ const text = words.join(", ");
448
+ // The state in words (colour is never alone): "read 1–120 · stale".
449
+ return isFaded(item) ? [text, item.state].filter(Boolean).join(" · ") : text;
450
+ }
451
+
452
+ // A label as the screen reader says it ("web/public 9 files 2 edited").
453
+ function spoken(label) {
454
+ if (label.type === "dir") return `${label.text.replace(/^…\//, "").replace(/\/$/, "")} ${label.count}${label.edited ? ` ${label.edited} edited` : ""}${label.cut ? ` ${label.cut} forgotten` : ""}`;
455
+ if (label.type === "leaf") return `${label.kind === "memory" ? "memory " : label.kind === "url" ? "url " : ""}${label.text}${label.edited ? " edited" : ""}${label.state && label.state !== "live" ? ` ${label.state}` : ""}`;
456
+ return `${label.text}${label.cut ? ` ${label.cut} forgotten` : ""}`;
457
+ }
458
+
459
+ // The rail's (or row's, footprint's) aria-label: the folded text spoken.
460
+ export function ariaLabel(labels, more = 0) {
461
+ if (!labels.length) return "Working set: nothing yet. Open the list.";
462
+ return `Working set: ${labels.map(spoken).join(", ")}${more ? `, and ${more} more` : ""}. Open the list.`;
463
+ }
464
+
465
+ // The footprint's pieces for a finished turn's +items+: its labels (at
466
+ // most +budget+, no last resort), the rest as one tail ("+1 url", "+3
467
+ // more"), and how many files it edited. null for a turn that touched
468
+ // nothing (no line).
469
+ export function footprint(items, budget = BUDGETS.footprint) {
470
+ if (!items.length) return null;
471
+ const { labels } = fold(items, budget, { lastResort: false });
472
+ const head = labels.slice(0, budget);
473
+ const rest = labels.slice(budget).flatMap((l) => l.members);
474
+ let tail = "";
475
+ if (rest.length) {
476
+ const kinds = new Set(rest.map((m) => m.kind));
477
+ tail = kinds.size === 1 ? `+${plural(rest.length, KIND_WORDS[[...kinds][0]])}` : `+${rest.length} more`;
478
+ }
479
+ return { labels: head, tail, edited: items.filter(edited).length, forgotten: items.filter(isForgotten).length };
480
+ }
481
+
482
+ // A label's text as the footprint and the row draw it: its glyph, text,
483
+ // count ("web/public/ · 9 files").
484
+ export function labelText(label) {
485
+ const glyph = label.glyph ? `${label.glyph} ` : "";
486
+ return `${glyph}${label.text}${label.count ? ` · ${label.count}` : ""}`;
487
+ }
488
+
489
+ // The footprint as plain text (its title, the specs): "touched web/public/
490
+ // · 9 files · ◆ 3 memories s1–5 · +1 url · edited 2".
491
+ export function footprintText(items, budget = BUDGETS.footprint) {
492
+ const fp = footprint(items, budget);
493
+ if (!fp) return "";
494
+ const parts = fp.labels.map((l) => {
495
+ const tag = l.kind === "memory" ? (l.type === "leaf" ? stepTag(l.members[0]) : stepRange(l.members)) : "";
496
+ return `${labelText(l)}${tag ? ` ${tag}` : ""}`;
497
+ });
498
+ if (fp.tail) parts.push(fp.tail);
499
+ if (fp.edited) parts.push(`edited ${fp.edited}`);
500
+ if (fp.forgotten) parts.push(`${fp.forgotten} forgotten`);
501
+ return `touched ${parts.join(" · ")}`;
502
+ }
503
+
504
+ // ── the fresh cue ─────────────────────────────────────────────────────────
505
+
506
+ // An item seen first at +seenAt+ (ms) is fresh until FLASH_MS after it.
507
+ export function isFresh(seenAt, now, ms = FLASH_MS) {
508
+ return Number.isFinite(seenAt) && now - seenAt < ms;
509
+ }
510
+
511
+ // How long until the next fresh cue ends (ms), null when none is on.
512
+ export function nextSettle(seenTimes, now, ms = FLASH_MS) {
513
+ let next = null;
514
+ for (const t of seenTimes) {
515
+ if (!isFresh(t, now, ms)) continue;
516
+ const left = t + ms - now;
517
+ if (next === null || left < next) next = left;
518
+ }
519
+ return next;
520
+ }