samagotchi 0.101.0 → 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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/bin/chi +13 -10
  4. data/docs/architecture.md +8 -6
  5. data/docs/cli.md +47 -7
  6. data/docs/configuration.md +25 -4
  7. data/docs/internals/bundles.md +1 -1
  8. data/docs/internals/engine.md +1 -1
  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 +86 -11
  12. data/docs/plugins.md +6 -3
  13. data/docs/sessions.md +28 -11
  14. data/docs/testing.md +7 -0
  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 +89 -53
  24. data/lib/samagotchi/engine.rb +131 -12
  25. data/lib/samagotchi/idle_scheduler.rb +16 -1
  26. data/lib/samagotchi/llm_context_edit.rb +32 -10
  27. data/lib/samagotchi/llm_context_forget.rb +12 -3
  28. data/lib/samagotchi/llm_context_view.rb +19 -7
  29. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  30. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  31. data/lib/samagotchi/message_parts.rb +8 -3
  32. data/lib/samagotchi/plugin/context.rb +2 -2
  33. data/lib/samagotchi/prompt.rb +1 -1
  34. data/lib/samagotchi/recap_command.rb +68 -0
  35. data/lib/samagotchi/recap_setup.rb +27 -14
  36. data/lib/samagotchi/send_command.rb +14 -11
  37. data/lib/samagotchi/session.rb +19 -12
  38. data/lib/samagotchi/session_commands.rb +44 -2
  39. data/lib/samagotchi/session_config.rb +411 -0
  40. data/lib/samagotchi/session_setup.rb +9 -9
  41. data/lib/samagotchi/sessions_command.rb +6 -2
  42. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  44. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  45. data/lib/samagotchi/terminal_ui.rb +8 -6
  46. data/lib/samagotchi/testing/bundle.rb +3 -2
  47. data/lib/samagotchi/version.rb +1 -1
  48. data/lib/samagotchi/web/app.rb +30 -13
  49. data/lib/samagotchi/web/public/app.js +2 -0
  50. data/lib/samagotchi/web/public/commands.js +8 -2
  51. data/lib/samagotchi/web/public/data.js +3 -2
  52. data/lib/samagotchi/web/public/index.html +20 -2
  53. data/lib/samagotchi/web/public/stream_handlers.js +4 -0
  54. data/lib/samagotchi/web/public/turn_html.js +5 -0
  55. data/lib/samagotchi/web/public/working_set.js +113 -21
  56. data/lib/samagotchi/web/public/working_set_view.js +136 -21
  57. data/lib/samagotchi/worker.rb +9 -6
  58. data/lib/samagotchi/working_set.rb +49 -11
  59. data/lib/samagotchi/working_set_command.rb +119 -0
  60. data/lib/samagotchi/working_set_steer.rb +250 -0
  61. metadata +5 -2
  62. data/lib/samagotchi/bundle_session_settings.rb +0 -251
@@ -5,9 +5,20 @@
5
5
  // and the step tags' jump to a call. The rail is working_set_rail.js's; the
6
6
  // rules are working_set.js's.
7
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
+ //
8
18
  // The server's projection (WorkingSet: a :working_set_updated event, a GET
9
19
  // 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
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
11
22
  // overlays its state and tokens on the rows' items by key (working_set.js
12
23
  // overlay), so a state is the item's now, not then. A new snapshot redraws
13
24
  // them all: the chip, the open popover, the footprints in place and the rail
@@ -21,8 +32,8 @@ import { html, nothing, render, svg } from "./vendor/lit-html.js";
21
32
  import { createChipPopover } from "./chip_popover.js";
22
33
  import { showRow } from "./refs.js";
23
34
  import {
24
- BUDGETS, fold, footprint, footprintText, GLYPHS, isFresh, itemsOfTurn, labelText, labelTitle, newerSnapshot, opsText, overlay,
25
- sessionItems, stepRange, stepTag, tokensText,
35
+ BUDGETS, dropRefusal, fold, footprint, footprintText, GLYPHS, INVERSE, isFresh, itemsOfTurn, labelText, labelTitle, newerSnapshot,
36
+ opsText, overlay, parseSteerLine, pendingSteers, sessionItems, steerId, steerLine, stepRange, stepTag, tokensText,
26
37
  } from "./working_set.js";
27
38
 
28
39
  // The popover's files fold to this many lines (groups open in place, one at
