samagotchi 0.24.0 → 0.26.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +16 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/configuration.md +22 -13
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/engine.md +478 -0
  9. data/docs/internals/prompt-caching.md +120 -0
  10. data/docs/plugins.md +18 -3
  11. data/docs/testing.md +12 -0
  12. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/loop-guard/plugin.rb +59 -11
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/cancellation_controller.rb +8 -0
  16. data/lib/samagotchi/client.rb +53 -3
  17. data/lib/samagotchi/command_steps.rb +233 -0
  18. data/lib/samagotchi/config.rb +14 -4
  19. data/lib/samagotchi/empty_answer_retry.rb +2 -2
  20. data/lib/samagotchi/engine.rb +73 -11
  21. data/lib/samagotchi/guardrails/shell_lex.rb +49 -5
  22. data/lib/samagotchi/idle_client.rb +14 -2
  23. data/lib/samagotchi/kernel_loop.rb +60 -6
  24. data/lib/samagotchi/llm/chat_loop.rb +18 -4
  25. data/lib/samagotchi/llm/errors.rb +26 -1
  26. data/lib/samagotchi/llm/http.rb +5 -4
  27. data/lib/samagotchi/llm/openai_chat.rb +5 -4
  28. data/lib/samagotchi/llm/prompt_cache.rb +34 -9
  29. data/lib/samagotchi/llm/usage.rb +18 -3
  30. data/lib/samagotchi/log_subscriber.rb +2 -1
  31. data/lib/samagotchi/plugin/api.rb +3 -2
  32. data/lib/samagotchi/prompt_warmup.rb +95 -0
  33. data/lib/samagotchi/session_metrics.rb +38 -4
  34. data/lib/samagotchi/system_prompt.rb +30 -9
  35. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -2
  36. data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
  37. data/lib/samagotchi/terminal_ui/event_renderer.rb +14 -2
  38. data/lib/samagotchi/terminal_ui/formatting.rb +31 -9
  39. data/lib/samagotchi/terminal_ui.rb +2 -0
  40. data/lib/samagotchi/thinking_tails.rb +104 -0
  41. data/lib/samagotchi/token_usage.rb +18 -6
  42. data/lib/samagotchi/tool_activity.rb +37 -3
  43. data/lib/samagotchi/tool_declarations.rb +14 -0
  44. data/lib/samagotchi/tool_view.rb +40 -4
  45. data/lib/samagotchi/tools/builtins.rb +4 -1
  46. data/lib/samagotchi/tools/execute.rb +2 -1
  47. data/lib/samagotchi/tools/registry.rb +11 -4
  48. data/lib/samagotchi/tools/shell.rb +32 -0
  49. data/lib/samagotchi/tools/task_runtime.rb +3 -3
  50. data/lib/samagotchi/turn_note.rb +26 -2
  51. data/lib/samagotchi/turn_state.rb +6 -0
  52. data/lib/samagotchi/turn_tally.rb +17 -6
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/public/command_view.js +74 -3
  55. data/lib/samagotchi/web/public/format.js +11 -1
  56. data/lib/samagotchi/web/public/index.html +45 -13
  57. data/lib/samagotchi/web/public/stage_model.js +5 -1
  58. data/lib/samagotchi/web/public/stage_view.js +1 -1
  59. data/lib/samagotchi/web/public/turn_view.js +3 -3
  60. data/lib/samagotchi/worker.rb +3 -0
  61. metadata +7 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "tool_view"
4
+
3
5
  module Samagotchi
4
6
  # A running count of one turn's tool calls, for the one-line tally a long,
5
7
  # tool-heavy turn shows under its activity row:
@@ -32,10 +34,13 @@ module Samagotchi
32
34
 
33
35
  # @param key [Object] one call's identity within the turn (the event's
34
36
  # [iteration, call_index])
35
- def started(key:, tool:, params: nil)
37
+ # @param description [String, nil] a command's description, which the
38
+ # last call shows in place of its params
39
+ def started(key:, tool:, params: nil, description: nil)
36
40
  call = (@calls[key] ||= { tool: tool.to_s, params: "", status: "running" })
37
41
  call[:tool] = tool.to_s unless tool.to_s.empty?
38
42
  call[:params] = params.to_s unless params.nil?
43
+ call[:description] = description if description
39
44
  call[:status] = "running"
40
45
  call
41
46
  end
@@ -43,9 +48,10 @@ module Samagotchi
43
48
  # A completion with no start seen still counts as a call (as the web's
44
49
  # activity rows do). Only "error" is a failure: a call a guardrail or an
