samagotchi 0.55.0 → 0.56.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +6 -2
  4. data/docs/cli.md +29 -7
  5. data/docs/configuration.md +84 -8
  6. data/docs/internals/background-tasks.md +1 -1
  7. data/docs/internals/engine.md +14 -0
  8. data/docs/internals/prompt-caching.md +8 -0
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  11. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  12. data/lib/samagotchi/cache_confirm.rb +51 -0
  13. data/lib/samagotchi/client_id.rb +12 -0
  14. data/lib/samagotchi/config.rb +7 -3
  15. data/lib/samagotchi/engine.rb +102 -8
  16. data/lib/samagotchi/engine_peers.rb +1 -0
  17. data/lib/samagotchi/host_config.rb +6 -3
  18. data/lib/samagotchi/host_registry.rb +4 -3
  19. data/lib/samagotchi/kernel_loop.rb +99 -16
  20. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  21. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  22. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  23. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  24. data/lib/samagotchi/model_settings.rb +5 -2
  25. data/lib/samagotchi/reasoning_history.rb +132 -0
  26. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  27. data/lib/samagotchi/self_report.rb +21 -0
  28. data/lib/samagotchi/session.rb +23 -2
  29. data/lib/samagotchi/session_commands.rb +55 -15
  30. data/lib/samagotchi/session_manager.rb +2 -1
  31. data/lib/samagotchi/session_setup.rb +7 -5
  32. data/lib/samagotchi/setting_explained.rb +20 -0
  33. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
  35. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  36. data/lib/samagotchi/terminal_ui.rb +13 -6
  37. data/lib/samagotchi/thinking.rb +20 -25
  38. data/lib/samagotchi/thinking_command.rb +33 -11
  39. data/lib/samagotchi/tools/builtins.rb +6 -2
  40. data/lib/samagotchi/tools/peers.rb +4 -2
  41. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/app.rb +23 -2
  44. data/lib/samagotchi/web/public/app.js +20 -1
  45. data/lib/samagotchi/web/public/commands.js +36 -3
  46. data/lib/samagotchi/web/public/composer.js +5 -1
  47. data/lib/samagotchi/web/public/index.html +6 -3
  48. data/lib/samagotchi/web/public/info_bar.js +5 -3
  49. data/lib/samagotchi/web/public/level_chip.js +174 -0
  50. data/lib/samagotchi/web/public/model_pick.js +1 -1
  51. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  52. data/lib/samagotchi/web/public/session_state.js +5 -0
  53. data/lib/samagotchi/web/public/stream_handlers.js +10 -2
  54. data/lib/samagotchi/web/public/thinking_chip.js +33 -138
  55. data/lib/samagotchi/worker.rb +17 -5
  56. metadata +8 -1
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cache_confirm"
3
4
  require_relative "thinking"
4
5
 
5
6
  module Samagotchi
@@ -10,6 +11,7 @@ module Samagotchi
10
11
  # model, its host, then thinking.level). A change takes effect at the
11
12
  # next turn's start (the REPL and the worker run it between turns), and
12
13
  # the reply says what it costs the prompt cache (Thinking.cache_cost).
14
+ # A costly change asks first (CacheConfirm); --yes (or -y) applies it.
13
15
  class ThinkingCommand
14
16
  NAME = "/thinking"
15
17
  SETTABLE = (Thinking::LEVELS - [Thinking::DEFAULT]).freeze
@@ -17,35 +19,43 @@ module Samagotchi
17
19
 
18
20
  COST_NOTES = {
19
21
  full: "On this model the whole prompt is read again (Gemma's <|think|> starts it).",
20
- provider: "The request's thinking fields change: a local llama.cpp or Splash server keeps its cache; " \
21
- "hosted APIs (OpenRouter → Claude, OpenAI) may restart theirs.",
22
+ provider: "The request's thinking fields change: a hosted API (OpenRouter, Claude, OpenAI) reads the whole " \
23
+ "conversation again.",
22
24
  tail: "Only the prompt's tail changes: the cache keeps the rest."
23
25
  }.freeze
24
26
 
25
- # @param engine [Engine] #thinking_explained, #thinking_override(=),
26
- # #session, #thinking_cache_cost
27
+ # @param engine [Engine] #thinking_explained(own:), #thinking_override(=),
28
+ # #session, #thinking_cache_cost, #history?
27
29
  # @param save [#call] saves the session after a change
28
30
  def initialize(engine:, save:)
29
31
  @engine = engine
30
32
  @save = save
31
33
  end
32
34
 
