samagotchi 0.51.0 → 0.53.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/README.md +1 -1
  4. data/bin/chi +11 -3
  5. data/docs/architecture.md +1 -1
  6. data/docs/cli.md +23 -8
  7. data/docs/configuration.md +59 -19
  8. data/docs/memory.md +11 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/releasing.md +6 -3
  11. data/docs/sessions.md +5 -1
  12. data/docs/testing.md +8 -1
  13. data/lib/samagotchi/analytics_file.rb +35 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  15. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  16. data/lib/samagotchi/bridge.rb +10 -13
  17. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  18. data/lib/samagotchi/bundle_command.rb +9 -13
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/children_status.rb +1 -1
  22. data/lib/samagotchi/commands/registry.rb +2 -1
  23. data/lib/samagotchi/config.rb +50 -182
  24. data/lib/samagotchi/config_text_edit.rb +8 -32
  25. data/lib/samagotchi/context_sources.rb +0 -7
  26. data/lib/samagotchi/context_window.rb +2 -2
  27. data/lib/samagotchi/continue_offer.rb +5 -4
  28. data/lib/samagotchi/cut_policy.rb +0 -1
  29. data/lib/samagotchi/delivery.rb +23 -0
  30. data/lib/samagotchi/engine.rb +63 -18
  31. data/lib/samagotchi/engine_peers.rb +1 -0
  32. data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
  33. data/lib/samagotchi/host_config.rb +183 -0
  34. data/lib/samagotchi/host_registry.rb +18 -10
  35. data/lib/samagotchi/last_turn.rb +48 -0
  36. data/lib/samagotchi/live_versions.rb +0 -6
  37. data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
  38. data/lib/samagotchi/llm_context_override.rb +3 -22
  39. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  40. data/lib/samagotchi/memory_bundle/builder.rb +23 -21
  41. data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
  42. data/lib/samagotchi/memory_bundle/installer.rb +4 -4
  43. data/lib/samagotchi/memory_bundle/manifest.rb +6 -28
  44. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  45. data/lib/samagotchi/memory_bundle/provenance.rb +8 -17
  46. data/lib/samagotchi/model_profile.rb +4 -4
  47. data/lib/samagotchi/model_settings.rb +46 -0
  48. data/lib/samagotchi/plugin/context.rb +3 -3
  49. data/lib/samagotchi/plugin/sessions.rb +2 -2
  50. data/lib/samagotchi/queued_command.rb +35 -0
  51. data/lib/samagotchi/reminder_store.rb +0 -27
  52. data/lib/samagotchi/reply_wait.rb +11 -13
  53. data/lib/samagotchi/sampling_settings.rb +4 -4
  54. data/lib/samagotchi/self_report.rb +8 -5
  55. data/lib/samagotchi/send_command.rb +40 -46
  56. data/lib/samagotchi/session.rb +45 -24
  57. data/lib/samagotchi/session_commands.rb +68 -11
  58. data/lib/samagotchi/session_inbox.rb +18 -9
  59. data/lib/samagotchi/session_manager.rb +44 -11
  60. data/lib/samagotchi/session_metrics.rb +15 -20
  61. data/lib/samagotchi/session_setup.rb +30 -0
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/steer_cut.rb +9 -2
  64. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  66. data/lib/samagotchi/terminal_ui/formatting.rb +26 -4
  67. data/lib/samagotchi/terminal_ui.rb +21 -4
  68. data/lib/samagotchi/thinking.rb +67 -7
  69. data/lib/samagotchi/thinking_command.rb +72 -0
  70. data/lib/samagotchi/tools/builtins.rb +7 -2
  71. data/lib/samagotchi/tools/peers.rb +3 -2
  72. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/vision_support.rb +4 -3
  75. data/lib/samagotchi/web/app.rb +56 -13
  76. data/lib/samagotchi/web/public/app.js +131 -20
  77. data/lib/samagotchi/web/public/command_complete.js +7 -2
  78. data/lib/samagotchi/web/public/data.js +8 -4
  79. data/lib/samagotchi/web/public/format.js +8 -4
  80. data/lib/samagotchi/web/public/index.html +8 -5
  81. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  82. data/lib/samagotchi/web/public/model_pick.js +3 -1
  83. data/lib/samagotchi/web/public/thinking_chip.js +179 -0
  84. data/lib/samagotchi/web/public/timing.js +15 -0
  85. data/lib/samagotchi/web/public/turn_events.js +23 -0
  86. data/lib/samagotchi/web/public/turn_view.js +328 -310
  87. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  88. data/lib/samagotchi/web/session_summary.rb +1 -1
  89. data/lib/samagotchi/worker.rb +43 -42
  90. data/lib/samagotchi/worker_inbound.rb +5 -5
  91. data/lib/samagotchi/worker_wakes.rb +0 -2
  92. data/lib/samagotchi/yaml_lines.rb +72 -0
  93. metadata +13 -1
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "thinking"
4
+
5
+ module Samagotchi
6
+ # /thinking, the session's own thinking level (Session#thinking): with no
7
+ # argument, the level the next turn runs at and where it came from;
8
+ # `/thinking off|low|medium|high` sets the session's own, `/thinking
9
+ # default` unsets it (the session follows the process default, the
10
+ # model, its host, then thinking.level). A change takes effect at the
11
+ # next turn's start (the REPL and the worker run it between turns), and
12
+ # the reply says what it costs the prompt cache (Thinking.cache_cost).
13
+ class ThinkingCommand
14
+ NAME = "/thinking"
15
+ SETTABLE = (Thinking::LEVELS - [Thinking::DEFAULT]).freeze
16
+ USAGE = "usage: #{NAME} [#{Thinking::LEVELS.join("|")}]".freeze
17
+
18
+ COST_NOTES = {
19
+ 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
+ tail: "Only the prompt's tail changes: the cache keeps the rest."
23
+ }.freeze
24
+
25
+ # @param engine [Engine] #thinking_explained, #thinking_override(=),
26
+ # #session, #thinking_cache_cost
27
+ # @param save [#call] saves the session after a change
28
+ def initialize(engine:, save:)
29
+ @engine = engine
30
+ @save = save
31
+ end
32
+
33
+ # @param args [String] what follows /thinking
34
+ # @return [Array(String, Boolean)] the reply and whether the level changed
35
+ # @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?
39
+
40
+ level = word.to_sym
41
+ raise ArgumentError, "unknown thinking level #{word}\n#{USAGE}" unless Thinking::LEVELS.include?(level)
42
+
43
+ session = @engine.session or raise ArgumentError, "no session yet"
44
+ before = @engine.thinking_explained
45
+ @engine.thinking_override = SETTABLE.include?(level) ? level : nil
46
+ @save.call(session)
47
+ after = @engine.thinking_explained
48
+ [[listing(after), *notes(before, after)].join("\n"), true]
49
+ end
50
+
51
+ # The level, where it came from, and what the session sets itself.
52
+ def listing(explained)
53
+ return "thinking: can't be resolved for this model" unless explained
54
+
55
+ lines = ["thinking: #{explained.label}"]
56
+ unless explained.own
57
+ lines << " this session's own: none (follows chi web --thinking / SAMAGOTCHI_THINKING_LEVEL, the model, " \
58
+ "its host, then thinking.level)"
59
+ end
60
+ lines.join("\n")
61
+ end
62
+
63
+ private
64
+
65
+ def notes(before, after)
66
+ return [] unless before && after
67
+ return ["(no change to what the next turn runs under)"] if before.level == after.level
68
+
69
+ ["From the next turn's start (a running turn keeps its own).", COST_NOTES[@engine.thinking_cache_cost]].compact
70
+ end
71
+ end
72
+ end
@@ -119,15 +119,19 @@ module Samagotchi
119
119
  # wrapper. The session's id, "chi" without one. And the model the