45
50
  # approval blocked still counts as a call, not as a failed one.
46
- def completed(key:, tool:, status:, params: nil)
47
- call = @calls[key] || started(key: key, tool: tool, params: params)
51
+ def completed(key:, tool:, status:, params: nil, description: nil)
52
+ call = @calls[key] || started(key: key, tool: tool, params: params, description: description)
48
53
  call[:params] = params.to_s if params
54
+ call[:description] = description if description
49
55
  call[:status] = status.to_s == "error" ? "error" : "ok"
50
56
  call
51
57
  end
@@ -58,7 +64,8 @@ module Samagotchi
58
64
  next unless part[:kind].to_s == "tool"
59
65
 
60
66
  key = [part[:iteration].to_i, part[:call_index].to_i]
61
- started(key: key, tool: part[:tool], params: part[:params])
67
+ view = part[:view].is_a?(Hash) ? part[:view].transform_keys(&:to_sym) : {}
68
+ started(key: key, tool: part[:tool], params: part[:params], description: ToolView.description_title(view))
62
69
  completed(key: key, tool: part[:tool], status: part[:status]) unless part[:status].to_s == "running"
63
70
  end
64
71
  self
@@ -85,8 +92,12 @@ module Samagotchi
85
92
  fields = [head] + top.map { |(tool, n), _| "#{tool} ×#{n}" }
86
93
  if last
87
94
  call = calls.last
88
- params = call[:params].to_s.gsub(/\s+/, " ").strip
89
- fields << "last: #{[call[:tool], params].reject(&:empty?).join(" ")}"
95
+ shown = if call[:description]
96
+ "#{call[:tool]}: #{call[:description]}"
97
+ else
98
+ [call[:tool], call[:params].to_s.gsub(/\s+/, " ").strip].reject(&:empty?).join(" ")
99
+ end
100
+ fields << "last: #{shown}"
90
101
  end
91
102
  cut(fields.join(SEPARATOR), width)
92
103
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.24.0"
4
+ VERSION = "0.26.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -17,12 +17,83 @@ export function rowHover(row) {
17
17
  // given: execute's may be relative to the project root), the command as
18
18
  // written (whitespace kept, its own scroll), a copy button, and a line
19
19
  // saying how much a cut command left out. "" for a call without a view.