33
- # @param args [String] what follows /thinking
34
- # @return [Array(String, Boolean)] the reply and whether the level changed
35
+ # @param args [String] what follows /thinking (--yes anywhere)
36
+ # @param confirm [Boolean] ask before a costly change (CacheConfirm);
37
+ # false applies it at once (chi send, a delegate, a start flag)
38
+ # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
39
+ # whether the level changed, and the confirm it asks for (nothing
40
+ # changed then)
35
41
  # @raise [ArgumentError] a word that isn't a level (with USAGE)
36
- def run(args)
37
- word = args.to_s.strip.downcase
38
- return [listing(@engine.thinking_explained), false] if word.empty?
42
+ def run(args, confirm: true)
43
+ word, yes = CacheConfirm.split(args)
44
+ return [listing(@engine.thinking_explained), false, nil] if word.empty?
39
45
 
40
46
  level = word.to_sym
41
47
  raise ArgumentError, "unknown thinking level #{word}\n#{USAGE}" unless Thinking::LEVELS.include?(level)
42
48
 
43
49
  session = @engine.session or raise ArgumentError, "no session yet"
50
+ own = SETTABLE.include?(level) ? level : nil
44
51
  before = @engine.thinking_explained
45
- @engine.thinking_override = SETTABLE.include?(level) ? level : nil
52
+ ask = confirm && !yes ? ask(before, own, word) : nil
53
+ return ask if ask
54
+
55
+ @engine.thinking_override = own
46
56
  @save.call(session)
47
57
  after = @engine.thinking_explained
48
- [[listing(after), *notes(before, after)].join("\n"), true]
58
+ [[listing(after), *notes(before, after)].join("\n"), true, nil]
49
59
  end
50
60
 
51
61
  # The level, where it came from, and what the session sets itself.
@@ -62,6 +72,18 @@ module Samagotchi
62
72
 
63
73
  private
64
74
 
75
+ # The reply that asks before a costly change, nil when it needn't.
76
+ def ask(before, own, word)
77
+ would = @engine.thinking_explained(own: own)
78
+ return nil unless before && would
79
+
80
+ cost = @engine.thinking_cache_cost
81
+ return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
82
+
83
+ step = CacheConfirm.confirm(NAME, word)
84
+ [[listing(before), "Switching to #{would.level} restarts the prompt cache: #{COST_NOTES[cost]}", step.text].join("\n"), false, step]
85
+ end
86
+
65
87
  def notes(before, after)
66
88
  return [] unless before && after
67
89
  return ["(no change to what the next turn runs under)"] if before.level == after.level
@@ -121,7 +121,8 @@ module Samagotchi
121
121
  # /model), which chi self reports; unset when unknown, so a value
122
122
  # inherited from an outer session never passes for this one. So too
123
123
  # the session's own thinking level (SAMAGOTCHI_SESSION_THINKING, unset
124
- # without one), which chi self puts first. A
124
+ # without one), which chi self puts first, and its own reasoning_history
125
+ # level (SAMAGOTCHI_SESSION_REASONING_HISTORY, the same way). A
125
126
  # worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
126
127
  # is unset: it was that run's, not the default of a chi run here.
127
128
  def parent_env(kctx, env: ENV)
@@ -129,9 +130,11 @@ module Samagotchi
129
130
  id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
130
131
  model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
131
132
  thinking = peers.respond_to?(:thinking) ? peers.thinking&.to_s : nil
133
+ history = peers.respond_to?(:reasoning_history) ? peers.reasoning_history&.to_s : nil
132
134
  { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
133
135
  SESSION_MODEL_ENV => model.empty? ? nil : model,
134
- SESSION_THINKING_ENV => thinking }.merge(cli_model_unset(env))
136
+ SESSION_THINKING_ENV => thinking,
137
+ SESSION_REASONING_HISTORY_ENV => history }.merge(cli_model_unset(env))
135
138
  end
136
139
 
137
140
  # The unsets that keep a `chi --model X` worker's default from passing
@@ -144,6 +147,7 @@ module Samagotchi
144
147
 
145
148
  SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
146
149
  SESSION_THINKING_ENV = "SAMAGOTCHI_SESSION_THINKING"
150
+ SESSION_REASONING_HISTORY_ENV = "SAMAGOTCHI_SESSION_REASONING_HISTORY"
147
151
 
148
152
  # task_create's parent_env, less the unsets of a variable the model
149
153
  # set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
@@ -9,10 +9,12 @@ module Samagotchi
9
9
  # delegate's approval is relayed through (relay: nil when this session
10
10
  # can't host one), and the model the session runs on (model_ref: the
