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,426 @@
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
+ // The server's projection (WorkingSet: a :working_set_updated event, a GET
9
+ // working_set when a session's set is seeded and when the expanded view
10
+ // opens) is one session-wide snapshot, the newer by its at kept; every form
11
+ // overlays its state and tokens on the rows' items by key (working_set.js
12
+ // overlay), so a state is the item's now, not then. A new snapshot redraws
13
+ // them all: the chip, the open popover, the footprints in place and the rail
14
+ // (onServer).
15
+ //
16
+ // A row's place is ordinals, stamped on the DOM: data-turn on a turn's
17
+ // .turn-work block (its place in the page's turns) and data-at="step.row"
18
+ // on its tool rows, live (stampRows) and reloaded (stampHistory); a jump
19
+ // finds the row by them, never by a row key.
20
+ import { html, nothing, render, svg } from "./vendor/lit-html.js";
21
+ import { createChipPopover } from "./chip_popover.js";
22
+ import { showRow } from "./refs.js";
23
+ import {
24
+ BUDGETS, fold, footprint, footprintText, GLYPHS, isFresh, itemsOfTurn, labelText, labelTitle, newerSnapshot, opsText, overlay,
25
+ sessionItems, stepRange, stepTag, tokensText,
26
+ } from "./working_set.js";
27
+
28
+ // The popover's files fold to this many lines (groups open in place, one at
29
+ // a time), its memories past this many to one group.
30
+ const POP_BUDGET = 6;
31
+
32
+ // A reloaded turn's items: its steps (timing.js turnGroups) as gens.
33
+ export function groupItems(group, ordinal) {
34
+ return itemsOfTurn({ gens: (group.steps || []).map((step) => ({ tools: step.tools || [] })) }, ordinal);
35
+ }
36
+
37
+ // The live turn's block and rows stamped with their ordinals (+turn+: the
38
+ // turn's model; new rows only).
39
+ export function stampRows(blockEl, turn, ordinal) {
40
+ if (!blockEl || !turn) return;
41
+ if (blockEl.dataset.turn !== String(ordinal)) blockEl.dataset.turn = String(ordinal);
42
+ turn.gens.forEach((gen, step) => {
43
+ gen.tools.forEach((row, i) => {
44
+ const el = blockEl.querySelector(`.activity-row[data-key="${String(row.key).replace(/"/g, '\\"')}"]`);
45
+ if (el && !el.dataset.at) el.dataset.at = `${step}.${i}`;
46
+ });
47
+ });
48
+ }
49
+
50
+ // A reloaded history's blocks stamped: +blocks+ the .turn-work elements,
51
+ // +ordinals+ each one's turn.
52
+ export function stampHistory(blocks, ordinals) {
53
+ blocks.forEach((block, i) => {
54
+ block.dataset.turn = String(ordinals[i]);
55
+ [...block.querySelectorAll(":scope > .gen")].forEach((gen, step) => {
56
+ [...gen.querySelectorAll(":scope > .activity-body > .activity-row")].forEach((row, j) => { row.dataset.at = `${step}.${j}`; });
57
+ });
58
+ });
59
+ }
60
+
61
+ function atAttr(at) {
62
+ return `${at.turn}.${at.step}.${at.row}`;
63
+ }
64
+
65
+ // A label's step tag in the footprint: a memory's step, a memory group's
66
+ // range (it jumps to the first); none for files and URLs.
67
+ function footTag(label) {
68
+ if (label.kind !== "memory") return nothing;
69
+ const first = label.members.find((m) => m.at);
70
+ if (!first) return nothing;
71
+ const text = label.type === "leaf" ? stepTag(first) : stepRange(label.members);
72
+ const title = label.type === "leaf" ? `entered at step ${first.at.step + 1}: go to the call` : `entered at ${text}: go to the first call`;
73
+ return html` <button type="button" class="ws-step p" data-ws="jump" data-at=${atAttr(first.at)} title=${title}>${text}</button>`;
74
+ }
75
+
76
+ // The footprint line of a finished turn's +items+ (turn +ordinal+), nothing
77
+ // for a turn that touched nothing: "touched web/public/ · 9 files · ◆ 3
78
+ // memories s1–5 · +1 url · edited 2". A click opens the expanded view at
79
+ // that turn; a step tag jumps to its call.
80
+ export function footprintTemplate(items, ordinal) {
81
+ const fp = footprint(items, BUDGETS.footprint);
82
+ if (!fp) return nothing;
83
+ const sep = html` <span class="sep">·</span> `;
84
+ 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)}`);
85
+ const text = footprintText(items, BUDGETS.footprint);
86
+ 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}</div>`;
87
+ }
88
+
89
+ // What a footprint shows that can change after it is drawn: its items'
90
+ // states (the server's). The same signature draws nothing.
91
+ function footSig(items) {
92
+ return items.map((i) => `${i.kind}:${i.key}:${i.state || "live"}`).join("|");
93
+ }
94
+
95
+ // The footprint as an element (the hand-off's), or null.
96
+ export function footprintNode(items, ordinal) {
97
+ const template = footprintTemplate(items, ordinal);
98
+ if (template === nothing) return null;
99
+ const frag = document.createDocumentFragment();
100
+ render(template, frag);
101
+ return frag.querySelector(".ws-foot");
102
+ }
103
+
104
+ function ringTemplate(pct) {
105
+ if (!Number.isFinite(pct)) return nothing;
106
+ const value = Math.max(0, Math.min(100, Math.round(pct)));
107
+ const len = 2 * Math.PI * 5.5;
108
+ const bucket = value >= 90 ? " danger" : value >= 70 ? " warn" : "";
109
+ 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>`;
110
+ }
111
+
112
+ // A group's member as the popover names it: its path below the group's
113
+ // directory ("adapters/by_api.rb"), else its leaf text.
114
+ function memberName(item, group) {
115
+ if (item.kind === "file" && group?.dir) return String(item.path || item.key).slice(group.dir.length + 1);
116
+ return item.label;
117
+ }
118
+
119
+ // +historyEl+: where finished turns are; +stageRoot()+: the stage (the live
120
+ // turn's block); +state()+: the open session's fields (session_state.js:
121
+ // ctxPct and the preloaded, muted and used memories); +session()+: its id;
122
+ // +infoEl+: the session bar's line (the chip's popover is delegated there);
123
+ // +onChange()+: the chip's count or dot changed (app.js updateInfoBar).
124
+ // +getWorkingSet(id)+: the server's projection (data.js; null: none asked).
125
+ export function createWorkingSet({ historyEl, stageRoot = () => null, state, session = () => null, infoEl, onChange = () => {}, getWorkingSet = null, doc = document }) {
126
+ // The finished turns' sets ({items, prompt}), the running turn's (null:
127
+ // none), the rail's first-sight times (its fresh cue), the session count
128
+ // at the last open (the chip's dot).
129
+ let turns = [];
130
+ let live = null;
131
+ let seenAt = () => undefined;
132
+ let openedCount = 0;
133
+ // The open view: its scope ("turn" / "session"), its turn, the group open
134
+ // in it.
135
+ let view = { scope: "session", turn: null };
136
+ let openGroup = null;
137
+ // The server's snapshot ({items, at}) and the session it is of; what
138
+ // redraws the rail when it changes.
139
+ let server = null;
140
+ let serverOf = null;
141
+ const listeners = new Set();
142
+ const lit = (items) => overlay(items, server);
143
+
144
+ const sessionSet = () => {
145
+ const st = state();
146
+ const set = sessionItems({
147
+ turns: turns.map((t) => t.items), live: live?.items || [], preloaded: st.preloadedMemories, muted: st.mutedMemories,
148
+ used: st.usedMemories,
149
+ });
150
+ return { items: lit(set.items), muted: set.muted };
151
+ };
152
+ const turnItems = (t) => lit(live && t === live.ordinal ? live.items : turns[t]?.items || []);
153
+ const promptOf = (t) => (live && t === live.ordinal ? live.prompt : turns[t]?.prompt || "");
154
+
155
+ const popover = createChipPopover({
156
+ id: "workingSetPopover", className: "ctx-popover ws-popover", width: 420, el: infoEl, anchor: ".ws-chip",
157
+ openers: ".ws-chip, .ts-ws, .ws-foot", sheet: true,
158
+ // The chip opens the session's scope; the rail and a footprint set
159
+ // theirs before they open it.
160
+ summary: (anchor) => {
161
+ if (anchor?.matches?.(".ws-chip")) {
162
+ view = { scope: "session", turn: live ? live.ordinal : turns.length - 1 };
163
+ // Opened: the chip's dot goes (chip()).
164
+ queueMicrotask(onChange);
165
+ }
166
+ return { view };
167
+ },
168
+ session,
169
+ // Opened: the server's set asked again (a file changed while the
170
+ // session sat idle shows stale).
171
+ show: (_data, opening) => {
172
+ draw();
173
+ if (opening) fetchServer();
174
+ },
175
+ onClose: () => { openGroup = null; onChange(); },
176
+ });
177
+
178
+ function rowTemplate(item, { session: inSession, group = null, now }) {
179
+ const fresh = isFresh(seenAt(item), now);
180
+ const tag = stepTag(item, { session: inSession });
181
+ const words = `${opsText(item)}${fresh ? " · just now" : ""}`;
182
+ const glyph = item.muted ? GLYPHS.muted : fresh ? "●" : GLYPHS[item.kind] || "";
183
+ const name = memberName(item, group);
184
+ const full = item.kind === "file" ? item.path || item.key : item.kind === "url" ? item.url || item.key : item.key;
185
+ let nameEl;
186
+ if (item.kind === "file") {
187
+ const ref = item.ref || { path: full };
188
+ 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>`;
189
+ } else if (item.kind === "url" && /^https?:\/\//i.test(full)) {
190
+ nameEl = html`<a class="t" href=${full} target="_blank" rel="noopener noreferrer" title=${full}>${name}</a>`;
191
+ } else {
192
+ nameEl = html`<span class="t" title=${full}>${name}</span>`;
193
+ }
194
+ let tagEl = nothing;
195
+ if (tag === "prompt") tagEl = html`<span class="ws-step p" title="preloaded into the system prompt">prompt</span>`;
196
+ else if (tag) {
197
+ const prompt = inSession ? promptOf(item.at.turn) : "";
198
+ const where = inSession ? `turn ${item.at.turn + 1}${prompt ? ` (${prompt})` : ""}, step ${item.at.step + 1}` : `step ${item.at.step + 1}`;
199
+ 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>`;
200
+ }
201
+ const edited = item.kind === "file" && item.ops.includes("edit");
202
+ const state = item.state && item.state !== "live" ? ` ${item.state}` : "";
203
+ const tokens = tokensText(item.tokens);
204
+ return html`<div class=${`ws-row ${item.kind}${fresh ? " fresh" : ""}${item.muted ? " muted" : ""}${group ? " member" : ""}${state}`}><span class="k">${glyph}</span>${nameEl}${edited ? html`<span class="e">✎</span>` : nothing}<span class="st">${words}</span>${tokens ? html`<span class="tk" title=${`about ${item.tokens} tokens of it are sent`}>${tokens}</span>` : nothing}${tagEl}</div>`;
205
+ }
206
+
207
+ // A section's labels: leaves as rows, groups as an opener row and, open,
208
+ // their members.
209
+ function labelsTemplate(labels, opts) {
210
+ return labels.map((label) => {
211
+ if (label.type === "leaf") return rowTemplate(label.members[0], opts);
212
+ const open = openGroup === label.id;
213
+ const state = label.state && label.state !== "live" ? ` ${label.state}` : "";
214
+ 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} forgotten`}>✂${label.cut}</span>` : nothing}<span class="chev">${open ? "close" : "open"}</span></button>`;
215
+ return html`${head}${open ? label.members.map((m) => rowTemplate(m, { ...opts, group: label })) : nothing}`;
216
+ });
217
+ }
218
+
219
+ function draw() {
220
+ const now = Date.now();
221
+ const st = state();
222
+ const inSession = view.scope === "session";
223
+ const set = inSession ? sessionSet() : { items: turnItems(view.turn), muted: [] };
224
+ const files = set.items.filter((i) => i.kind === "file");
225
+ const memories = set.items.filter((i) => i.kind === "memory");
226
+ const urls = set.items.filter((i) => i.kind === "url");
227
+ const opts = { session: inSession, now };
228
+ const fileLabels = fold(files, POP_BUDGET).labels;
229
+ // Memories are rows of their own; past the budget one group that opens.
230
+ const memoryLabels = fold(memories, memories.length > POP_BUDGET ? 1 : Infinity).labels;
231
+ const preloaded = memories.filter((m) => m.preloaded).length;
232
+ const memMeta = [`${memories.length - preloaded} used`, preloaded ? `${preloaded} preloaded` : "", set.muted.length ? `${set.muted.length} muted` : ""].filter(Boolean).join(" · ");
233
+ const turnLabel = view.turn !== null && view.turn >= 0 ? `turn ${view.turn + 1}` : "this turn";
234
+ const pct = Number.isFinite(st.ctxPct) ? ` · ctx ${Math.round(st.ctxPct)} %` : "";
235
+ const empty = !set.items.length && !set.muted.length;
236
+ 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>
237
+ ${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}
238
+ ${files.length ? html`<div class="ctx-pop-meta">files · ${files.length}</div>${labelsTemplate(fileLabels, opts)}` : nothing}
239
+ ${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}
240
+ ${urls.length ? html`<div class="ctx-pop-meta">urls · ${urls.length}</div>${labelsTemplate(fold(urls, POP_BUDGET).labels, opts)}` : nothing}
241
+ <div class="ctx-pop-meta ws-hint">A file opens its ref; a step tag goes to the call. Esc closes.</div>`;
242
+ render(tpl, popover.pop);
243
+ }
244
+
245
+ // The row at +at+ ({turn, step, row}): the stage's block for the live
246
+ // turn, else the history's.
247
+ function rowAt(at) {
248
+ const sel = `.turn-work[data-turn="${at.turn}"]`;
249
+ const blocks = [...(stageRoot()?.querySelectorAll(sel) || []), ...[...historyEl.querySelectorAll(sel)].reverse()];
250
+ for (const block of blocks) {
251
+ const row = block.querySelector(`.activity-row[data-at="${at.step}.${at.row}"]`);
252
+ if (row) return row;
253
+ }
254
+ return null;
255
+ }
256
+
257
+ function jump(at) {
258
+ const row = rowAt(at);
259
+ if (!row) return false;
260
+ if (popover.isOpen()) popover.close();
261
+ showRow(row);
262
+ return true;
263
+ }
264
+
265
+ const parseAt = (text) => {
266
+ const [turn, step, row] = String(text).split(".").map(Number);
267
+ return [turn, step, row].every(Number.isInteger) ? { turn, step, row } : null;
268
+ };
269
+
270
+ popover.pop.addEventListener("click", (e) => {
271
+ const act = e.target.closest("[data-ws]");
272
+ if (!act) return;
273
+ if (act.dataset.ws === "jump") {
274
+ const at = parseAt(act.dataset.at);
275
+ if (at) jump(at);
276
+ } else if (act.dataset.ws === "scope") {
277
+ view = { ...view, scope: act.dataset.scope };
278
+ openGroup = null;
279
+ draw();
280
+ popover.replace();
281
+ } else if (act.dataset.ws === "group") {
282
+ openGroup = openGroup === act.dataset.id ? null : act.dataset.id;
283
+ draw();
284
+ popover.replace();
285
+ }
286
+ });
287
+
288
+ // A footprint: its step tag jumps, the rest opens that turn's view.
289
+ function footClick(e) {
290
+ const foot = e.target.closest?.(".ws-foot");
291
+ if (!foot) return;
292
+ const tag = e.target.closest(".ws-step[data-at]");
293
+ if (tag) {
294
+ const at = parseAt(tag.dataset.at);
295
+ if (at) jump(at);
296
+ return;
297
+ }
298
+ if (popover.isOpen() && popover.opener() === foot) return popover.close();
299
+ view = { scope: "turn", turn: Number(foot.dataset.wsTurn) };
300
+ popover.open(foot);
301
+ }
302
+ doc.addEventListener("click", footClick);
303
+ doc.addEventListener("keydown", (e) => {
304
+ if ((e.key === "Enter" || e.key === " ") && e.target.matches?.(".ws-foot")) {
305
+ e.preventDefault();
306
+ footClick(e);
307
+ }
308
+ });
309
+
310
+ function changed() {
311
+ onChange();
312
+ if (popover.isOpen()) popover.refresh();
313
+ }
314
+
315
+ // The footprints on the page (the history's, the hand-off's) drawn again
316
+ // in place when their items' states changed: the same node (its focus,
317
+ // the popover's opener), its children and label replaced.
318
+ function refreshFootprints() {
319
+ for (const root of [historyEl, stageRoot()]) {
320
+ for (const foot of root?.querySelectorAll(".ws-foot[data-ws-turn]") || []) {
321
+ const ordinal = Number(foot.dataset.wsTurn);
322
+ if (!Number.isInteger(ordinal)) continue;
323
+ const items = turnItems(ordinal);
324
+ if (!items.length || foot.dataset.wsSig === footSig(items)) continue;
325
+ const next = footprintNode(items, ordinal);
326
+ if (!next) continue;
327
+ foot.replaceChildren(...next.childNodes);
328
+ foot.setAttribute("aria-label", next.getAttribute("aria-label"));
329
+ foot.dataset.wsSig = next.dataset.wsSig;
330
+ }
331
+ }
332
+ }
333
+
334
+ // A newer server snapshot redraws every form; an older one (a GET that
335
+ // answered after a newer event) is dropped.
336
+ function takeServer(snapshot) {
337
+ const next = newerSnapshot(server, snapshot);
338
+ if (next === server) return;
339
+ server = next;
340
+ serverOf = session();
341
+ changed();
342
+ refreshFootprints();
343
+ for (const fn of listeners) fn();
344
+ }
345
+
346
+ // The server's set for the open session, asked again; an answer for a
347
+ // session no longer open is dropped.
348
+ function fetchServer() {
349
+ const id = session();
350
+ if (!getWorkingSet || !id) return;
351
+ Promise.resolve()
352
+ .then(() => getWorkingSet(id))
353
+ .then((snapshot) => { if (session() === id) takeServer(snapshot); })
354
+ .catch(() => {});
355
+ }
356
+
357
+ return {
358
+ // A session's history drawn anew: its turns' sets (+list+: [{items,
359
+ // prompt}]), no running turn yet; the chip's dot starts off.
360
+ seed(list) {
361
+ turns = list.slice();
362
+ live = null;
363
+ if (popover.isOpen()) popover.close();
364
+ // Another session's snapshot goes; the same session's (a resync)
365
+ // stays until the newer one comes.
366
+ if (serverOf !== session()) {
367
+ server = null;
368
+ serverOf = session();
369
+ }
370
+ openedCount = sessionSet().items.length;
371
+ refreshFootprints();
372
+ fetchServer();
373
+ },
374
+ // A :working_set_updated event's set ({items, at}).
375
+ update(snapshot) { takeServer(snapshot); },
376
+ // +fn()+ runs after each new server snapshot (the rail's redraw).
377
+ onServer(fn) { listeners.add(fn); },
378
+ // The running turn's items (its model +turn+), its ordinal the next
379
+ // turn's; the chip follows.
380
+ liveItems(turn, prompt = "") {
381
+ const ordinal = turns.length;
382
+ const items = itemsOfTurn(turn, ordinal);
383
+ const before = live?.items.length ?? -1;
384
+ live = { ordinal, items, prompt };
385
+ if (items.length !== before) changed();
386
+ return { items: lit(items), ordinal };
387
+ },
388
+ // The rail's first-sight times, for the popover's fresh rows.
389
+ setSeen(fn) { seenAt = fn; },
390
+ // The running turn hands off: its set joins the turns; its footprint
391
+ // line (null: it touched nothing) goes after its block.
392
+ handOff(turn, prompt = "") {
393
+ const ordinal = turns.length;
394
+ const items = itemsOfTurn(turn, ordinal);
395
+ turns.push({ items, prompt });
396
+ live = null;
397
+ changed();
398
+ return footprintNode(lit(items), ordinal);
399
+ },
400
+ // The running turn left without a hand-off (a reset): forgotten.
401
+ release() {
402
+ if (!live) return;
403
+ live = null;
404
+ changed();
405
+ },
406
+ // The rail opens the running turn's view.
407
+ openTurn(anchor) {
408
+ if (popover.isOpen() && popover.opener() === anchor) return popover.close();
409
+ view = { scope: "turn", turn: live ? live.ordinal : turns.length - 1 };
410
+ popover.open(anchor);
411
+ openedCount = sessionSet().items.length;
412
+ onChange();
413
+ },
414
+ // The session bar's chip: {n, changed} (the dot), null when the set is
415
+ // empty (no chip). Opening it clears the dot.
416
+ chip() {
417
+ const n = sessionSet().items.length;
418
+ if (popover.isOpen()) openedCount = n;
419
+ return n ? { n, changed: n !== openedCount } : null;
420
+ },
421
+ jump,
422
+ ctxPct: () => state().ctxPct,
423
+ isOpen: () => popover.isOpen(),
424
+ popover,
425
+ };
426
+ }
@@ -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.
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tool_response"
4
+ require_relative "tool_ids"
5
+ require_relative "token_usage"
6
+ require_relative "file_ref"
7
+ require_relative "llm_context_edit"
8
+ require_relative "llm_context_stale"
9
+ require_relative "llm_context_view"
10
+ require_relative "tools/memory"
11
+
12
+ module Samagotchi
13
+ # The working set as the model really has it (the web's rail, chip,
14
+ # popover and footprints overlay it, web.working_set): every file,
15
+ # memory and URL the conversation's tool runs touched, each still +live+,
16
+ # +stale+ (a file changed since the model last saw it) or +forgotten+
17
+ # (nothing of it is still sent under the session's current strategy),
18
+ # with what its content outputs cost in tokens as sent. Pure: built from
19
+ # a conversation, its folder, a strategy and a stat, no session, no
20
+ # Engine. See docs/internals/working-set.md.
21
+ #
22
+ # The runs are the ones that pair with their calls for sure
23
+ # (LLMContextStale.paired_runs, the call as dispatched: aliases resolve
24
+ # as the loops resolve them); a "ran as:" corrected call or an output
25
+ # holding the run separator pairs with none and is left out. A failed run
26
+ # (its output an "Error", a denied call's too) and an interrupted one
27
+ # touch nothing.
28
+ #
29
+ # Content outputs (read, memory_read, web_fetch) carry the tokens and
30
+ # decide forgotten; edit, write and memory_write add ops and stamps only
31
+ # (their content is in the call's arguments, which no strategy cuts).
32
+ # Stale: the item's latest run left a stamp (ToolRun#stamp) and the file
33
+ # now differs from it in mtime or size, or is gone; a run with no stamp
34
+ # (an old session's) never makes a file stale.
35
+ module WorkingSet
36
+ FILE = "file"
37
+ MEMORY = "memory"
38
+ URL = "url"
39
+ LIVE = "live"
40
+ STALE = "stale"
41
+ FORGOTTEN = "forgotten"
42
+
43
+ # One thing the model touched: +kind+ file|memory|url; +key+ a file's
44
+ # absolute path (FileRef), a memory's name, a URL (whitespace
45
+ # collapsed); +state+ live|stale|forgotten; +tokens+ its content
46
+ # outputs as sent; +ops+ read|edit for a file, read|write for a memory,
47
+ # read for a URL, in first-seen order; +count+ the runs that touched
48
+ # it; +first_id+ the first one's chi id (ToolIds).
49
+ Item = Data.define(:kind, :key, :state, :tokens, :ops, :count, :first_id)
50
+
51
+ # A projection and the conversation's size (entries) it was built
52
+ # from: the web keeps the newer of an event's and a GET's by it.
53
+ Snapshot = Data.define(:items, :at) do
54
+ def empty? = items.empty?
55
+
56
+ # The JSON the event and the route carry: {items: [...], at:}.
57
+ def to_h = { items: items.map(&:to_h), at: at }
58
+ end
59
+
60
+ FILE_OPS = { "read" => "read", "edit" => "edit", "write" => "edit" }.freeze
61
+ CONTENT = %w[read memory_read web_fetch].freeze
62
+ MEMORY_READ = Tools::MemoryRead::NAME
63
+ MEMORY_WRITE = Tools::MemoryWrite::NAME
64
+ WEB_FETCH = "web_fetch"
65
+ # A stale stub that an edit (not a later read) made: the model wrote
66
+ # the file, so what it has of it is still current (llm_context.stale_edits).
67
+ BY_EDIT = /superseded by a later (?:edit|write)\z/
68
+ # A partial memory_read's last part (Tools::MemoryRead.call).
69
+ MISSING = /\AError: memory not found: (.*)\z/m
70
+
71
+ # What one paired run touched: its item's identity and op, whether its
72
+ # output is content, its stored and sent texts, its stamp.
73
+ Touch = Data.define(:kind, :key, :op, :content, :run, :sent, :share)
74
+ private_constant :Touch
75
+
76
+ module_function
77
+
78
+ # @param conversation [Array<Hash>] the stored entries (symbol keys at
79
+ # the top, as Session.load has them)
80
+ # @param cwd [String, nil] the session's folder: a relative path's base
81
+ # @param strategy [Symbol, Array<Symbol>] :none or the layers, as
82
+ # LLMContextView takes it (the session's current one)
83
+ # @param stat [#call] File.stat, or a stand-in (specs)
84
+ # @return [Snapshot]
85
+ def build(conversation, cwd:, strategy:, stat: File.method(:stat))
86
+ conversation = Array(conversation)
87
+ view = LLMContextView.new(strategy: strategy)
88
+ stored = LLMContextStale.paired_runs(conversation)
89
+ sent = if view.none?
90
+ {}
91
+ else
92
+ LLMContextStale.paired_runs(view.messages(conversation)).to_h { |run| [run.ref.id, run.text] }
93
+ end
94
+ touches = stored.flat_map { |run| touches(run, sent.fetch(run.ref.id, run.text), cwd) }
95
+ items = touches.group_by { |touch| [touch.kind, touch.key] }.map do |(kind, key), group|
96
+ item(kind, key, group, conversation, view, stat)
97
+ end
98
+ Snapshot.new(items: items, at: conversation.size)
99
+ end
100
+
101
+ # The items +run+ touched (none when it failed or was interrupted).
102
+ def touches(run, sent, cwd)
103
+ name = run.call[:name].to_s
104
+ return [] if failed?(run.text)
105
+
106
+ base = { run: run, sent: sent, content: CONTENT.include?(name), share: 1 }
107
+ if FILE_OPS.key?(name)
108
+ path = FileRef.for(name, run.call, cwd: cwd)&.path
109
+ path ? [Touch.new(kind: FILE, key: path, op: FILE_OPS[name], **base)] : []
110
+ elsif name == MEMORY_READ
111
+ names = found_names(run)
112
+ names.map { |memory| Touch.new(kind: MEMORY, key: memory, op: "read", **base.merge(share: names.size)) }
113
+ elsif name == MEMORY_WRITE
114
+ memory = run.call[:path].to_s.strip
115
+ memory.empty? ? [] : [Touch.new(kind: MEMORY, key: memory, op: "write", **base)]
116
+ elsif name == WEB_FETCH
117
+ url = run.call[:content].to_s.gsub(/\s+/, " ").strip
118
+ url.empty? ? [] : [Touch.new(kind: URL, key: url, op: "read", **base)]
119
+ else
120
+ []
121
+ end
122
+ end
123
+
124
+ # LLMContextStale's failed rule (an "Error" after the "[name]" lead; a
125
+ # denied call's "Error:" too), or an interrupted run.
126
+ def failed?(text)
127
+ ToolResponse.interrupted_text?(text) || text.sub(ToolResponse::LEAD, "").lstrip.start_with?("Error")
128
+ end
129
+
130
+ # A memory_read's names (Tools::MemoryRead.parse_names: none for the
131
+ # index listing), less the ones its output says weren't found.
132
+ def found_names(run)
133
+ names = Tools::MemoryRead.parse_names(run.call[:content].to_s)
134
+ last = run.text.split(ToolResponse::SEPARATOR).last.to_s
135
+ missing = (match = MISSING.match(last)) ? match[1].split(",").map(&:strip) : []
136
+ names - missing
137
+ end
138
+
139
+ def item(kind, key, group, conversation, view, stat)
140
+ first = group.first
141
+ contents = group.select(&:content)
142
+ tokens = contents.sum { |touch| touch.sent.length / TokenUsage::CHARS_PER_TOKEN / touch.share }.ceil
143
+ state = if contents.any? && contents.none? { |touch| still_sent?(touch, conversation, view) }
144
+ FORGOTTEN
145
+ elsif kind == FILE && changed?(key, group.last.run, conversation, stat)
146
+ STALE
147
+ else
148
+ LIVE
149
+ end
150
+ Item.new(kind: kind, key: key, state: state, tokens: tokens, ops: group.map(&:op).uniq, count: group.size,
151
+ first_id: first.run.ref.id)
152
+ end
153
+
154
+ # Whether the model is still sent +touch+'s output: whole (with the
155
+ # forget layer's "[#id] " after its lead, when it shows ids), or a
156
+ # stub that keeps some of it: a forget's kept lines, a stale stub an
157
+ # edit made (the model wrote the file).
158
+ def still_sent?(touch, conversation, view)
159
+ stored = touch.run.text
160
+ sent = touch.sent
161
+ return true if sent == stored || sent == with_id(stored, touch.run.ref.id)
162
+
163
+ edit = LLMContextEdit.on(conversation[touch.run.index])[touch.run.ref.id]
164
+ return true unless edit&.applied? && view.layers.include?(edit.kind)
165
+
166
+ edit.kind == :forget ? edit.keep? : edit.note.match?(BY_EDIT)
167
+ end
168
+
169
+ def with_id(text, id)
170
+ match = text.match(ToolResponse::LEAD)
171
+ match ? "#{match[0]}[##{id}] #{match.post_match}" : nil
172
+ end
173
+
174
+ # Whether the file at +path+ differs from +run+'s stamp (the item's
175
+ # latest run): no stamp, not changed.
176
+ def changed?(path, run, conversation, stat)
177
+ stamp = stamp_of(conversation[run.index], run.ref.run)
178
+ return false unless stamp
179
+
180
+ now = stat.call(path)
181
+ mtime_ns = (now.mtime.to_i * 1_000_000_000) + now.mtime.nsec
182
+ mtime_ns != field(stamp, :mtime_ns) || now.size != field(stamp, :size)
183
+ rescue SystemCallError
184
+ # Gone (or unreadable): not what the model saw.
185
+ true
186
+ end
187
+
188
+ # +entry+'s tool_stamps at the run's place, a Hash with symbol keys
189
+ # (live) or string keys (after a reload); nil when none.
190
+ def stamp_of(entry, place)
191
+ stamps = entry[:tool_stamps]
192
+ stamp = stamps.is_a?(Array) ? stamps[place] : nil
193
+ stamp.is_a?(Hash) && field(stamp, :mtime_ns) ? stamp : nil
194
+ end
195
+
196
+ def field(hash, key) = hash.key?(key) ? hash[key] : hash[key.to_s]
197
+
198
+ private_class_method :touches, :failed?, :found_names, :item, :still_sent?, :with_id, :changed?, :stamp_of, :field
199
+ end
200
+ end