samagotchi 0.102.0 → 0.103.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/bin/chi +23 -191
  4. data/docs/architecture.md +54 -0
  5. data/docs/cli.md +114 -7
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +2 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-forget.md +11 -1
  10. data/docs/internals/remote-sessions.md +303 -0
  11. data/docs/internals/working-set.md +95 -11
  12. data/docs/plugins.md +26 -2
  13. data/docs/sessions.md +12 -5
  14. data/docs/sub-agent.md +8 -0
  15. data/lib/samagotchi/answer_command.rb +27 -25
  16. data/lib/samagotchi/bridge.rb +24 -4
  17. data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
  18. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  19. data/lib/samagotchi/bridge_client.rb +22 -38
  20. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  22. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  24. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  25. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/chi_invocation.rb +43 -0
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/flags.rb +5 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  31. data/lib/samagotchi/context_command.rb +80 -73
  32. data/lib/samagotchi/engine.rb +12 -8
  33. data/lib/samagotchi/idle_recap.rb +7 -1
  34. data/lib/samagotchi/kernel_loop.rb +13 -1
  35. data/lib/samagotchi/launch_mode.rb +22 -0
  36. data/lib/samagotchi/llm_context_forget.rb +13 -6
  37. data/lib/samagotchi/note_command.rb +8 -7
  38. data/lib/samagotchi/parent_report.rb +55 -43
  39. data/lib/samagotchi/remote_command.rb +170 -0
  40. data/lib/samagotchi/remotes.rb +130 -0
  41. data/lib/samagotchi/run_options.rb +233 -0
  42. data/lib/samagotchi/send_command/busy.rb +58 -0
  43. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  44. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  45. data/lib/samagotchi/send_command.rb +173 -108
  46. data/lib/samagotchi/session.rb +30 -5
  47. data/lib/samagotchi/session_access/contract.rb +129 -0
  48. data/lib/samagotchi/session_access/dispatch.rb +133 -0
  49. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  50. data/lib/samagotchi/session_access/local.rb +349 -0
  51. data/lib/samagotchi/session_access/remote.rb +638 -0
  52. data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
  53. data/lib/samagotchi/session_access.rb +141 -0
  54. data/lib/samagotchi/session_archive_command.rb +9 -5
  55. data/lib/samagotchi/session_commands.rb +20 -15
  56. data/lib/samagotchi/session_delete_command.rb +8 -4
  57. data/lib/samagotchi/session_manager.rb +7 -2
  58. data/lib/samagotchi/session_setup.rb +11 -6
  59. data/lib/samagotchi/sessions_command.rb +36 -14
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
  61. data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
  63. data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
  64. data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
  65. data/lib/samagotchi/terminal_ui.rb +2 -2
  66. data/lib/samagotchi/title_command.rb +9 -11
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +360 -69
  69. data/lib/samagotchi/web/public/data.js +3 -2
  70. data/lib/samagotchi/web/public/index.html +20 -4
  71. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  72. data/lib/samagotchi/web/public/stage_view.js +3 -2
  73. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  74. data/lib/samagotchi/web/public/working_set.js +159 -28
  75. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  76. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  77. data/lib/samagotchi/web/session_access_routes.rb +343 -0
  78. data/lib/samagotchi/worker_sidecar.rb +12 -5
  79. data/lib/samagotchi/working_set.rb +88 -14
  80. data/lib/samagotchi/working_set_command.rb +133 -30
  81. data/lib/samagotchi/working_set_pin.rb +34 -0
  82. data/lib/samagotchi/working_set_steer.rb +191 -27
  83. metadata +19 -1
@@ -197,9 +197,10 @@ export function unarchiveSession(id, opts = {}) {
197
197
  return api(`/api/sessions/${encodeURIComponent(id)}/unarchive`, { method: "POST", body: JSON.stringify({}), ...opts });
198
198
  }
199
199
 