120
120
  # session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
121
121
  # /model), which chi self reports; unset when unknown, so a value
122
- # inherited from an outer session never passes for this one. A
122
+ # inherited from an outer session never passes for this one. So too
123
+ # the session's own thinking level (SAMAGOTCHI_SESSION_THINKING, unset
124
+ # without one), which chi self puts first. A
123
125
  # worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
124
126
  # is unset: it was that run's, not the default of a chi run here.
125
127
  def parent_env(kctx, env: ENV)
126
128
  peers = kctx.peers
127
129
  id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
128
130
  model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
131
+ thinking = peers.respond_to?(:thinking) ? peers.thinking&.to_s : nil
129
132
  { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
130
- SESSION_MODEL_ENV => model.empty? ? nil : model }.merge(cli_model_unset(env))
133
+ SESSION_MODEL_ENV => model.empty? ? nil : model,
134
+ SESSION_THINKING_ENV => thinking }.merge(cli_model_unset(env))
131
135
  end
132
136
 
133
137
  # The unsets that keep a `chi --model X` worker's default from passing
@@ -139,6 +143,7 @@ module Samagotchi
139
143
  end
140
144
 
141
145
  SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
146
+ SESSION_THINKING_ENV = "SAMAGOTCHI_SESSION_THINKING"
142
147
 
143
148
  # task_create's parent_env, less the unsets of a variable the model