20
- export function commandBlockHtml(view) {
20
+ //
21
+ // A view with steps (CommandSteps) shows them in the command's place, its
22
+ // leading cd as one more "in" tag, and a "raw" toggle (a checkbox, CSS
23
+ // only, so a live and a reloaded row behave alike; beside the first step
24
+ // when there's no "in" tag) that swaps the steps for the command as
25
+ // written. The copy button copies the raw command
26
+ // either way. A view without steps (the parser's fallback) is the raw
27
+ // block alone: nothing hides the command.
28
+ //
29
+ // +title+ is the row's: the model's description of the command, when it
30
+ // gave one, cut to fit. The block starts with the whole description when
31
+ // the title is cut, never in place of the command.
32
+ export function commandBlockHtml(view, title = null) {
21
33
  const command = view?.command;
22
34
  if (!command) return "";
23
- const cwd = view.cwd ? `<div class="activity-command-cwd">in <span>${escapeHtml(view.cwd)}</span></div>` : "";
35
+ const desc = view.description && view.description !== title
36
+ ? `<div class="activity-command-desc">${escapeHtml(view.description)}</div>`
37
+ : "";
38
+ const steps = Array.isArray(view.steps) && view.steps.length ? view.steps : null;
39
+ const dirs = [view.cwd, steps && view.cd].filter(Boolean);
40
+ const where = dirs.length
41
+ ? `in ${dirs.map((d) => `<span>${escapeHtml(d)}</span>`).join(" <span class=\"activity-command-sep\">›</span> ")}`
42
+ : "";
24
43
  const cut = view.truncated
25
44
  ? `<div class="activity-command-cut">… (${command.length.toLocaleString("en-US")} of ${Number(view.chars || 0).toLocaleString("en-US")} chars)</div>`
26
45
  : "";
27
- return `<div class="activity-command code-wrap">${cwd}<pre><code>${escapeHtml(command)}</code></pre>${copyButtonHtml("code")}${cut}</div>`;
46
+ const raw = `<pre><code>${escapeHtml(command)}</code></pre>`;
47
+ if (!steps) {
48
+ const cwd = where ? `<div class="activity-command-cwd">${where}</div>` : "";
49
+ return `<div class="activity-command code-wrap">${desc}${cwd}${raw}${copyButtonHtml("code")}${cut}</div>`;
50
+ }
51
+ // No "in" tag: the toggle alone, floated beside the first step (an
52
+ // empty line for it would sit above the steps).
53
+ const head = (where ? `<div class="activity-command-head"><span class="activity-command-cwd">${where}</span>` : `<div class="activity-command-head bare">`) +
54
+ `<label class="activity-command-raw" title="Show the command as written"><input type="checkbox">raw</label></div>`;
55
+ return `<div class="activity-command code-wrap has-steps">${desc}${head}${stepsHtml(steps)}${raw}${copyButtonHtml("code")}${cut}</div>`;
56
+ }
57
+
58
+ // What an op between two steps reads as: an arrow for "then" (&&, ;, a
59
+ // new line, a pipe), "else" for ||, "&" for one put in the background.
60
+ const OP_GLYPHS = { "&&": "→", ";": "→", "\n": "→", "|": "→", "|&": "→", "||": "else", "&": "&" };
61
+ const OP_NAMES = { "\n": "new line", "&": "after starting the step before in the background" };
62
+
63
+ function opHtml(op) {
64
+ if (!op) return `<span class="step-op"></span>`;
65
+ const glyph = OP_GLYPHS[op] || op;
66
+ const name = OP_NAMES[op] || op;
67
+ return `<span class="step-op${op === "||" ? " else" : ""}" title="${escapeHtml(name)}">${escapeHtml(glyph)}</span>`;
68
+ }
69
+
70
+ // A step's redirections to or from nowhere (2>&1, 2>/dev/null, </dev/null),
71
+ // dimmed: plumbing, not what the step does.
72
+ const PLUMBING = /(^|\s)(\d*>&\d+|&>\/dev\/null|\d*>>?\s*\/dev\/null|<\s*\/dev\/null)(?=\s|$)/g;
73
+
74
+ export function stepTextHtml(text) {
75
+ let out = "";
76
+ let at = 0;
77
+ for (const m of String(text).matchAll(PLUMBING)) {
78
+ const start = m.index + m[1].length;
79
+ out += `${escapeHtml(text.slice(at, start))}<span class="step-plumbing">${escapeHtml(m[2])}</span>`;
80
+ at = start + m[2].length;
81
+ }
82
+ return out + escapeHtml(String(text).slice(at));
83
+ }
84
+
85
+ // The steps as a list: a label as a small heading above its step, the op
86
+ // that links a step to the one before, its text, its limit and heredoc
87
+ // as chips (beside the text, below it when the row is narrow).
88
+ export function stepsHtml(steps) {
89
+ const items = steps.map((step) => {
90
+ const label = step.label ? `<li class="step-label">${escapeHtml(step.label)}</li>` : "";
91
+ const limit = step.limit ? `<span class="step-chip" title="output limited">${escapeHtml(step.limit)}</span>` : "";
92
+ const heredoc = step.heredoc
93
+ ? `<span class="step-chip heredoc" title="a heredoc: its text in the raw command">${escapeHtml(step.heredoc.tag)} · ` +
94
+ `${Number(step.heredoc.lines)} ${step.heredoc.lines === 1 ? "line" : "lines"}</span>`
95
+ : "";
96
+ return `${label}<li class="activity-step">${opHtml(step.op)}<span class="step-body"><code>${stepTextHtml(step.text)}</code>${limit}${heredoc}</span></li>`;
97
+ });
98
+ return `<ol class="activity-steps">${items.join("")}</ol>`;
28
99
  }
@@ -23,11 +23,21 @@ export function noteHtml(note) {
23
23
  return `<div class="note-line">${escapeHtml(from)}</div><div class="note-text">${escapeHtml(note?.content ?? "")}</div>`;
24
24
  }
25
25
 
26
+ // Who a steer names as its sender: a plugin's own label, chi's sender ids
27
+ // in words (the TUI's Formatting#steer_sender agrees;
28
+ // spec/shared/labels_matrix.json). "": no one named.
29
+ const STEER_SENDERS = { parent_agent: "parent agent", chi_send: "chi send", plugin_send: "plugin" };
30
+ export function steerSender(source) {
31
+ const id = String(source ?? "");
32
+ return STEER_SENDERS[id] || id;
33
+ }
34
+
26
35
  // A plugin's steer (a nudge put into the running turn): one line that
27
36
  // names who nudged the model, its text collapsed under it. +bubble+: on its
28
37
  // own between bubbles (no step holds it), not a row of a step.
29
38
  export function steerRowHtml({ source, text } = {}, { bubble = false } = {}) {
30
- const who = source ? `${source} nudged the model` : "nudged the model";
39
+ const sender = steerSender(source);
40
+ const who = sender ? `${sender} nudged the model` : "nudged the model";
31
41
  return `<details class="${bubble ? "bubble " : ""}steer-row"><summary>${escapeHtml(who)}</summary>` +
32
42
  `<div class="steer-text">${escapeHtml(text ?? "")}</div></details>`;
33
43
  }
@@ -5,14 +5,20 @@
5
5
  <title>Chi</title>
6
6
  <meta name="viewport" content="width=device-width,initial-scale=1">
7
7
  <style>
8
- :root{--bg:#0b0d11;--bg-panel:#151821;--bg-inset:#0f1115;--bg-raised:#22262f;--bg-hover:#1b1f27;--border:#1f232b;--border-strong:#2d333f;--text:#e6e8eb;--text-bright:#f0f3f6;--text-2:#c9d1d9;--text-3:#9aa4b2;--text-muted:#8b949e;--text-faint:#6e7681;--accent:#7aa5ff;--accent-strong:#1f6feb;--accent-bg:#1b2336;--accent-ring:rgba(122,165,255,.18);--primary:#2a68d1;--ok:#3fb950;--ok-text:#7ee787;--ok-bg:#1a2e1a;--warn:#d29922;--warn-text:#e3b341;--warn-bright:#f2cc60;--warn-bg:#2b2108;--warn-border:#6b5416;--danger:#f85149;--danger-bg:#da3633;--danger-text:#ff7b72;--danger-bg-dim:#3d1f1f;--think:#8957e5;--think-text:#d2a8ff;--accent-border:#223150;--accent-tint:color-mix(in srgb,var(--accent) 7%,transparent);--narr:color-mix(in srgb,var(--accent) 38%,var(--text-muted));
8
+ :root{color-scheme:dark;--bg:#0b0d11;--bg-panel:#151821;--bg-inset:#0f1115;--bg-raised:#22262f;--bg-hover:#1b1f27;--border:#1f232b;--border-strong:#2d333f;--text:#e6e8eb;--text-bright:#f0f3f6;--text-2:#c9d1d9;--text-3:#9aa4b2;--text-muted:#8b949e;--text-faint:#6e7681;--accent:#7aa5ff;--accent-strong:#1f6feb;--accent-bg:#1b2336;--accent-ring:rgba(122,165,255,.18);--primary:#2a68d1;--ok:#3fb950;--ok-text:#7ee787;--ok-bg:#1a2e1a;--warn:#d29922;--warn-text:#e3b341;--warn-bright:#f2cc60;--warn-bg:#2b2108;--warn-border:#6b5416;--danger:#f85149;--danger-bg:#da3633;--danger-text:#ff7b72;--danger-bg-dim:#3d1f1f;--think:#8957e5;--think-text:#d2a8ff;--accent-border:#223150;--accent-tint:color-mix(in srgb,var(--accent) 7%,transparent);--narr:color-mix(in srgb,var(--accent) 38%,var(--text-muted));
9
9
  --glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35);
10
- --diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc}
10
+ --diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc;
11
+ --card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25)}
12
+ /* Light palette: follows the system setting. :not([data-theme="dark"]) leaves room for a later in-page toggle (data-theme on <html>); there is none yet. Each palette sets color-scheme, so native radios, inputs and scrollbars follow it. */
13
+ @media (prefers-color-scheme:light){:root:not([data-theme="dark"]){color-scheme:light;--bg:#f5f6f8;--bg-panel:#ffffff;--bg-inset:#f1f3f6;--bg-raised:#e8ebf0;--bg-hover:#eceff4;--border:#dde1e7;--border-strong:#c3c9d2;--text:#1f2328;--text-bright:#0d1117;--text-2:#2f363d;--text-3:#475160;--text-muted:#57606a;--text-faint:#636c76;--accent:#0b5cd5;--accent-strong:#0550ae;--accent-bg:#e2ecfd;--accent-ring:rgba(11,92,213,.2);--primary:#1f6feb;--ok:#1a7f37;--ok-text:#116329;--ok-bg:#dafbe1;--warn:#9a6700;--warn-text:#7d4e00;--warn-bright:#875c00;--warn-bg:#fff6cc;--warn-border:#d4a72c;--danger:#cf222e;--danger-bg:#cf222e;--danger-text:#a40e26;--danger-bg-dim:#ffebe9;--think:#8250df;--think-text:#6639ba;--accent-border:#b8cef5;
14
+ --glass-border:rgba(31,35,40,.10);--glass-highlight:rgba(255,255,255,.7);--glass-shadow:0 8px 30px rgba(31,35,40,.10);
15
+ --diff-add-bg:rgba(26,127,55,.12);--diff-add-text:#116329;--diff-del-bg:rgba(207,34,46,.10);--diff-del-text:#82071e;
16
+ --card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14)}}
11
17
  *{box-sizing:border-box;margin:0;padding:0}