200
- // Deletes the session for good; the server stops a live worker first.
200
+ // Deletes the session for good; force=true has the server stop a live
201
+ // worker first (the page's confirm says so; without it, 409 worker_running).
201
202
  export function deleteSession(id, opts = {}) {
202
- return api(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", ...opts });
203
+ return api(`/api/sessions/${encodeURIComponent(id)}?force=true`, { method: "DELETE", ...opts });
203
204
  }
204
205
 
205
206
  // One EventSource listener per handler, named by the handler's key (the
@@ -914,6 +914,7 @@ body.working-set .ts-live{position:relative;padding-right:264px}
914
914
  .ws-item.leave,.ws-more.leave{opacity:0;pointer-events:none}
915
915
  /* Just entered (FLASH_MS): an accent dot for the glyph and a tint; it settles (the tint fades 800 ms). */
916
916
  .ws-item.fresh{background:var(--accent-tint)}
917
+ .ws-item.pending{outline:1px dashed var(--warn-border);outline-offset:-1px;background:var(--warn-bg)}
917
918
  .ws-item.fresh .k{font-size:0;display:flex;align-items:center;justify-content:center;height:var(--ws-line)}
918
919
  .ws-item.fresh .k::before{content:"";display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--accent-ring)}
919
920
  .ws-more{padding-left:17px;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint);font-variant-numeric:tabular-nums}
@@ -973,7 +974,7 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
973
974
  .ws-row a.t:hover{text-decoration:underline}
974
975
  .ws-row .e{flex:none;color:var(--accent);font-weight:600}
975
976
  .ws-row .n{flex:none;color:var(--text-faint)}
976
- .ws-row .st{flex:none;margin-left:auto;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint)}
977
+ .ws-row .st{flex:0 4 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:auto;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint)}
977
978
  .ws-row .chev{flex:none;margin-left:auto;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint)}
978
979
  .ws-row .ws-step{flex:none}
979
980
  .ws-row .tk,.ws-row .c{flex:none;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint);font-variant-numeric:tabular-nums}
@@ -981,7 +982,8 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
981
982
  .ws-row.fresh{background:var(--accent-tint)}
982
983
  .ws-row.fresh .k{color:var(--accent)}
983
984
  .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
+ /* Steering (drop / undrop, pin / unpin): 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; a pinned row's 📌. */
986
+ .ws-pin{flex:none;font-size:10px;line-height:1}
985
987
  .ws-act{flex:none;display:inline-flex;gap:4px;opacity:0}
986
988
  .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
989
  @media (hover:none){.ws-act{opacity:1}}
@@ -992,9 +994,14 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
992
994
  .ws-ghead>.ws-group-head{flex:1;min-width:0}
993
995
  .ws-row.pending{border:1px dashed var(--warn-border);background:var(--warn-bg)}
994
996
  .ws-row.pending:hover{background:var(--warn-bg)}
997
+ /* A pending row: its pill and undo take the tokens' and the step tag's room, so the name stays readable. */
998
+ .ws-row.pending .tk,.ws-row.pending .ws-step{display:none}
995
999
  .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)}
1000
+ .ws-pending{display:flex;align-items:center;gap:6px;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}
1001
+ .ws-pending .ws-act-b{flex:none;margin-left:auto;white-space:nowrap;color:var(--warn-text);border-color:var(--warn-border)}
1002
+ .ws-pending-t{min-width:0}
1003
+ /* A phone's sheet: one line, the rows' pills say when. */
1004
+ .ws-popover.sheet .ws-pending-when{display:none}
998
1005
  .ws-popover.sheet .ws-row .ws-act{display:none}
999
1006
  .ws-strip{display:flex;gap:8px;padding:2px 6px 6px 26px}
1000
1007
  .ws-strip .ws-act-b{padding:0 14px;font-size:12.5px;line-height:28px;border-radius:6px}
@@ -1004,6 +1011,15 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
1004
1011
  .ws-popover.sheet .ctx-pop-head>.ctx-pop-kind{order:3;flex:1 0 calc(100% - 30px)}
1005
1012
  .ws-popover.sheet .ctx-pop-head>.ws-ring{order:4}
1006
1013
  .ws-popover.sheet .ws-row{height:30px;line-height:30px}
