samagotchi 0.67.0 → 0.68.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/docs/architecture.md +6 -3
  4. data/docs/internals/engine.md +58 -9
  5. data/docs/sessions.md +1 -1
  6. data/docs/testing.md +1 -1
  7. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  8. data/lib/samagotchi/effective_model.rb +283 -0
  9. data/lib/samagotchi/engine.rb +211 -358
  10. data/lib/samagotchi/level_setting_command.rb +99 -0
  11. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  12. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  13. data/lib/samagotchi/session.rb +22 -8
  14. data/lib/samagotchi/session_setting.rb +86 -0
  15. data/lib/samagotchi/thinking_command.rb +24 -76
  16. data/lib/samagotchi/thinking_notices.rb +169 -0
  17. data/lib/samagotchi/version.rb +1 -1
  18. data/lib/samagotchi/web/app.rb +55 -84
  19. data/lib/samagotchi/web/public/app.js +74 -505
  20. data/lib/samagotchi/web/public/bubbles.js +291 -0
  21. data/lib/samagotchi/web/public/cards_place.js +184 -0
  22. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  23. data/lib/samagotchi/web/public/command_list.js +12 -10
  24. data/lib/samagotchi/web/public/index.html +15 -7
  25. data/lib/samagotchi/web/public/info_bar.js +3 -3
  26. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  27. data/lib/samagotchi/web/public/init_row.js +21 -10
  28. data/lib/samagotchi/web/public/level_chip.js +29 -80
  29. data/lib/samagotchi/web/public/lists_view.js +64 -9
  30. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  31. data/lib/samagotchi/web/public/model_picker.js +26 -22
  32. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  33. data/lib/samagotchi/web/public/refs.js +13 -0
  34. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  35. data/lib/samagotchi/web/public/stage_model.js +33 -0
  36. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  37. data/lib/samagotchi/web/public/stage_view.js +56 -335
  38. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  39. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  40. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  41. data/lib/samagotchi/worker.rb +11 -16
  42. metadata +14 -1
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "effective_model"
4
+ require_relative "log"
5
+ require_relative "thinking"
6
+
7
+ module Samagotchi
8
+ # What the thinking level a turn runs at leads to, said to the user: a
9
+ # level its host can't do (an effort on a native host, or on a llama.cpp
10
+ # chat template that takes none), off that the model didn't honour, and a
11
+ # host refusing the thinking fields or the reasoning sent back. Each
12
+ # notice once per session and host; the log lines each time.
13
+ #
14
+ # Turn thread only (the turn's settings and its stream handler): no lock.
15
+ # What it needs from the Engine comes through lookups, called at use.
16
+ class ThinkingNotices
17
+ # @param session [#call] → Session, nil (its id: once per session)
18
+ # @param profile [#call] → ModelProfile (a native host's)
19
+ # @param notify [#call] (text, level, source) → a notice
20
+ def initialize(session:, profile:, notify:)
21
+ @session = session
22
+ @profile = profile
23
+ @notify = notify
24
+ @said = Set.new
25
+ @turn = nil
26
+ @generation_thought = nil
27
+ end
28
+
29
+ # [level, HostRegistry::ModelTarget] the turn runs at, nil before one.
30
+ attr_reader :turn
31
+
32
+ # The turn's thinking level on +target+ (its EffectiveModel's), what
33
+ # the notices after it read; an effort a native host can't do is said
34
+ # now.
35
+ def turn_started(level, target)
36
+ @turn = [level, target]
37
+ announce_level(level, target)
38
+ end
39
+
40
+ # A generation starts (or a dropped one streams again from the start).
41
+ def generation_started = @generation_thought = nil
42
+
43
+ # Whether this generation's thinking lane said anything: nil while it
44
+ # streamed none, false while only whitespace (Gemma 4's empty thought
45
+ # with thinking off), true once a real thought came.
46
+ def note_thought(thinking)
47
+ return if thinking.empty?
48
+
49
+ @generation_thought ||= thinking.match?(/\S/)
50
+ end
51
+
52
+ # A generation completed: off that the model didn't honour, an effort
53
+ # its chat template ignores.
54
+ def generation_completed(event)
55
+ check_honoured(event)
56
+ announce_effort_ignored
57
+ end
58
+
59
+ # The host refused the level's request fields (gpt-oss can't turn
60
+ # thinking off) and the chat loop sent the request without them: said
61
+ # once per session and host, standing in for the not-honoured notice.
62
+ def thinking_refused(event)
63
+ _level, target = @turn
64
+ return unless target
65
+
66
+ Log.warn(:model, "thinking_refused", level: event[:level], host: target.entry.name, model: event[:model],
67
+ detail: event[:detail])
68
+ @said << [session_id, target.entry.name, :not_honoured]
69
+ notice_once(:refused, target, :warn,
70
+ "#{target.entry.name} refused thinking: #{event[:level]} for #{event[:model]} (#{event[:detail]}); " \
71
+ "sent without it, so thinking stays as the model has it")
72
+ rescue StandardError => e
73
+ failed(:thinking_refused, e)
74
+ end
75
+
76
+ # The host refused a sampling field (the empty-answer retry's
77
+ # temperature on a model that takes none) and the chat loop asked again
78
+ # without them, as it will for the model from now on: a log line.
79
+ def sampling_refused(event)
80
+ Log.info(:model, "sampling_refused", model: event[:model], detail: event[:detail])
81
+ rescue StandardError => e
82
+ failed(:sampling_refused, e)
83
+ end
84
+
85
+ # The host refused the model's reasoning sent back (reasoning_history)
86
+ # and the chat loop asked the step again without it, as it will from
87
+ # now on: logged, and said once per session and host.
88
+ def reasoning_history_refused(event)
89
+ _level, target = @turn
90
+ return unless target
91
+
92
+ Log.warn(:model, "reasoning_history_refused", level: event[:level], host: target.entry.name, model: event[:model],
93
+ detail: event[:detail])
94
+ return unless @said.add?([session_id, target.entry.name, :reasoning_history_refused])
95
+
96
+ @notify.call("#{target.entry.name} refused reasoning sent back (reasoning_history: #{event[:level]}) for " \
97
+ "#{event[:model]} (#{event[:detail]}); sent without it from now on", :warn, "reasoning history")
98
+ rescue StandardError => e
99
+ failed(:reasoning_history_refused, e)
100
+ end
101
+
102
+ private
103
+
104
+ # An effort on a native host, which has no knob for it: said once per
105
+ # session and host.
106
+ def announce_level(level, target)
107
+ return if target.entry.chat?
108
+
109
+ profile = @profile.call
110
+ return if Thinking.native(level, profile).honoured
111
+
112
+ notice_once(:unsupported, target, :info,
113
+ "#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
114
+ "thinking stays as the model has it")
115
+ rescue StandardError => e
116
+ failed(:announce_level, e)
117
+ end
118
+
119
+ # An effort on a llama.cpp chat host whose chat template takes none
120
+ # (its /props says so): said once per session and host, as for a native
121
+ # host. Read from the /props answer the turn's window probe left in the
122
+ # cache, so it asks the server nothing.
123
+ def announce_effort_ignored
124
+ level, target = @turn
125
+ return unless target&.entry&.chat? && Thinking::EFFORTS.include?(level)
126
+
127
+ props = target.client.cached_server_props(model: target.bare_model)
128
+ return unless Thinking.effort_ignored?(level, props)
129
+
130
+ notice_once(:unsupported, target, :info,
131
+ "#{level} isn't supported by #{target.bare_model}'s chat template on #{target.entry.name} " \
132
+ "(/props: supports_reasoning_effort false); thinking stays as the model has it")
133
+ rescue StandardError => e
134
+ failed(:effort_ignored, e)
135
+ end
136
+
137
+ # Thinking off, and the model thought anyway: logged each time, said
138
+ # once per session and host. A thinking lane of whitespace alone is no
139
+ # thought.
140
+ def check_honoured(event)
141
+ level, target = @turn
142
+ chars = event[:thinking_chars].to_i
143
+ return unless level == :off && chars.positive? && target
144
+ return if @generation_thought == false
145
+
146
+ Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
147
+ notice_once(:not_honoured, target, :warn,
148
+ "off wasn't honoured by #{target.bare_model} on #{target.entry.name} " \
149
+ "(#{chars} chars of thinking); a sampling: override on the host or model may turn it off " \
150
+ "(see Thinking in docs/configuration.md)")
151
+ rescue StandardError => e
152
+ failed(:check_honoured, e)
153
+ end
154
+
155
+ def notice_once(kind, target, level, text)
156
+ return unless @said.add?([session_id, target.entry.name, kind])
157
+
158
+ @notify.call(text, level, "thinking")
159
+ end
160
+
161
+ def session_id = @session.call&.id
162
+
163
+ # A notice that failed: none is said, the turn goes on; a log line
164
+ # (once per process for the notice, model and error class).
165
+ def failed(notice, error)
166
+ EffectiveModel::Unresolved.fallback(:thinking_notice_failed, @turn&.last&.model || notice.to_s, error, nil)
167
+ end
168
+ end
169
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.67.0"
4
+ VERSION = "0.68.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -26,8 +26,8 @@ require_relative "../host_registry"
26
26
  require_relative "../llm_context_override"