144
149
  # set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
@@ -8,10 +8,11 @@ module Samagotchi
8
8
  # canceled (a waiting tool returns on it), and the question flow a
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
- # resolved ref, live after /model). Engine hands KernelLoop one that follows its current
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
13
  # session (Engine::PeerView); this plain one is for specs and a kernel
13
14
  # without an Engine.
14
- Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, keyword_init: true) do
15
+ Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, keyword_init: true) do
15
16
  # @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
16
17
  def cancelled?
17
18
  cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
@@ -22,7 +22,7 @@ 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].freeze
25
+ MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING].freeze
26
26
  # task_wait and task_get add it to a task the user stopped (the web's
27
27
  # stop-task button), so the model doesn't rerun the job.
28
28
  # 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.51.0"
4
+ VERSION = "0.53.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -24,7 +24,7 @@ module Samagotchi
24
24
  # @param target [HostRegistry::ModelTarget]
25
25
  # @param profile [ModelProfile, nil] the model's prompt profile (native)
26
26
  # @param adapter [LLM::OpenAIChat, nil] the chat host's adapter
27
- # @param models [Hash, nil] ConfigFile.model_settings (specs)
27
+ # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
28
28
  # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
29
29
  # @return [Answer]
30
30
  def self.for(target, names:, profile: nil, adapter: nil, models: nil)
@@ -51,8 +51,9 @@ module Samagotchi
51
51
  rescue StandardError
52
52
  {}
53
53
  end
54
- key, value = ConfigFile.model_setting(names, :vision, models: models)
55
- return Answer.new(value: value, reason: value ? nil : "models: #{key} sets vision: false") if key
54
+ if (setting = ConfigFile.model_setting(names, :vision, models: models))
55
+ return Answer.new(value: setting.value, reason: setting.value ? nil : "models: #{setting.key} sets vision: false")
56
+ end
56
57
  return nil if entry.vision.nil?
57
58
 
58
59
  Answer.new(value: entry.vision, reason: entry.vision ? nil : "hosts.#{entry.name} sets vision: false")
@@ -9,7 +9,9 @@ require "uri"
9
9
  require "rack"
10
10
  require "rack/request"
11
11
 
12
+ require_relative "../analytics_file"
12
13
  require_relative "../client_id"
14
+ require_relative "../delivery"
13
15
  require_relative "../answer_tail"
14
16
  require_relative "../bridge_client"
15
17
  require_relative "../bridge/bounded_queue"
@@ -23,6 +25,7 @@ require_relative "../host_registry"
23
25
  require_relative "../llm_context_override"
24
26
  require_relative "../model_catalog"
25
27
  require_relative "../model_profile"
28
+ require_relative "../thinking"
26
29
  require_relative "../sampling_settings"
27
30
  require_relative "../served_model"
28
31
  require_relative "../project_scope"
@@ -476,6 +479,7 @@ module Samagotchi
476
479
  summary = SamplingSettings.summary(target, names: names, models: settings)
477
480
  model[:sampling] = summary if summary
478
481
  add_llm_context(model, target, names, settings)
482
+ add_thinking(model, target, names, settings)
479
483
  end
480
484
  rescue StandardError
481
485
  nil
@@ -489,6 +493,14 @@ module Samagotchi
489
493
  nil
490
494
  end
491
495
 
496
+ # The thinking level +model+ starts at, with where it came from (the
497
+ # start page's think chip; Thinking::Explained#summary, no session).
498
+ def add_thinking(model, target, names, settings)
499
+ model[:thinking] = Thinking.explain(target, names: names, models: settings).summary
500
+ rescue StandardError
501
+ nil
502
+ end
503
+
492
504
  # The served model a session's last generation reported (analytics.json,
493
505
  # SessionMetrics#persist), for a session no worker runs: shown only
494
506
  # while the name it answered is still the session's model, as
@@ -525,6 +537,19 @@ module Samagotchi
525
537
  LLMContextStrategy.for_session(session, registry: host_registry)
526
538
  end
527
539
 
540
+ # A session's thinking level with where it came from and its own
541
+ # (Thinking::Explained#summary), as its worker would resolve it; nil
542
+ # when it can't be. Config and the registry only: no network.
543
+ def thinking_for(session)
544
+ registry = host_registry
545
+ name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
546
+ target = registry.resolve(name)
547
+ names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
548
+ Thinking.explain(target, names: names, session: session.thinking).summary
549
+ rescue StandardError
550
+ nil
551
+ end
552
+
528
553
  # Built again when config.yml's hosts: changed (compared as read: the
529
554
  # YAML read is mtime-cached), so the model picker lists a host added
