samagotchi 0.100.1 → 0.102.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +14 -10
  4. data/docs/architecture.md +10 -2
  5. data/docs/cli.md +100 -5
  6. data/docs/configuration.md +26 -4
  7. data/docs/internals/bundles.md +1 -1
  8. data/docs/internals/engine.md +1 -0
  9. data/docs/internals/llm-context-forget.md +14 -0
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/internals/working-set.md +205 -0
  12. data/docs/plugins.md +6 -3
  13. data/docs/sessions.md +28 -11
  14. data/docs/testing.md +8 -1
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
  16. data/lib/samagotchi/bridge.rb +9 -3
  17. data/lib/samagotchi/bundle_command/check.rb +5 -1
  18. data/lib/samagotchi/bundle_command/try/command.rb +6 -6
  19. data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
  20. data/lib/samagotchi/bundle_command.rb +4 -4
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/commands/registry.rb +12 -1
  23. data/lib/samagotchi/config.rb +92 -53
  24. data/lib/samagotchi/engine.rb +153 -4
  25. data/lib/samagotchi/host_keys.rb +35 -18
  26. data/lib/samagotchi/idle_scheduler.rb +16 -1
  27. data/lib/samagotchi/llm_context_edit.rb +32 -10
  28. data/lib/samagotchi/llm_context_forget.rb +12 -3
  29. data/lib/samagotchi/llm_context_view.rb +19 -7
  30. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  31. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  32. data/lib/samagotchi/message_parts.rb +8 -3
  33. data/lib/samagotchi/plugin/context.rb +2 -2
  34. data/lib/samagotchi/prompt.rb +1 -1
  35. data/lib/samagotchi/recap_command.rb +68 -0
  36. data/lib/samagotchi/recap_setup.rb +27 -14
  37. data/lib/samagotchi/send_command.rb +14 -11
  38. data/lib/samagotchi/session.rb +19 -12
  39. data/lib/samagotchi/session_commands.rb +44 -2
  40. data/lib/samagotchi/session_config.rb +411 -0
  41. data/lib/samagotchi/session_setup.rb +9 -9
  42. data/lib/samagotchi/sessions_command.rb +6 -2
  43. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  44. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  45. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  46. data/lib/samagotchi/terminal_ui.rb +8 -6
  47. data/lib/samagotchi/testing/bundle.rb +3 -2
  48. data/lib/samagotchi/testing/fake_executable.rb +6 -16
  49. data/lib/samagotchi/testing/fake_executable_launcher +6 -0
  50. data/lib/samagotchi/tool_response.rb +10 -6
  51. data/lib/samagotchi/tool_runner.rb +25 -6
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +76 -11
  54. data/lib/samagotchi/web/public/app.js +35 -3
  55. data/lib/samagotchi/web/public/chip_popover.js +56 -17
  56. data/lib/samagotchi/web/public/commands.js +8 -2
  57. data/lib/samagotchi/web/public/data.js +7 -0
  58. data/lib/samagotchi/web/public/index.html +145 -1
  59. data/lib/samagotchi/web/public/info_bar.js +16 -4
  60. data/lib/samagotchi/web/public/info_bar_model.js +7 -3
  61. data/lib/samagotchi/web/public/refs.js +10 -1
  62. data/lib/samagotchi/web/public/stage_model.js +6 -5
  63. data/lib/samagotchi/web/public/stage_view.js +34 -2
  64. data/lib/samagotchi/web/public/stream_handlers.js +10 -1
  65. data/lib/samagotchi/web/public/turn_html.js +5 -0
  66. data/lib/samagotchi/web/public/turn_view.js +16 -4
  67. data/lib/samagotchi/web/public/working_set.js +612 -0
  68. data/lib/samagotchi/web/public/working_set_rail.js +278 -0
  69. data/lib/samagotchi/web/public/working_set_view.js +541 -0
  70. data/lib/samagotchi/web/server.rb +6 -4
  71. data/lib/samagotchi/worker.rb +9 -6
  72. data/lib/samagotchi/working_set.rb +238 -0
  73. data/lib/samagotchi/working_set_command.rb +119 -0
  74. data/lib/samagotchi/working_set_steer.rb +250 -0
  75. metadata +11 -2
  76. data/lib/samagotchi/bundle_session_settings.rb +0 -251
@@ -39,6 +39,7 @@ import * as model from "./turn_model.js";
39
39
  import { createHold } from "./hold.js";
40
40
  import { linkedLine } from "./notice_links.js";
41
41
  import { taskCommand, taskStopButton } from "./task_stop.js";
42
+ import { footprintTemplate, groupItems, stampHistory } from "./working_set_view.js";
42
43
 
43
44
  // ---- The templates: one step, its thinking, its label ----
44
45
 
