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
@@ -0,0 +1,541 @@
1
+ // The working set's page parts (web.working_set, the stage view): the turns'
2
+ // sets the page keeps (a reload's, then one per hand-off), the footprint
3
+ // line under each finished turn's block, the session bar's "set N" chip,
4
+ // the expanded view (a chip_popover.js popover; a bottom sheet on a phone)
5
+ // and the step tags' jump to a call. The rail is working_set_rail.js's; the
6
+ // rules are working_set.js's.
7
+ //
8
+ // Steering (P3a: drop / undrop): the list's rows and groups post
9
+ // `/working-set drop|undrop KEY…` lines (+sendLine+, the session command
10
+ // route). Between turns one runs at once: its command_ran re-reads the
11
+ // conversation and the re-emitted set strikes the item `dropped`. While a
12
+ // turn runs it is queued: its command_queued (waits: "turn_end"; any
13
+ // client's, and the snapshot's replay after a reload) makes it pending, a
14
+ // dashed-warn row with a pill and an undo (the inverse line, queued after
15
+ // it), until its command_ran. On a phone's sheet a tap on a row opens its
16
+ // action strip under it, one at a time.
17
+ //
18
+ // The server's projection (WorkingSet: a :working_set_updated event, a GET
19
+ // working_set when a session's set is seeded and when the expanded view
20
+ // opens) is one session-wide snapshot: an event always taken, a GET's
21
+ // answer unless an event overtook it (working_set.js newerSnapshot); every form
22
+ // overlays its state and tokens on the rows' items by key (working_set.js
23
+ // overlay), so a state is the item's now, not then. A new snapshot redraws
24
+ // them all: the chip, the open popover, the footprints in place and the rail
25
+ // (onServer).
26
+ //
27
+ // A row's place is ordinals, stamped on the DOM: data-turn on a turn's
28
+ // .turn-work block (its place in the page's turns) and data-at="step.row"
29
+ // on its tool rows, live (stampRows) and reloaded (stampHistory); a jump
30
+ // finds the row by them, never by a row key.
31
+ import { html, nothing, render, svg } from "./vendor/lit-html.js";
32
+ import { createChipPopover } from "./chip_popover.js";
33
+ import { showRow } from "./refs.js";
34
+ import {
35
+ BUDGETS, dropRefusal, fold, footprint, footprintText, GLYPHS, INVERSE, isFresh, itemsOfTurn, labelText, labelTitle, newerSnapshot,
36
+ opsText, overlay, parseSteerLine, pendingSteers, sessionItems, steerId, steerLine, stepRange, stepTag, tokensText,
37
+ } from "./working_set.js";
38
+
39
+ // The popover's files fold to this many lines (groups open in place, one at
40
+ // a time), its memories past this many to one group.
41
+ const POP_BUDGET = 6;
42
+
43
+ // A reloaded turn's items: its steps (timing.js turnGroups) as gens.
44
+ export function groupItems(group, ordinal) {
45
+ return itemsOfTurn({ gens: (group.steps || []).map((step) => ({ tools: step.tools || [] })) }, ordinal);
46
+ }
47
+
48
+ // The live turn's block and rows stamped with their ordinals (+turn+: the
49
+ // turn's model; new rows only).
50
+ export function stampRows(blockEl, turn, ordinal) {
51
+ if (!blockEl || !turn) return;
52
+ if (blockEl.dataset.turn !== String(ordinal)) blockEl.dataset.turn = String(ordinal);
53
+ turn.gens.forEach((gen, step) => {
54
+ gen.tools.forEach((row, i) => {
55
+ const el = blockEl.querySelector(`.activity-row[data-key="${String(row.key).replace(/"/g, '\\"')}"]`);
56
+ if (el && !el.dataset.at) el.dataset.at = `${step}.${i}`;
57
+ });
58
+ });
59
+ }
60
+
61
+ // A reloaded history's blocks stamped: +blocks+ the .turn-work elements,
62
+ // +ordinals+ each one's turn.
63
+ export function stampHistory(blocks, ordinals) {
64
+ blocks.forEach((block, i) => {
65
+ block.dataset.turn = String(ordinals[i]);
66
+ [...block.querySelectorAll(":scope > .gen")].forEach((gen, step) => {
67
+ [...gen.querySelectorAll(":scope > .activity-body > .activity-row")].forEach((row, j) => { row.dataset.at = `${step}.${j}`; });
68
+ });
69
+ });
70
+ }
71
+
72
+ function atAttr(at) {
73
+ return `${at.turn}.${at.step}.${at.row}`;
74
+ }
75
+
76
+ // A label's step tag in the footprint: a memory's step, a memory group's
77
+ // range (it jumps to the first); none for files and URLs.
78
+ function footTag(label) {
79
+ if (label.kind !== "memory") return nothing;
80
+ const first = label.members.find((m) => m.at);
81
+ if (!first) return nothing;
82
+ const text = label.type === "leaf" ? stepTag(first) : stepRange(label.members);
83
+ const title = label.type === "leaf" ? `entered at step ${first.at.step + 1}: go to the call` : `entered at ${text}: go to the first call`;
84
+ return html` <button type="button" class="ws-step p" data-ws="jump" data-at=${atAttr(first.at)} title=${title}>${text}</button>`;
85
+ }
86
+
87
+ // The footprint line of a finished turn's +items+ (turn +ordinal+), nothing
88
+ // for a turn that touched nothing: "touched web/public/ · 9 files · ◆ 3
89
+ // memories s1–5 · +1 url · edited 2". A click opens the expanded view at
90
+ // that turn; a step tag jumps to its call.
91
+ export function footprintTemplate(items, ordinal) {
92
+ const fp = footprint(items, BUDGETS.footprint);
93
+ if (!fp) return nothing;
94
+ const sep = html` <span class="sep">·</span> `;
95
+ const parts = fp.labels.map((l, i) => html`${i ? sep : nothing}<span class=${`l ${l.kind}${l.state && l.state !== "live" ? ` ${l.state}` : ""}`} title=${labelTitle(l)}>${l.glyph ? html`<span class="k">${l.glyph}</span> ` : nothing}${labelText({ ...l, glyph: "" })}</span>${footTag(l)}`);
96
+ const text = footprintText(items, BUDGETS.footprint);
97
+ return html`<div class="ws-foot" role="button" tabindex="0" data-ws-turn=${ordinal} data-ws-sig=${footSig(items)} aria-label=${`Working set of this turn: ${text.replace(/^touched /, "")}. Open the list.`}><span class="w">touched</span> ${parts}${fp.tail ? html`${sep}<span class="w">${fp.tail}</span>` : nothing}${fp.edited ? html`${sep}<span class="e">edited ${fp.edited}</span>` : nothing}${fp.forgotten ? html`${sep}<span class="f">${fp.forgotten} forgotten</span>` : nothing}${fp.dropped ? html`${sep}<span class="f">${fp.dropped} dropped</span>` : nothing}</div>`;
98
+ }
99
+
100
+ // What a footprint shows that can change after it is drawn: its items'
101
+ // states (the server's). The same signature draws nothing.
102
+ function footSig(items) {
103
+ return items.map((i) => `${i.kind}:${i.key}:${i.state || "live"}`).join("|");
104
+ }
105
+
106
+ // The footprint as an element (the hand-off's), or null.
107
+ export function footprintNode(items, ordinal) {
108
+ const template = footprintTemplate(items, ordinal);
109
+ if (template === nothing) return null;
110
+ const frag = document.createDocumentFragment();
111
+ render(template, frag);
112
+ return frag.querySelector(".ws-foot");
113
+ }
114
+
115
+ function ringTemplate(pct) {
116
+ if (!Number.isFinite(pct)) return nothing;
117
+ const value = Math.max(0, Math.min(100, Math.round(pct)));
118
+ const len = 2 * Math.PI * 5.5;
119
+ const bucket = value >= 90 ? " danger" : value >= 70 ? " warn" : "";
120
+ return html`<svg class=${`ws-ring${bucket}`} viewBox="0 0 14 14" role="img">${svg`<title>context ${value}% used</title><circle class="track" cx="7" cy="7" r="5.5"></circle><circle class="fill" cx="7" cy="7" r="5.5" stroke-dasharray=${`${(len * value) / 100} ${len}`}></circle>`}</svg>`;
121
+ }
122
+
123
+ // A group's member as the popover names it: its path below the group's
124
+ // directory ("adapters/by_api.rb"), else its leaf text.
125
+ function memberName(item, group) {
126
+ if (item.kind === "file" && group?.dir) return String(item.path || item.key).slice(group.dir.length + 1);
127
+ return item.label;
128
+ }
129
+
130
+ // +historyEl+: where finished turns are; +stageRoot()+: the stage (the live
131
+ // turn's block); +state()+: the open session's fields (session_state.js:
132
+ // ctxPct and the preloaded, muted and used memories); +session()+: its id;
133
+ // +infoEl+: the session bar's line (the chip's popover is delegated there);
134
+ // +onChange()+: the chip's count or dot changed (app.js updateInfoBar).
135
+ // +getWorkingSet(id)+: the server's projection (data.js; null: none asked).
136
+ // +sendLine(line)+: posts a steer line as the session's command (null: no
137
+ // steering, the list reads only).
138
+ export function createWorkingSet({ historyEl, stageRoot = () => null, state, session = () => null, infoEl, onChange = () => {}, getWorkingSet = null,
139
+ sendLine = null, doc = document }) {
140
+ // The finished turns' sets ({items, prompt}), the running turn's (null:
141
+ // none), the rail's first-sight times (its fresh cue), the session count
142
+ // at the last open (the chip's dot).
143
+ let turns = [];
144
+ let live = null;
145
+ let seenAt = () => undefined;
146
+ let openedCount = 0;
147
+ // The open view: its scope ("turn" / "session"), its turn, the group open
148
+ // in it.
149
+ let view = { scope: "session", turn: null };
150
+ let openGroup = null;
151
+ // The server's snapshot ({items, at}) and the session it is of; what
152
+ // redraws the rail when it changes. +events+ counts the events taken, so
153
+ // a GET knows whether one came while it was on its way.
154
+ let server = null;
155
+ let serverOf = null;
156
+ let events = 0;
157
+ // The steer lines queued for after the running turn, by command id, and
158
+ // the session they are of; the row whose action strip is open (a sheet).
159
+ const queued = new Map();
160
+ let queuedOf = null;
161
+ let stripOpen = null;
162
+ const listeners = new Set();
163
+ const lit = (items) => overlay(items, server);
164
+
165
+ const sessionSet = () => {
166
+ const st = state();
167
+ const set = sessionItems({
168
+ turns: turns.map((t) => t.items), live: live?.items || [], preloaded: st.preloadedMemories, muted: st.mutedMemories,
169
+ used: st.usedMemories,
170
+ });
171
+ return { items: lit(set.items), muted: set.muted };
172
+ };
173
+ const turnItems = (t) => lit(live && t === live.ordinal ? live.items : turns[t]?.items || []);
174
+ const promptOf = (t) => (live && t === live.ordinal ? live.prompt : turns[t]?.prompt || "");
175
+
176
+ const popover = createChipPopover({
177
+ id: "workingSetPopover", className: "ctx-popover ws-popover", width: 420, el: infoEl, anchor: ".ws-chip",
178
+ openers: ".ws-chip, .ts-ws, .ws-foot", sheet: true,
179
+ // The chip opens the session's scope; the rail and a footprint set
180
+ // theirs before they open it.
181
+ summary: (anchor) => {
182
+ if (anchor?.matches?.(".ws-chip")) {
183
+ view = { scope: "session", turn: live ? live.ordinal : turns.length - 1 };
184
+ // Opened: the chip's dot goes (chip()).
185
+ queueMicrotask(onChange);
186
+ }
187
+ return { view };
188
+ },
189
+ session,
190
+ // Opened: the server's set asked again (a file changed while the
191
+ // session sat idle shows stale).
192
+ show: (_data, opening) => {
193
+ draw();
194
+ if (opening) fetchServer();
195
+ },
196
+ onClose: () => { openGroup = null; onChange(); },
197
+ });
198
+
199
+ const pending = () => pendingSteers([...queued.values()]);
200
+
201
+ // A steer button: +op+ on +items+ ("undo" sends the inverse of a pending op).
202
+ function actButton(op, items, { label = op, title = "", disabled = false } = {}) {
203
+ const ids = items.map(steerId).join(" ");
204
+ return html`<button type="button" class=${`ws-act-b ${op}`} data-ws="steer" data-op=${op} data-ids=${ids} title=${title} ?disabled=${disabled}>${label}</button>`;
205
+ }
206
+
207
+ // What a row offers: a pending op's pill and undo, an undrop, a drop (or
208
+ // why not); nothing for an item no op applies to.
209
+ function rowActions(item, pend) {
210
+ if (!sendLine) return { pill: nothing, buttons: nothing };
211
+ const op = pend.get(steerId(item));
212
+ if (op) {
213
+ const pill = html`<span class="ws-pend" title="applies at the next turn's start">${op === "drop" ? "drops next turn" : "back next turn"}</span>`;
214
+ return { pill, buttons: actButton(INVERSE[op], [item], { label: "undo", title: `take back the queued ${op}` }) };
215
+ }
216
+ if (item.state === "dropped") {
217
+ return { pill: nothing, buttons: actButton("undrop", [item], { title: "send it whole again from the next request (a cache break)" }) };
218
+ }
219
+ const why = dropRefusal(item);
220
+ if (why === null) return { pill: nothing, buttons: nothing };
221
+ return { pill: nothing, buttons: actButton("drop", [item], { title: why || "send a stub instead of its reads from the next turn's start", disabled: !!why }) };
222
+ }
223
+
224
+ function rowTemplate(item, { session: inSession, group = null, now, pend = new Map() }) {
225
+ const fresh = isFresh(seenAt(item), now);
226
+ const tag = stepTag(item, { session: inSession });
227
+ const words = `${opsText(item)}${fresh ? " · just now" : ""}`;
228
+ const glyph = item.muted ? GLYPHS.muted : fresh ? "●" : GLYPHS[item.kind] || "";
229
+ const name = memberName(item, group);
230
+ const full = item.kind === "file" ? item.path || item.key : item.kind === "url" ? item.url || item.key : item.key;
231
+ let nameEl;
232
+ if (item.kind === "file") {
233
+ const ref = item.ref || { path: full };
234
+ nameEl = html`<span class="t ref" data-ref-kind="file" data-ref-path=${ref.path || full} data-ref-line=${ref.line || nothing} data-ref-end-line=${ref.end_line || nothing} title=${full}>${name}</span>`;
235
+ } else if (item.kind === "url" && /^https?:\/\//i.test(full)) {
236
+ nameEl = html`<a class="t" href=${full} target="_blank" rel="noopener noreferrer" title=${full}>${name}</a>`;
237
+ } else {
238
+ nameEl = html`<span class="t" title=${full}>${name}</span>`;
239
+ }
240
+ let tagEl = nothing;
241
+ if (tag === "prompt") tagEl = html`<span class="ws-step p" title="preloaded into the system prompt">prompt</span>`;
242
+ else if (tag) {
243
+ const prompt = inSession ? promptOf(item.at.turn) : "";
244
+ const where = inSession ? `turn ${item.at.turn + 1}${prompt ? ` (${prompt})` : ""}, step ${item.at.step + 1}` : `step ${item.at.step + 1}`;
245
+ tagEl = html`<button type="button" class=${`ws-step${item.kind === "memory" ? " p" : ""}`} data-ws="jump" data-at=${atAttr(item.at)} title=${`entered at ${where}: go to the call`}>${tag}</button>`;
246
+ }
247
+ const edited = item.kind === "file" && item.ops.includes("edit");
248
+ const state = item.state && item.state !== "live" ? ` ${item.state}` : "";
249
+ const tokens = tokensText(item.tokens);
250
+ const id = steerId(item);
251
+ const { pill, buttons } = rowActions(item, pend);
252
+ const isPending = pill !== nothing;
253
+ const strip = stripOpen === id && buttons !== nothing;
254
+ const open = item.kind === "file" ? html`<button type="button" class="ws-act-b open ref" data-ref-kind="file" data-ref-path=${item.ref?.path || full}>open</button>` : nothing;
255
+ const row = html`<div class=${`ws-row ${item.kind}${fresh ? " fresh" : ""}${item.muted ? " muted" : ""}${group ? " member" : ""}${state}${isPending ? " pending" : ""}${strip ? " strip-open" : ""}`} data-ws-id=${id}><span class="k">${glyph}</span>${nameEl}${edited ? html`<span class="e">✎</span>` : nothing}<span class="st">${words}</span>${pill}${tokens ? html`<span class="tk" title=${`about ${item.tokens} tokens of it are sent`}>${tokens}</span>` : nothing}${tagEl}${buttons !== nothing ? html`<span class="ws-act">${buttons}</span>` : nothing}</div>`;
256
+ // One template either way, so the tapped row stays the same node (a
257
+ // click outside the popover closes it).
258
+ return html`${row}${strip ? html`<div class="ws-strip">${buttons}${open}</div>` : nothing}`;
259
+ }
260
+
261
+ // A section's labels: leaves as rows, groups as an opener row and, open,
262
+ // their members.
263
+ function labelsTemplate(labels, opts) {
264
+ return labels.map((label) => {
265
+ if (label.type === "leaf") return rowTemplate(label.members[0], opts);
266
+ const open = openGroup === label.id;
267
+ const state = label.state && label.state !== "live" ? ` ${label.state}` : "";
268
+ const head = html`<button type="button" class=${`ws-row ws-group-head ${label.kind}${state}`} data-ws="group" data-id=${label.id} aria-expanded=${String(open)} title=${labelTitle(label)}><span class="k">${open ? "▾" : "▸"}</span><span class="t">${label.glyph ? `${label.glyph} ` : ""}${label.text}</span>${label.count ? html`<span class="n">· ${label.count}</span>` : nothing}${label.edited ? html`<span class="e">✎${label.edited}</span>` : nothing}${label.cut ? html`<span class="c" title=${`${label.cut} cut (forgotten or dropped)`}>✂${label.cut}</span>` : nothing}<span class="chev">${open ? "close" : "open"}</span></button>`;
269
+ // A group drops its members that can be, not pending already.
270
+ const droppable = sendLine ? label.members.filter((m) => dropRefusal(m) === "" && !opts.pend.has(steerId(m))) : [];
271
+ const act = droppable.length ? html`<span class="ws-act">${actButton("drop", droppable, { label: `drop ${droppable.length}`, title: "send stubs instead of their reads from the next turn's start" })}</span>` : nothing;
272
+ return html`<div class="ws-ghead">${head}${act}</div>${open ? label.members.map((m) => rowTemplate(m, { ...opts, group: label })) : nothing}`;
273
+ });
274
+ }
275
+
276
+ function draw() {
277
+ const now = Date.now();
278
+ const st = state();
279
+ const inSession = view.scope === "session";
280
+ const set = inSession ? sessionSet() : { items: turnItems(view.turn), muted: [] };
281
+ const files = set.items.filter((i) => i.kind === "file");
282
+ const memories = set.items.filter((i) => i.kind === "memory");
283
+ const urls = set.items.filter((i) => i.kind === "url");
284
+ const pend = pending();
285
+ const opts = { session: inSession, now, pend };
286
+ const fileLabels = fold(files, POP_BUDGET).labels;
287
+ // Memories are rows of their own; past the budget one group that opens.
288
+ const memoryLabels = fold(memories, memories.length > POP_BUDGET ? 1 : Infinity).labels;
289
+ const preloaded = memories.filter((m) => m.preloaded).length;
290
+ const memMeta = [`${memories.length - preloaded} used`, preloaded ? `${preloaded} preloaded` : "", set.muted.length ? `${set.muted.length} muted` : ""].filter(Boolean).join(" · ");
291
+ const turnLabel = view.turn !== null && view.turn >= 0 ? `turn ${view.turn + 1}` : "this turn";
292
+ const pct = Number.isFinite(st.ctxPct) ? ` · ctx ${Math.round(st.ctxPct)} %` : "";
293
+ const empty = !set.items.length && !set.muted.length;
294
+ const tpl = html`<div class="ctx-pop-head"><strong>working set</strong><span class="ws-scope" role="group" aria-label="Scope"><button type="button" class=${!inSession ? "on" : ""} aria-pressed=${String(!inSession)} data-ws="scope" data-scope="turn" ?disabled=${view.turn === null || view.turn < 0}>${turnLabel}</button><button type="button" class=${inSession ? "on" : ""} aria-pressed=${String(inSession)} data-ws="scope" data-scope="session">session</button></span><span class="ctx-pop-kind">${set.items.length} item${set.items.length === 1 ? "" : "s"}${pct}</span>${ringTemplate(st.ctxPct)}<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>
295
+ ${empty ? html`<div class="ctx-pop-meta">Nothing yet: the files, memories and URLs ${inSession ? "this session's turns read" : "this turn reads"} show here.</div>` : nothing}
296
+ ${files.length ? html`<div class="ctx-pop-meta">files · ${files.length}</div>${labelsTemplate(fileLabels, opts)}` : nothing}
297
+ ${memories.length || set.muted.length ? html`<div class="ctx-pop-meta">memories${memMeta ? ` · ${memMeta}` : ""}</div>${labelsTemplate(memoryLabels, opts)}${set.muted.map((m) => rowTemplate(m, opts))}` : nothing}
298
+ ${urls.length ? html`<div class="ctx-pop-meta">urls · ${urls.length}</div>${labelsTemplate(fold(urls, POP_BUDGET).labels, opts)}` : nothing}
299
+ ${pend.size ? html`<div class="ws-pending" role="status">${pend.size} change${pend.size === 1 ? "" : "s"} pending · apply at the next turn's start · <button type="button" class="ws-act-b" data-ws="undo-all">undo all</button></div>` : nothing}
300
+ <div class="ctx-pop-meta ws-hint">A file opens its ref; a step tag goes to the call.${sendLine ? " Drop sends stubs instead of an item's reads from the next turn." : ""} Esc closes.</div>`;
301
+ render(tpl, popover.pop);
302
+ }
303
+
304
+ // The row at +at+ ({turn, step, row}): the stage's block for the live
305
+ // turn, else the history's.
306
+ function rowAt(at) {
307
+ const sel = `.turn-work[data-turn="${at.turn}"]`;
308
+ const blocks = [...(stageRoot()?.querySelectorAll(sel) || []), ...[...historyEl.querySelectorAll(sel)].reverse()];
309
+ for (const block of blocks) {
310
+ const row = block.querySelector(`.activity-row[data-at="${at.step}.${at.row}"]`);
311
+ if (row) return row;
312
+ }
313
+ return null;
314
+ }
315
+
316
+ function jump(at) {
317
+ const row = rowAt(at);
318
+ if (!row) return false;
319
+ if (popover.isOpen()) popover.close();
320
+ showRow(row);
321
+ return true;
322
+ }
323
+
324
+ const parseAt = (text) => {
325
+ const [turn, step, row] = String(text).split(".").map(Number);
326
+ return [turn, step, row].every(Number.isInteger) ? { turn, step, row } : null;
327
+ };
328
+
329
+ // Posts +op+ for the ids (the items' steer words, from the set).
330
+ function steer(op, ids) {
331
+ if (!sendLine || !ids.length) return;
332
+ const all = [...sessionSet().items, ...turnItems(view.turn)];
333
+ const byId = new Map(all.map((it) => [steerId(it), it]));
334
+ const items = ids.map((id) => byId.get(id)).filter(Boolean);
335
+ if (items.length) Promise.resolve(sendLine(steerLine(op, items))).catch(() => {});
336
+ }
337
+
338
+ popover.pop.addEventListener("click", (e) => {
339
+ const act = e.target.closest("[data-ws]");
340
+ // A sheet's row: a tap outside its links and buttons opens its strip.
341
+ if (!act && popover.pop.classList.contains("sheet")) {
342
+ const row = e.target.closest(".ws-row[data-ws-id]");
343
+ if (row && !e.target.closest("a, button, .ref")) {
344
+ stripOpen = stripOpen === row.dataset.wsId ? null : row.dataset.wsId;
345
+ draw();
346
+ popover.replace();
347
+ }
348
+ return;
349
+ }
350
+ if (!act) return;
351
+ if (act.dataset.ws === "steer") {
352
+ steer(act.dataset.op, act.dataset.ids.split(" ").filter(Boolean));
353
+ } else if (act.dataset.ws === "undo-all") {
354
+ const pend = pending();
355
+ for (const op of ["drop", "undrop"]) {
356
+ const ids = [...pend].filter(([, o]) => o === op).map(([id]) => id);
357
+ steer(INVERSE[op], ids);
358
+ }
359
+ } else if (act.dataset.ws === "jump") {
360
+ const at = parseAt(act.dataset.at);
361
+ if (at) jump(at);
362
+ } else if (act.dataset.ws === "scope") {
363
+ view = { ...view, scope: act.dataset.scope };
364
+ openGroup = null;
365
+ draw();
366
+ popover.replace();
367
+ } else if (act.dataset.ws === "group") {
368
+ openGroup = openGroup === act.dataset.id ? null : act.dataset.id;
369
+ draw();
370
+ popover.replace();
371
+ }
372
+ });
373
+
374
+ // A footprint: its step tag jumps, the rest opens that turn's view.
375
+ function footClick(e) {
376
+ const foot = e.target.closest?.(".ws-foot");
377
+ if (!foot) return;
378
+ const tag = e.target.closest(".ws-step[data-at]");
379
+ if (tag) {
380
+ const at = parseAt(tag.dataset.at);
381
+ if (at) jump(at);
382
+ return;
383
+ }
384
+ if (popover.isOpen() && popover.opener() === foot) return popover.close();
385
+ view = { scope: "turn", turn: Number(foot.dataset.wsTurn) };
386
+ popover.open(foot);
387
+ }
388
+ doc.addEventListener("click", footClick);
389
+ doc.addEventListener("keydown", (e) => {
390
+ if ((e.key === "Enter" || e.key === " ") && e.target.matches?.(".ws-foot")) {
391
+ e.preventDefault();
392
+ footClick(e);
393
+ }
394
+ });
395
+
396
+ function changed() {
397
+ onChange();
398
+ if (popover.isOpen()) popover.refresh();
399
+ }
400
+
401
+ // The footprints on the page (the history's, the hand-off's) drawn again
402
+ // in place when their items' states changed: the same node (its focus,
403
+ // the popover's opener), its children and label replaced.
404
+ function refreshFootprints() {
405
+ for (const root of [historyEl, stageRoot()]) {
406
+ for (const foot of root?.querySelectorAll(".ws-foot[data-ws-turn]") || []) {
407
+ const ordinal = Number(foot.dataset.wsTurn);
408
+ if (!Number.isInteger(ordinal)) continue;
409
+ const items = turnItems(ordinal);
410
+ if (!items.length || foot.dataset.wsSig === footSig(items)) continue;
411
+ const next = footprintNode(items, ordinal);
412
+ if (!next) continue;
413
+ foot.replaceChildren(...next.childNodes);
414
+ foot.setAttribute("aria-label", next.getAttribute("aria-label"));
415
+ foot.dataset.wsSig = next.dataset.wsSig;
416
+ }
417
+ }
418
+ }
419
+
420
+ // A newer server snapshot redraws every form; an older one (a GET that
421
+ // answered after a newer event) is dropped. +how+: newerSnapshot's.
422
+ function takeServer(snapshot, how) {
423
+ const next = newerSnapshot(server, snapshot, how);
424
+ if (next === server) return;
425
+ server = next;
426
+ serverOf = session();
427
+ changed();
428
+ refreshFootprints();
429
+ for (const fn of listeners) fn();
430
+ }
431
+
432
+ // The server's set for the open session, asked again; an answer for a
433
+ // session no longer open is dropped.
434
+ function fetchServer() {
435
+ const id = session();
436
+ if (!getWorkingSet || !id) return;
437
+ const asked = events;
438
+ Promise.resolve()
439
+ .then(() => getWorkingSet(id))
440
+ .then((snapshot) => { if (session() === id) takeServer(snapshot, { overtaken: events !== asked }); })
441
+ .catch(() => {});
442
+ }
443
+
444
+ return {
445
+ // A session's history drawn anew: its turns' sets (+list+: [{items,
446
+ // prompt}]), no running turn yet; the chip's dot starts off.
447
+ seed(list) {
448
+ turns = list.slice();
449
+ live = null;
450
+ // The snapshot's replay (after this) brings the queued lines again.
451
+ queued.clear();
452
+ queuedOf = session();
453
+ stripOpen = null;
454
+ // Another session's snapshot goes; the same session's (a resync, after
455
+ // a steer from the list) stays until the newer one comes, and so does
456
+ // the open list.
457
+ if (serverOf !== session()) {
458
+ if (popover.isOpen()) popover.close();
459
+ server = null;
460
+ serverOf = session();
461
+ } else if (popover.isOpen()) {
462
+ if (view.turn !== null && view.turn >= turns.length) view = { scope: "session", turn: turns.length - 1 };
463
+ popover.refresh();
464
+ }
465
+ openedCount = sessionSet().items.length;
466
+ refreshFootprints();
467
+ fetchServer();
468
+ },
469
+ // A :working_set_updated event's set ({items, at}).
470
+ update(snapshot) {
471
+ events += 1;
472
+ takeServer(snapshot, { event: true });
473
+ },
474
+ // +fn()+ runs after each new server snapshot (the rail's redraw).
475
+ onServer(fn) { listeners.add(fn); },
476
+ // A command queued for after the running turn (its command_queued,
477
+ // waits "turn_end"): a steer line is pending until its command_ran.
478
+ commandQueued(event) {
479
+ if (queuedOf !== session()) {
480
+ queued.clear();
481
+ queuedOf = session();
482
+ }
483
+ if (event?.waits !== "turn_end" || !event.command_id || !parseSteerLine(event.line)) return;
484
+ queued.set(event.command_id, event.line);
485
+ changed();
486
+ },
487
+ // A command ran (or was dropped): its line is no longer pending.
488
+ commandRan(event) {
489
+ if (event?.command_id && queued.delete(event.command_id)) changed();
490
+ },
491
+ // The steer lines pending (for the specs and the rail).
492
+ pending: () => pending(),
493
+ // The running turn's items (its model +turn+), its ordinal the next
494
+ // turn's; the chip follows.
495
+ liveItems(turn, prompt = "") {
496
+ const ordinal = turns.length;
497
+ const items = itemsOfTurn(turn, ordinal);
498
+ const before = live?.items.length ?? -1;
499
+ live = { ordinal, items, prompt };
500
+ if (items.length !== before) changed();
501
+ return { items: lit(items), ordinal };
502
+ },
503
+ // The rail's first-sight times, for the popover's fresh rows.
504
+ setSeen(fn) { seenAt = fn; },
505
+ // The running turn hands off: its set joins the turns; its footprint
506
+ // line (null: it touched nothing) goes after its block.
507
+ handOff(turn, prompt = "") {
508
+ const ordinal = turns.length;
509
+ const items = itemsOfTurn(turn, ordinal);
510
+ turns.push({ items, prompt });
511
+ live = null;
512
+ changed();
513
+ return footprintNode(lit(items), ordinal);
514
+ },
515
+ // The running turn left without a hand-off (a reset): forgotten.
516
+ release() {
517
+ if (!live) return;
518
+ live = null;
519
+ changed();
520
+ },
521
+ // The rail opens the running turn's view.
522
+ openTurn(anchor) {
523
+ if (popover.isOpen() && popover.opener() === anchor) return popover.close();
524
+ view = { scope: "turn", turn: live ? live.ordinal : turns.length - 1 };
525
+ popover.open(anchor);
526
+ openedCount = sessionSet().items.length;
527
+ onChange();
528
+ },
529
+ // The session bar's chip: {n, changed} (the dot), null when the set is
530
+ // empty (no chip). Opening it clears the dot.
531
+ chip() {
532
+ const n = sessionSet().items.length;
533
+ if (popover.isOpen()) openedCount = n;
534
+ return n ? { n, changed: n !== openedCount } : null;
535
+ },
536
+ jump,
537
+ ctxPct: () => state().ctxPct,
538
+ isOpen: () => popover.isOpen(),
539
+ popover,
540
+ };
541
+ }
@@ -66,7 +66,8 @@ module Samagotchi
66
66
  # @return [Integer] the exit status
67
67
  def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false,
68
68
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
69
- editor: Config::BY_KEY["web.editor"].default, new_token: false)
69
+ editor: Config::BY_KEY["web.editor"].default, new_token: false,
70
+ working_set: Config::BY_KEY["web.working_set"].default)
70
71
  rotate_token if new_token
71
72
  setting = host_setting(host)
72
73
  lan = Lan.wanted?(setting) ? Lan.choose(setting) : nil
@@ -95,7 +96,7 @@ module Samagotchi
95
96
  return 0
96
97
  end
97
98
  if start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
98
- annotate_presets: annotate_presets, editor: editor, lan: lan)
99
+ annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set)
99
100
  0
100
101
  else
101
102
  1
@@ -119,7 +120,8 @@ module Samagotchi
119
120
  # @return [Boolean] false when the port was taken (said so on stderr)
120
121
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
121
122
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
122
- editor: Config::BY_KEY["web.editor"].default, token_path: Token.path)
123
+ editor: Config::BY_KEY["web.editor"].default, token_path: Token.path,
124
+ working_set: Config::BY_KEY["web.working_set"].default)
123
125
  config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
124
126
  Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
125
127
  end
@@ -133,7 +135,7 @@ module Samagotchi
133
135
  lan_option = { ip: lan.ip, token: Token::Source.new(token_path) }
134
136
  end
135
137
  app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
136
- annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option)
138
+ annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set)
137
139
  Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
138
140
  Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
139
141
  # Subscribed before the first scan, which makes the first check.
@@ -259,7 +259,7 @@ module Samagotchi
259
259
  })
260
260
  # A recap written while a continue offer waits says the turn stopped
261
261
  # unfinished (before the idle jobs start).
262
- @engine.recap&.awaiting_continue = -> { @turn_flow.awaiting_continue? }
262
+ @engine.recap_awaiting_continue = -> { @turn_flow.awaiting_continue? }
263
263
  # The seq of the last turn's end event: a command queued before it was
264
264
  # queued while that turn ran.
265
265
  @turn_end_seq = 0
@@ -943,14 +943,17 @@ module Samagotchi
943
943
  # finishes) comes after its command_queued. The cards it shows go out at
944
944
  # once (btw's "thinking…" card), after its command_queued, which the
945
945
  # UIs draw its line at (Engine#running_anytime). Its handler reads
946
- # copies (ctx.messages) and shows things through ctx only. One saves:
947
- # /title, a field no turn writes, through Session#save, which takes the
948
- # session's lock and writes a running turn's checkpoint (as
949
- # WorkerSignals' save off the turn thread does), so the turn's own saves
950
- # keep it.
946
+ # copies (ctx.messages) and shows things through ctx only. One that
947
+ # changed the session (/title, /recap off: fields no turn writes) is
948
+ # saved here, as a queued one is (#run_command), through Session#save,
949
+ # which takes the session's lock and writes a running turn's checkpoint
950
+ # (as WorkerSignals' save off the turn thread does), so the turn's own
951
+ # saves keep it.
951
952
  def start_anytime_command(command)
952
953
  @engine.spawn_anytime do
953
954
  result = command_result(command) { @engine.running_anytime { @commands.run(command.line, confirm: confirm?(command)) } }
955
+ # Saved before its command_ran: a client that reads the file on the reply finds it.
956
+ save_or_log(:command) { save_session } unless Array(result.changed).empty?
954
957
  announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
955
958
  anytime: true, confirm: result.confirm)
956
959
  rescue StandardError => e