530
555
  # while chi web runs. An injected registry (specs) stays.
@@ -607,6 +632,11 @@ module Samagotchi
607
632
  llm_context, error = request_llm_context(body["llm_context"])
608
633
  return error if error
609
634
 
635
+ # thinking: the session's own thinking level (the start page's think
636
+ # chip; chi --thinking), checked the same way.
637
+ thinking, error = request_thinking(body["thinking"])
638
+ return error if error
639
+
610
640
  folder = dir ? { working_directory: dir } : {}
611
641
  # model: the model the session starts on (the start page's picker,
612
642
  # spelled as GET /api/models lists it); blank means the default.
@@ -617,7 +647,8 @@ module Samagotchi
617
647
  # until that turn is saved.
618
648
  preview = idle ? body["preview"].to_s.strip : ""
619
649
  folder[:title] = preview unless preview.empty?
620
- folder[:llm_context] = llm_context if llm_context
650
+ setup = SessionSetup.new(llm_context: llm_context, thinking: thinking)
651
+ folder[:setup] = setup unless setup.empty?
621
652
  begin
622
653
  session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
623
654
  rescue ModelProfile::MissingModel => e
@@ -635,8 +666,8 @@ module Samagotchi
635
666
 
636
667
  # POST /api/sessions with continues: <id, prefix or last:id>: the next
637
668
  # link of that session's chain (SessionManager.continue_session), in its
638
- # folder, on its model and with its llm_context, so dir, model and
639
- # llm_context are refused beside it. 409 continued (with next_id, the
669
+ # folder, on its model and with its llm_context and thinking level, so
670
+ # dir, model, llm_context and thinking are refused beside it. 409 continued (with next_id, the
640
671
  # link that continues it already: the page opens that one),
641
672
  # open_children (ids), folder_gone, busy, scratch or owned_by_tui.
642
673
  def handle_continue(body, prompt, idle)
@@ -644,9 +675,9 @@ module Samagotchi
644
675
  unless given.is_a?(String) && !given.strip.empty?
645
676
  return error_response(400, "invalid_continues", "continues is a session id")
646
677
  end
647
- if (fields = %w[dir model model_name llm_context].select { |key| body.key?(key) }).any?
678
+ if (fields = %w[dir model model_name llm_context thinking].select { |key| body.key?(key) }).any?
648
679
  return error_response(400, "invalid_continues",
649
- "continues takes the previous session's folder, model and llm_context; " \
680
+ "continues takes the previous session's folder, model, llm_context and thinking; " \
650
681
  "leave out #{fields.join(", ")}")
651
682
  end
652
683
 
@@ -701,6 +732,20 @@ module Samagotchi
701
732
  [nil, error_response(400, "invalid_llm_context", e.message)]
702
733
  end
703
734
 
735
+ # The create request's "thinking" (a level word as /thinking takes it,
736
+ # "default" for none) as the session's own level: [level, nil] (nil
737
+ # when absent or default), or [nil, a 400 response] for anything else.
738
+ def request_thinking(raw)
739
+ return [nil, nil] if raw.nil?
740
+
741
+ word = raw.is_a?(String) ? raw.strip.downcase : nil
742
+ unless word && Thinking::LEVELS.map(&:to_s).include?(word)
743
+ return [nil, error_response(400, "invalid_thinking", "thinking is one of #{Thinking::LEVELS.join(", ")}")]
744
+ end
745
+
746
+ [Thinking.session_level(word), nil]
747
+ end
748
+
704
749
  def handle_show(req, id)
705
750
  # The page's light re-reads: never the whole conversation.
706
751
  return handle_cards_read(id) if req.params["cards"] == "1"
@@ -750,6 +795,8 @@ module Samagotchi
750
795
  # The LLM context strategy the next turn runs under (the info bar's
751
796
  # chip): the worker's, else worked out here from the session file.
752
797
  session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
798
+ # The thinking level the next turn runs at (the think chip), the same way.
799
+ session_json = session_json.merge(thinking: (snapshot && snapshot["thinking"]) || thinking_for(session))
753
800
  raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