27
27
  require_relative "../model_catalog"
28
28
  require_relative "../model_profile"
29
+ require_relative "../session_setting"
29
30
  require_relative "../thinking"
30
- require_relative "../reasoning_history"
31
31
  require_relative "../sampling_settings"
32
32
  require_relative "../served_model"
33
33
  require_relative "../project_scope"
@@ -557,52 +557,6 @@ module Samagotchi
557
557
  nil
558
558
  end
559
559
 
560
- # A session's LLM context strategy, apply rule and budget, each with
561
- # where it came from (LLMContextStrategy::Explained#summary), as its
562
- # worker would resolve them; nil when they can't be.
563
- # (LLMContextStrategy.for_session, shared with the ctx % the session
564
- # lists count.)
565
- def llm_context_for(session)
566
- LLMContextStrategy.for_session(session, registry: host_registry)
567
- end
568
-
569
- # A session's toolkit as its file names it ({name:, label:}), nil on
570
- # full: without a worker nothing resolves it (a bundle's kits load in
571
- # the worker), so the tools it leaves out aren't known here.
572
- def toolkit_for(session)
573
- toolkit = session.toolkit
574
- toolkit && !toolkit.full? ? { name: toolkit.name, label: toolkit.label } : nil
575
- end
576
-
577
- # A session's thinking level with where it came from and its own
578
- # (SettingExplained#summary), as its worker would resolve it; nil
579
- # when it can't be. Config and the registry only: no network.
580
- def thinking_for(session)
581
- registry = host_registry
582
- name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
583
- target = registry.resolve(name)
584
- names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
585
- Thinking.explain(target, names: names, session: session.thinking).summary
586
- rescue StandardError
587
- nil
588
- end
589
-
590
- # A session's reasoning_history level with where it came from and its
591
- # own (SettingExplained#summary), as its worker would resolve it; nil
592
- # on a native host (it ignores the level) or when it can't be. Config
593
- # and the registry only: no network.
594
- def reasoning_history_for(session)
595
- registry = host_registry
596
- name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
597
- target = registry.resolve(name)
598
- return nil unless target.entry&.chat?
599
-
600
- names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
601
- ReasoningHistory.explain(target, names: names, session: session.reasoning_history).summary
602
- rescue StandardError
603
- nil
604
- end
605
-
606
560
  # Built again when config.yml's hosts: changed (compared as read: the