11
11
  # resolved ref, live after /model), and the session's own thinking level
12
- # (thinking: nil without one). Engine hands KernelLoop one that follows its current
12
+ # (thinking: nil without one) and reasoning_history level
13
+ # (reasoning_history: nil without one). Engine hands KernelLoop one that follows its current
13
14
  # session (Engine::PeerView); this plain one is for specs and a kernel
14
15
  # without an Engine.
15
- Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, keyword_init: true) do
16
+ Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, :reasoning_history,
17
+ keyword_init: true) do
16
18
  # @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
17
19
  def cancelled?
18
20
  cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
@@ -22,7 +22,8 @@ module Samagotchi
22
22
  OUTPUT_TAIL_READ_BYTES = 16 * 1024
23
23
  SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
24
24
  # Set by chi (Builtins.parent_env); the model's env can't change it.
25
- MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING].freeze
25
+ MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
26
+ SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
26
27
  # task_wait and task_get add it to a task the user stopped (the web's
27
28
  # stop-task button), so the model doesn't rerun the job.
28
29
  # A record whose pid isn't a process group chi started (missing, 0, 1,
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.55.0"
4
+ VERSION = "0.56.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -26,6 +26,7 @@ require_relative "../llm_context_override"
26
26
  require_relative "../model_catalog"
27
27
  require_relative "../model_profile"
28
28
  require_relative "../thinking"
29
+ require_relative "../reasoning_history"
29
30
  require_relative "../sampling_settings"
30
31
  require_relative "../served_model"
31
32
  require_relative "../project_scope"
@@ -517,7 +518,7 @@ module Samagotchi
517
518
  end
518
519
 
519
520
  # The thinking level +model+ starts at, with where it came from (the
520
- # start page's think chip; Thinking::Explained#summary, no session).
521
+ # start page's think chip; SettingExplained#summary, no session).
521
522
  def add_thinking(model, target, names, settings)
522
523
  model[:thinking] = Thinking.explain(target, names: names, models: settings).summary
523
524
  rescue StandardError
@@ -561,7 +562,7 @@ module Samagotchi
561
562
  end
562
563
 
563
564
  # A session's thinking level with where it came from and its own
564
- # (Thinking::Explained#summary), as its worker would resolve it; nil
565
+ # (SettingExplained#summary), as its worker would resolve it; nil
565
566
  # when it can't be. Config and the registry only: no network.
566
567
  def thinking_for(session)
567
568
  registry = host_registry
@@ -573,6 +574,22 @@ module Samagotchi
573
574
  nil
574
575
  end
575
576
 
577
+ # A session's reasoning_history level with where it came from and its
578
+ # own (SettingExplained#summary), as its worker would resolve it; nil
579
+ # on a native host (it ignores the level) or when it can't be. Config
580
+ # and the registry only: no network.
581
+ def reasoning_history_for(session)
582
+ registry = host_registry
583
+ name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
584
+ target = registry.resolve(name)
585
+ return nil unless target.entry&.chat?
586
+
587
+ names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
588
+ ReasoningHistory.explain(target, names: names, session: session.reasoning_history).summary
589
+ rescue StandardError
590
+ nil
591
+ end
592
+
576
593
  # Built again when config.yml's hosts: changed (compared as read: the
577
594
  # YAML read is mtime-cached), so the model picker lists a host added
578
595
  # while chi web runs. An injected registry (specs) stays.
@@ -820,6 +837,10 @@ module Samagotchi
820
837
  session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
821
838
  # The thinking level the next turn runs at (the think chip), the same way.
822
839
  session_json = session_json.merge(thinking: (snapshot && snapshot["thinking"]) || thinking_for(session))
840
+ # Its reasoning history (the reasoning chip): the worker's (nil on a
841
+ # native host), else the server's reading (an older worker, none).
842
+ history = snapshot&.key?("reasoning_history") ? snapshot["reasoning_history"] : reasoning_history_for(session)
843
+ session_json = session_json.merge(reasoning_history: history)
823
844
  raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
