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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.101.0"
4
+ VERSION = "0.102.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -670,13 +670,13 @@ module Samagotchi
670
670
  thinking, error = request_thinking(body["thinking"])
671
671
  return error if error
672
672
 
673
- # bundle_settings: the session's own bundle settings (chi --set:
674
- # {bundle => {key => scalar}}), checked against the installed
675
- # bundles the same way.
673
+ # settings: the session's own settings, flat as chi --set names
674
+ # them ({"recap.enabled" => false, "cost-cap.cap" => 3}), checked
675
+ # the same way (SessionConfig.from_request).
676
676
  begin
677
- bundle_settings = BundleSessionSettings.from_request(body["bundle_settings"])
678
- rescue BundleSessionSettings::Error => e
679
- return error_response(400, "invalid_bundle_settings", e.message)
677
+ settings = SessionConfig.from_request(body["settings"])
678
+ rescue SessionConfig::Error => e
679
+ return error_response(400, "invalid_settings", e.message)
680
680
  end
681
681
 
682
682
  folder = dir ? { working_directory: dir } : {}
@@ -689,7 +689,7 @@ module Samagotchi
689
689
  # until that turn is saved.
690
690
  preview = idle ? body["preview"].to_s.strip : ""
691
691
  folder[:preview] = preview unless preview.empty?
692
- setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, bundle_settings: bundle_settings)
692
+ setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, settings: settings)
693
693
  folder[:setup] = setup unless setup.empty?
694
694
  begin
695
695
  session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
@@ -709,7 +709,7 @@ module Samagotchi
709
709
  # POST /api/sessions with continues: <id, prefix or last:id>: the next
710
710
  # link of that session's chain (SessionManager.continue_session), in its
711
711
  # folder and with its own setup (SessionSetup: llm_context, thinking
712
- # and reasoning_history levels, bundle settings), so dir, llm_context, thinking and bundle_settings are
712
+ # and reasoning_history levels, settings), so dir, llm_context, thinking and settings are
713
713
  # refused beside it (a create takes no reasoning_history). It runs on
714
714
  # model (or model_name) when given, else on the previous link's: one
715
715
  # whose host is gone falls back to the default, and the 201's
@@ -724,9 +724,9 @@ module Samagotchi
724
724
  unless given.is_a?(String) && !given.strip.empty?
725
725
  return error_response(400, "invalid_continues", "continues is a session id")
726
726
  end
727
- if (fields = %w[dir llm_context thinking bundle_settings].select { |key| body.key?(key) }).any?
727
+ if (fields = %w[dir llm_context thinking settings].select { |key| body.key?(key) }).any?
728
728
  return error_response(400, "invalid_continues",
729
- "continues takes the previous session's folder, llm_context, thinking and bundle_settings; " \
729
+ "continues takes the previous session's folder, llm_context, thinking and settings; " \
730
730
  "leave out #{fields.join(", ")}")
731
731
  end
732
732
 
@@ -1486,15 +1486,24 @@ module Samagotchi
1486
1486
  # ── The working set (web.working_set) ───────────────────────────────
1487
1487
 
1488
1488
  # GET /api/sessions/:id/working_set: the working set as the model has
1489
- # it ({items:, at:}, WorkingSet), what the page overlays on its rows'
1489
+ # it ({items:, at:, running:}, WorkingSet), what the page overlays on its rows'
1490
1490
  # set when it seeds a session's and when the expanded view opens (a
1491
1491
  # file changed while the session sat idle). A live worker's snapshot
1492
1492
  # messages are the truth, as #handle_show's, else the file's; the
1493
1493
  # strategy is the session's llm ctx chip's, the folder its working