12
18
  body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:radial-gradient(1100px 700px at 8% 0%,color-mix(in srgb,var(--accent) 13%,transparent),transparent 70%),radial-gradient(900px 700px at 100% 100%,color-mix(in srgb,var(--think) 9%,transparent),transparent 70%),var(--bg);background-attachment:fixed;color:var(--text);height:100vh;display:flex;flex-direction:column}
13
19
  /* Glass: the composer card, the strip cards, the all-sessions cards and the question cards (by selector, so app.js is untouched). */
14
20
  #dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,.bubble.question,.bubble.plugin-card{background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
15
- #topStrip .card:hover,#allList .card:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);border-color:rgba(255,255,255,.14)}
21
+ #topStrip .card:hover,#allList .card:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);border-color:var(--card-hover-border)}
16
22
  #topStrip .card.active,#allList .card.active{background:color-mix(in srgb,var(--accent-bg) 75%,transparent);border-color:color-mix(in srgb,var(--accent) 35%,transparent)}
17
23
  /* A session that waits on the user (a question, an approval, a card with actions): the warning colour of its question card, also when active or hovered. */
18
24
  #topStrip .card.waiting,#allList .card.waiting{border-color:color-mix(in srgb,var(--warn) 75%,transparent)}
@@ -23,7 +29,7 @@ body.no-glass #dock,body.no-glass .turn-stage,body.no-glass .dock-card,body.no-g
23
29
  body.no-glass .bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}