824
845
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
825
846
  json_response(200, {
@@ -30,6 +30,7 @@ import {
30
30
  chipModel as thinkingChipModel, createThinkingControl,
31
31
  withChoice as withThinkingChoice,
32
32
  } from "./thinking_chip.js";
33
+ import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
33
34
  import { renderInfoBar } from "./info_bar.js";
34
35
  import { createShownTexts } from "./shown_texts.js";
35
36
  import { MODEL_KEY } from "./model_pick.js";
@@ -67,7 +68,7 @@ import { createLiveTurn } from "./live_turn.js";
67
68
  import { installRefClicks, setRefKinds } from "./refs.js";
68
69
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
69
70
  import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
70
- import { drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
71
+ import { applyConfirm, drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
71
72
  import { createComposerCommands, routeComposerLine, routeSessionLine } from "./composer.js";
72
73
  import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
73
74
  import { createStreamHandlers } from "./stream_handlers.js";
@@ -134,6 +135,15 @@ const thinkingControl = createThinkingControl({
134
135
  running: () => liveTurn.running,
135
136
  run: (line) => runSessionCommand(line),
136
137
  });
138
+ // The open session's reasoning history chip, after the thinking one (a
139
+ // chat host's only): /reasoning-history runs in the session's worker.
140
+ const reasoningHistoryControl = createReasoningHistoryControl({
141
+ el: infoTextEl,
142
+ summary: () => (selected ? current.reasoningHistory : null),
143
+ session: () => selected,
144
+ running: () => liveTurn.running,
145
+ run: (line) => runSessionCommand(line),
146
+ });
137
147
  // The start page's thinking chip: the picked model's level, and a level
138
148
  // for the next new chat only (sent with its create, then dropped).
139
149
  const thinkingNewEl = $("#thinkingNew");
@@ -763,6 +773,7 @@ function renderSessionView(data, { kept = [] } = {}) {
763
773
  const activeTurnId = current.timing.activeTurn?.id || null;
764
774
  llmContextControl.refresh();
765
775
  thinkingControl.refresh();
776
+ reasoningHistoryControl.refresh();
766
777
  if (data.messages && data.messages.length) renderHistory(data.messages);
767
778
  else renderHistory(data.history || []);
768
779
  restoreCommandBubbles(historyEl, kept);
@@ -1429,6 +1440,7 @@ function updateInfoBar() {
1429
1440
  kids: childrenSummary({ id: selected }, allSessions),
1430
1441
  llmContext: current.llmContext,
1431
1442
  thinking: current.thinking,
1443
+ reasoningHistory: current.reasoningHistory,
1432
1444
  // The model notes the session's prompt carried.
1433
1445
  notes: promptNotesChip(current.promptNotes),
1434
1446
  memories: current.usedMemories,
@@ -1596,6 +1608,7 @@ const streamHandlers = createStreamHandlers({
1596
1608
  contextChips,
1597
1609
  llmContextControl,
1598
1610
  thinkingControl,
1611
+ reasoningHistoryControl,
1599
1612
  sentIds,
1600
1613
  earlyRestores,
1601
1614
  stream: {
@@ -2284,6 +2297,12 @@ const annotateBar = createAnnotateBar({
2284
2297
  },
2285
2298
  });
2286
2299
  historyEl.addEventListener("scroll", annotateBar.schedule);
2300
+ // A command bubble's Apply (CacheConfirm): sends the change's --yes line
2301
+ // once; its own bubble then says what it did.
2302
+ historyEl.addEventListener("click", (e) => {
2303
+ const button = e.target.closest?.("[data-confirm-line]");
2304
+ if (button) applyConfirm(button, runSessionCommand);
2305
+ });
2287
2306
  turnDom.scrollEl?.addEventListener("scroll", annotateBar.schedule, true);
2288
2307
  turnRoots().forEach((root) => installCopy(root));
2289
2308
 
@@ -148,13 +148,17 @@ export function continueLine(choice, reason = "") {
148
148
  // it shows; its command_ran fills that bubble in (`commandId`). So is a
149
149
  // command queued for after the running turn (`queued`): its command_queued
150
150
  // (waits: "turn_end") draws it `waiting`, saying so, until it ran or was
151
- // dropped (styled as failed).
151
+ // dropped (styled as failed). A change that restarts the prompt cache asks
152
+ // first (CacheConfirm): `confirm` is the line that applies it, and this
153
+ // tab's own bubble (`own`) gets an Apply button that sends it.
152
154
  export const QUEUED_TEXT = "queued: runs after this turn";
155
+ export const APPLY_TEXT = "Apply (restarts the cache)";
153
156
 
154
157
  export function commandView(event, myId) {
155
158
  const own = isOwn(event.client_id, myId);
156
159
  const waiting = event.waits === "turn_end";
157
160
  return {
161
+ own,
158
162
  label: own ? null : clientLabel(event.client_id),
159
163
  line: event.line || "",
160
164
  text: event.output || (waiting ? QUEUED_TEXT : ""),
@@ -166,6 +170,7 @@ export function commandView(event, myId) {
166
170
  queued: waiting || event.queued === true,
167
171
  waiting,
168
172
  commandId: event.command_id || null,
173
+ confirm: typeof event.confirm?.line === "string" ? event.confirm.line : null,
169
174
  // A card's action (Nudge…): no bubble unless it says something back.
170
175
  hidden: event.card === true && !event.output && event.status !== "error",
171
176
  };
@@ -183,8 +188,12 @@ export function historyHasCommandBubble(historyBubbles, line) {
183
188
  }
184
189
 
185
190
  // A command bubble's line and output (again, when its command_ran fills in
186
- // an anytime command's bubble in place).
187
- export function drawCommandBubble(div, { label, line, text, busy = false, failed = false, waiting = false, commandId = null }) {
191
+ // an anytime command's bubble in place), and this tab's own change's Apply
192
+ // button when it asks first (a delegated click sends its line: app.js).
193
+ // Command bubbles are page-only: after a reload the button is gone, and
194
+ // the change is typed again.
195
+ export function drawCommandBubble(div, { label, line, text, busy = false, failed = false, waiting = false, commandId = null,
196
+ own = false, confirm = null }) {
188
197
  div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "") + (waiting ? " queued" : "");
189
198
  if (commandId) div.dataset.commandId = commandId;
190
199
  div.replaceChildren();
@@ -198,6 +207,30 @@ export function drawCommandBubble(div, { label, line, text, busy = false, failed
198
207
  body.textContent = text;
199
208
  div.appendChild(body);
200
209
  }
210
+ if (own && confirm) {
211
+ const apply = document.createElement("button");
212
+ apply.type = "button";
213
+ apply.className = "command-confirm";
214
+ apply.dataset.confirmLine = confirm;
215
+ apply.title = confirm;
216
+ apply.textContent = APPLY_TEXT;
217
+ div.appendChild(apply);
218
+ }
219
+ }
220
+
221
+ // A click on a bubble's Apply button: sends its --yes line once (the
222
+ // button is disabled at once), and enables it again when the line wasn't
223
+ // sent (+run+ resolves false or throws), so the user can try again.
224
+ export async function applyConfirm(button, run) {
225
+ if (!button || button.disabled) return;
226
+ button.disabled = true;
227
+ let sent = false;
228
+ try {
229
+ sent = await run(button.dataset.confirmLine);
230
+ } catch {
231
+ sent = false;
232
+ }
233
+ if (!sent) button.disabled = false;
201
234
  }
202
235
 
203
236
  // The prompt and command bubbles in the history, in order, by kind
@@ -86,14 +86,18 @@ export function createComposerCommands(deps) {
86
86
 
87
87
  // A session command from a control (the llm ctx chip), not the composer:
88
88
  // its command_ran shows its line and reply, as a typed one's would.
89
+ // Resolves true once the worker took it, false when it wasn't sent (no
90
+ // session, a refusal or a network error: the failed bubble says why).
89
91
  async function run(line) {
90
92
  const id = session();
91
- if (!id) return;
93
+ if (!id) return false;
92
94
  try {
93
95
  await sendCommand(id, line, { clientId });
94
96
  followWokenWorker();
97
+ return true;
95
98
  } catch (e) {
96
99
  failed(line, e.message);
100
+ return false;
97
101
  }
98
102
  }
99
103
 
@@ -307,6 +307,9 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
307
307
  .bubble.command.queued .command-output{color:var(--text-faint)}
308
308
  .command-line{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-3)}
309
309
  .command-output{margin:6px 0 0;white-space:pre-wrap;font-size:12px;color:var(--text-2)}
310
+ .bubble.command .command-confirm{margin-top:8px;padding:3px 10px;border-radius:6px;font:inherit;font-size:12px;cursor:pointer;background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border)}
311
+ .bubble.command .command-confirm:hover:not(:disabled){border-color:var(--accent)}
312
+ .bubble.command .command-confirm:disabled{opacity:.55;cursor:default}
310
313
  .bubble.continue{border-left:3px solid var(--accent);background:var(--bg-panel);padding:10px 12px}
311
314
  .bubble.continue.resolved{border-left-color:var(--text-muted);opacity:.8}
312
315
  .continue-text{font-size:14px;color:var(--text);margin-bottom:6px}
@@ -704,9 +707,9 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
704
707
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
705
708
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
706
709
  /* The LLM context and thinking chips (llm_context_chip.js, thinking_chip.js): model-like chips that open their popovers; accent when the session set its own. */
707
- #infoBar .meta .llmctx-chip,#infoBar .meta .think-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
708
- #infoBar .meta .llmctx-chip:hover,#infoBar .meta .think-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
709
- #infoBar .meta .llmctx-chip.own,#infoBar .meta .think-chip.own{color:var(--accent);border-color:var(--accent-border)}
710
+ #infoBar .meta .llmctx-chip,#infoBar .meta .think-chip,#infoBar .meta .rh-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
711
+ #infoBar .meta .llmctx-chip:hover,#infoBar .meta .think-chip:hover,#infoBar .meta .rh-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
712
+ #infoBar .meta .llmctx-chip.own,#infoBar .meta .think-chip.own,#infoBar .meta .rh-chip.own{color:var(--accent);border-color:var(--accent-border)}
710
713
  .llmctx-form{display:flex;flex-direction:column;gap:8px;margin:0}
711
714
  .llmctx-form .ctx-pop-head strong{font-family:inherit}
712
715
  .llmctx-field{display:flex;align-items:center;gap:8px}
@@ -1,6 +1,6 @@
1
1
  // The session bar's info line (#infoText): the open session's id, its copy
2
2
  // chi --attach button, status, model, chain links, parent and delegates,
3
- // llm ctx and thinking chips, model notes, memories, folder, ctx, speed,
3
+ // llm ctx, thinking and reasoning history chips, model notes, memories, folder, ctx, speed,
4
4
  // session clock and the worker's version badge. A lit-html template over a
5
5
  // plain model app.js builds (updateInfoBar), so the markup lives here and
6
6
  // runs under node --test (spec/web/public/info_bar.test.js).
@@ -15,6 +15,7 @@ import { memChipText, memChipTitle } from "./format.js";
15
15
  import { ALL_SESSIONS_HASH, sessionHash } from "./route.js";
16
16
  import { chipTemplate as llmContextChip } from "./llm_context_chip.js";
17
17
  import { chipTemplate as thinkingChip } from "./thinking_chip.js";
18
+ import { chipTemplate as reasoningHistoryChip } from "./reasoning_history_chip.js";
18
19
 
19
20
  export const PREV = "←";
20
21
  export const NEXT = "→";
@@ -60,7 +61,8 @@ function kidsTemplate(kids) {
60
61
  // id, copied (the copy button just copied), status, model ({text, title,
61
62
  // mismatch}: format.js modelLabel), chain ({prev, next}: chainLinkTemplate's
62
63
  // links or null), parent ({id, title} or null), kids (format.js
63
- // childrenSummary), llmContext and thinking (the chips' summaries), notes
64
+ // childrenSummary), llmContext, thinking and reasoningHistory (the chips'
65
+ // summaries; reasoningHistory null on a native host: no chip), notes
64
66
  // ({text, title} or null), memories (the used names), dir, ctx ({text,
65
67
  // tip}), speed ({text, tip} or null), clock (the session time's text, ""
66
68
  // for none), worker ({badge, busy} or null).
@@ -68,7 +70,7 @@ export function infoBarHtml(bar) {
68
70
  if (!bar) return nothing;
69
71
  const attach = `chi --attach ${bar.id}`;
70
72
  const memories = bar.memories || [];
71
- return html`<div class="meta"><span class="id" title=${bar.id}>${String(bar.id).slice(0, 8)}</span><button type="button" class="copy-attach" title=${`Copy "${attach}" to attach a terminal`}>${bar.copied ? "copied ✓" : "copy chi --attach"}</button><span class=${bar.status ? `status ${bar.status}` : "status"}>${bar.status || ""}</span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${llmContextChip(bar.llmContext)}${thinkingChip(bar.thinking)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class="model speed" title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}${bar.clock ? html`<span class="model session-time">${bar.clock}</span>` : nothing}${workerBadgeTemplate(bar.worker)}</div>`;
73
+ return html`<div class="meta"><span class="id" title=${bar.id}>${String(bar.id).slice(0, 8)}</span><button type="button" class="copy-attach" title=${`Copy "${attach}" to attach a terminal`}>${bar.copied ? "copied ✓" : "copy chi --attach"}</button><span class=${bar.status ? `status ${bar.status}` : "status"}>${bar.status || ""}</span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${llmContextChip(bar.llmContext)}${thinkingChip(bar.thinking)}${reasoningHistoryChip(bar.reasoningHistory)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class="model speed" title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}${bar.clock ? html`<span class="model session-time">${bar.clock}</span>` : nothing}${workerBadgeTemplate(bar.worker)}</div>`;
72
74
  }
73
75
 
74
76
  // Draws +bar+ into +el+ (#infoText), with the memories in its tooltip.
@@ -0,0 +1,174 @@
1
+ // A level chip in the session bar and its popover: the level the next turn
2
+ // runs at ("think low", "reasoning all"; "· session" when the session set
3
+ // its own) and a form with a level select that runs the setting's command
4
+ // in the session's worker (the reply shows in the conversation, like a
5
+ // typed one). The summary is the setting's SettingExplained#summary
6
+ // ({level, source, own}: the worker's snapshot, a command_ran, or the
7
+ // server's own reading of the session file).
8
+ //
9
+ // A config names the setting (thinking_chip.js, reasoning_history_chip.js):
10
+ // name the popover's heading ("Thinking")
11
+ // prefix the chip's word ("think")
12
+ // command the command ("/thinking")
13
+ // levels the settable levels (the select's options after "follow")
14
+ // anchor the chip's selector (its data attribute: each config's
15
+ // chipTemplate draws it; clicks are delegated on the same
16
+ // element as the other chips')
17
+ // formClass, popoverClass, nowClass the form's, popover's and "Now:"
18
+ // line's own classes
19
+ // when(fresh) the form's note on when it applies (fresh: the start
20
+ // page's new chat, thinking only)
21
+ //
22
+ // The start page's new chat (kind NEW_CHAT) has a thinking chip only
23
+ // (thinking_chip.js keeps its choice): the form shows the model's level and
24
+ // takes a choice for the next create.
25
+
26
+ import { NEW_CHAT, SESSION, popoverPlace } from "./llm_context_chip.js";
27
+ import { html, nothing, render } from "./vendor/lit-html.js";
28
+
29
+ export const DEFAULT = "default";
30
+
31
+ // The chip: its text, tooltip and whether the session (or the new chat)
32
+ // set its own; null without a summary (an older worker, or a host the
33
+ // setting means nothing on).
34
+ export function chipModel(cfg, summary, { kind = SESSION } = {}) {
35
+ if (!summary || !summary.level) return null;
36
+ const own = !!summary.own;
37
+ const what = kind === NEW_CHAT ? "the new chat" : "this session";
38
+ const from = own ? what : summary.source || "the model's own";
39
+ const title = `${cfg.name}: ${summary.level} (${from}). Click to change it for ${what}.`;
40
+ return { text: `${cfg.prefix} ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
41
+ }
42
+
43
+ // The command line for a select value ("default" unsets the session's own),
44
+ // with --yes: picking a level in the form is the choice, and the form
45
+ // names the cost (CacheConfirm asks nothing then); null when it is what
46
+ // the form opened with.
47
+ export function commandLine(cfg, level, initial = null) {
48
+ if (initial !== null && level === initial) return null;
49
+ return `${cfg.command} ${level} --yes`;
50
+ }
51
+
52
+ // "Now: low (session)", "Now: default (the model's own)".
53
+ export function nowText(summary) {
54
+ return `Now: ${summary.level} (${summary.source || "the model's own"})`;
55
+ }
56
+
57
+ const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
58
+
59
+ // The popover's form (a lit-html template). For the new chat (kind
60
+ // NEW_CHAT), +summary+ is the model's and +choice+ the level the form
61
+ // shows; +now+ is the summary its "Now:" line reads (refresh passes a
62
+ // newer one, the rest stays as the form opened).
63
+ export function formTemplate(cfg, summary, { running = false, kind = SESSION, choice = null, now = summary } = {}) {
64
+ const fresh = kind === NEW_CHAT;
65
+ const chosen = (fresh ? choice : summary.own) || DEFAULT;
66
+ const follow = summary.own && !fresh ? "follow the model" : `follow the model (${summary.level})`;
67
+ return html`<form class=${`${cfg.formClass} llmctx-form`}><div class="ctx-pop-head"><strong>${cfg.name}</strong
68
+ ><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
69
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
70
+ ><div class=${`ctx-pop-meta ${cfg.nowClass}`}>${nowText(now)}</div
71
+ ><label class="llmctx-field"><span>Level</span><select name="level">${option(DEFAULT, follow, chosen)}${
72
+ cfg.levels.map((level) => option(level, level, chosen))}</select></label
73
+ ><div class="ctx-pop-meta">${cfg.when(fresh)}${running ? " A turn is running: it applies after this turn." : ""}</div
74
+ ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
75
+ }
76
+
77
+ // The chip's popover, as createLlmContextControl's: el is where the chip
78
+ // is drawn (clicks are delegated: #infoText, info_bar.js);
79
+ // summary(): the open session's summary; session(): its id; running(): a
80
+ // turn runs; run(line): sends the command. The start page's (kind
81
+ // NEW_CHAT): summary() is the picked model's, choice() the pending level
82
+ // and choose(next) takes a Set's (null for "follow the model"). id and
83
+ // anchor (the chip's selector, by default its data attribute) keep it
84
+ // apart from the other controls on the same element (the anchor defaults
85
+ // to the config's).
86
+ export function createLevelControl({ cfg, el, summary, session = () => null, running = () => false, run,
87
+ kind = SESSION, choice = () => null, choose = null,
88
+ id, anchor: anchorSel = cfg.anchor,
89
+ doc = document, win = window }) {
90
+ const fresh = kind === NEW_CHAT;
91
+ const pop = doc.createElement("div");
92
+ pop.id = id;
93
+ pop.className = `ctx-popover ${cfg.popoverClass}`;
94
+ pop.setAttribute("role", "dialog");
95
+ pop.hidden = true;
96
+ doc.body.appendChild(pop);
97
+ let initial = null;
98
+ let openFor = null;
99
+ // What the open form was drawn from: the model's or session's summary
100
+ // and the form's options. A closed popover is empty, so each open draws
101
+ // a fresh form (an unsent pick is gone).
102
+ let shown = null;
103
+ const draw = (now) => render(formTemplate(cfg, shown.data, { ...shown.opts, now }), pop);
104
+
105
+ function place(anchor) {
106
+ const width = Math.min(320, win.innerWidth - 32);
107
+ pop.style.width = `${width}px`;
108
+ pop.style.maxHeight = "";
109
+ const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
110
+ innerWidth: win.innerWidth, innerHeight: win.innerHeight });
111
+ pop.style.left = `${at.left}px`;
112
+ pop.style.top = at.top === undefined ? "" : `${at.top}px`;
113
+ pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
114
+ if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
115
+ }
116
+
117
+ function open(anchor) {
118
+ const data = summary();
119
+ if (!data) return;
120
+ initial = (fresh ? choice() : data.own) || DEFAULT;
121
+ openFor = session();
122
+ shown = { data, opts: { running: running(), kind, choice: choice() } };
123
+ draw(data);
124
+ pop.hidden = false;
125
+ place(anchor);
126
+ }
127
+
128
+ function close() {
129
+ pop.hidden = true;
130
+ initial = null;
131
+ openFor = null;
132
+ shown = null;
133
+ render(nothing, pop);
134
+ }
135
+
136
+ function isOpen() { return !pop.hidden; }
137
+
138
+ // A new summary: an open form keeps the user's pick; only "Now:" follows
139
+ // (the same template again: lit writes that line alone).
140
+ function refresh() {
141
+ if (!isOpen()) return;
142
+ const data = summary();
143
+ if (!data || session() !== openFor) return close();
144
+ draw(data);
145
+ }
146
+
147
+ el.addEventListener("click", (e) => {
148
+ const anchor = e.target.closest(anchorSel);
149
+ if (!anchor) return;
150
+ e.stopPropagation();
151
+ if (isOpen()) close();
152
+ else open(anchor);
153
+ });
154
+ pop.addEventListener("submit", (e) => {
155
+ e.preventDefault();
156
+ const level = e.target.elements.level.value;
157
+ const was = initial;
158
+ close();
159
+ if (fresh) return choose(level === DEFAULT ? null : level);
160
+ const line = commandLine(cfg, level, was);
161
+ if (line) run(line);
162
+ });
163
+ pop.addEventListener("click", (e) => {
164
+ if (e.target.closest("[data-act]")?.dataset.act === "close") close();
165
+ });
166
+ doc.addEventListener("click", (e) => {
167
+ if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
168
+ });
169
+ doc.addEventListener("keydown", (e) => {
170
+ if (e.key === "Escape" && isOpen()) close();
171
+ });
172
+
173
+ return { close, refresh, get open() { return isOpen(); } };
174
+ }
@@ -22,7 +22,7 @@ export const RECENT_KEY = "chi_model_recent";
22
22
 
23
23
  // The payload's models as rows: {name, host, id, isDefaultHost}, and
24
24
  // sampling (the configured request parameters as /model words them) when
25
- // the model has some, thinking (the level it starts at, Thinking::Explained#summary),
25
+ // the model has some, thinking (the level it starts at, SettingExplained#summary),
26
26
  // and llm_context (the LLM context it starts under,
27
27
  // LLMContextStrategy::Explained#summary) when the server sent one, and
28
28
  // configured when the host declares the id (hosts.<name>.models), and