1494
- # directory.
1494
+ # directory. running: a turn runs. The snapshot's messages lag it, so
1495
+ # the answer is then the turn's last :working_set_updated (the
1496
+ # snapshot's working_set, TurnAccumulator#working_set); before the
1497
+ # turn emitted one, or from an older worker without the field, it is
1498
+ # the session's messages, behind that turn's events, and the page
1499
+ # keeps theirs (working_set.js newerSnapshot).
1495
1500
  def handle_working_set(_req, id)
1496
1501
  session = @session_class.load(id, state_dir: default_state_dir)
1497
1502
  live = bridge_get_json(id, "snapshot")
1503
+ running = !live&.dig("snapshot", "current_turn").nil?
1504
+ turn_set = running && running_working_set(live["snapshot"]["working_set"])
1505
+ return json_response(200, turn_set.merge(running: true)) if turn_set
1506
+
1498
1507
  messages = live_or_saved(live && live["snapshot"], "messages") { session.messages }
1499
1508
  # A snapshot's entries have string keys (paired_runs reads symbols).
1500
1509
  messages = Array(messages).map { |message| message.is_a?(Hash) ? message.transform_keys(&:to_sym) : message }
@@ -1502,7 +1511,15 @@ module Samagotchi
1502
1511
  SessionSetting::LLM_CONTEXT.saved_summary(session, registry: host_registry)
1503
1512
  end
1504
1513
  snapshot = WorkingSet.build(messages, cwd: session.working_directory, strategy: working_set_strategy(llm_context))
1505
- json_response(200, snapshot.to_h)
1514
+ json_response(200, snapshot.to_h.merge(running: running))
1515
+ end
1516
+
1517
+ # The running turn's set from a worker's snapshot ({"items", "at"}), as
1518
+ # {items:, at:}; nil when it has none (none emitted yet, an older worker).
1519
+ def running_working_set(set)
1520
+ return nil unless set.is_a?(Hash) && set["items"].is_a?(Array) && set["at"].is_a?(Integer)
1521
+
1522
+ { items: set["items"], at: set["at"] }
1506
1523
  end
1507
1524
 
1508
1525
  # The strategy an llm ctx chip names ("none", "stale,forget"), as
@@ -236,6 +236,8 @@ const workingSet = view === "stage" && document.body.dataset.workingSet === "1"
236
236
  ? createWorkingSet({
237
237
  historyEl, stageRoot: () => turnDom.root, state: () => current, session: () => selected, infoEl: infoTextEl,
238
238
  onChange: () => updateInfoBar(), getWorkingSet,
239
+ // A drop or undrop from the list: the session's command, as typed.
240
+ sendLine: (line) => (selected ? sendCommand(selected, line, { clientId }) : null),
239
241
  })
240
242
  : null;
241
243
  document.body.classList.toggle("working-set", !!workingSet);
@@ -109,13 +109,19 @@ const WEB_LOCAL_REPLIES = {
109
109
  "/detach": "/detach: a terminal's command; close the tab to leave, the worker keeps running",
110
110
  "/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
111
111
  "/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
112
- "/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
112
+ "/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now. /recap on|off|default sets this session's recap here too",
113
113
  "/unqueue": "/unqueue: a terminal's command; here the × on a message queued for after the turn removes it",
114
114
  "/stats": "/stats: not in the web yet; each turn shows its time, the session bar the context use and speed (hover ctx for tokens and cost), and /stats in a terminal (chi --attach) has the rest",
115
115
  };
116
116
 
117
+ // The ones whose words after the name are the worker's (/recap off): the
118
+ // page answers only the bare name.
119
+ const WORKER_WITH_WORDS = new Set(["/recap"]);
120
+
117
121
  export function webLocalReply(text) {
118
- const name = String(text || "").trim().split(/\s+/)[0].toLowerCase();
122
+ const words = String(text || "").trim().split(/\s+/);
123
+ const name = words[0].toLowerCase();
124
+ if (WORKER_WITH_WORDS.has(name) && words.length > 1) return null;
119
125
  return WEB_LOCAL_REPLIES[name] || null;
120
126
  }
121
127
 