607
561
  # YAML read is mtime-cached), so the model picker lists a host added
608
562
  # while chi web runs. An injected registry (specs) stays.
@@ -828,46 +782,15 @@ module Samagotchi
828
782
  live = bridge_get_json(id, "snapshot")
829
783
  turn_snapshot = live && live["snapshot"]
830
784
  snapshot = live && live["session_state_snapshot"]
831
- last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
785
+ last_event_seq = live_or_saved(snapshot, "event_seq") { bridge_event_seq(id) }
832
786
  current_turn = turn_snapshot && turn_snapshot["current_turn"]
833
787
  owner = session_owner(id)
834
- # The file's question only while a worker owns the session: one a
835
- # dead worker saved can't be answered (its turn is gone), nor a chi
836
- # REPL's (it shares nothing with the web).
837
- # A question between turns (the step-limit one) is the snapshot's own.
838
- pending = if turn_snapshot
839
- current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
840
- elsif owner&.worker?
841
- session.pending_question
842
- end
843
- # A /model in the worker changes it before the file catches up.
844
- session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
845
- session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
788
+ pending = shown_pending_question(session, turn_snapshot, owner)
846
789
  # What the worker's server said it served for that model (after a turn);
847
790
  # without a worker, what the session's analytics.json last saved.
848
791
  persisted = snapshot ? nil : read_analytics(id)