1014
+ /* + add: the list's foot (a sheet's last row), one KEY typed; an added item the set doesn't hold yet is a pending row of its own. */
1015
+ .ws-add{display:flex;align-items:center;gap:6px;margin-top:8px}
1016
+ .ws-add-in{flex:1;min-width:0;height:26px;box-sizing:border-box;padding:0 8px;border:1px dashed var(--border-strong);border-radius:6px;background:transparent;color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
1017
+ .ws-add-in::placeholder{color:var(--text-faint)}
1018
+ .ws-add-in:focus{outline:none;border-style:solid;border-color:var(--accent)}
1019
+ .ws-add .ws-act-b{line-height:22px;padding:0 10px}
1020
+ .ws-row.added .k{color:var(--warn-text);font-weight:600}
1021
+ .ws-popover.sheet .ws-add-in{height:34px;font-size:16px}
1022
+ .ws-popover.sheet .ws-add .ws-act-b{line-height:32px;padding:0 14px;font-size:12.5px;border-radius:6px}
1007
1023
  /* The session bar's chip, in the mem chip's place: a dot (the accent: changed since its list was last opened) and the count. */
1008
1024
  #infoBar .meta .ws-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 7px;border:1px solid transparent;cursor:pointer;font-family:inherit;font-weight:400;white-space:nowrap;font-variant-numeric:tabular-nums}
1009
1025
  #infoBar .meta .ws-chip:hover:not(:disabled){filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
@@ -183,7 +183,12 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
183
183
  reveal,
184
184
  // A new turn, or none: no height held yet.
185
185
  resetHeld: () => { held = null; },
186
- // The tallest content seen is forgotten (a new turn, the cloud closed by hand).
187
- forgetContent: () => { maxContent = 0; },
186
+ // The tallest content seen and the height held for it are forgotten (a
187
+ // new turn, the cloud closed by hand): the frame refits to what shows now.
188
+ forgetContent: () => {
189
+ maxContent = 0;
190
+ held = null;
191
+ fitFrame();
192
+ },
188
193
  };
189
194
  }