@@ -298,8 +298,9 @@ export function openEvents(handlers = {}, opts = {}) {
298
298
  };
299
299
  }
300
300
 
301
- // A session's working set as the model has it (WorkingSet: {items, at}),
302
- // what the stage view's working set overlays on its rows (web.working_set).
301
+ // A session's working set as the model has it (WorkingSet: {items, at,
302
+ // running}), what the stage view's working set overlays on its rows
303
+ // (web.working_set).
303
304
  export function getWorkingSet(id, opts = {}) {
304
305
  return api(`/api/sessions/${encodeURIComponent(id)}/working_set`, opts);
305
306
  }
@@ -905,9 +905,10 @@ body.working-set .ts-live{position:relative;padding-right:264px}
905
905
  /* The server's states (WorkingSet): stale faded; forgotten struck, faint and faded (a group only when all its members are). The words are the popover's and the titles' (colour is never alone). */
906
906
  .ws-item.stale{opacity:.55}
907
907
  .ws-item.forgotten{opacity:.5}
908
- .ws-item.forgotten .t,.ws-row.forgotten .t,.ws-foot .l.forgotten{text-decoration:line-through;color:var(--text-faint)}
908
+ .ws-item.forgotten,.ws-item.dropped{opacity:.5}
909
+ .ws-item.forgotten .t,.ws-row.forgotten .t,.ws-foot .l.forgotten,.ws-item.dropped .t,.ws-row.dropped .t,.ws-foot .l.dropped{text-decoration:line-through;color:var(--text-faint)}
909
910
  .ws-row.stale .k,.ws-row.stale .t,.ws-foot .l.stale{opacity:.55}
910
- .ws-row.forgotten .k,.ws-row.forgotten .t,.ws-foot .l.forgotten{opacity:.5}
911
+ .ws-row.forgotten .k,.ws-row.forgotten .t,.ws-foot .l.forgotten,.ws-row.dropped .k,.ws-row.dropped .t,.ws-foot .l.dropped{opacity:.5}
911
912
  .ws-item.enter{animation:ws-in .2s ease}
912
913
  @keyframes ws-in{from{opacity:0}}
913
914
  .ws-item.leave,.ws-more.leave{opacity:0;pointer-events:none}
@@ -980,6 +981,23 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
980
981
  .ws-row.fresh{background:var(--accent-tint)}
981
982
  .ws-row.fresh .k{color:var(--accent)}
982
983
  .ws-row.muted{opacity:.6}
984
+ /* Steering (drop / undrop): a row's actions on hover and keyboard focus (always where there is no hover); a pending op's row dashed warn with its pill, the footer counting them. */
985
+ .ws-act{flex:none;display:inline-flex;gap:4px;opacity:0}
986
+ .ws-row:hover .ws-act,.ws-row:focus-within .ws-act,.ws-ghead:hover .ws-act,.ws-ghead:focus-within .ws-act,.ws-row.pending .ws-act{opacity:1}
987
+ @media (hover:none){.ws-act{opacity:1}}
988
+ .ws-act-b{flex:none;padding:0 6px;border:1px solid var(--border-strong);border-radius:4px;background:transparent;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;font-weight:400;line-height:16px;color:var(--text-muted);cursor:pointer}
989
+ .ws-act-b:hover:not(:disabled){filter:none;color:var(--accent);border-color:var(--accent);background:var(--accent-bg)}
990
+ .ws-act-b:disabled{opacity:.5;cursor:default}
991
+ .ws-ghead{display:flex;align-items:center;gap:4px}
992
+ .ws-ghead>.ws-group-head{flex:1;min-width:0}
993
+ .ws-row.pending{border:1px dashed var(--warn-border);background:var(--warn-bg)}
994
+ .ws-row.pending:hover{background:var(--warn-bg)}
995
+ .ws-pend{flex:none;padding:0 6px;border:1px solid var(--warn-border);border-radius:999px;background:var(--warn-bg);color:var(--warn-text);font-family:ui-sans-serif,system-ui,sans-serif;font-size:10.5px;line-height:15px;white-space:nowrap}
996
+ .ws-pending{margin-top:8px;padding:4px 8px;border:1px dashed var(--warn-border);border-radius:6px;background:var(--warn-bg);color:var(--warn-text);font-size:11.5px;line-height:20px}
997
+ .ws-pending .ws-act-b{color:var(--warn-text);border-color:var(--warn-border)}
998
+ .ws-popover.sheet .ws-row .ws-act{display:none}
999
+ .ws-strip{display:flex;gap:8px;padding:2px 6px 6px 26px}
1000
+ .ws-strip .ws-act-b{padding:0 14px;font-size:12.5px;line-height:28px;border-radius:6px}
983
1001
  .ws-popover.sheet{border-radius:14px}