754
801
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
755
802
  json_response(200, {
@@ -1066,7 +1113,9 @@ module Samagotchi
1066
1113
  return error_response(400, "bad_images", images) if images.is_a?(String)
1067
1114
 
1068
1115
  delivery = body["delivery"] || body[:delivery]
1069
- return error_response(400, "bad_delivery", "delivery must be a string") unless delivery.nil? || delivery.is_a?(String)
1116
+ if (refused = Delivery.refusal(delivery))
1117
+ return error_response(400, refused[:error], refused[:detail])
1118
+ end
1070
1119
 
1071
1120
  result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images,
1072
1121
  delivery: delivery, state_dir: @state_dir, manager: @manager,
@@ -1674,13 +1723,7 @@ module Samagotchi
1674
1723
  end
1675
1724
 
1676
1725
  def read_analytics(session_id)
1677
- path = File.join(@session_class.session_dir(session_id, state_dir: default_state_dir), "analytics.json")
1678
- return {} unless File.file?(path)
1679
-
1680
- data = JSON.parse(File.read(path))
1681
- data.is_a?(Hash) ? data : {}
1682
- rescue JSON::ParserError, SystemCallError
1683
- {}
1726
+ AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
1684
1727
  end
1685
1728
 
1686
1729
  def merge_timing_records(persisted, live)
@@ -26,14 +26,18 @@ import {
26
26
  chipHtml as llmContextChipHtml, chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_CHAT,
27
27
  withChoice,
28
28
  } from "./llm_context_chip.js";
29
+ import {
30
+ chipHtml as thinkingChipHtml, chipModel as thinkingChipModel, createThinkingControl,
31
+ withChoice as withThinkingChoice,
32
+ } from "./thinking_chip.js";
29
33
  import { renderStable } from "./stable_html.js";
30
34
  import { createShownTexts } from "./shown_texts.js";
31
35
  import { MODEL_KEY } from "./model_pick.js";
32
36
  import { emptySessionState } from "./session_state.js";
33
37
  import { createModelPicker } from "./model_picker.js";
34
38
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
35
- import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
36
- import { createTurnView, turnHistoryHtml } from "./turn_view.js";
39
+ import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, firstPreview, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
40
+ import { createTurnView, turnHistoryNodes } from "./turn_view.js";
37
41
  import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
38
42
  import { createStageView } from "./stage_view.js";
39
43
  import { createFamilyPop, familyChipHtml, familyRowsHtml } from "./families_view.js";
@@ -58,12 +62,13 @@ import { applySessionEvent, batchCounts, batchTargets, batchToastText, families,
58
62
  import { createNotifications } from "./notifications.js";
59
63
  import { tabTitle, topBarTitle } from "./session_title.js";
60
64
  import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
65
+ import { anchorInsertIndex, commandAnchor, isShellCommandLine, keepsCommandBubble } from "./kept_commands.js";
61
66
  import {
62
- appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, STOPPED_LINE_CLASS,
67
+ appendAboveLiveTiming, cancelLineText, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
63
68
  } from "./timing.js";
64
69
  import { createLiveTurn } from "./live_turn.js";
65
70
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
66
- import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, historyHasCommandBubble, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
71
+ import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, historyHasCommandBubble, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerChoiceRoute, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
67
72
 
68
73
  const $ = (s) => document.querySelector(s);
69
74
  const topStripEl = $("#topStrip");
@@ -117,6 +122,41 @@ const llmContextNewControl = createLlmContextControl({
117
122
  id: "llmContextNewPopover",
118
123
  anchor: "#llmContextNew",
119
124
  });
125
+ // The open session's thinking chip, beside the llm ctx one: /thinking runs
126
+ // in the session's worker.
127
+ const thinkingControl = createThinkingControl({
128
+ el: infoTextEl,
129
+ summary: () => (selected ? current.thinking : null),
130
+ session: () => selected,
131
+ running: () => liveTurn.running,
132
+ run: (line) => runSessionCommand(line),
133
+ });
134
+ // The start page's thinking chip: the picked model's level, and a level
135
+ // for the next new chat only (sent with its create, then dropped).
136
+ const thinkingNewEl = $("#thinkingNew");
137
+ let newChatThinking = null;
138
+ const newChatThinkingSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
139
+ const thinkingNewControl = createThinkingControl({
140
+ el: thinkingNewEl,
141
+ summary: newChatThinkingSummary,
142
+ kind: NEW_CHAT,
143
+ choice: () => newChatThinking,
144
+ choose: (next) => {
145
+ newChatThinking = next;
146
+ renderNewChatThinking();
147
+ promptEl.focus();
148
+ },
149
+ id: "thinkingNewPopover",
150
+ anchor: "#thinkingNew",
151
+ });
152
+ function renderNewChatThinking() {
153
+ const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_CHAT });
154
+ thinkingNewEl.hidden = !chip;
155
+ if (!chip) return thinkingNewControl.close();
156
+ thinkingNewEl.textContent = chip.text;
157
+ thinkingNewEl.title = chip.title;
158
+ thinkingNewEl.classList.toggle("own", chip.own);
159
+ }
120
160
  // Hidden without the model's values (the list not in, an older server).
121
161
  function renderNewChatLlmContext() {
122
162
  const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_CHAT });