@@ -341,7 +341,7 @@ export function createStageView({
341
341
  if (rail) {
342
342
  const { items, ordinal } = workingSet.liveItems(turn, promptWords());
343
343
  stampRows(blockEl, turn, ordinal);
344
- rail.draw(items, { ctxPct: workingSet.ctxPct() });
344
+ rail.draw(items, { ctxPct: workingSet.ctxPct(), pending: workingSet.pending() });
345
345
  }
346
346
  const calls = slots.ticks.length;
347
347
  // An ended turn says each thing once: its steps in the chip (the label
@@ -437,7 +437,8 @@ export function createStageView({
437
437
  function setCloud(open) {
438
438
  cloudOpen = open;
439
439
  cloudBody.hidden = !open || cloud.hidden;
440
- // Closed by hand: its room isn't held at the scroller's end.
440
+ // Closed by hand: its room isn't held (the stage shrinks back to what
441
+ // shows).
441
442
  if (!open) frame.forgetContent();
442
443
  cloud.classList.toggle("open", open);
443
444
  stage.classList.toggle("cloud-open", open);
@@ -244,6 +244,8 @@ export function createStreamHandlers(deps) {
244
244
  state().llmContext = data.llm_context;
245
245
  updateInfoBar();
246
246
  llmContextControl.refresh();
247
+ // The working set's list offers pin only under forget.
248
+ workingSet?.strategyChanged();
247
249
  }
248
250
  // /thinking (or /model) changed the level the next turn runs at.
249
251
  if (data.thinking) {
@@ -315,7 +317,7 @@ export function createStreamHandlers(deps) {
315
317
  },
316
318
  // The server's working set (WorkingSet), at a request and at turn end;
317
319
  // ignored with web.working_set off.
318
- working_set_updated: (data) => { workingSet?.update({ items: data.items, at: data.at }); },
320
+ working_set_updated: (data) => { workingSet?.update({ items: data.items, at: data.at, ...(data.adds ? { adds: data.adds } : {}) }); },
319
321
  context_status: (data) => {
320
322
  const current = state();
321
323
  // After /model (Engine#announce_switched_context!): the new model's
@@ -15,9 +15,12 @@
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
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.
18
+ // tokens (and whether the user pinned it) by key (overlay), never its at.
19
+ // The user steers it with `/working-set drop|undrop|pin|unpin|add|unadd KEY…`
20
+ // lines (steerLine, addLine); one queued for after the running turn is
21
+ // pending (pendingSteers) until its command_ran, and an add stays pending
22
+ // until the next turn sends its note (the server's adds). Pins show only
23
+ // under a strategy with the forget layer (forgetOn).
21
24
 
22
25
  export const FLASH_MS = 4000;
23
26
  // Labels per form: the rail's four lines, the row's and the footprint's
@@ -217,9 +220,11 @@ function serverItem(index, item) {
217
220
  return index.urls.find((it) => String(it.key).startsWith(head)) || null;
218
221
  }
219
222
 
220
- // +items+ with the server's state and tokens (+server+: a snapshot {items,
221
- // at}, or null); an item it doesn't know stays live, without tokens. New
222
- // objects only where something changed; never touches at, ops or count.
223
+ // +items+ with the server's state, tokens, pinned and hasDrop (its
224
+ // has_drop: some output holds the user's drop) (+server+: a snapshot
225
+ // {items, at}, or null); an item it doesn't know stays live, without
226
+ // tokens, unpinned. New objects only where something changed; never
227
+ // touches at, ops or count.
223
228
  export function overlay(items, server) {
224
229
  if (!server) return items;
225
230
  const index = serverIndex(server);
@@ -229,8 +234,14 @@ export function overlay(items, server) {
229
234
  const tokens = found && Number.isFinite(found.tokens) ? found.tokens : undefined;
230
235
  // A cut URL row's full key, the one a steer line names.
231
236
  const serverKey = found && found.key !== item.key ? found.key : undefined;
232
- if (item.state === state && item.tokens === tokens && item.serverKey === serverKey) return item;
237
+ const pinned = found?.pinned === true ? true : undefined;
238
+ const hasDrop = found?.has_drop === true ? true : undefined;
239
+ if (item.state === state && item.tokens === tokens && item.serverKey === serverKey && item.pinned === pinned && item.hasDrop === hasDrop) return item;
233
240
  const next = { ...item, state };
241
+ if (pinned) next.pinned = true;
242
+ else delete next.pinned;
243
+ if (hasDrop) next.hasDrop = true;
244
+ else delete next.hasDrop;
234
245
  if (tokens === undefined) delete next.tokens;
235
246
  else next.tokens = tokens;
236
247
  if (serverKey === undefined) delete next.serverKey;
@@ -544,26 +555,33 @@ export function nextSettle(seenTimes, now, ms = FLASH_MS) {
544
555
  return next;
545
556
  }
546
557
 
547
- // ── steering (drop / undrop) ──────────────────────────────────────────────
558
+ // ── steering (drop / undrop, pin / unpin) ─────────────────────────────────
548
559
 
549
560
  export const STEER_COMMAND = "/working-set";
550
- const STEER_LINE = /^\/working-set\s+(drop|undrop)\s+(\S.*)$/;
561
+ const STEER_LINE = /^\/working-set\s+(drop|undrop|pin|unpin|add|unadd)\s+(\S.*)$/;
551
562
  const URL_WORD = /^[a-z][a-z0-9+.-]*:\/\//i;
552
- export const INVERSE = Object.freeze({ drop: "undrop", undrop: "drop" });
563
+ export const INVERSE = Object.freeze({ drop: "undrop", undrop: "drop", pin: "unpin", unpin: "pin", add: "unadd", unadd: "add" });
564
+ export const STEER_OPS = Object.freeze(Object.keys(INVERSE));
553
565
 
554
566
  // 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) {
567
+ // path, relative to +dir+ (the session's folder, which the worker resolves
568
+ // it against) when inside it, else absolute; memory:NAME; a URL (the
569
+ // server's full one for a cut row).
570
+ export function steerWord(item, dir = "") {
557
571
  if (item.kind === "memory") return `memory:${item.key}`;
558
572
  if (item.kind === "url") return item.serverKey || item.key;
559
- return item.key;
573
+ const base = String(dir || "").replace(/\/+$/, "");
574
+ return base && item.key.startsWith(`${base}/`) ? item.key.slice(base.length + 1) : item.key;
560
575
  }
561
576
 
562
- // The id the page keys an item by ("file:/abs/a.rb"), from a steer word.
563
- function wordId(word) {
577
+ // The id the page keys an item by ("file:/abs/a.rb"), from a steer word; a
578
+ // relative path is +dir+'s.
579
+ function wordId(word, dir = "") {
564
580
  if (word.startsWith("memory:")) return `memory:${word.slice("memory:".length)}`;
565
581
  if (URL_WORD.test(word)) return `url:${word}`;
566
- return `file:${word}`;
582
+ const base = String(dir || "").replace(/\/+$/, "");
583
+ if (!base || word.startsWith("/")) return `file:${word}`;
584
+ return `file:${base}/${word.replace(/^(\.\/)+/, "")}`;
567
585
  }
568
586
 
569
587
  // An item's id as steer lines name it.
@@ -571,27 +589,62 @@ export function steerId(item) {
571
589
  return wordId(steerWord(item));
572
590
  }
573
591
 
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(" ")}`;
592
+ // A line's words as the worker reads them (WorkingSetSteer.words):
593
+ // whitespace-separated, a quoted one ("…" or '…') keeps its spaces, a
594
+ // backslash escapes its quote or itself; an unclosed quote runs to the end.
595
+ const WORD = /\s*(?:"((?:[^"\\]|\\.)*)"?|'((?:[^'\\]|\\.)*)'?|(\S+))/ys;
596
+ export function steerWords(text) {
597
+ const words = [];
598
+ const source = String(text || "");
599
+ WORD.lastIndex = 0;
600
+ let m;
601
+ while (WORD.lastIndex < source.length && (m = WORD.exec(source))) {
602
+ const quoted = m[1] ?? m[2];
603
+ const word = quoted === undefined ? m[3] : quoted.replace(/\\(["'\\])/g, "$1");
604
+ if (word) words.push(word);
605
+ }
606
+ return words;
607
+ }
608
+
609
+ // +word+ as a line names it (WorkingSetSteer.quote): quoted when it has
610
+ // whitespace or starts with a quote, else as it is.
611
+ export function quoteWord(word) {
612
+ return /\s|^["']/.test(word) ? `"${word.replace(/["\\]/g, "\\$&")}"` : word;
577
613
  }
578
614
 
579
- // A steer line's op and ids ({op, ids}), or null for any other line.
580
- export function parseSteerLine(line) {
615
+ // The line that does +op+ to +items+ ("/working-set drop lib/a.rb memory:x"),
616
+ // a file inside +dir+ (the session's folder) by its relative path, a key
617
+ // with spaces quoted.
618
+ export function steerLine(op, items, dir = "") {
619
+ return `${STEER_COMMAND} ${op} ${items.map((item) => quoteWord(steerWord(item, dir))).join(" ")}`;
620
+ }
621
+
622
+ // A steer line's op and ids ({op, ids}), or null for any other line; a
623
+ // relative path is +dir+'s.
624
+ export function parseSteerLine(line, dir = "") {
581
625
  const m = STEER_LINE.exec(String(line || "").trim());
582
626
  if (!m) return null;
583
- return { op: m[1], ids: m[2].split(/\s+/).filter(Boolean).map(wordId) };
627
+ return { op: m[1], ids: steerWords(m[2]).map((word) => wordId(word, dir)) };
584
628
  }
585
629
 
586
630
  // 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();
631
+ // item id: its last op ("drop", "undrop", "pin", "unpin", "add"). An op
632
+ // followed by its inverse nets to nothing (the worker runs both, the second
633
+ // undoing the first); a pin after a drop is the pin (it takes the pending
634
+ // drop off), a drop after a pin is refused (the pin stays). +adds+: the ids
635
+ // the server's snapshot says are added already (made between turns, its
636
+ // adds), pending before any line; an unadd with no add pending is refused.
637
+ // A relative path is +dir+'s (the session's folder).
638
+ export function pendingSteers(lines, dir = "", adds = []) {
639
+ const pending = new Map(adds.map((id) => [id, "add"]));
591
640
  for (const line of lines) {
592
- const parsed = parseSteerLine(line);
641
+ const parsed = parseSteerLine(line, dir);
593
642
  if (!parsed) continue;
594
643
  for (const id of parsed.ids) {
644
+ // A drop after a pending pin: the worker refuses it (unpin it first).
645
+ if (parsed.op === "drop" && pending.get(id) === "pin") continue;
646
+ // An unadd of nothing added: the worker refuses it.
647
+ if (parsed.op === "unadd" && pending.get(id) !== "add") continue;
595
648
  if (pending.get(id) === INVERSE[parsed.op]) pending.delete(id);
596
649
  else pending.set(id, parsed.op);
597
650
  }
@@ -601,12 +654,90 @@ export function pendingSteers(lines) {
601
654
 
602
655
  // Why +item+ can't be dropped (a title for its disabled button), "" when
603
656
  // 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).
657
+ // server doesn't know it yet: a running call). A pinned one asks for an
658
+ // unpin first (WorkingSetSteer::Refused's words).
605
659
  export function dropRefusal(item) {
606
660
  if (item.muted || item.state === "dropped") return null;
607
661
  if (item.preloaded && !item.ops.includes("read")) return "preloaded into the system prompt: not dropped from here";
608
662
  if (!Number.isFinite(item.tokens)) return null;
663
+ if (item.pinned) return "pinned by the user: unpin it first";
609
664
  if (!item.ops.includes("read")) return "only edited or written: nothing of it to drop";
610
665
  if (item.state === "forgotten") return "forgotten: nothing of it is sent";
611
666
  return "";
612
667
  }
668
+
669
+ // Whether +item+ offers pin or unpin: one the server knows (its tokens), an
670
+ // edit-only one too (its later reads are covered), not a muted or dropped
671
+ // one, nor one holding a drop (undrop it first), nor a memory only preloaded.
672
+ export function pinnable(item) {
673
+ if (item.muted || item.state === "dropped" || item.hasDrop) return false;
674
+ if (item.preloaded && !item.ops.includes("read")) return false;
675
+ return Number.isFinite(item.tokens);
676
+ }
677
+
678
+ // Whether the strategy an llm ctx summary names ({strategy: "stale,forget"},
679
+ // session_state's llmContext) has the forget layer: pins mean something
680
+ // only there.
681
+ export function forgetOn(llmContext) {
682
+ return String(llmContext?.strategy || "").split(/[\s,|]+/).includes("forget");
683
+ }
684
+
685
+ // Whether +item+ offers undrop: dropped, or holding the user's drop on some
686
+ // of its outputs (hasDrop: the rest forgotten by the model's layers, or
687
+ // still sent).
688
+ export function undroppable(item) {
689
+ return item.state === "dropped" || item.hasDrop === true;
690
+ }
691
+
692
+ // ── + add ─────────────────────────────────────────────────────────────────
693
+
694
+ // The line that adds what the user typed (one KEY: a path, memory:NAME or a
695
+ // URL; quoted when it has spaces), or null for nothing typed.
696
+ export function addLine(text) {
697
+ const word = String(text || "").trim();
698
+ return word ? `${STEER_COMMAND} add ${quoteWord(word)}` : null;
699
+ }
700
+
701
+ // The ids (kind:key) of a server snapshot's adds (WorkingSet::Add: the
702
+ // adds the next turn's note asks the model to read).
703
+ export function serverAddIds(server) {
704
+ return (server?.adds || []).filter((a) => a && a.kind && a.key).map((a) => `${a.kind}:${a.key}`);
705
+ }
706
+
707
+ // An item for an id ("file:/abs/a.rb", "memory:x", "url:https://…") that
708
+ // the set doesn't hold yet: a pending add's row (added), a file by its path
709
+ // relative to +dir+ when inside it; +label+ the server's name when it has one.
710
+ export function addedItem(id, dir = "", label = "") {
711
+ const at = id.indexOf(":");
712
+ const kind = id.slice(0, at);
713
+ const key = id.slice(at + 1);
714
+ const item = { kind, key, ops: [], at: null, count: 0, state: "live", added: true };
715
+ if (kind === "file") {
716
+ const base = String(dir || "").replace(/\/+$/, "");
717
+ item.path = label || (base && key.startsWith(`${base}/`) ? key.slice(base.length + 1) : key);
718
+ item.label = item.path;
719
+ } else if (kind === "url") {
720
+ item.url = key;
721
+ item.label = urlLabel(key);
722
+ } else {
723
+ item.label = key;
724
+ }
725
+ return item;
726
+ }
727
+
728
+ // The pending adds of items +items+ doesn't hold, as rows (addedItem), in
729
+ // the order they were added; the server's labels where it has them.
730
+ export function addedItems(pending, items, server = null, dir = "") {
731
+ const held = new Set(items.map(steerId));
732
+ const labels = new Map((server?.adds || []).map((a) => [`${a.kind}:${a.key}`, a.label]));
733
+ return [...pending].filter(([id, op]) => op === "add" && !held.has(id)).map(([id]) => addedItem(id, dir, labels.get(id) || ""));
734
+ }
735
+
736
+ // Whether +item+ offers refresh (an add of an item the set holds: the model
737
+ // reads it again): stale, or forgotten without a drop of the user's (undrop
738
+ // brings that back); one the server knows, not muted nor only preloaded.
739
+ export function refreshable(item) {
740
+ if (item.muted || item.added || (item.preloaded && !item.ops.includes("read"))) return false;
741
+ if (!Number.isFinite(item.tokens)) return false;
742
+ return item.state === "stale" || (item.state === "forgotten" && item.hasDrop !== true);
743
+ }
@@ -7,8 +7,31 @@
7
7
  // so nothing restarts; a fold fades the absorbed lines out in place and the
8
8
  // lines below slide up once (each line sits at its slot by a transform).
9
9
  // Collapsed (kept per browser), it is its head line only, with the newest
10
- // entry ticking through it.
11
- import { BUDGETS, ariaLabel, fold, isFresh, labelTitle, nextSettle, shown } from "./working_set.js";
10
+ // entry ticking through it. Read-only: steering is the expanded view's; a
11
+ // line with a pending op (a /working-set line queued for the turn's end) is
12
+ // dashed, its title saying what applies next turn; an item added that the
13
+ // turn's set doesn't hold is a dashed `+ name · next turn` line of its own.
14
+
15
+ // A pending op's words on a line's title.
16
+ const PENDING_WORDS = { drop: "drops", undrop: "comes back", pin: "pinned", unpin: "unpinned", add: "read again" };
17
+
18
+ // The pending ops of +label+'s members (+pending+: working_set.js
19
+ // pendingSteers' Map), as "drops next turn" words; "" when none.
20
+ export function pendingText(label, pending) {
21
+ if (!pending?.size) return "";
22
+ const ops = [...new Set(label.members.map((m) => pending.get(steerId(m))).filter(Boolean))];
23
+ return ops.length ? `${ops.map((op) => PENDING_WORDS[op]).join(", ")} next turn` : "";
24
+ }
25
+ import { addedItems, BUDGETS, ariaLabel, fold, GLYPHS, isFresh, labelTitle, nextSettle, shown, steerId } from "./working_set.js";
26
+
27
+ // The pending adds +items+ doesn't hold as lines: `+ name · next turn`.
28
+ export function addLabels(items, pending) {
29
+ if (!pending?.size) return [];
30
+ return addedItems(pending, items).map((item) => ({
31
+ id: `add:${steerId(item)}`, type: "leaf", kind: item.kind, glyph: "+", text: `${GLYPHS[item.kind] ? `${GLYPHS[item.kind]} ` : ""}${item.kind === "file" ? item.path.split("/").pop() : item.label} · next turn`,
32
+ n: 1, edited: 0, cut: 0, state: "live", members: [], added: true,
33
+ }));
34
+ }
12
35
  import { readStored, writeStored } from "./storage.js";
13
36
  import { reducedMotion } from "./stage_frame.js";
14
37
 
@@ -157,16 +180,17 @@ export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
157
180
 
158
181
  // A state (the server's: stale faded, forgotten struck) is a class and a
159
182
  // mark on the same line: it never refolds and never moves a line.
160
- function fillLine(node, label, fresh) {
183
+ function fillLine(node, label, fresh, pend) {
161
184
  const state = label.state && label.state !== "live" ? ` ${label.state}` : "";
162
- node.className = `ws-item ${label.kind}${label.type !== "leaf" ? " group" : ""}${fresh ? " fresh" : ""}${state}`;
185
+ node.className = `ws-item ${label.kind}${label.type !== "leaf" ? " group" : ""}${fresh ? " fresh" : ""}${state}${pend ? " pending" : ""}`;
163
186
  const [k, t, n, e, c] = node.children;
164
187
  k.textContent = label.glyph || "";
165
188
  t.textContent = label.text;
166
189
  n.textContent = label.count ? `· ${label.count}` : "";
167
190
  e.textContent = label.edited ? (label.type === "leaf" ? "✎" : `✎${label.edited}`) : "";
168
191
  c.textContent = label.type !== "leaf" && label.cut ? `✂${label.cut}` : "";
169
- node.title = labelTitle(label);
192
+ if (label.added) node.title = "added: the model reads it at the next turn's start";
193
+ else node.title = pend ? `${labelTitle(label)}\n${pend}` : labelTitle(label);
170
194
  }
171
195
 
172
196
  // A line at its slot. A new line is placed before it is in the page (no
@@ -185,8 +209,9 @@ export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
185
209
  }
186
210
 
187
211
  // Draws +items+ (the running turn's, working_set.js itemsOfTurn); +ctxPct+
188
- // the ring's value (null: no ring yet).
189
- function draw(items, { ctxPct = null, now = Date.now() } = {}) {
212
+ // the ring's value (null: no ring yet); +pending+ the queued steer ops
213
+ // (working_set.js pendingSteers).
214
+ function draw(items, { ctxPct = null, now = Date.now(), pending = null } = {}) {
190
215
  // A turn joined mid-way (a reload, another tab): what is there already
191
216
  // is not new.
192
217
  for (const item of items) {
@@ -194,7 +219,8 @@ export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
194
219
  if (!seen.has(id)) seen.set(id, first ? -Infinity : now);
195
220
  }
196
221
  first = false;
197
- const budget = form() === "rail" ? BUDGETS.rail : BUDGETS.row;
222
+ const adds = addLabels(items, pending);
223
+ const budget = Math.max(1, (form() === "rail" ? BUDGETS.rail : BUDGETS.row) - adds.length);
198
224
  const folded = fold(items, budget, { kept });
199
225
  kept = folded.groups;
200
226
  const view = shown(folded.labels, budget);
@@ -222,8 +248,9 @@ export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
222
248
 
223
249
  // The lines, by key: an unchanged one is left alone.
224
250
  const animate = !reducedMotion();
225
- const lines = view.labels.map((label) => ({ id: label.id, label, fresh: freshOf(label) }));
251
+ const lines = view.labels.map((label) => ({ id: label.id, label, fresh: freshOf(label), pend: pendingText(label, pending) }));
226
252
  if (view.more) lines.push({ id: "more", more: view.more });
253
+ for (const label of adds) lines.push({ id: label.id, label, fresh: false, pend: "added next turn" });
227
254
  const ids = new Set(lines.map((l) => l.id));
228
255
  for (const [id, entry] of nodes) {
229
256
  if (ids.has(id)) continue;
@@ -235,7 +262,7 @@ export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
235
262
  }
236
263
  lines.forEach((line, i) => {
237
264
  let entry = nodes.get(line.id);
238
- const key = JSON.stringify(line.more ? ["more", line.more] : [line.id, line.label.text, line.label.count || "", line.label.n, line.label.edited, line.label.cut || 0, line.label.state || "live", line.fresh]);
265
+ const key = JSON.stringify(line.more ? ["more", line.more] : [line.id, line.label.text, line.label.count || "", line.label.n, line.label.edited, line.label.cut || 0, line.label.state || "live", line.fresh, line.pend]);
239
266
  if (!entry) {
240
267
  const node = line.more ? el("div", "ws-more") : lineNode(line.label);
241
268
  entry = { node, key: null, slot: -1 };
@@ -247,14 +274,14 @@ export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
247
274
  if (entry.key !== key) {
248
275
  entry.key = key;
249
276
  if (line.more) entry.node.textContent = `+ ${line.more} more`;
250
- else fillLine(entry.node, line.label, line.fresh);
277
+ else fillLine(entry.node, line.label, line.fresh, line.pend);
251
278
  }
252
279
  if (entry.slot !== i) {
253
280
  place(entry.node, i);
254
281
  entry.slot = i;
255
282
  }
256
283
  });
257
- rail.classList.toggle("empty", !items.length);
284
+ rail.classList.toggle("empty", !items.length && !adds.length);
258
285
  scheduleSettle(now);
259
286
  }
260
287