@@ -840,8 +841,11 @@ function failedLineTemplate(record) {
840
841
  // duration from the timing's tool records), then the answer and the turn's
841
842
  // timing line under it.
842
843
  // +thumbs(images)+ draws a prompt's or a tool row's image thumbs (a
843
- // template, images.js thumbsTemplate).
844
- export function turnHistoryNodes(items, timing, { thumbs }) {
844
+ // template, images.js thumbsTemplate). +workingSet+ (the stage view's
845
+ // web.working_set, never the turn view's): each block stamped with its
846
+ // turn's ordinal and its rows with theirs (working_set_view.js), and the
847
+ // turn's footprint line after it.
848
+ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false }) {
845
849
  const timingTemplate = (group) => {
846
850
  const record = group.record;
847
851
  // Numbered by its place in the turn records, as live (a failed turn
@@ -875,12 +879,16 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
875
879
  };
876
880
  const steerTemplate = (i, bubble) => steerRowTemplate({ source: items[i].source, text: items[i].content }, { bubble });
877
881
  const parts = [];
878
- // Each block's steps, in order: its outline once the nodes exist.
882
+ // Each block's steps, in order: its outline once the nodes exist; and
883
+ // its turn's place among the turns (the working set's stamp).
879
884
  const outlines = [];
885
+ const blockTurns = [];
886
+ let turnOrdinal = -1;
880
887
  for (const group of turnGroups(items, timing)) {
881
888
  if (group.kind === "note") { parts.push(noteBubbleTemplate(items[group.i])); continue; }
882
889
  if (group.kind === "steer") { parts.push(steerTemplate(group.i, true)); continue; }
883
890
  if (group.kind === "answer") { parts.push(answerTemplate(group.answer, nothing)); continue; }
891
+ turnOrdinal += 1;
884
892
  const prompt = items[group.user];
885
893
  // A context wake turn starts with its note.
886
894
  parts.push(prompt.role === "note" ? noteBubbleTemplate(prompt) : userTemplate(prompt));
@@ -909,6 +917,8 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
909
917
  }));
910
918
  parts.push(html`<details class="turn-work done"><summary>${model.blockSummary({ gens, ended: true })}</summary>${steps}</details>`);
911
919
  outlines.push(gens);
920
+ blockTurns.push(turnOrdinal);
921
+ if (workingSet) parts.push(footprintTemplate(groupItems(group, turnOrdinal), turnOrdinal));
912
922
  }
913
923
  // Steers with no step to sit in, or in a turn that shows no block: on
914
924
  // their own after the prompt.
@@ -935,7 +945,9 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
935
945
  }
936
946
  const out = nodesOf(parts);
937
947
  // A finished turn's block as the live end leaves it: its outline, open.
938
- [...out.children].filter((el) => el.matches(".turn-work")).forEach((block, i) => outlineBlock(block, outlines[i] || []));
948
+ const blocks = [...out.children].filter((el) => el.matches(".turn-work"));
949
+ blocks.forEach((block, i) => outlineBlock(block, outlines[i] || []));
950
+ if (workingSet) stampHistory(blocks, blockTurns);
939
951
  // Each answer bubble and step text knows its message's refs
940
952
  // (answer_refs.js; the page pairs them after the render), the index
941
953
  // stays out of the DOM.