984
1002
  /* A phone's sheet: the name, the scope and × on the first line, the count and the ring under them. */
985
1003
  .ws-popover.sheet .ctx-pop-close{order:2}
@@ -228,6 +228,8 @@ export function createStreamHandlers(deps) {
228
228
  // the conversation (!rollback, !cmd, a continue answered no) re-reads it.
229
229
  command_ran: async (data) => {
230
230
  const id = selected();
231
+ // A queued /working-set line is no longer pending (the list's pill).
232
+ workingSet?.commandRan(data);
231
233
  const view = commandView(data, clientId);
232
234
  if (view.modelName && view.modelName !== state().model) {
233
235
  state().model = view.modelName;
@@ -290,6 +292,8 @@ export function createStreamHandlers(deps) {
290
292
  // turn: its bubble goes here, so the cards it shows come after it. One
291
293
  // queued for after the turn (/model X mid-turn) shows waiting here.
292
294
  command_queued: (data) => {
295
+ // A /working-set line queued for after the turn: pending in the list.
296
+ workingSet?.commandQueued(data);
293
297
  // A card's action waits for its command_ran: shown only if it answers.
294
298
  if ((data.anytime || data.waits) && !data.card && !commandBubble(data.command_id)) addCommandBubble(commandView(data, clientId));
295
299
  },
@@ -60,6 +60,11 @@ export function llmEditLine(edit) {
60
60
  const tokens = Number(edit.tokens) > 0 ? ` · ~${compactTokens(Number(edit.tokens))}` : "";
61
61
  return `✂ stubbed: ${edit.note || "stale"}${tokens}`;
62
62
  }
63
+ // The user's drop (/working-set drop, the working set's list).
64
+ if (edit.kind === "drop") {
65
+ const tokens = Number(edit.tokens) > 0 ? ` · ~${compactTokens(Number(edit.tokens))}` : "";
66
+ return `✂ dropped by the user${tokens}`;
67
+ }
63
68
  return "";
64
69
  }
65
70
 
@@ -14,8 +14,10 @@
14
14
  // touch unions its ops and bumps its count, never its at: within a turn
15
15
  // the set is append-only, which keeps the fold stable. state is "live" from
16
16
  // the rows; the server's projection (GET working_set, :working_set_updated:
17
- // WorkingSet) overlays its state (live, stale, forgotten) and tokens by key
18
- // (overlay), never its at.
17
+ // WorkingSet) overlays its state (live, stale, forgotten, dropped) and
18
+ // tokens by key (overlay), never its at. The user steers it with
19
+ // `/working-set drop|undrop KEY…` lines (steerLine); one queued for after the
20
+ // running turn is pending (pendingSteers) until its command_ran.
19
21
 
20
22
  export const FLASH_MS = 4000;
21
23
  // Labels per form: the rail's four lines, the row's and the footprint's
@@ -186,7 +188,7 @@ export function sessionItems({ turns = [], live = [], preloaded = [], muted = []
186
188
 
187
189
  // ── the server's truth ────────────────────────────────────────────────────
188
190
 
189
- export const STATES = Object.freeze(["live", "stale", "forgotten"]);
191
+ export const STATES = Object.freeze(["live", "stale", "forgotten", "dropped"]);
190
192
  const ELLIPSIS = "\u2026";
191
193
 
192
194
  // The server's items by "kind:key", and its URLs (a cut row key matches one
@@ -225,21 +227,31 @@ export function overlay(items, server) {
225
227
  const found = serverItem(index, item);
226
228
  const state = found ? found.state : "live";
227
229
  const tokens = found && Number.isFinite(found.tokens) ? found.tokens : undefined;
228
- if (item.state === state && item.tokens === tokens) return item;
230
+ // A cut URL row's full key, the one a steer line names.
231
+ const serverKey = found && found.key !== item.key ? found.key : undefined;
232
+ if (item.state === state && item.tokens === tokens && item.serverKey === serverKey) return item;
229
233
  const next = { ...item, state };
230
234
  if (tokens === undefined) delete next.tokens;
231
235
  else next.tokens = tokens;
236
+ if (serverKey === undefined) delete next.serverKey;
237
+ else next.serverKey = serverKey;
232
238
  return next;
233
239
  });
234
240
  }
235
241
 
236
- // Which snapshot to keep: +next+ when there is none yet or it is built from
237
- // as much of the conversation as the kept one (its at), else +kept+. A GET
238
- // that answered after a newer event never wins over it.
239
- export function newerSnapshot(kept, next) {
242
+ // Which snapshot to keep, +next+ or +kept+. Events come in the order the
243
+ // server built them, so an event (+event+) always wins: a smaller at is a
244
+ // rollback that shortened the conversation, not an older set. A GET's
245
+ // answer loses to an event that came while it was on its way (+overtaken+),
246
+ // and, asked while a turn runs (its running: it is built from the session's
247
+ // messages, behind that turn's events), to a kept one from more of the
248
+ // conversation (its at).
249
+ export function newerSnapshot(kept, next, { event = false, overtaken = false } = {}) {
240
250
  if (!next || !Array.isArray(next.items) || !Number.isFinite(next.at)) return kept;
241
- if (!kept || next.at >= kept.at) return next;
242
- return kept;
251
+ if (!kept || event) return next;
252
+ if (overtaken) return kept;
253
+ if (next.running && next.at < kept.at) return kept;
254
+ return next;
243
255
  }
244
256
 
245
257
  // Tokens as the popover shows them: "~840", "~1.2k", "~12k".
@@ -251,13 +263,17 @@ export function tokensText(tokens) {
251
263
  }
252
264
 
253
265
  const isForgotten = (item) => item.state === "forgotten";
254
- const isFaded = (item) => item.state === "stale" || item.state === "forgotten";
255
-
256
- // A group's state: forgotten when all its members are, stale when all are
257
- // stale or forgotten, else live (spec §3: struck only when all forgotten,
258
- // faded when all stale or forgotten).
266
+ // Nothing of it is sent: the model forgot it, or the user dropped it.
267
+ const isStruck = (item) => item.state === "forgotten" || item.state === "dropped";
268
+ const isFaded = (item) => item.state === "stale" || isStruck(item);
269
+
270
+ // A group's state: dropped when all its members are, forgotten when all are
271
+ // struck (forgotten or dropped), stale when all are stale or struck, else
272
+ // live (spec §3: struck only when all forgotten, faded when all stale or
273
+ // forgotten).
259
274
  export function groupState(members) {
260
- if (members.length && members.every(isForgotten)) return "forgotten";
275
+ if (members.length && members.every((m) => m.state === "dropped")) return "dropped";
276
+ if (members.length && members.every(isStruck)) return "forgotten";
261
277
  if (members.length && members.every(isFaded)) return "stale";
262
278
  return "live";
263
279
  }
@@ -278,7 +294,7 @@ export function leafText(item) {
278
294
  function leafLabel(item) {
279
295
  return {
280
296
  id: `${item.kind}:${item.key}`, type: "leaf", kind: item.kind, glyph: GLYPHS[item.kind] || "", text: leafText(item),
281
- n: 1, edited: edited(item) ? 1 : 0, cut: isForgotten(item) ? 1 : 0, state: item.state || "live", members: [item],
297
+ n: 1, edited: edited(item) ? 1 : 0, cut: isStruck(item) ? 1 : 0, state: item.state || "live", members: [item],
282
298
  };
283
299
  }
284
300
 
@@ -295,7 +311,7 @@ function groupLabel(spec, members) {
295
311
  const edits = members.filter(edited).length;
296
312
  // The state marks (spec §3): ✂n the forgotten members, the group's own
297
313
  // state from all of them.
298
- const marks = { cut: members.filter(isForgotten).length, state: groupState(members) };
314
+ const marks = { cut: members.filter(isStruck).length, state: groupState(members) };
299
315
  if (spec.type === "dir") {
300
316
  return { id: `dir:${spec.dir}`, type: "dir", kind: "file", glyph: "", text: dirLabel(spec.dir), count: plural(n, "file"), n, edited: edits, ...marks, members, dir: spec.dir };
301
317
  }
@@ -451,9 +467,16 @@ export function opsText(item) {
451
467
 
452
468
  // A label as the screen reader says it ("web/public 9 files 2 edited").
453
469
  function spoken(label) {
454
- if (label.type === "dir") return `${label.text.replace(/^…\//, "").replace(/\/$/, "")} ${label.count}${label.edited ? ` ${label.edited} edited` : ""}${label.cut ? ` ${label.cut} forgotten` : ""}`;
470
+ if (label.type === "dir") return `${label.text.replace(/^…\//, "").replace(/\/$/, "")} ${label.count}${label.edited ? ` ${label.edited} edited` : ""}${cutWords(label)}`;
455
471
  if (label.type === "leaf") return `${label.kind === "memory" ? "memory " : label.kind === "url" ? "url " : ""}${label.text}${label.edited ? " edited" : ""}${label.state && label.state !== "live" ? ` ${label.state}` : ""}`;
456
- return `${label.text}${label.cut ? ` ${label.cut} forgotten` : ""}`;
472
+ return `${label.text}${cutWords(label)}`;
473
+ }
474
+
475
+ // A group's struck members in words: " 2 forgotten, 1 dropped".
476
+ function cutWords(label) {
477
+ const dropped = label.members.filter((m) => m.state === "dropped").length;
478
+ const forgotten = label.cut - dropped;
479
+ return [forgotten ? ` ${forgotten} forgotten` : "", dropped ? ` ${dropped} dropped` : ""].filter(Boolean).join(",");
457
480
  }
458
481
 
459
482
  // The rail's (or row's, footprint's) aria-label: the folded text spoken.
@@ -476,7 +499,8 @@ export function footprint(items, budget = BUDGETS.footprint) {
476
499
  const kinds = new Set(rest.map((m) => m.kind));
477
500
  tail = kinds.size === 1 ? `+${plural(rest.length, KIND_WORDS[[...kinds][0]])}` : `+${rest.length} more`;
478
501
  }
479
- return { labels: head, tail, edited: items.filter(edited).length, forgotten: items.filter(isForgotten).length };
502
+ return { labels: head, tail, edited: items.filter(edited).length, forgotten: items.filter(isForgotten).length,
503
+ dropped: items.filter((i) => i.state === "dropped").length };
480
504
  }
481
505
 
482
506
  // A label's text as the footprint and the row draw it: its glyph, text,
@@ -498,6 +522,7 @@ export function footprintText(items, budget = BUDGETS.footprint) {
498
522
  if (fp.tail) parts.push(fp.tail);
499
523
  if (fp.edited) parts.push(`edited ${fp.edited}`);
500
524
  if (fp.forgotten) parts.push(`${fp.forgotten} forgotten`);
525
+ if (fp.dropped) parts.push(`${fp.dropped} dropped`);
501
526
  return `touched ${parts.join(" · ")}`;
502
527
  }
503
528
 
@@ -518,3 +543,70 @@ export function nextSettle(seenTimes, now, ms = FLASH_MS) {
518
543
  }
519
544
  return next;
520
545
  }
546
+
547
+ // ── steering (drop / undrop) ──────────────────────────────────────────────
548
+
549
+ export const STEER_COMMAND = "/working-set";
550
+ const STEER_LINE = /^\/working-set\s+(drop|undrop)\s+(\S.*)$/;
551
+ const URL_WORD = /^[a-z][a-z0-9+.-]*:\/\//i;
552
+ export const INVERSE = Object.freeze({ drop: "undrop", undrop: "drop" });
553
+
554
+ // The word a steer line names +item+ by (WorkingSetSteer.target): a file's
555
+ // absolute path, memory:NAME, a URL (the server's full one for a cut row).
556
+ export function steerWord(item) {
557
+ if (item.kind === "memory") return `memory:${item.key}`;
558
+ if (item.kind === "url") return item.serverKey || item.key;
559
+ return item.key;
560
+ }
561
+
562
+ // The id the page keys an item by ("file:/abs/a.rb"), from a steer word.
563
+ function wordId(word) {
564
+ if (word.startsWith("memory:")) return `memory:${word.slice("memory:".length)}`;
565
+ if (URL_WORD.test(word)) return `url:${word}`;
566
+ return `file:${word}`;
567
+ }
568
+
569
+ // An item's id as steer lines name it.
570
+ export function steerId(item) {
571
+ return wordId(steerWord(item));
572
+ }
573
+
574
+ // The line that does +op+ to +items+ ("/working-set drop /abs/a.rb memory:x").
575
+ export function steerLine(op, items) {
576
+ return `${STEER_COMMAND} ${op} ${items.map(steerWord).join(" ")}`;
577
+ }
578
+
579
+ // A steer line's op and ids ({op, ids}), or null for any other line.
580
+ export function parseSteerLine(line) {
581
+ const m = STEER_LINE.exec(String(line || "").trim());
582
+ if (!m) return null;
583
+ return { op: m[1], ids: m[2].split(/\s+/).filter(Boolean).map(wordId) };
584
+ }
585
+
586
+ // The ops queued for after the running turn (+lines+, in queue order), per
587
+ // item id: "drop" or "undrop". An op followed by its inverse nets to
588
+ // nothing (the worker runs both, the second undoing the first).
589
+ export function pendingSteers(lines) {
590
+ const pending = new Map();
591
+ for (const line of lines) {
592
+ const parsed = parseSteerLine(line);
593
+ if (!parsed) continue;
594
+ for (const id of parsed.ids) {
595
+ if (pending.get(id) === INVERSE[parsed.op]) pending.delete(id);
596
+ else pending.set(id, parsed.op);
597
+ }
598
+ }
599
+ return pending;
600
+ }
601
+
602
+ // Why +item+ can't be dropped (a title for its disabled button), "" when
603
+ // it can, null when it has no drop at all (muted, dropped already, or the
604
+ // server doesn't know it yet: a running call).
605
+ export function dropRefusal(item) {
606
+ if (item.muted || item.state === "dropped") return null;
607
+ if (item.preloaded && !item.ops.includes("read")) return "preloaded into the system prompt: not dropped from here";
608
+ if (!Number.isFinite(item.tokens)) return null;
609
+ if (!item.ops.includes("read")) return "only edited or written: nothing of it to drop";
610
+ if (item.state === "forgotten") return "forgotten: nothing of it is sent";
611
+ return "";
612
+ }