@@ -1057,6 +1097,11 @@ function updateComposerMode() {
1057
1097
  newChatLlmContext = null;
1058
1098
  renderNewChatLlmContext();
1059
1099
  }
1100
+ thinkingNewControl.close();
1101
+ if (newChatThinking) {
1102
+ newChatThinking = null;
1103
+ renderNewChatThinking();
1104
+ }
1060
1105
  }
1061
1106
  const running = liveTurn.running && !!selected;
1062
1107
  cancelBtn.style.display = running ? "" : "none";
@@ -1233,8 +1278,9 @@ async function resync({ endText = null } = {}) {
1233
1278
  return;
1234
1279
  }
1235
1280
  if (id !== selected) return;
1281
+ const kept = keptCommandBubbles(id);
1236
1282
  resetTurnView();
1237
- renderSessionView(data);
1283
+ renderSessionView(data, { kept });
1238
1284
  // Preview is read-only: a worker is woken only by POST /turn, so attach
1239
1285
  // only when a live bridge exists.
1240
1286
  selectSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
@@ -1249,10 +1295,45 @@ async function resync({ endText = null } = {}) {
1249
1295
  }
1250
1296
  }
1251
1297
 
1298
+ // The prompt and command bubbles in the history, in order, by kind
1299
+ // (kept_commands.js): a prompt, a `!cmd`'s (saved, so redrawn) or another.
1300
+ function historyAnchors() {
1301
+ const els = [...historyEl.querySelectorAll(".bubble.user, .bubble.command")];
1302
+ const kinds = els.map((el) => {
1303
+ if (el.classList.contains("user")) return "user";
1304
+ const line = el.querySelector(":scope > .command-line")?.textContent;
1305
+ return !el.classList.contains("failed") && isShellCommandLine(line) ? "shell" : "other";
1306
+ });
1307
+ return { els, kinds };
1308
+ }
1309
+
1310
+ // This session's own command bubbles (/help, /model …: drawn by this page,
1311
+ // never saved) with their anchors, before a resync redraws the history.
1312
+ // In-flight ones (busy, queued) are left out: the snapshot draws them again.
1313
+ function keptCommandBubbles(id) {
1314
+ const { els, kinds } = historyAnchors();
1315
+ return els
1316
+ .map((el, i) => ({ el, anchor: commandAnchor(kinds, i) }))
1317
+ .filter(({ el }) => el.dataset.keptFor === id && !el.classList.contains("busy") && !el.classList.contains("queued"))
1318
+ .filter(({ el }) => keepsCommandBubble(el.querySelector(":scope > .command-line")?.textContent));
1319
+ }
1320
+
1321
+ // The kept bubbles back into the redrawn history, each after its turn.
1322
+ function restoreCommandBubbles(kept) {
1323
+ if (!kept.length) return;
1324
+ historyEl.querySelector(".hint")?.remove();
1325
+ kept.forEach(({ el, anchor }) => {
1326
+ const { els, kinds } = historyAnchors();
1327
+ const at = anchorInsertIndex(kinds, anchor);
1328
+ if (at < els.length) els[at].before(el);
1329
+ else historyEl.appendChild(el);
1330
+ });
1331
+ }
1332
+
1252
1333
  // A session view: its messages, then the turn in progress and the queued
1253
1334
  // prompts (a live worker's snapshot) replayed through the stream handlers,
1254
1335
  // then a pending question.