24
30
  body.no-glass .bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}
25
31
  button,input,textarea,select{font:inherit}
26
- button{background:var(--primary);border:0;color:#fff;padding:8px 12px;border-radius:8px;cursor:pointer;transition:filter .12s ease,background .12s ease}
32
+ button{background:var(--primary);border:0;color:var(--on-solid);padding:8px 12px;border-radius:8px;cursor:pointer;transition:filter .12s ease,background .12s ease}
27
33
  button:hover:not(:disabled){filter:brightness(1.12)}
28
34
  button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
29
35
  button.ghost{background:var(--bg-raised);color:var(--text-2)}
@@ -91,8 +97,8 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
91
97
  .bubble.user.queued{border-left-style:dashed;opacity:.8}
92
98
  .bubble.user.steered{border-left-style:dashed;border-left-color:var(--accent);opacity:.9}
93
99
  .bubble.user.failed{border-left-color:var(--danger);opacity:.8}
94
- .bubble.user .state-badge{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:2px 7px;border-radius:999px;background:rgba(255,255,255,.08);color:var(--text);font-size:11px;line-height:1.2;white-space:nowrap;flex-shrink:0}
95
- .bubble.user .state-badge::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.8;box-shadow:0 0 0 1px rgba(255,255,255,.18)}
100
+ .bubble.user .state-badge{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:2px 7px;border-radius:999px;background:var(--badge-bg);color:var(--text);font-size:11px;line-height:1.2;white-space:nowrap;flex-shrink:0}
101
+ .bubble.user .state-badge::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.8;box-shadow:0 0 0 1px var(--badge-ring)}
96
102
  .bubble.user.queued .state-badge::before{background:var(--accent);box-shadow:0 0 0 1px var(--accent-ring)}
97
103
  .bubble.user.steered .state-badge::before{background:var(--accent);box-shadow:0 0 0 1px var(--accent-ring)}
98
104
  .bubble.user.failed .state-badge::before{background:var(--danger);box-shadow:0 0 0 1px color-mix(in srgb,var(--danger) 40%,transparent)}
@@ -141,6 +147,32 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
141
147
  .activity-command-cwd{margin-bottom:2px}
142
148
  .activity-command-cwd>span{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-muted)}
143
149
  .activity-command-cut{margin-top:2px}