@@ -83,7 +94,7 @@ export function footprintTemplate(items, ordinal) {
83
94
  const sep = html` <span class="sep">·</span> `;
84
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)}`);
85
96
  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>`;
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>`;
87
98
  }
88
99
 
89
100
  // What a footprint shows that can change after it is drawn: its items'
@@ -122,7 +133,10 @@ function memberName(item, group) {
122
133
  // +infoEl+: the session bar's line (the chip's popover is delegated there);
123
134
  // +onChange()+: the chip's count or dot changed (app.js updateInfoBar).
124
135
  // +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 }) {
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 }) {
126
140
  // The finished turns' sets ({items, prompt}), the running turn's (null:
127
141
  // none), the rail's first-sight times (its fresh cue), the session count
128
142
  // at the last open (the chip's dot).
@@ -135,9 +149,16 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
135
149
  let view = { scope: "session", turn: null };
136
150
  let openGroup = null;
137
151
  // The server's snapshot ({items, at}) and the session it is of; what
138
- // redraws the rail when it changes.
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.
139
154
  let server = null;
140
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;
141
162
  const listeners = new Set();
142
163
  const lit = (items) => overlay(items, server);
143
164
 
@@ -175,7 +196,32 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
175
196
  onClose: () => { openGroup = null; onChange(); },
176
197
  });
177
198
 
178
- function rowTemplate(item, { session: inSession, group = null, now }) {
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() }) {
179
225
  const fresh = isFresh(seenAt(item), now);
180
226
  const tag = stepTag(item, { session: inSession });
181
227
  const words = `${opsText(item)}${fresh ? " · just now" : ""}`;
@@ -201,7 +247,15 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
201
247
  const edited = item.kind === "file" && item.ops.includes("edit");
202
248
  const state = item.state && item.state !== "live" ? ` ${item.state}` : "";
203
249
  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>`;
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}`;
205
259
  }
206
260
 
207
261
  // A section's labels: leaves as rows, groups as an opener row and, open,
@@ -211,8 +265,11 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
211
265
  if (label.type === "leaf") return rowTemplate(label.members[0], opts);
212
266
  const open = openGroup === label.id;
213
267
  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}`;
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}`;
216
273
  });
217
274
  }
218
275
 
@@ -224,7 +281,8 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
224
281
  const files = set.items.filter((i) => i.kind === "file");
225
282
  const memories = set.items.filter((i) => i.kind === "memory");
226
283
  const urls = set.items.filter((i) => i.kind === "url");
227
- const opts = { session: inSession, now };
284
+ const pend = pending();
285
+ const opts = { session: inSession, now, pend };
228
286
  const fileLabels = fold(files, POP_BUDGET).labels;
229
287
  // Memories are rows of their own; past the budget one group that opens.
230
288
  const memoryLabels = fold(memories, memories.length > POP_BUDGET ? 1 : Infinity).labels;
@@ -238,7 +296,8 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
238
296
  ${files.length ? html`<div class="ctx-pop-meta">files · ${files.length}</div>${labelsTemplate(fileLabels, opts)}` : nothing}
239
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}
240
298
  ${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>`;
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>`;
242
301
  render(tpl, popover.pop);
243
302
  }
244
303
 
@@ -267,10 +326,37 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
267
326
  return [turn, step, row].every(Number.isInteger) ? { turn, step, row } : null;
268
327
  };
269
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
+
270
338
  popover.pop.addEventListener("click", (e) => {
271
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
+ }
272
350
  if (!act) return;
273
- if (act.dataset.ws === "jump") {
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") {
274
360
  const at = parseAt(act.dataset.at);
275
361
  if (at) jump(at);
276
362
  } else if (act.dataset.ws === "scope") {
@@ -332,9 +418,9 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
332
418
  }
333
419
 
334
420
  // 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);
421
+ // answered after a newer event) is dropped. +how+: newerSnapshot's.
422
+ function takeServer(snapshot, how) {
423
+ const next = newerSnapshot(server, snapshot, how);
338
424
  if (next === server) return;
339
425
  server = next;
340
426
  serverOf = session();
@@ -348,9 +434,10 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
348
434
  function fetchServer() {
349
435
  const id = session();
350
436
  if (!getWorkingSet || !id) return;
437
+ const asked = events;
351
438
  Promise.resolve()
352
439
  .then(() => getWorkingSet(id))
353
- .then((snapshot) => { if (session() === id) takeServer(snapshot); })
440
+ .then((snapshot) => { if (session() === id) takeServer(snapshot, { overtaken: events !== asked }); })
354
441
  .catch(() => {});
355
442
  }
356
443
 
@@ -360,21 +447,49 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
360
447
  seed(list) {
361
448
  turns = list.slice();
362
449
  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.
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.
366
457
  if (serverOf !== session()) {
458
+ if (popover.isOpen()) popover.close();
367
459
  server = null;
368
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();
369
464
  }
370
465
  openedCount = sessionSet().items.length;
371
466
  refreshFootprints();
372
467
  fetchServer();
373
468
  },
374
469
  // A :working_set_updated event's set ({items, at}).
375
- update(snapshot) { takeServer(snapshot); },
470
+ update(snapshot) {
471
+ events += 1;
472
+ takeServer(snapshot, { event: true });
473
+ },
376
474
  // +fn()+ runs after each new server snapshot (the rail's redraw).
377
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(),
378
493
  // The running turn's items (its model +turn+), its ordinal the next
379
494
  // turn's; the chip follows.
380
495
  liveItems(turn, prompt = "") {
@@ -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
@@ -39,10 +39,12 @@ module Samagotchi
39
39
  LIVE = "live"
40
40
  STALE = "stale"
41
41
  FORGOTTEN = "forgotten"
42
+ # Forgotten, every output of it by the user's drop (WorkingSetSteer).
43
+ DROPPED = "dropped"
42
44
 
43
45
  # One thing the model touched: +kind+ file|memory|url; +key+ a file's
44
46
  # absolute path (FileRef), a memory's name, a URL (whitespace
45
- # collapsed); +state+ live|stale|forgotten; +tokens+ its content
47
+ # collapsed); +state+ live|stale|forgotten|dropped; +tokens+ its content
46
48
  # outputs as sent; +ops+ read|edit for a file, read|write for a memory,
47
49
  # read for a URL, in first-seen order; +count+ the runs that touched
48
50
  # it; +first_id+ the first one's chi id (ToolIds).
@@ -85,17 +87,47 @@ module Samagotchi
85
87
  def build(conversation, cwd:, strategy:, stat: File.method(:stat))
86
88
  conversation = Array(conversation)
87
89
  view = LLMContextView.new(strategy: strategy)
90
+ items = all_touches(conversation, cwd, view).group_by { |touch| [touch.kind, touch.key] }.map do |(kind, key), group|
91
+ item(kind, key, group, conversation, view, stat)
92
+ end
93
+ Snapshot.new(items: items, at: conversation.size)
94
+ end
95
+
96
+ # One content output (read, memory_read, web_fetch) of an item: its
97
+ # chi +id+ (ToolIds), the +index+ of the tool_response entry holding
98
+ # it, whether the id is +derived+ (a legacy entry's: nothing can name
99
+ # it) and whether the model is still +sent+ it under the strategy.
100
+ Output = Data.define(:id, :index, :derived, :sent)
101
+
102
+ # The content outputs of the item +kind+/+key+ (#build's identity: a
103
+ # file's absolute path, a memory's name, a URL), in conversation
104
+ # order, by the same pairing #build uses, so what acts on an item's
105
+ # outputs and the projection never disagree. Edit-only items have
106
+ # none. A memory_read of several names is one output of each.
107
+ # @return [Array<Output>]
108
+ def outputs_of(conversation, kind:, key:, strategy:, cwd:)
109
+ conversation = Array(conversation)
110
+ view = LLMContextView.new(strategy: strategy)
111
+ all_touches(conversation, cwd, view).filter_map do |touch|
112
+ next unless touch.content && touch.kind == kind && touch.key == key
113
+
114
+ ref = touch.run.ref
115
+ Output.new(id: ref.id, index: touch.run.index, derived: ref.derived?,
116
+ sent: still_sent?(touch, conversation, view))
117
+ end
118
+ end
119
+
120
+ # Every paired run's touches, with what +view+ sends of its output.
121
+ def all_touches(conversation, cwd, view)
88
122
  stored = LLMContextStale.paired_runs(conversation)
89
- sent = if view.none?
123
+ # Under none with no drop the view sends the conversation as it is.
124
+ messages = view.messages(conversation)
125
+ sent = if messages.equal?(conversation)
90
126
  {}
91
127
  else
92
- LLMContextStale.paired_runs(view.messages(conversation)).to_h { |run| [run.ref.id, run.text] }
128
+ LLMContextStale.paired_runs(messages).to_h { |run| [run.ref.id, run.text] }
93
129
  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)
130
+ stored.flat_map { |run| touches(run, sent.fetch(run.ref.id, run.text), cwd) }
99
131
  end
100
132
 
101
133
  # The items +run+ touched (none when it failed or was interrupted).
@@ -141,7 +173,7 @@ module Samagotchi
141
173
  contents = group.select(&:content)
142
174
  tokens = contents.sum { |touch| touch.sent.length / TokenUsage::CHARS_PER_TOKEN / touch.share }.ceil
143
175
  state = if contents.any? && contents.none? { |touch| still_sent?(touch, conversation, view) }
144
- FORGOTTEN
176
+ contents.all? { |touch| dropped?(touch, conversation) } ? DROPPED : FORGOTTEN
145
177
  elsif kind == FILE && changed?(key, group.last.run, conversation, stat)
146
178
  STALE
147
179
  else
@@ -161,11 +193,17 @@ module Samagotchi
161
193
  return true if sent == stored || sent == with_id(stored, touch.run.ref.id)
162
194
 
163
195
  edit = LLMContextEdit.on(conversation[touch.run.index])[touch.run.ref.id]
164
- return true unless edit&.applied? && view.layers.include?(edit.kind)
196
+ return true unless edit && view.sends?(edit)
165
197
 
166
198
  edit.kind == :forget ? edit.keep? : edit.note.match?(BY_EDIT)
167
199
  end
168
200
 
201
+ # Whether +touch+'s output holds the user's applied drop.
202
+ def dropped?(touch, conversation)
203
+ edit = LLMContextEdit.on(conversation[touch.run.index])[touch.run.ref.id]
204
+ !edit.nil? && edit.kind == LLMContextEdit::DROP && edit.applied?
205
+ end
206
+
169
207
  def with_id(text, id)
170
208
  match = text.match(ToolResponse::LEAD)
171
209
  match ? "#{match[0]}[##{id}] #{match.post_match}" : nil
@@ -195,6 +233,6 @@ module Samagotchi
195
233
 
196
234
  def field(hash, key) = hash.key?(key) ? hash[key] : hash[key.to_s]
197
235
 
198
- private_class_method :touches, :failed?, :found_names, :item, :still_sent?, :with_id, :changed?, :stamp_of, :field
236
+ private_class_method :all_touches, :touches, :failed?, :found_names, :item, :still_sent?, :dropped?, :with_id, :changed?, :stamp_of, :field
199
237
  end
200
238
  end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "working_set"
4
+ require_relative "working_set_steer"
5
+
6
+ module Samagotchi
7
+ # /working-set: with no arguments the session's working set (WorkingSet,
8
+ # as the web's list shows it: each item, what was done to it, its state
9
+ # and tokens, then the changes the next turn starts with); `drop KEY…`
10
+ # and `undrop KEY…` steer it (WorkingSetSteer). KEY: a path (absolute or
11
+ # the session's folder's), memory:NAME, a URL.
12
+ #
13
+ # An op runs between turns (mid_turn :queue: a line sent while a turn
14
+ # runs waits for its end), on a copy of the session's messages that
15
+ # replaces them, so the next turn's first request is the first with it.
16
+ class WorkingSetCommand
17
+ NAME = "/working-set"
18
+ USAGE = "usage: #{NAME} [drop KEY… | undrop KEY…] (KEY: a path, memory:NAME or a URL)".freeze
19
+ VERBS = [WorkingSetSteer::DROP, WorkingSetSteer::UNDROP].freeze
20
+ OP_WORDS = { WorkingSetSteer::DROP => "dropped", WorkingSetSteer::UNDROP => "undropped" }.freeze
21
+
22
+ # @param engine [Engine] #session, #working_set_strategy,
23
+ # #clone_messages, #replace_messages
24
+ # @param save [#call] saves the session after a change
25
+ def initialize(engine:, save:)
26
+ @engine = engine
27
+ @save = save
28
+ end
29
+
30
+ # @param args [String] what follows /working-set
31
+ # @return [Array(String, Boolean, Symbol)] the reply, whether the
32
+ # conversation changed, and :ok or :error (every key refused)
33
+ # @raise [ArgumentError] a line that isn't one (its message and USAGE)
34
+ def run(args)
35
+ verb, *words = args.to_s.split
36
+ return [listing, false, :ok] if verb.nil?
37
+ raise ArgumentError, USAGE unless VERBS.include?(verb) && !words.empty?
38
+
39
+ session = @engine.session or raise ArgumentError, "no session yet"
40
+ cwd = session.working_directory
41
+ strategy = @engine.working_set_strategy
42
+ messages = @engine.clone_messages(session.messages)
43
+ lines = []
44
+ done = 0
45
+ words.each do |word|
46
+ target = WorkingSetSteer.target(word, cwd: cwd)
47
+ result = if verb == WorkingSetSteer::DROP
48
+ WorkingSetSteer.drop(messages, target, strategy: strategy, cwd: cwd)
49
+ else
50
+ sent = !WorkingSetSteer.pending?(messages, WorkingSetSteer::DROP, target)
51
+ WorkingSetSteer.undrop(messages, target, strategy: strategy, cwd: cwd, sent: sent)
52
+ end
53
+ messages = WorkingSetSteer.record!(messages, result)
54
+ lines << done_line(result)
55
+ done += 1
56
+ rescue WorkingSetSteer::Refused => e
57
+ lines << "#{verb} #{word}: #{e.message}"
58
+ end
59
+ if done.positive?
60
+ @engine.replace_messages(messages)
61
+ @save.call(session)
62
+ end
63
+ [lines.join("\n"), done.positive?, done.positive? ? :ok : :error]
64
+ end
65
+
66
+ # The set under the strategy the next turn runs, and the pending ops.
67
+ def listing
68
+ session = @engine.session
69
+ return "no session yet" unless session
70
+
71
+ messages = Array(session.messages)
72
+ items = WorkingSet.build(messages, cwd: session.working_directory, strategy: @engine.working_set_strategy).items
73
+ return "working set: empty (the files, memories and URLs the turns read show here)\n#{USAGE}" if items.empty?
74
+
75
+ rows = items.map { |item| [name(item, session.working_directory), words(item)] }
76
+ width = rows.map { |row, _| row.length }.max
77
+ lines = ["working set (#{items.size} item#{"s" unless items.size == 1}):"]
78
+ lines.concat(rows.map { |row, text| " #{row.ljust(width)} #{text}" })
79
+ pending = WorkingSetSteer.pending_ops(messages)
80
+ unless pending.empty?
81
+ lines << "at the next turn's start: #{pending.map { |op| "#{OP_WORDS[op["op"]]} #{op["label"]}" }.join(", ")}"
82
+ end
83
+ lines << USAGE
84
+ lines.join("\n")
85
+ end
86
+
87
+ private
88
+
89
+ def name(item, cwd)
90
+ case item.kind
91
+ when WorkingSet::FILE then WorkingSetSteer.target(item.key, cwd: cwd).label
92
+ when WorkingSet::MEMORY then "#{WorkingSetSteer::MEMORY_PREFIX}#{item.key}"
93
+ else item.key
94
+ end
95
+ end
96
+
97
+ def words(item)
98
+ parts = [item.ops.join(", ")]
99
+ parts << "×#{item.count}" if item.count > 1
100
+ parts << item.state unless item.state == WorkingSet::LIVE
101
+ parts << "~#{item.tokens} tokens" if item.tokens.positive?
102
+ parts.join(" · ")
103
+ end
104
+
105
+ def done_line(done)
106
+ what = "#{OP_WORDS[done.op]} #{done.target.label} (#{done.ids.join(", ")})"
107
+ if done.op == WorkingSetSteer::DROP
108
+ frees = done.freed_chars.positive? ? "frees #{WorkingSetSteer.k(done.freed_chars)}" : "frees nothing, the stubs are as long"
109
+ "#{what}: the next turn's first request sends the stubs (#{frees}; the server reads the " \
110
+ "#{WorkingSetSteer.k(done.tail_chars)} after them again)"
111
+ elsif done.free
112
+ "#{what}: its drop was never sent; nothing changes"
113
+ else
114
+ "#{what}: the next request sends it whole again (the server reads the #{WorkingSetSteer.k(done.tail_chars)} " \
115
+ "from it on again)"
116
+ end
117
+ end
118
+ end
119
+ end