1255
- function renderSessionView(data) {
1336
+ function renderSessionView(data, { kept = [] } = {}) {
1256
1337
  const s = data.session;
1257
1338
  setMarkdownWarning(data.markdown_warning);
1258
1339
  setCapabilities(data.capabilities);
@@ -1279,20 +1360,23 @@ function renderSessionView(data) {
1279
1360
  current.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
1280
1361
  current.llmContext = s.llm_context || null;
1281
1362
  llmContextControl.refresh();
1363
+ current.thinking = s.thinking || null;
1364
+ thinkingControl.refresh();
1282
1365
  current.parentId = s.parent_id || null;
1283
1366
  current.continues = s.continues || null;
1284
1367
  current.commands = Array.isArray(data.commands) ? data.commands : [];
1285
1368
  if (data.messages && data.messages.length) {
1286
1369
  const first = data.messages.find((m) => m.role === "user");
1287
- if (first && first.content) current.firstPreview = normalize(first.content).slice(0,80) + (normalize(first.content).length>80?"…":"");
1370
+ if (first && first.content) current.firstPreview = firstPreview(first.content);
1288
1371
  renderHistory(data.messages);
1289
1372
  } else {
1290
1373
  renderHistory(data.history || []);
1291
1374
  if (!current.firstPreview && data.history && data.history.length) {
1292
- const txt = normalize(String(data.history[0]||"")).slice(0,80);
1293
- if(txt) current.firstPreview = txt;
1375
+ const txt = firstPreview(data.history[0]);
1376
+ if (txt) current.firstPreview = txt;
1294
1377
  }
1295
1378
  }
1379
+ restoreCommandBubbles(kept);
1296
1380
  // A failed turn's prompt went back to the user: shown after the
1297
1381
  // conversation, marked failed, as it was live (prompt_restored).
1298
1382
  if (data.failed_turn) {
@@ -1352,7 +1436,7 @@ function renderHistory(items) {
1352
1436
  return;
1353
1437
  }
1354
1438
  if (typeof items[0] === "object" && items[0].role) {
1355
- historyEl.innerHTML = turnHistoryHtml(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) });
1439
+ historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) }));
1356
1440
  } else {
1357
1441
  historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
1358
1442
  }