150
+ .activity-command-desc{font-size:12px;line-height:1.4;color:var(--text-2);margin-bottom:3px;overflow-wrap:anywhere}
151
+ /* The command as its steps (CommandSteps): the op that links each to the one before, labels as small headings, limits and heredocs as chips; "raw" swaps in the command as written (CSS only). */
152
+ .activity-command.has-steps{background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;padding:4px 8px 5px}
153
+ .activity-command-head{display:flex;align-items:center;gap:8px;min-height:20px;padding-right:30px}
154
+ .activity-command-head.bare{float:right;min-height:0;margin-left:8px}
155
+ .has-steps .activity-command-cwd{flex:1 1 auto;min-width:0;margin:0;overflow-wrap:anywhere}
156
+ .activity-command-cwd>.activity-command-sep{font-family:inherit;color:var(--text-faint)}
157
+ .activity-command-raw{flex:none;display:inline-flex;align-items:center;height:18px;padding:0 7px;border:1px solid var(--border-strong);border-radius:9px;font-size:11px;color:var(--text-muted);cursor:pointer;user-select:none;-webkit-user-select:none}
158
+ .activity-command-raw:hover{color:var(--text-2)}
159
+ .activity-command-raw input{position:absolute;opacity:0;width:0;height:0;margin:0}
160
+ .activity-command-raw:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:1px}
161
+ .activity-command-raw:has(input:checked){color:var(--accent);border-color:var(--accent-border);background:var(--accent-tint)}
162
+ .activity-steps{list-style:none;margin:2px 0 0;padding:0;max-height:220px;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.5;color:var(--text-2)}
163
+ .activity-step{display:flex;align-items:baseline;gap:6px;min-width:0}
164
+ .step-body{flex:1 1 auto;min-width:0;display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 6px}
165
+ .step-body>code{min-width:0;font:inherit;white-space:pre-wrap;overflow-wrap:anywhere}
166
+ .step-op{flex:none;width:2.4em;text-align:right;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint)}
167
+ .step-op.else{color:var(--warn-text)}
168
+ .step-label{margin:5px 0 0 calc(2.4em + 6px);font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;font-weight:600;color:var(--text-muted)}
169
+ .step-label:first-child{margin-top:1px}
170
+ .step-chip{flex:none;align-self:center;padding:0 6px;border:1px solid var(--border-strong);border-radius:8px;font-family:ui-sans-serif,system-ui,sans-serif;font-size:10.5px;line-height:15px;color:var(--text-muted);white-space:nowrap}
171
+ .step-chip.heredoc{border-style:dashed}
172
+ .step-plumbing{color:var(--text-faint)}
173
+ .activity-command.has-steps>pre{display:none;max-height:220px;margin-top:2px;padding:0 4px;background:none;border:0}
174
+ .has-steps:has(.activity-command-raw input:checked)>pre{display:block}
175
+ .has-steps:has(.activity-command-raw input:checked)>.activity-steps{display:none}
144
176
  @media(max-width:600px){
145
177
  .activity-params{flex:1 1 0}
146
178
  .activity-output{margin-left:0}
@@ -341,7 +373,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
341
373
  /* The selection bubble (app.js Annotate): Annotate + the presets (web.annotate_presets), small pills with a gap (they wrap on a narrow page). */
342
374
  #annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
343
375
  #annotateBar[hidden]{display:none}
344
- #annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px rgba(0,0,0,.5)}
376
+ #annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px var(--shadow-strong)}
345
377
  #annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
346
378
  /* Copy buttons (copy.js): top-right in an answer and a code block, left of a prompt (its right edge is the page's); shown on hover or focus, always (dim) on touch. They hold no text: "copied ✓" is CSS content. */
347
379
  .bubble.output,.bubble.user{position:relative}
@@ -382,7 +414,7 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
382
414
  .thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
383
415
  .thumb{max-height:120px;max-width:200px;border-radius:6px;border:1px solid var(--border-strong);cursor:zoom-in;object-fit:contain;background:var(--bg)}
384
416
  .activity-row .thumbs{flex-basis:100%}
385
- #lightbox{position:fixed;inset:0;z-index:50;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;cursor:zoom-out}
417
+ #lightbox{position:fixed;inset:0;z-index:50;background:var(--backdrop);display:flex;align-items:center;justify-content:center;cursor:zoom-out}
386
418
  #lightbox[hidden]{display:none}
387
419
  #lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
388
420
  #composer textarea{flex:1 1 auto;min-height:72px;resize:none;background:transparent;border:0;color:var(--text);padding:2px;border-radius:0;font-size:15px;line-height:1.45}
@@ -573,7 +605,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
573
605
  #infoBar .meta{font-size:11px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
574
606
  #infoBar .meta .id{font-family:ui-monospace,monospace;font-weight:700;font-size:12px;color:var(--accent)}
575
607
  #infoBar .meta .copy-attach{padding:2px 8px;border-radius:6px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong)}
576
- #infoBar .meta .copy-attach:hover{color:#fff;border-color:var(--border-strong)}
608
+ #infoBar .meta .copy-attach:hover{color:var(--text-hover);border-color:var(--border-strong)}
577
609
  #infoBar .meta .status{font-size:11px;padding:2px 6px;border-radius:999px}
578
610
  #infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);word-break:break-all}
579
611
  /* The footer sits on the card's glass: its chips are tinted, not solid, so the glass reads through them. */
@@ -585,7 +617,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
585
617
  #infoBar .meta .worker-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:2px 2px 2px 6px;border-radius:6px;background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border);white-space:nowrap}
586
618
  #infoBar .meta .worker-badge:not(:has(button)){padding-right:6px}
587
619
  #infoBar .meta .worker-restart{padding:1px 8px;border-radius:5px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--warn-bright);border:1px solid var(--warn-border)}
588
- #infoBar .meta .worker-restart:hover:not(:disabled){background:var(--warn-border);color:#fff}
620
+ #infoBar .meta .worker-restart:hover:not(:disabled){background:var(--warn-border);color:var(--on-warn)}
589
621
  #infoBar .meta .worker-restart:disabled{opacity:.7;cursor:default}
590
622
  #infoBar .preview-line{font-size:13px;word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;line-height:1.4;max-height:2.8em}
591
623
  #infoBar .preview-line .preview, #infoBar .preview-line .mem{word-break:break-word}
@@ -596,7 +628,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
596
628
  #infoBar #infoArchiveBtn{background:transparent;color:var(--text-muted);border:1px solid var(--border-strong)}
597
629
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
598
630
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
599
- #infoBar #infoDeleteBtn:hover{color:#fff;background:var(--danger-bg);border-color:var(--danger-bg)}
631
+ #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
600
632
  /* Phones: the footer is one line; its meta scrolls sideways (all of it stays reachable) and the first-message line, the strip card's title, goes. */
601
633
  @media(max-width:600px){
602
634
  /* The key hint: cut off at this width, and phones have no Shift+Enter. */
@@ -718,7 +750,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
718
750
  .card.archived{opacity:.6}
719
751
  .card.archived:hover{opacity:.85}
720
752
  .card .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
721
- #toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:13px}
753
+ #toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
722
754
  #toast[hidden]{display:none}
723
755
  #toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
724
756
  #toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
@@ -736,7 +768,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
736
768
  #allList .card{position:relative}
737
769
  .card-del{position:absolute;top:6px;right:6px;padding:2px 7px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--text-muted);font-size:12px;line-height:1.2;cursor:pointer;opacity:0;transition:opacity .15s}
738
770
  #allList .card:hover .card-del, .card-del:focus-visible{opacity:1}
739
- .card-del:hover{color:#fff;background:var(--danger-bg);border-color:var(--danger-bg)}
771
+ .card-del:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
740
772
  @media (hover:none){.card-del{opacity:1}}
741
773
  .card.active{background:var(--accent-bg);border-color:var(--accent-border)}
742
774
  .card .preview{font-size:14px;line-height:1.4;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -4,6 +4,7 @@
4
4
  // covers the rules; stage_view.js draws them.
5
5
  import { toolName } from "./activity.js";
6
6
  import { splitSentences } from "./sentences.js";
7
+ import { steerSender } from "./format.js";
7
8
  import { hookNoticeLabel } from "./turn_events.js";
8
9
 
9
10
  const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
@@ -174,7 +175,10 @@ export function handOffOrder({ prompt = null, block = null, extras = [], answer
174
175
  // chi's own asking-again row (a notice with a line and no hook) does not:
175
176
  // the status line tells that already. null: nothing flashes.
176
177
  export function flashOf(what, data = {}) {
177
- if (what === "steer") return { kind: "steer", text: data.source ? `${data.source} nudged the model` : "nudged the model" };
178
+ if (what === "steer") {
179
+ const sender = steerSender(data.source);
180
+ return { kind: "steer", text: sender ? `${sender} nudged the model` : "nudged the model" };
181
+ }
178
182
  if (what !== "notice" || !data.hook) return null;
179
183
  return { kind: data.level === "warn" ? "warn" : "info", text: data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}` };
180
184
  }
@@ -234,7 +234,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
234
234
  name.textContent = tool.name;
235
235
  const title = node("span", "ts-title", now);
236
236
  title.textContent = tool.title;
237
- // The full command when the call has one (the title is its first line).
237
+ // The full command when the call has one (the title is its first step +N).
238
238
  title.title = tool.command || tool.title;
239
239
  } else {
240
240
  const word = node("span", "ts-title quiet", now);
@@ -54,8 +54,8 @@ function fillActivityRow(el, row, sessionId) {
54
54
  const outEl = el.querySelector(".activity-output");
55
55
  // The call's full command, above its output (running too).
56
56
  if (row.view?.command && !el.querySelector(".activity-command")) {
57
- if (outEl) outEl.insertAdjacentHTML("beforebegin", commandBlockHtml(row.view));
58
- else el.insertAdjacentHTML("beforeend", commandBlockHtml(row.view));
57
+ if (outEl) outEl.insertAdjacentHTML("beforebegin", commandBlockHtml(row.view, row.title));
58
+ else el.insertAdjacentHTML("beforeend", commandBlockHtml(row.view, row.title));
59
59
  }
60
60
  if (outEl) {
61
61
  if (row.status === "running") {
@@ -691,7 +691,7 @@ function reloadRowHtml(r, thumbs) {
691
691
  : "";
692
692
  return `<div class="activity-row" data-key="${escapeHtml(r.key)}"><span class="activity-status ${activityStatusClass(r.status)}">${activityStatusLabel(r.status)}</span>` +
693
693
  `<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
694
- `${commandBlockHtml(r.view)}${output}` +
694
+ `${commandBlockHtml(r.view, r.title)}${output}` +
695
695
  `${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
696
696
  }
697
697
 
@@ -112,6 +112,9 @@ module Samagotchi
112
112
  # stream gets the session's history and status in its snapshot, not
113
113
  # an empty session until the first turn.
114
114
  @engine.session = @session
115
+ # Input already waiting in the inbox starts the next turn at once: no
116
+ # turn-end warm-up then.
117
+ @engine.next_turn_waiting = -> { SessionInbox.find_new_input_files(@session_dir).any? }
115
118
  @turn_flow = TurnFlow.new(engine: @engine)
116
119
  # Its question's answer is queued as the matching /continue (the
117
120
  # Bridge exists by the time anyone answers).
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -130,7 +130,9 @@ files:
130
130
  - docs/hooks.md
131
131
  - docs/internals/background-tasks.md
132
132
  - docs/internals/context-telemetry.md
133
+ - docs/internals/engine.md
133
134
  - docs/internals/gemma4-contract.md
135
+ - docs/internals/prompt-caching.md
134
136
  - docs/internals/tool-guardrails.md
135
137
  - docs/memory.md
136
138
  - docs/plugins.md
@@ -196,6 +198,7 @@ files:
196
198
  - lib/samagotchi/cli/flags.rb
197
199
  - lib/samagotchi/cli/parent_wait.rb
198
200
  - lib/samagotchi/client.rb
201
+ - lib/samagotchi/command_steps.rb
199
202
  - lib/samagotchi/commands/registry.rb
200
203
  - lib/samagotchi/config.rb
201
204
  - lib/samagotchi/config_text_edit.rb
@@ -322,6 +325,7 @@ files:
322
325
  - lib/samagotchi/prompt.rb
323
326
  - lib/samagotchi/prompt_history.rb
324
327
  - lib/samagotchi/prompt_literal_guard.rb
328
+ - lib/samagotchi/prompt_warmup.rb
325
329
  - lib/samagotchi/question_desk.rb
326
330
  - lib/samagotchi/recap_setup.rb
327
331
  - lib/samagotchi/recap_store.rb
@@ -373,6 +377,7 @@ files:
373
377
  - lib/samagotchi/terminal_ui/version_lines.rb
374
378
  - lib/samagotchi/text_diff.rb
375
379
  - lib/samagotchi/thinking.rb
380
+ - lib/samagotchi/thinking_tails.rb
376
381
  - lib/samagotchi/thinking_ticker.rb
377
382
  - lib/samagotchi/thought_stream_splitter.rb
378
383
  - lib/samagotchi/token_usage.rb
@@ -402,6 +407,7 @@ files:
402
407
  - lib/samagotchi/tools/register_reminder.rb
403
408
  - lib/samagotchi/tools/registry.rb
404
409
  - lib/samagotchi/tools/send_note.rb
410
+ - lib/samagotchi/tools/shell.rb
405
411
  - lib/samagotchi/tools/task_create.rb
406
412
  - lib/samagotchi/tools/task_get.rb
407
413
  - lib/samagotchi/tools/task_list.rb