@@ -0,0 +1,612 @@
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, dropped) and
18
+ // tokens by key (overlay), never its at. The user steers it with
19
+ // `/working-set drop|undrop KEY…` lines (steerLine); one queued for after the
20
+ // running turn is pending (pendingSteers) until its command_ran.
21
+
22
+ export const FLASH_MS = 4000;
23
+ // Labels per form: the rail's four lines, the row's and the footprint's
24
+ // three, the collapsed head's newest one.
25
+ export const BUDGETS = Object.freeze({ rail: 4, row: 3, footprint: 3, head: 1 });
26
+ export const GLYPHS = Object.freeze({ file: "", memory: "◆", url: "↗", muted: "◇" });
27
+ // A directory label longer than this keeps its last two segments.
28
+ const DIR_MAX = 22;
29
+
30
+ const FILE_OPS = { read: "read", edit: "edit", write: "edit" };
31
+ const MEMORY_OPS = { memory_read: "read", memory_write: "write" };
32
+
33
+ // The names a memory row names: memory_read's title can list several
34
+ // ("a,b"; an empty one is an index listing: none), memory_write's is one.
35
+ export function memoryNames(title) {
36
+ return String(title || "").split(",").map((name) => name.trim()).filter(Boolean);
37
+ }
38
+
39
+ // The URL in a web_fetch row's params (url="…", Ruby-inspect quoted, cut at
40
+ // 80 characters with a "…": the cut URL is kept as is), or null.
41
+ export function urlOfParams(params) {
42
+ const m = /(?:^|\s)url="((?:[^"\\]|\\.)*)"?/.exec(String(params || ""));
43
+ if (!m) return null;
44
+ const url = m[1].replace(/\\(.)/g, "$1");
45
+ return url || null;
46
+ }
47
+
48
+ // A URL as a short label: its host, then its last two path segments behind
49
+ // "…/" when there are more ("github.com/…/pull/812").
50
+ export function urlLabel(url) {
51
+ const text = String(url || "");
52
+ const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)([^?#]*)/i.exec(text);
53
+ if (!m) return text;
54
+ const segments = m[2].split("/").filter(Boolean);
55
+ if (segments.length <= 2) return [m[1], ...segments].join("/");
56
+ return `${m[1]}/…/${segments.slice(-2).join("/")}`;
57
+ }
58
+
59
+ function basename(path) {
60
+ const parts = String(path).split("/").filter(Boolean);
61
+ return parts.length ? parts[parts.length - 1] : String(path);
62
+ }
63
+
64
+ function dirOf(path) {
65
+ const parts = String(path).split("/");
66
+ parts.pop();
67
+ return parts.join("/");
68
+ }
69
+
70
+ // A directory as a group's label: its path with a trailing "/" when it
71
+ // fits DIR_MAX, else "…/" and its last two segments ("…/web/public/").
72
+ export function dirLabel(dir) {
73
+ const clean = String(dir).replace(/\/+$/, "");
74
+ const full = `${clean}/`;
75
+ if (full.length <= DIR_MAX) return full;
76
+ const segments = clean.split("/").filter(Boolean);
77
+ return `…/${segments.slice(-2).join("/")}/`;
78
+ }
79
+
80
+ // What one finished row brings into the set: [{kind, key, op, …}], none
81
+ // unless it completed ok.
82
+ export function rowEntries(row) {
83
+ if (!row || row.status !== "ok") return [];
84
+ const tool = row.tool;
85
+ if (FILE_OPS[tool]) {
86
+ const path = row.ref?.kind === "file" && row.ref.path ? row.ref.path : row.title;
87
+ if (!path) return [];
88
+ const ref = row.ref?.kind === "file" ? row.ref : null;
89
+ // Shown (and grouped) by the title when it is the path relative to the
90
+ // session's folder (the ref's path is absolute; a cut title starts "…").
91
+ const title = String(row.title || "");
92
+ const rel = title && !title.startsWith("\u2026") && String(path).endsWith(title) ? title : String(path);
93
+ return [{ kind: "file", key: String(path), op: FILE_OPS[tool], path: rel, ref }];
94
+ }
95
+ if (MEMORY_OPS[tool]) {
96
+ return memoryNames(row.title).map((name) => ({ kind: "memory", key: name, op: MEMORY_OPS[tool] }));
97
+ }
98
+ if (tool === "web_fetch") {
99
+ const url = urlOfParams(row.params);
100
+ return url ? [{ kind: "url", key: url, op: "read", url }] : [];
101
+ }
102
+ return [];
103
+ }
104
+
105
+ // A row's read lines ("1–120") when its ref names them.
106
+ function linesOf(ref) {
107
+ if (!ref?.line) return "";
108
+ return ref.end_line && ref.end_line !== ref.line ? `${ref.line}–${ref.end_line}` : String(ref.line);
109
+ }
110
+
111
+ function newItem(entry, at) {
112
+ const item = { kind: entry.kind, key: entry.key, label: leafText(entry), ops: [entry.op], at, count: 1, state: "live" };
113
+ if (entry.path) item.path = entry.path;
114
+ if (entry.url) item.url = entry.url;
115
+ if (entry.ref) item.ref = entry.ref;
116
+ const lines = entry.op === "read" ? linesOf(entry.ref) : "";
117
+ if (lines) item.lines = lines;
118
+ return item;
119
+ }
120
+
121
+ function touch(item, op) {
122
+ if (!item.ops.includes(op)) item.ops = [...item.ops, op];
123
+ item.count += 1;
124
+ }
125
+
126
+ // A turn's items ({gens: [{tools: rows}]}, the live turn's model or a
127
+ // reloaded turn's steps as gens), in order of first entry; +turnOrdinal+
128
+ // is the turn's place in the page's turns.
129
+ export function itemsOfTurn(turn, turnOrdinal) {
130
+ const items = [];
131
+ const byKey = new Map();
132
+ (turn?.gens || []).forEach((gen, step) => {
133
+ (gen?.tools || []).forEach((row, rowIndex) => {
134
+ for (const entry of rowEntries(row)) {
135
+ const id = `${entry.kind}:${entry.key}`;
136
+ const found = byKey.get(id);
137
+ if (found) touch(found, entry.op);
138
+ else {
139
+ const item = newItem(entry, { turn: turnOrdinal, step, row: rowIndex });
140
+ byKey.set(id, item);
141
+ items.push(item);
142
+ }
143
+ }
144
+ });
145
+ });
146
+ return items;
147
+ }
148
+
149
+ // The session's set: every turn's items merged (the first entry's at kept,
150
+ // ops unioned, counts summed), the preloaded memories first (tag
151
+ // "prompt"), the used memories no row explains (a turn without its saved
152
+ // rows) after them; the muted memories listed apart, never counted.
153
+ // @param turns the finished turns' item lists, in order
154
+ // @param live the running turn's items (or none)
155
+ export function sessionItems({ turns = [], live = [], preloaded = [], muted = [], used = [] } = {}) {
156
+ const items = [];
157
+ const byKey = new Map();
158
+ for (const name of preloaded || []) {
159
+ if (byKey.has(`memory:${name}`)) continue;
160
+ const item = { kind: "memory", key: name, label: name, ops: [], at: null, count: 0, state: "live", preloaded: true };
161
+ byKey.set(`memory:${name}`, item);
162
+ items.push(item);
163
+ }
164
+ for (const list of [...turns, live || []]) {
165
+ for (const it of list) {
166
+ const id = `${it.kind}:${it.key}`;
167
+ const found = byKey.get(id);
168
+ if (!found) {
169
+ const copy = { ...it, ops: [...it.ops] };
170
+ byKey.set(id, copy);
171
+ items.push(copy);
172
+ } else {
173
+ for (const op of it.ops) if (!found.ops.includes(op)) found.ops = [...found.ops, op];
174
+ found.count += it.count;
175
+ if (!found.at) found.at = it.at;
176
+ }
177
+ }
178
+ }
179
+ for (const name of used || []) {
180
+ if (byKey.has(`memory:${name}`)) continue;
181
+ const item = { kind: "memory", key: name, label: name, ops: ["read"], at: null, count: 0, state: "live" };
182
+ byKey.set(`memory:${name}`, item);
183
+ items.push(item);
184
+ }
185
+ const mutedItems = (muted || []).map((name) => ({ kind: "memory", key: name, label: name, ops: [], at: null, count: 0, state: "live", muted: true }));
186
+ return { items, muted: mutedItems };
187
+ }
188
+
189
+ // ── the server's truth ────────────────────────────────────────────────────
190
+
191
+ export const STATES = Object.freeze(["live", "stale", "forgotten", "dropped"]);
192
+ const ELLIPSIS = "\u2026";
193
+
194
+ // The server's items by "kind:key", and its URLs (a cut row key matches one
195
+ // by prefix).
196
+ function serverIndex(server) {
197
+ const byKey = new Map();
198
+ const urls = [];
199
+ for (const it of server?.items || []) {
200
+ if (!it || !STATES.includes(it.state)) continue;
201
+ byKey.set(`${it.kind}:${it.key}`, it);
202
+ if (it.kind === "url") urls.push(it);
203
+ }
204
+ return { byKey, urls };
205
+ }
206
+
207
+ // The server's item for +item+: files by absolute path (a title fallback
208
+ // won't match), memories by name, URLs by the row's key (its params
209
+ // preview: cut at 80 characters with a "…", whose head must start the
210
+ // server's URL). null when the server doesn't know it (a running call).
211
+ function serverItem(index, item) {
212
+ const exact = index.byKey.get(`${item.kind}:${item.key}`);
213
+ if (exact || item.kind !== "url") return exact || null;
214
+ const key = String(item.key);
215
+ if (!key.endsWith(ELLIPSIS)) return null;
216
+ const head = key.slice(0, -1);
217
+ return index.urls.find((it) => String(it.key).startsWith(head)) || null;
218
+ }
219
+
220
+ // +items+ with the server's state and tokens (+server+: a snapshot {items,
221
+ // at}, or null); an item it doesn't know stays live, without tokens. New
222
+ // objects only where something changed; never touches at, ops or count.
223
+ export function overlay(items, server) {
224
+ if (!server) return items;
225
+ const index = serverIndex(server);
226
+ return items.map((item) => {
227
+ const found = serverItem(index, item);
228
+ const state = found ? found.state : "live";
229
+ const tokens = found && Number.isFinite(found.tokens) ? found.tokens : undefined;
230
+ // A cut URL row's full key, the one a steer line names.
231
+ const serverKey = found && found.key !== item.key ? found.key : undefined;
232
+ if (item.state === state && item.tokens === tokens && item.serverKey === serverKey) return item;
233
+ const next = { ...item, state };
234
+ if (tokens === undefined) delete next.tokens;
235
+ else next.tokens = tokens;
236
+ if (serverKey === undefined) delete next.serverKey;
237
+ else next.serverKey = serverKey;
238
+ return next;
239
+ });
240
+ }
241
+
242
+ // Which snapshot to keep, +next+ or +kept+. Events come in the order the
243
+ // server built them, so an event (+event+) always wins: a smaller at is a
244
+ // rollback that shortened the conversation, not an older set. A GET's
245
+ // answer loses to an event that came while it was on its way (+overtaken+),
246
+ // and, asked while a turn runs (its running: it is built from the session's
247
+ // messages, behind that turn's events), to a kept one from more of the
248
+ // conversation (its at).
249
+ export function newerSnapshot(kept, next, { event = false, overtaken = false } = {}) {
250
+ if (!next || !Array.isArray(next.items) || !Number.isFinite(next.at)) return kept;
251
+ if (!kept || event) return next;
252
+ if (overtaken) return kept;
253
+ if (next.running && next.at < kept.at) return kept;
254
+ return next;
255
+ }
256
+
257
+ // Tokens as the popover shows them: "~840", "~1.2k", "~12k".
258
+ export function tokensText(tokens) {
259
+ if (!Number.isFinite(tokens)) return "";
260
+ if (tokens < 1000) return `~${Math.round(tokens)}`;
261
+ const k = tokens / 1000;
262
+ return `~${k < 10 ? String(Math.round(k * 10) / 10).replace(/\.0$/, "") : Math.round(k)}k`;
263
+ }
264
+
265
+ const isForgotten = (item) => item.state === "forgotten";
266
+ // Nothing of it is sent: the model forgot it, or the user dropped it.
267
+ const isStruck = (item) => item.state === "forgotten" || item.state === "dropped";
268
+ const isFaded = (item) => item.state === "stale" || isStruck(item);
269
+
270
+ // A group's state: dropped when all its members are, forgotten when all are
271
+ // struck (forgotten or dropped), stale when all are stale or struck, else
272
+ // live (spec §3: struck only when all forgotten, faded when all stale or
273
+ // forgotten).
274
+ export function groupState(members) {
275
+ if (members.length && members.every((m) => m.state === "dropped")) return "dropped";
276
+ if (members.length && members.every(isStruck)) return "forgotten";
277
+ if (members.length && members.every(isFaded)) return "stale";
278
+ return "live";
279
+ }
280
+
281
+ function edited(item) {
282
+ return item.kind === "file" && item.ops.includes("edit");
283
+ }
284
+
285
+ // A leaf's text: a file's name, a memory's name, a URL's short label.
286
+ export function leafText(item) {
287
+ if (item.kind === "file") return basename(item.path || item.key);
288
+ if (item.kind === "url") return urlLabel(item.url || item.key);
289
+ return String(item.key);
290
+ }
291
+
292
+ // ── the fold ──────────────────────────────────────────────────────────────
293
+
294
+ function leafLabel(item) {
295
+ return {
296
+ id: `${item.kind}:${item.key}`, type: "leaf", kind: item.kind, glyph: GLYPHS[item.kind] || "", text: leafText(item),
297
+ n: 1, edited: edited(item) ? 1 : 0, cut: isStruck(item) ? 1 : 0, state: item.state || "live", members: [item],
298
+ };
299
+ }
300
+
301
+ function plural(n, word) {
302
+ if (n === 1) return `${n} ${word}`;
303
+ return `${n} ${word === "memory" ? "memories" : `${word}s`}`;
304
+ }
305
+
306
+ const KIND_WORDS = { memory: "memory", url: "url", file: "file" };
307
+
308
+ // A group spec as a label over +members+ (items, in entry order).
309
+ function groupLabel(spec, members) {
310
+ const n = members.length;
311
+ const edits = members.filter(edited).length;
312
+ // The state marks (spec §3): ✂n the forgotten members, the group's own
313
+ // state from all of them.
314
+ const marks = { cut: members.filter(isStruck).length, state: groupState(members) };
315
+ if (spec.type === "dir") {
316
+ 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 };
317
+ }
318
+ if (spec.type === "kind") {
319
+ 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 };
320
+ }
321
+ const dirs = new Set(members.map((m) => dirOf(m.path || m.key)));
322
+ return { id: "files", type: "files", kind: "file", glyph: "", text: `${plural(n, "file")} · ${plural(dirs.size, "dir")}`, n, edited: edits, ...marks, members };
323
+ }
324
+
325
+ // Whether label +l+ belongs to group +spec+.
326
+ function belongs(l, spec) {
327
+ if (spec.type === "dir") return l.type === "leaf" && l.kind === "file" && (l.members[0].path || l.members[0].key).startsWith(`${spec.dir}/`);
328
+ if (spec.type === "kind") return l.type === "leaf" && l.kind === spec.of;
329
+ return l.kind === "file" && (l.type === "leaf" || l.type === "dir");
330
+ }
331
+
332
+ // +labels+ with +spec+'s members folded into one label at the first one's
333
+ // slot, or null when it would hold fewer than +least+.
334
+ function applyGroup(labels, spec, least = 2) {
335
+ const at = [];
336
+ labels.forEach((l, i) => { if (belongs(l, spec)) at.push(i); });
337
+ if (at.length < least) return null;
338
+ const members = at.flatMap((i) => labels[i].members);
339
+ const group = groupLabel(spec, members);
340
+ const out = [];
341
+ labels.forEach((l, i) => {
342
+ if (i === at[0]) out.push(group);
343
+ else if (!at.includes(i)) out.push(l);
344
+ });
345
+ return out;
346
+ }
347
+
348
+ // The folders, in order: each looks at the labels and names the group it
349
+ // would fold, or null. Later rules are new entries here, not loop changes.
350
+ function byDirectory(labels) {
351
+ const counts = new Map();
352
+ for (const l of labels) {
353
+ if (l.type !== "leaf" || l.kind !== "file") continue;
354
+ const parts = String(l.members[0].path || l.members[0].key).split("/");
355
+ parts.pop();
356
+ for (let d = parts.length; d > 0; d -= 1) {
357
+ const dir = parts.slice(0, d).join("/");
358
+ if (!dir) continue;
359
+ counts.set(dir, (counts.get(dir) || 0) + 1);
360
+ }
361
+ }
362
+ // The deepest directory holding two or more (ties: the one holding more,
363
+ // then the earlier), so web/public/ and lib/samagotchi/llm/ fold apart
364
+ // instead of into lib/samagotchi/.
365
+ let best = null;
366
+ for (const [dir, n] of counts) {
367
+ if (n < 2) continue;
368
+ const depth = dir.split("/").length;
369
+ if (!best || depth > best.depth || (depth === best.depth && n > best.n)) best = { dir, depth, n };
370
+ }
371
+ return best ? { type: "dir", dir: best.dir } : null;
372
+ }
373
+
374
+ function byKind(labels) {
375
+ for (const of of ["memory", "url"]) {
376
+ if (labels.filter((l) => l.type === "leaf" && l.kind === of).length >= 2) return { type: "kind", of };
377
+ }
378
+ return null;
379
+ }
380
+
381
+ function lastResort(labels) {
382
+ return labels.filter((l) => l.kind === "file" && (l.type === "leaf" || l.type === "dir")).length >= 2 ? { type: "files" } : null;
383
+ }
384
+
385
+ const FOLDERS = [byDirectory, byKind, lastResort];
386
+
387
+ function specKey(spec) {
388
+ return spec.type === "dir" ? `dir:${spec.dir}` : spec.type === "kind" ? `kind:${spec.of}` : "files";
389
+ }
390
+
391
+ // +items+ (in entry order) as at most +budget+ labels: leaves while they
392
+ // fit, else the folders' groups, each at its first member's slot. +kept+:
393
+ // the groups an earlier fold of the same set made (its +groups+): applied
394
+ // first, so a group once folded stays (the set only grows). +lastResort+:
395
+ // false leaves the "N files · K dirs" rule out (the footprint's tails say
396
+ // the rest instead). @return {labels, groups}
397
+ export function fold(items, budget, { kept = [], lastResort: useLast = true } = {}) {
398
+ let labels = items.map(leafLabel);
399
+ const groups = [];
400
+ const done = new Set();
401
+ for (const spec of kept) {
402
+ if (done.has(specKey(spec))) continue;
403
+ const next = applyGroup(labels, spec, 1);
404
+ if (next) {
405
+ labels = next;
406
+ groups.push(spec);
407
+ done.add(specKey(spec));
408
+ }
409
+ }
410
+ const folders = useLast ? FOLDERS : FOLDERS.filter((f) => f !== lastResort);
411
+ while (labels.length > budget) {
412
+ const spec = folders.map((f) => f(labels)).find(Boolean);
413
+ const next = spec && applyGroup(labels, spec);
414
+ if (!next) break;
415
+ labels = next;
416
+ groups.push(spec);
417
+ }
418
+ return { labels, groups };
419
+ }
420
+
421
+ // The labels a form shows for +budget+: all of them when they fit, else
422
+ // the first budget − 1 and how many members the rest hold (its "+ n more").
423
+ export function shown(labels, budget) {
424
+ if (labels.length <= budget) return { labels, more: 0 };
425
+ const head = labels.slice(0, Math.max(0, budget - 1));
426
+ return { labels: head, more: labels.slice(head.length).reduce((n, l) => n + l.n, 0) };
427
+ }
428
+
429
+ // A label's hover: its members, one per line ("path ✎").
430
+ export function labelTitle(label) {
431
+ 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");
432
+ }
433
+
434
+ // ── tags, texts ───────────────────────────────────────────────────────────
435
+
436
+ // Where an item entered: "s3" (step 3 of its turn), "t2·s3" in the
437
+ // session's scope, "prompt" for a preloaded memory, "" when unknown.
438
+ export function stepTag(item, { session = false } = {}) {
439
+ if (item?.preloaded) return "prompt";
440
+ const at = item?.at;
441
+ if (!at) return "";
442
+ return session ? `t${at.turn + 1}·s${at.step + 1}` : `s${at.step + 1}`;
443
+ }
444
+
445
+ // A group's steps as a range ("s1–5"), or its one step ("s2").
446
+ export function stepRange(members) {
447
+ const steps = [...new Set(members.filter((m) => m.at).map((m) => m.at.step + 1))].sort((a, b) => a - b);
448
+ if (!steps.length) return "";
449
+ return steps.length === 1 ? `s${steps[0]}` : `s${steps[0]}–${steps[steps.length - 1]}`;
450
+ }
451
+
452
+ // What was done to an item, in the expanded view's words.
453
+ export function opsText(item) {
454
+ if (item.muted) return "muted (--mute)";
455
+ if (item.preloaded) return item.ops.length ? "preloaded, read" : "preloaded";
456
+ const words = [];
457
+ if (item.ops.includes("read")) {
458
+ const reads = item.ops.length === 1 && item.count > 1 ? ` ×${item.count}` : item.lines ? ` ${item.lines}` : "";
459
+ words.push(item.kind === "url" ? "fetched" : `read${reads}`);
460
+ }
461
+ if (item.ops.includes("edit")) words.push(item.ops.length === 1 && item.count > 1 ? `edited ×${item.count}` : "edited");
462
+ if (item.ops.includes("write")) words.push("written");
463
+ const text = words.join(", ");
464
+ // The state in words (colour is never alone): "read 1–120 · stale".
465
+ return isFaded(item) ? [text, item.state].filter(Boolean).join(" · ") : text;
466
+ }
467
+
468
+ // A label as the screen reader says it ("web/public 9 files 2 edited").
469
+ function spoken(label) {
470
+ if (label.type === "dir") return `${label.text.replace(/^…\//, "").replace(/\/$/, "")} ${label.count}${label.edited ? ` ${label.edited} edited` : ""}${cutWords(label)}`;
471
+ 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}` : ""}`;
472
+ return `${label.text}${cutWords(label)}`;
473
+ }
474
+
475
+ // A group's struck members in words: " 2 forgotten, 1 dropped".
476
+ function cutWords(label) {
477
+ const dropped = label.members.filter((m) => m.state === "dropped").length;
478
+ const forgotten = label.cut - dropped;
479
+ return [forgotten ? ` ${forgotten} forgotten` : "", dropped ? ` ${dropped} dropped` : ""].filter(Boolean).join(",");
480
+ }
481
+
482
+ // The rail's (or row's, footprint's) aria-label: the folded text spoken.
483
+ export function ariaLabel(labels, more = 0) {
484
+ if (!labels.length) return "Working set: nothing yet. Open the list.";
485
+ return `Working set: ${labels.map(spoken).join(", ")}${more ? `, and ${more} more` : ""}. Open the list.`;
486
+ }
487
+
488
+ // The footprint's pieces for a finished turn's +items+: its labels (at
489
+ // most +budget+, no last resort), the rest as one tail ("+1 url", "+3
490
+ // more"), and how many files it edited. null for a turn that touched
491
+ // nothing (no line).
492
+ export function footprint(items, budget = BUDGETS.footprint) {
493
+ if (!items.length) return null;
494
+ const { labels } = fold(items, budget, { lastResort: false });
495
+ const head = labels.slice(0, budget);
496
+ const rest = labels.slice(budget).flatMap((l) => l.members);
497
+ let tail = "";
498
+ if (rest.length) {
499
+ const kinds = new Set(rest.map((m) => m.kind));
500
+ tail = kinds.size === 1 ? `+${plural(rest.length, KIND_WORDS[[...kinds][0]])}` : `+${rest.length} more`;
501
+ }
502
+ return { labels: head, tail, edited: items.filter(edited).length, forgotten: items.filter(isForgotten).length,
503
+ dropped: items.filter((i) => i.state === "dropped").length };
504
+ }
505
+
506
+ // A label's text as the footprint and the row draw it: its glyph, text,
507
+ // count ("web/public/ · 9 files").
508
+ export function labelText(label) {
509
+ const glyph = label.glyph ? `${label.glyph} ` : "";
510
+ return `${glyph}${label.text}${label.count ? ` · ${label.count}` : ""}`;
511
+ }
512
+
513
+ // The footprint as plain text (its title, the specs): "touched web/public/
514
+ // · 9 files · ◆ 3 memories s1–5 · +1 url · edited 2".
515
+ export function footprintText(items, budget = BUDGETS.footprint) {
516
+ const fp = footprint(items, budget);
517
+ if (!fp) return "";
518
+ const parts = fp.labels.map((l) => {
519
+ const tag = l.kind === "memory" ? (l.type === "leaf" ? stepTag(l.members[0]) : stepRange(l.members)) : "";
520
+ return `${labelText(l)}${tag ? ` ${tag}` : ""}`;
521
+ });
522
+ if (fp.tail) parts.push(fp.tail);
523
+ if (fp.edited) parts.push(`edited ${fp.edited}`);
524
+ if (fp.forgotten) parts.push(`${fp.forgotten} forgotten`);
525
+ if (fp.dropped) parts.push(`${fp.dropped} dropped`);
526
+ return `touched ${parts.join(" · ")}`;
527
+ }
528
+
529
+ // ── the fresh cue ─────────────────────────────────────────────────────────
530
+
531
+ // An item seen first at +seenAt+ (ms) is fresh until FLASH_MS after it.
532
+ export function isFresh(seenAt, now, ms = FLASH_MS) {
533
+ return Number.isFinite(seenAt) && now - seenAt < ms;
534
+ }
535
+
536
+ // How long until the next fresh cue ends (ms), null when none is on.
537
+ export function nextSettle(seenTimes, now, ms = FLASH_MS) {
538
+ let next = null;
539
+ for (const t of seenTimes) {
540
+ if (!isFresh(t, now, ms)) continue;
541
+ const left = t + ms - now;
542
+ if (next === null || left < next) next = left;
543
+ }
544
+ return next;
545
+ }
546
+
547
+ // ── steering (drop / undrop) ──────────────────────────────────────────────
548
+
549
+ export const STEER_COMMAND = "/working-set";
550
+ const STEER_LINE = /^\/working-set\s+(drop|undrop)\s+(\S.*)$/;
551
+ const URL_WORD = /^[a-z][a-z0-9+.-]*:\/\//i;
552
+ export const INVERSE = Object.freeze({ drop: "undrop", undrop: "drop" });
553
+
554
+ // The word a steer line names +item+ by (WorkingSetSteer.target): a file's
555
+ // absolute path, memory:NAME, a URL (the server's full one for a cut row).
556
+ export function steerWord(item) {
557
+ if (item.kind === "memory") return `memory:${item.key}`;
558
+ if (item.kind === "url") return item.serverKey || item.key;
559
+ return item.key;
560
+ }
561
+
562
+ // The id the page keys an item by ("file:/abs/a.rb"), from a steer word.
563
+ function wordId(word) {
564
+ if (word.startsWith("memory:")) return `memory:${word.slice("memory:".length)}`;
565
+ if (URL_WORD.test(word)) return `url:${word}`;
566
+ return `file:${word}`;
567
+ }
568
+
569
+ // An item's id as steer lines name it.
570
+ export function steerId(item) {
571
+ return wordId(steerWord(item));
572
+ }
573
+
574
+ // The line that does +op+ to +items+ ("/working-set drop /abs/a.rb memory:x").
575
+ export function steerLine(op, items) {
576
+ return `${STEER_COMMAND} ${op} ${items.map(steerWord).join(" ")}`;
577
+ }
578
+
579
+ // A steer line's op and ids ({op, ids}), or null for any other line.
580
+ export function parseSteerLine(line) {
581
+ const m = STEER_LINE.exec(String(line || "").trim());
582
+ if (!m) return null;
583
+ return { op: m[1], ids: m[2].split(/\s+/).filter(Boolean).map(wordId) };
584
+ }
585
+
586
+ // The ops queued for after the running turn (+lines+, in queue order), per
587
+ // item id: "drop" or "undrop". An op followed by its inverse nets to
588
+ // nothing (the worker runs both, the second undoing the first).
589
+ export function pendingSteers(lines) {
590
+ const pending = new Map();
591
+ for (const line of lines) {
592
+ const parsed = parseSteerLine(line);
593
+ if (!parsed) continue;
594
+ for (const id of parsed.ids) {
595
+ if (pending.get(id) === INVERSE[parsed.op]) pending.delete(id);
596
+ else pending.set(id, parsed.op);
597
+ }
598
+ }
599
+ return pending;
600
+ }
601
+
602
+ // Why +item+ can't be dropped (a title for its disabled button), "" when
603
+ // it can, null when it has no drop at all (muted, dropped already, or the
604
+ // server doesn't know it yet: a running call).
605
+ export function dropRefusal(item) {
606
+ if (item.muted || item.state === "dropped") return null;
607
+ if (item.preloaded && !item.ops.includes("read")) return "preloaded into the system prompt: not dropped from here";
608
+ if (!Number.isFinite(item.tokens)) return null;
609
+ if (!item.ops.includes("read")) return "only edited or written: nothing of it to drop";
610
+ if (item.state === "forgotten") return "forgotten: nothing of it is sent";
611
+ return "";
612
+ }