@@ -1965,7 +2049,7 @@ function updateInfoBar() {
1965
2049
  const attachCmd = `chi --attach ${selected}`;
1966
2050
  const copied = Date.now() - attachCopiedAt < 2000;
1967
2051
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1968
- const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${chainHtml}${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${notesHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
2052
+ const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${chainHtml}${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${thinkingChipHtml(current.thinking, escapeHtml)}${notesHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
1969
2053
  // The session clock ticks every second while a turn runs: only its text
1970
2054
  // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
1971
2055
  renderStable(infoTextEl, metaHtml, { ".session-time": timingText });
@@ -2020,10 +2104,7 @@ function memoriesTooltip(used, preloaded, muted) {
2020
2104
  }
2021
2105
 
2022
2106
  function currentSessionDuration() {
2023
- if (liveTurn.running && current.timing.startedAt) {
2024
- return elapsedSince(current.timing.startedAt);
2025
- }
2026
- return current.timing.sessionDurationMs;
2107
+ return sessionDurationNow(current.timing, liveTurn.running);
2027
2108
  }
2028
2109
 
2029
2110
  // The stage takes a running turn's rows (its extras); else the history.
@@ -2281,6 +2362,12 @@ const streamHandlers = {
2281
2362
  updateInfoBar();
2282
2363
  llmContextControl.refresh();
2283
2364
  }
2365
+ // /thinking (or /model) changed the level the next turn runs at.
2366
+ if (data.thinking) {
2367
+ current.thinking = data.thinking;
2368
+ updateInfoBar();
2369
+ thinkingControl.refresh();
2370
+ }
2284
2371
  if (view.resync) await resync();
2285
2372
  // A card's action that isn't anytime (the step-limit question's
2286
2373
  // /continue) has no bubble: nothing to remove. An anytime or queued
@@ -2433,10 +2520,27 @@ function handleCreate() {
2433
2520
 
2434
2521
  async function handleSendTurn() {
2435
2522
  if (current.owner === "tui") return;
2436
- const prompt = turnText(promptEl.value, chips);
2523
+ let prompt = turnText(promptEl.value, chips);
2437
2524
  if (!prompt) return;
2438
2525
  // An image-only send's text is its placeholders: not for ↑.
2439
2526
  const typed = promptEl.value.trim() !== "";
2527
+ // /cut TEXT and /queue TEXT (the terminals' words for a delivery): the
2528
+ // text as a cut, or the page's own answer (/queue, the bare word).
2529
+ let delivery = null;
2530
+ const choice = steerChoiceRoute(prompt, { running: liveTurn.running && !!selected });
2531
+ if (choice?.reply) {
2532
+ if (selected) addCommandBubble({ label: null, line: prompt, text: choice.reply });
2533
+ else showNotice(choice.reply);
2534
+ promptEl.value = "";
2535
+ fitPrompt();
2536
+ return;
2537
+ }
2538
+ // The composer holds the line as typed until the ack (composerAfterAck).
2539
+ const line = prompt;
2540
+ if (choice) {
2541
+ prompt = choice.send;
2542
+ delivery = choice.delivery || null;
2543
+ }
2440
2544
  if (!selected) {
2441
2545
  // The start page's own reply (a web-local command) or the hint for a
2442
2546
  // typo: neither needs a session, so none is made for it (2.29).
@@ -2450,10 +2554,13 @@ async function handleSendTurn() {
2450
2554
  actionBtn.disabled = true;
2451
2555
  try {
2452
2556
  const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt,
2453
- llm_context: choiceWords(newChatLlmContext) });
2557
+ llm_context: choiceWords(newChatLlmContext),
2558
+ thinking: newChatThinking || undefined });
2454
2559
  // The choice was for this chat only.
2455
2560
  newChatLlmContext = null;
2456
2561
  renderNewChatLlmContext();
2562
+ newChatThinking = null;
2563
+ renderNewChatThinking();
2457
2564
  addCreatedSession(s);
2458
2565
  await select(s.id);
2459
2566
  } catch (e) {
@@ -2485,7 +2592,7 @@ async function handleSendTurn() {
2485
2592
  const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending });
2486
2593
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
2487
2594
  if (!current.firstPreview || current.firstPreview==="—") {
2488
- current.firstPreview = normalize(prompt).slice(0,80) + (normalize(prompt).length>80?"…":"");
2595
+ current.firstPreview = firstPreview(prompt);
2489
2596
  updateInfoBar();
2490
2597
  }
2491
2598
  actionBtn.disabled = true;
@@ -2493,7 +2600,7 @@ async function handleSendTurn() {
2493
2600
  for (const chip of sending) {
2494
2601
  if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
2495
2602
  }
2496
- const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed });
2603
+ const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
2497
2604
  if (ack?.command_id) {
2498
2605
  // The worker knew it as a command (its list was newer than ours):
2499
2606
  // its command_ran shows it, not a prompt bubble.
@@ -2509,7 +2616,7 @@ async function handleSendTurn() {
2509
2616
  // (and images) go back into the composer, without replacing text typed
2510
2617
  // since (4.13). The composer still holds the text just sent: it goes.
2511
2618
  const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
2512
- promptEl.value = composerAfterAck({ sent: prompt, current: promptEl.value, refill: restored?.refill || "" });
2619
+ promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
2513
2620
  fitPrompt();
2514
2621
  clearChips();
2515
2622
  if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
@@ -2589,6 +2696,8 @@ async function runSessionCommand(line) {
2589
2696
  function addCommandBubble(view) {
2590
2697
  removeHintIfEmpty();
2591
2698
  const div = document.createElement("div");
2699
+ // Page-only: kept across a resync of this session (keptCommandBubbles).
2700
+ if (selected) div.dataset.keptFor = selected;
2592
2701
  drawCommandBubble(div, view);
2593
2702
  const follow = isNearBottom();
2594
2703
  appendToHistory(div);
@@ -2667,6 +2776,7 @@ const modelPicker = createModelPicker({
2667
2776
  center: $("#center"),
2668
2777
  onPick: () => {
2669
2778
  renderNewChatLlmContext();
2779
+ renderNewChatThinking();
2670
2780
  promptEl.focus();
2671
2781
  },
2672
2782
  });
@@ -2683,6 +2793,7 @@ async function loadModels() {
2683
2793
  modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
2684
2794
  modelPickEl.hidden = false;
2685
2795
  renderNewChatLlmContext();
2796
+ renderNewChatThinking();
2686
2797
  }
2687
2798
  // The picker's choice for a create request; nothing (the server's default)
2688
2799
  // while the list is not in.
@@ -4,17 +4,22 @@
4
4
 
5
5
  // The commands to offer while the composer holds one "/word" being typed
6
6
  // (no space or newline yet): the session's slash commands that start with
7
- // it, by name. The terminal UIs' own (local: /stats, /exit …) don't run here.
7
+ // it, by name. A UI's own (local) one is offered only when its uis name
8
+ // the web (/cut); the terminals' own (/stats, /exit …) don't run here.
8
9
  export function commandMatches(commands, text) {
9
10
  const typed = String(text || "");
10
11
  if (!/^\/\S*$/.test(typed)) return [];
11
12
  const needle = typed.toLowerCase();
12
13
  return (Array.isArray(commands) ? commands : [])
13
- .filter((c) => c && !c.local && typeof c.name === "string" && c.name.startsWith("/"))
14
+ .filter((c) => c && runsInWeb(c) && typeof c.name === "string" && c.name.startsWith("/"))
14
15
  .filter((c) => c.name.toLowerCase().startsWith(needle))
15
16
  .sort((a, b) => a.name.localeCompare(b.name));
16
17
  }
17
18
 
19
+ function runsInWeb(command) {
20
+ return !command.local || (Array.isArray(command.uis) && command.uis.includes("web"));
21
+ }
22
+
18
23
  // What picking +name+ does to the composer: {text, send}. The name
19
24
  // completes with a space for its arguments; ⏎ on a name already typed in
20
25
  // full sends it as it is (⏎ on "/help" runs /help).