849
- if snapshot
850
- session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
851
- served_expected_by: snapshot["served_expected_by"])
852
- # The model notes its prompt carries now (a /model there changes them).
853
- session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
854
- else
855
- session_json = session_json.merge(saved_served_model(session, persisted))
856
- end
857
- # The LLM context strategy the next turn runs under (the info bar's
858
- # chip): the worker's, else worked out here from the session file.
859
- session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
860
- # The thinking level the next turn runs at (the think chip), the same way.
861
- session_json = session_json.merge(thinking: (snapshot && snapshot["thinking"]) || thinking_for(session))
862
- # Its reasoning history (the reasoning chip): the worker's (nil on a
863
- # native host), else the server's reading (an older worker, none).
864
- history = snapshot&.key?("reasoning_history") ? snapshot["reasoning_history"] : reasoning_history_for(session)
865
- session_json = session_json.merge(reasoning_history: history)
866
- # Its toolkit when it isn't full (the tools chip): the worker's, which
867
- # resolved it, else the name saved in the session file.
868
- toolkit = snapshot&.key?("toolkit") ? snapshot["toolkit"] : toolkit_for(session)
869
- session_json = session_json.merge(toolkit: toolkit)
870
- raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
792
+ session_json = shown_session_json(session, snapshot, owner, persisted)
793
+ raw_messages = live_or_saved(turn_snapshot, "messages") { session.messages }
871
794
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
872
795
  json_response(200, {
873
796
  session: session_json,
@@ -886,7 +809,7 @@ module Samagotchi
886
809
  plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
887
810
  # Plugins' slow setup still running (chi.init): the page's init row.
888
811
  init_tasks: turn_snapshot ? Array(turn_snapshot["init_tasks"]) : [],
889
- cards: cards_for_display(turn_snapshot ? turn_snapshot["cards"] : saved_cards(id)),
812
+ cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }),
890
813
  # The composer's / autocomplete; the built-ins until a worker
891
814
  # names its plugins' too.
892
815
  commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
@@ -903,6 +826,54 @@ module Samagotchi
903
826
  error_response(404, "not_found", e.message)
904
827
  end
905
828
 
829
+ # A live worker's +key+ as its reading (+live+: a snapshot, a tail)
830
+ # says it, nil included; without a worker (+live+ nil) the block's,
831
+ # the server's own reading of the saved session. A nil value from a
832
+ # worker is not a missing one (contrast SessionSetting#snapshot_value's
833
+ # :or).
834
+ def live_or_saved(live, key)
835
+ live ? live[key] : yield
836
+ end
837
+
838
+ # The file's question only while a worker owns the session: one a
839
+ # dead worker saved can't be answered (its turn is gone), nor a chi
840
+ # REPL's (it shares nothing with the web).
841
+ # A question between turns (the step-limit one) is the snapshot's own.
842
+ def shown_pending_question(session, turn_snapshot, owner)
843
+ if turn_snapshot
844
+ current_turn = turn_snapshot["current_turn"]
845
+ current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
846
+ elsif owner&.worker?
847
+ session.pending_question
848
+ end
849
+ end
850
+
851
+ # The full show's session part: the file's, with what a live worker's
852
+ # state +snapshot+ knows first (its model, status, served model,
853
+ # prompt notes and chips); without one, the served model from
854
+ # +persisted+ (analytics.json). Sets a worker's model on +session+:
855
+ # a /model in the worker changes it before the file catches up.
856
+ def shown_session_json(session, snapshot, owner, persisted)
857
+ session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
858
+ session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
859
+ if snapshot
860
+ session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
861
+ served_expected_by: snapshot["served_expected_by"])
862
+ # The model notes its prompt carries now (a /model there changes them).
863
+ session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
864
+ else
865
+ session_json = session_json.merge(saved_served_model(session, persisted))
866
+ end
867
+ # The per-session settings' chips (the info bar's llm ctx, think,
868
+ # reasoning and tools): the worker's, else worked out here from the
869
+ # session file (SessionSetting#snapshot_value, #saved_summary).
870
+ SessionSetting::ALL.each do |setting|
871
+ chip = setting.snapshot_value(snapshot) { setting.saved_summary(session, registry: host_registry) }
872
+ session_json = session_json.merge(setting.key => chip)
873
+ end
874
+ session_json
875
+ end
876
+
906
877
  # ?cards=1: the page's re-read when a card arrives, for its rendered
907
878
  # body (the stream carries the card's plain text only). The worker's
908
879
  # cards from its light GET tail, else the saved ones; the session file
@@ -911,7 +882,7 @@ module Samagotchi
911
882
  return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
912
883
 
913
884
  tail = live_tail(id)
914
- json_response(200, { cards: cards_for_display(tail ? tail["cards"] : saved_cards(id)) })
885
+ json_response(200, { cards: cards_for_display(live_or_saved(tail, "cards") { saved_cards(id) }) })
915
886
  end
916
887
 
917
888
  # ?tail=1: the page's re-read at the end of a turn wants the final