samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -8,6 +8,7 @@ require "yaml"
8
8
 
9
9
  require_relative "config"
10
10
  require_relative "context_note"
11
+ require_relative "steer"
11
12
  require_relative "turn_note"
12
13
  require_relative "model_profile"
13
14
  require_relative "thought_stream_splitter"
@@ -15,14 +16,17 @@ require_relative "cancellation_controller"
15
16
  require_relative "context_window"
16
17
  require_relative "kernel_loop"
17
18
  require_relative "tools/builtins"
19
+ require_relative "tools/task_runtime"
18
20
  require_relative "session_commands"
19
21
  require_relative "plugin/loader"
20
22
  require_relative "log"
21
23
  require_relative "log_subscriber"
24
+ require_relative "client"
22
25
  require_relative "host_registry"
23
26
  require_relative "llm/backend"
24
27
  require_relative "llm/openai_chat"
25
28
  require_relative "session"
29
+ require_relative "archive_store"
26
30
  require_relative "session_observer"
27
31
  require_relative "tool_declarations"
28
32
  require_relative "session_metrics"
@@ -41,6 +45,10 @@ require_relative "served_model"
41
45
  require_relative "image_store"
42
46
  require_relative "vision_context"
43
47
  require_relative "vision_support"
48
+ require_relative "sampling_settings"
49
+ require_relative "thinking"
50
+ require_relative "answer_display"
51
+ require_relative "edit_preview"
44
52
 
45
53
  module Samagotchi
46
54
  # Engine owns the core agent logic: system prompt construction, tool
@@ -74,16 +82,25 @@ module Samagotchi
74
82
  # @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
75
83
  # prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
76
84
  DEFAULT_SYSTEM_MEMORIES = %w[identity].freeze
85
+ # What memory_write answers in a scratch session.
86
+ SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
77
87
 
78
88
  # @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
89
+ # @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
90
+ # delegate (a child would outlive it) nor plugin fork
79
91
  def initialize(mode: :assist, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
80
- plugins: true)
92
+ plugins: true, scratch: false)
81
93
  @mode = mode.to_sym
94
+ @scratch = scratch
82
95
  @chat_backend = nil
83
96
  @chat_backend_mutex = Mutex.new
97
+ @host_registry = host_registry || HostRegistry.new
98
+ # A model given here (a worker's session model) is the one it runs,
99
+ # so its host is checked; the config default is only checked once it
100
+ # is used (the REPL checks the model it starts on, #switch_model!).
84
101
  @default_model_name = ModelProfile.required_model_name(model_name)
102
+ ModelProfile.check_host!(model_name, hosts: @host_registry.entries) unless model_name.to_s.strip.empty?
85
103
  @effective_model_name = @default_model_name
86
- @host_registry = host_registry || HostRegistry.new
87
104
  # An injected client (specs) stands in for every host's client.
88
105
  @host_registry.client_override = client if client
89
106
  @client = @host_registry.resolve(@effective_model_name).client
@@ -105,6 +122,7 @@ module Samagotchi
105
122
  # to load is announced, and a required guardrail's failure denies
106
123
  # every tool call. Rules load now too, so their errors are announced.
107
124
  @guardrail_failures = Guardrails::LoadFailures.new
125
+ @guardrail_rules_mutex = Mutex.new
108
126
  # Plugins that failed to load: announced apart, as plugins (not
109
127
  # guardrails: no tool call is denied for them).
110
128
  @plugin_failures = Guardrails::LoadFailures.new
@@ -113,6 +131,12 @@ module Samagotchi
113
131
  # The tools this session offers (the prompts' declarations and the
114
132
  # kernel's dispatch): the built-ins, per Engine.
115
133
  @tools = Tools::Builtins.registry
134
+ if @scratch
135
+ # A scratch session's children would outlive it; its memories
136
+ # would too (write and edit into the memories: ScratchWrites).
137
+ [Tools::Delegate::NAME, Tools::DelegateResult::NAME].each { |name| @tools.unregister(name) }
138
+ @tools[Tools::MemoryWrite::NAME].handler = ->(_call, _kctx) { SCRATCH_MEMORY_WRITE }
139
+ end
116
140
  # Likewise the slash commands its SessionCommands run.
117
141
  @command_registry = SessionCommands.register_builtins(Commands::Registry.new)
118
142
  # Installed bundles' plugins add commands, tools and hooks to these
@@ -220,6 +244,9 @@ module Samagotchi
220
244
  @activity_seq = 0
221
245
  @turn_running = false
222
246
  @active_cancel_controller = nil
247
+ # Plugin steers ({text:, source:}) waiting for the running turn's next
248
+ # boundary (#steer); guarded by @activity_mutex.
249
+ @steers = []
223
250
  # Reminder names the interactive REPL's IdleReminders callback marked due;
224
251
  # the REPL polls them to decide when to run a synthetic reminder turn.
225
252
  @due_reminder_names = []
@@ -302,6 +329,58 @@ module Samagotchi
302
329
  @activity_mutex.synchronize { @turn_running }
303
330
  end
304
331
 
332
+ # Put +text+ into the running turn, as a UI's steering does: it joins the
333
+ # conversation at the loop's next iteration boundary as its own user
334
+ # message (kind: "steer", source:). Callable from any thread; never
335
+ # blocks. With no turn running it does nothing (it never starts one).
336
+ # True means queued, not merged: at the after-answer boundary, and when
337
+ # the turn ends first, it is dropped (logged).
338
+ # @return [Boolean] whether it was queued
339
+ def steer(text, source:)
340
+ text = text.to_s.strip
341
+ return false if text.empty?
342
+
343
+ @activity_mutex.synchronize do
344
+ return false unless @turn_running
345
+
346
+ @steers << { text: text, source: source.to_s }
347
+ end
348
+ true
349
+ end
350
+
351
+ # The drain a turn's loop gets: the caller's lines (a UI's steering; nil
352
+ # in a --non-interactive run) and the plugin steers. At the after-answer
353
+ # boundary the steers are dropped: the model answered, and a nudge would
354
+ # only restart the turn. The steer part fails on its own, never taking
355
+ # the user's lines with it.
356
+ def turn_drain(pending_input)
357
+ lambda do |at_answer: false|
358
+ lines = pending_input ? Array(pending_input.call) : []
359
+ lines + take_steers(at_answer)
360
+ end
361
+ end
362
+ private :turn_drain
363
+
364
+ def take_steers(drop)
365
+ steers = @activity_mutex.synchronize do
366
+ taken = @steers
367
+ @steers = []
368
+ taken
369
+ end
370
+ return steers unless drop
371
+
372
+ log_dropped_steers(steers, "answered")
373
+ []
374
+ rescue StandardError
375
+ []
376
+ end
377
+ private :take_steers
378
+
379
+ def log_dropped_steers(steers, why)
380
+ steers.each { |steer| Log.info(:turn, "steer_dropped", source: steer[:source], why: why, chars: steer[:text].length) }
381
+ end
382
+ private :log_dropped_steers
383
+
305
384
  # @return [Array<String>] reminder names queued for a synthetic REPL turn
306
385
  def due_reminder_names
307
386
  @activity_mutex.synchronize { @due_reminder_names.dup }
@@ -341,7 +420,7 @@ module Samagotchi
341
420
  snapshot = @activity_mutex.synchronize { @session&.messages }
342
421
  return "[]" if snapshot.nil?
343
422
 
344
- JSON.generate(Array(snapshot).map(&:dup))
423
+ JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
345
424
  end
346
425
 
347
426
  # Emit a :recap_ready event (additive slot) carrying the generated recap
@@ -775,7 +854,7 @@ module Samagotchi
775
854
  def switch_model!(model_name, persist_default: false)
776
855
  # Resolve alias first (alias may point to qualified ref)
777
856
  aliased = ConfigFile.resolve_model_alias(model_name)
778
- resolved = ModelProfile.required_model_name(aliased)
857
+ resolved = ModelProfile.check_host!(ModelProfile.required_model_name(aliased), hosts: @host_registry.entries)
779
858
  @effective_model_name = resolved
780
859
  bare = bare_model_name(resolved)
781
860
  @model_lookup_names = [model_name, aliased, resolved]
@@ -795,10 +874,6 @@ module Samagotchi
795
874
  resolved
796
875
  end
797
876
 
798
- def reset_model!
799
- switch_model!(@default_model_name)
800
- end
801
-
802
877
  # ── Hooks API ──────────────────────────────────────────────────────────────
803
878
 
804
879
  # Register a hook callback for a named lifecycle event.
@@ -869,8 +944,6 @@ module Samagotchi
869
944
  clear_due_reminder_names!
870
945
  due
871
946
  end
872
- # Alias for backward compatibility.
873
- alias maybe_inject_reminders collect_due_reminders
874
947
 
875
948
  # @return [Boolean] whether any reminder is due now (the store's view,
876
949
  # which #collect_due_reminders would inject), regardless of the REPL queue
@@ -892,9 +965,12 @@ module Samagotchi
892
965
  target = @host_registry.resolve(@effective_model_name)
893
966
  served, served_for = served_model_for(snapshot, target: target)
894
967
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for)
895
- unless snapshot[:context_window_tokens]
968
+ unless snapshot.dig(:context, :window_tokens)
896
969
  window = current_context_window(target)
897
- snapshot = snapshot.merge(context_window_tokens: window.tokens, context_window_source: window.source) if window
970
+ if window
971
+ snapshot = snapshot.merge(context: snapshot[:context].merge(window_tokens: window.tokens,
972
+ window_source: window.source.to_s))
973
+ end
898
974
  end
899
975
  # The chat loop uses no prompt profile: drop one a native turn reported.
900
976
  return snapshot.except(:profile, :profile_source) if target.entry.chat?
@@ -906,10 +982,38 @@ module Samagotchi
906
982
  snapshot
907
983
  end
908
984
 
985
+ # The status line's ctx from the saved context, for a worker that has run
986
+ # no turn yet (it woke after an idle exit): nil when either count is
987
+ # unknown or context.status is off.
988
+ def saved_context_status(context)
989
+ return nil unless context && @kernel.respond_to?(:context_display)
990
+
991
+ @kernel.context_display(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
992
+ end
993
+
909
994
  # The effective model is on a chat host (api: openai), whose loop uses
910
995
  # no prompt profile.
911
996
  def chat_model? = @host_registry.resolve(@effective_model_name).entry.chat?
912
997
 
998
+ # "temperature=0.6 (hosts.work)" for /model: the effective model's
999
+ # configured sampling, nil when none.
1000
+ def sampling_summary
1001
+ target = @host_registry.resolve(@effective_model_name)
1002
+ SamplingSettings.summary(target, names: model_lookup_names(target))
1003
+ rescue StandardError
1004
+ nil
1005
+ end
1006
+
1007
+ # "off (models: qwen)" for /model: the effective model's thinking level
1008
+ # and where it came from, nil when none is set.
1009
+ def thinking_summary
1010
+ target = @host_registry.resolve(@effective_model_name)
1011
+ level, source = Thinking.resolve(target, names: model_lookup_names(target))
1012
+ source ? "#{level} (#{source})" : nil
1013
+ rescue StandardError
1014
+ nil
1015
+ end
1016
+
913
1017
  # The model the server serves for the current model, and the name asked
914
1018
  # for: what the last generation of that name reported, else llama.cpp's
915
1019
  # model_alias (/props, one short cached probe; not with probe: false),
@@ -941,12 +1045,13 @@ module Samagotchi
941
1045
  # :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
942
1046
  def session_state_snapshot
943
1047
  served_pair = served_model(probe: false)
1048
+ metrics = @metrics.snapshot
944
1049
  {
945
1050
  status: @session&.status,
946
1051
  message_count: (@session&.messages || []).size,
947
1052
  last_prompt: @session&.last_prompt,
948
1053
  event_seq: @session_observer&.event_count,
949
- metrics: @metrics.snapshot,
1054
+ metrics: metrics,
950
1055
  pending_question: @question_mutex.synchronize { @pending_question&.dup },
951
1056
  used_memory_names: @used_memory_mutex.synchronize { @used_memory_names.dup },
952
1057
  preloaded_memory_names: preloaded_memory_names,
@@ -955,7 +1060,7 @@ module Samagotchi
955
1060
  model_name: @effective_model_name,
956
1061
  served_model: served_pair[0],
957
1062
  served_model_for: served_pair[1],
958
- context_status: @last_context_status&.dup,
1063
+ context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
959
1064
  recap_enabled: !@recap.nil?,
960
1065
  recap_min_user_turns: @recap&.min_user_turns,
961
1066
  recap_inactivity_seconds: @recap&.inactivity&.to_i
@@ -1034,6 +1139,9 @@ module Samagotchi
1034
1139
  base.empty? ? nil : base
1035
1140
  end
1036
1141
 
1142
+ # A memory read (memory_read, or read of a memories/*.md file) as it
1143
+ # starts: its names join used_memory_names.
1144
+ # @return [Array<String>, nil] the names this call read, nil for any other event
1037
1145
  def capture_used_memory_from_event(event)
1038
1146
  return unless event.is_a?(Hash) && event[:type] == :tool_call_started
1039
1147
 
@@ -1044,6 +1152,7 @@ module Samagotchi
1044
1152
  return if names.empty?
1045
1153
 
1046
1154
  add_used_memory_names(names)
1155
+ names
1047
1156
  end
1048
1157
 
1049
1158
  # ── Guardrails ─────────────────────────────────────────────────────────────
@@ -1069,6 +1178,14 @@ module Samagotchi
1069
1178
  # its own.
1070
1179
  attr_writer :session_state_dir
1071
1180
 
1181
+ # The metrics load the session's saved records from its state dir, once.
1182
+ def bind_metrics(session)
1183
+ return unless session.respond_to?(:id) && session.id
1184
+
1185
+ @metrics.state_dir = session_state_dir
1186
+ @metrics.session_id = session.id
1187
+ end
1188
+
1072
1189
  def session_state_dir = @session_state_dir || Session.default_state_dir
1073
1190
 
1074
1191
  # What a Bridge adds to a plugin's ctx.messages while a turn runs (the
@@ -1085,33 +1202,60 @@ module Samagotchi
1085
1202
  # The gate's core checks, in order.
1086
1203
  def guardrail_checks
1087
1204
  rules = guardrail_rules
1088
- [@guardrail_failures, rules.hook_asks, guardrail_protected_paths, rules]
1205
+ [@guardrail_failures, rules.hook_asks, guardrail_protected_paths, (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch),
1206
+ rules].compact
1089
1207
  end
1090
1208
 
1091
1209
  # The YAML rules: config.yml's `guardrails:` section (rules, disable) and
1092
1210
  # installed bundles'. One that doesn't parse is a required load failure
1093
- # (every call is denied).
1211
+ # (every call is denied). Read again when one of those files changed
1212
+ # (a stat of each per tool call), so a long-lived worker follows edits.
1094
1213
  # @return [Guardrails::Rules]
1095
1214
  def guardrail_rules
1096
- @guardrail_rules ||= begin
1097
- section = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
1098
- section = section["guardrails"] if section.is_a?(Hash)
1099
- rules = []
1100
- disable = []
1101
- begin
1102
- raise Guardrails::Rules::ParseError, "guardrails must be a mapping" unless section.nil? || section.is_a?(Hash)
1103
-
1104
- rules = Guardrails::Rules.parse(section && section["rules"], source: "config")
1105
- disable = Guardrails::Rules.parse_disable(section && section["disable"])
1106
- rescue Guardrails::Rules::ParseError => e
1107
- Log.warn(:guardrails, "config_rules_invalid", echo: "[samagotchi:guardrails] config.yml guardrails rules: #{e.message}")
1108
- @guardrail_failures.add("rules in config.yml", e.message, required: true)
1215
+ @guardrail_rules_mutex.synchronize do
1216
+ stamp = guardrail_rules_stamp
1217
+ if @guardrail_rules.nil? || stamp != @guardrail_rules_stamp
1218
+ @guardrail_failures.drop(:rules)
1219
+ @guardrail_rules = load_guardrail_rules
1220
+ @guardrail_rules_stamp = stamp
1109
1221
  end
1110
- Guardrails::Rules.new(rules + bundle_guardrail_rules, disable: disable,
1111
- enabled: Samagotchi::Config.get("guardrails.enabled") != false)
1222
+ @guardrail_rules
1112
1223
  end
1113
1224
  end
1114
1225
 
1226
+ # [path, mtime, size] of config.yml and every installed bundle's
1227
+ # manifest.json and guardrails/ file.
1228
+ def guardrail_rules_stamp
1229
+ require_relative "memory_bundle/provenance"
1230
+ paths = [Samagotchi::ConfigFile.global_path] +
1231
+ Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "{manifest.json,guardrails/*}")]
1232
+ paths.compact.sort.map do |path|
1233
+ stat = File.stat(path)
1234
+ [path, stat.mtime.to_r, stat.size]
1235
+ rescue SystemCallError
1236
+ [path]
1237
+ end
1238
+ end
1239
+
1240
+ def load_guardrail_rules
1241
+ section = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
1242
+ section = section["guardrails"] if section.is_a?(Hash)
1243
+ rules = []
1244
+ disable = []
1245
+ begin
1246
+ raise Guardrails::Rules::ParseError, "guardrails must be a mapping" unless section.nil? || section.is_a?(Hash)
1247
+
1248
+ rules = Guardrails::Rules.parse(section && section["rules"], source: "config")
1249
+ disable = Guardrails::Rules.parse_disable(section && section["disable"])
1250
+ rescue Guardrails::Rules::ParseError => e
1251
+ Log.warn(:guardrails, "config_rules_invalid", echo: "[samagotchi:guardrails] config.yml guardrails rules: #{e.message}")
1252
+ @guardrail_failures.add("rules in config.yml", e.message, required: true, group: :rules)
1253
+ end
1254
+ Guardrails::Rules.new(rules + bundle_guardrail_rules, disable: disable,
1255
+ enabled: Samagotchi::Config.get("guardrails.enabled") != false)
1256
+ end
1257
+ private :guardrail_rules_stamp, :load_guardrail_rules
1258
+
1115
1259
  # Installed bundles' guardrails/*.yml, by bundle name then file name.
1116
1260
  # A file that is missing, changed since install (sha256) or doesn't
1117
1261
  # parse is a required load failure.
@@ -1121,7 +1265,7 @@ module Samagotchi
1121
1265
  MemoryBundle::Provenance.each_installed_with_guardrails do |bundle_name, data|
1122
1266
  if data[:error]
1123
1267
  Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{data[:error]}", bundle: bundle_name)
1124
- @guardrail_failures.add("rules (bundle #{bundle_name})", data[:error], required: true)
1268
+ @guardrail_failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
1125
1269
  next
1126
1270
  end
1127
1271
  dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
@@ -1143,14 +1287,14 @@ module Samagotchi
1143
1287
  rules.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
1144
1288
  rescue Guardrails::Rules::ParseError, Psych::Exception => e
1145
1289
  Log.warn(:guardrails, "rules_file_invalid", echo: "[samagotchi:guardrails] #{what}: #{e.message}", bundle: bundle_name, file: basename.to_s)
1146
- @guardrail_failures.add(what, e.message, required: true)
1290
+ @guardrail_failures.add(what, e.message, required: true, group: :rules)
1147
1291
  end
1148
1292
  end
1149
1293
  end
1150
1294
  rules
1151
1295
  rescue StandardError => e
1152
1296
  Log.error(:guardrails, "bundle_rules_failed", echo: "[samagotchi:guardrails] failed to read installed bundles' rules: #{e.class}: #{e.message}", error: e.class.name)
1153
- @guardrail_failures.add("bundle rules", "#{e.class}: #{e.message}", required: true)
1297
+ @guardrail_failures.add("bundle rules", "#{e.class}: #{e.message}", required: true, group: :rules)
1154
1298
  rules || []
1155
1299
  end
1156
1300
 
@@ -1224,17 +1368,62 @@ module Samagotchi
1224
1368
 
1225
1369
  # A plugin tool is asked about by its label, as its row shows it.
1226
1370
  label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools)
1227
- payload = Guardrails::Approval.payload(verdict, label: label)
1371
+ # verdict.call is the call that will run (a hook may have replaced it).
1372
+ payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call))
1228
1373
  Guardrails::Approval.settle(verdict, open_question(payload), payload[:approval][:scopes])
1229
1374
  end
1230
1375
 
1376
+ # The dry-run diff of an edit/write call for its approval; a preview
1377
+ # that fails only leaves the diff out, it never denies the call.
1378
+ def approval_preview(call)
1379
+ EditPreview.for(call)
1380
+ rescue StandardError => e
1381
+ Log.warn(:guardrails, "edit_preview_failed", error: "#{e.class}: #{e.message}")
1382
+ nil
1383
+ end
1384
+ private :approval_preview
1385
+
1231
1386
  # The conversation as a hook may read it: a frozen array of copied
1232
1387
  # messages, so a hook cannot change what the turn sends or stores.
1233
1388
  def hook_messages(messages)
1234
- Array(messages).map(&:dup).freeze
1389
+ AnswerDisplay.strip_all(messages).map(&:dup).freeze
1235
1390
  end
1236
1391
  private :hook_messages
1237
1392
 
1393
+ # How the turn ended, on the session (Session#last_turn): the caller's
1394
+ # save puts it in the file the session hub watches.
1395
+ def record_last_turn(session, outcome, seconds, origin)
1396
+ client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
1397
+ source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
1398
+ elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
1399
+ else "client"
1400
+ end
1401
+ session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
1402
+ "seconds" => seconds.round(1), "origin" => source }
1403
+ end
1404
+ private :record_last_turn
1405
+
1406
+ # Keep what the after_turn hooks presented as the answer's `display`
1407
+ # and tell the observers (:answer_display), after the turn_completed
1408
+ # they already had: the web re-reads the answer then. A turn_completed
1409
+ # with display_pending (after_turn hooks were about to run) always gets
1410
+ # one, `display: nil` when nothing was presented, so the web holds the
1411
+ # answer's pop until then and never swaps it after. Terminals are not
1412
+ # sinks of it; they printed the answer already. The session is saved by
1413
+ # the caller, as after every turn.
1414
+ def store_answer_display(session, display, pending: false)
1415
+ synchronize_events do
1416
+ messages = Array(session.messages)
1417
+ if display.changed? && messages.last.equal?(display.target)
1418
+ replace_session_messages(session, messages[0...-1] + [display.target.merge(AnswerDisplay::KEY => display.text)])
1419
+ @session_observer.notify({ type: :answer_display, display: display.text })
1420
+ elsif pending
1421
+ @session_observer.notify({ type: :answer_display, display: nil })
1422
+ end
1423
+ end
1424
+ end
1425
+ private :store_answer_display
1426
+
1238
1427
  # ── The hook runtime (Hooks::Runtime) ─────────────────────────────────────
1239
1428
 
1240
1429
  # The three things a hook can do beyond reading its event. Each gets the
@@ -1243,11 +1432,19 @@ module Samagotchi
1243
1432
  Hooks::Runtime.new(
1244
1433
  notify: ->(text:, level:, hook:) { hook_notify(text, level, hook) },
1245
1434
  ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { hook_ask_user(question, options, header, allow_freeform, hook) },
1246
- stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) }
1435
+ stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) },
1436
+ steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) }
1247
1437
  )
1248
1438
  end
1249
1439
  private :hook_runtime
1250
1440
 
1441
+ # What a steer is attributed to: the bundle of a bundle hook's label
1442
+ # ("<file> (bundle <name>)"), the label of any other.
1443
+ def hook_source(hook)
1444
+ hook.to_s[/\(bundle (.+)\)\z/, 1] || hook.to_s
1445
+ end
1446
+ private :hook_source
1447
+
1251
1448
  # One line to the user (:hook_notice). During a turn it is a turn
1252
1449
  # event: the turn's sink (the REPL) and the observers (bridge, log).
1253
1450
  # Outside one (a plugin's command at the prompt) it is announced with
@@ -1321,45 +1518,24 @@ module Samagotchi
1321
1518
  @question_mutex.synchronize { @pending_question&.dup }
1322
1519
  end
1323
1520
 
1324
- # Request a structured question from the user. Called from KernelLoop's
1325
- # turn thread (via dispatch): validates and cleans the model's payload,
1326
- # then #open_question. Returns a normalized JSON string for the
1327
- # tool_response.
1521
+ # Ask the model's question (ask_user_question): the kernel's
1522
+ # question_handler, called on the turn thread with the payload
1523
+ # Tools::AskUserQuestion.validate made. Opens it (#open_question) and
1524
+ # returns the answer as JSON for the tool result.
1328
1525
  # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
1329
1526
  # @return [String] normalized answer JSON
1330
1527
  def request_question(payload)
1331
- # Strip wire control tokens (<|...|> / stray <|,|>) that can bleed into the
1332
- # question text when the model wraps the tool call in markup.
1333
- question = strip_wire_tokens(payload[:question])
1334
- options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(payload[:options])
1335
- # Fallback for string JSON that lenient missed
1336
- if options.empty? && payload[:options].is_a?(String)
1337
- options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(payload[:options].to_s)
1338
- end
1339
- if question.empty? || options.empty?
1340
- return JSON.generate({ error: "invalid question", detail: "question and 2-8 options required (got #{options.size})" })
1341
- end
1342
- # Dumb-model salvage: allow single option (don't hard error, just render what we have)
1343
- if options.size == 1
1344
- # keep as is
1345
- elsif options.size < 2
1346
- return JSON.generate({ error: "invalid question", detail: "question and 2-8 options required (got #{options.size})" })
1347
- end
1348
- if options.size > 8
1349
- options = options.first(8)
1350
- end
1351
-
1352
- clean_header = strip_wire_tokens(payload[:header])
1353
- result = open_question(
1354
- question: question,
1355
- options: options,
1356
- header: clean_header.empty? ? nil : clean_header,
1357
- multi_select: !!payload[:multi_select],
1358
- allow_freeform: !!payload[:allow_freeform]
1359
- )
1528
+ result = open_question(**payload.slice(:question, :options, :header),
1529
+ multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform])
1530
+ # Dismissed (the card's dismiss, Esc): an answer of its own, not a
1531
+ # tool failure the model learns to avoid the tool from.
1532
+ result = { dismissed: true, id: result[:id], note: QUESTION_DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
1360
1533
  result.is_a?(String) ? result : JSON.generate(result)
1361
1534
  end
1362
1535
 
1536
+ QUESTION_DISMISSED_NOTE = "The user dismissed the question without answering. Go on with your best judgement, " \
1537
+ "or ask in your reply if you can't."
1538
+
1363
1539
  # Open a question for the UIs and wait for its answer. Emits
1364
1540
  # :question_requested, persists it to the session, and BLOCKS until
1365
1541
  # answer_question / cancel_question wakes it (or the turn is cancelled).
@@ -1379,7 +1555,7 @@ module Samagotchi
1379
1555
  # Persist to session file for WEB stub + resume (generic for all UIs)
1380
1556
  if @session
1381
1557
  @session.pending_question = pending.dup
1382
- begin; @session.save; rescue StandardError; nil; end
1558
+ begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1383
1559
  end
1384
1560
  # Generic emit for all UIs (TUI, WEB, Bridge, future). Observers that
1385
1561
  # stash this event (e.g. TerminalUI handle_question_event) will
@@ -1402,7 +1578,7 @@ module Samagotchi
1402
1578
  @pending_question = nil
1403
1579
  if @session
1404
1580
  @session.pending_question = nil
1405
- begin; @session.save; rescue StandardError; nil; end
1581
+ begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1406
1582
  end
1407
1583
  emit_event(nil, { type: :question_answered, id: id, answer: ans })
1408
1584
  return ans
@@ -1413,7 +1589,7 @@ module Samagotchi
1413
1589
  @pending_question = nil
1414
1590
  if @session
1415
1591
  @session.pending_question = nil
1416
- begin; @session.save; rescue StandardError; nil; end
1592
+ begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1417
1593
  end
1418
1594
  emit_event(nil, { type: :question_answered, id: id, answer: sync_res })
1419
1595
  return sync_res
@@ -1431,7 +1607,7 @@ module Samagotchi
1431
1607
  @question_mutex.synchronize { @pending_question = nil }
1432
1608
  if @session
1433
1609
  @session.pending_question = nil
1434
- begin; @session.save; rescue StandardError; nil; end
1610
+ begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1435
1611
  end
1436
1612
  return { error: "no answer", detail: "handler failed to capture selection", id: id }
1437
1613
  end
@@ -1453,7 +1629,7 @@ module Samagotchi
1453
1629
  @pending_question = nil
1454
1630
  if @session
1455
1631
  @session.pending_question = nil
1456
- begin; @session.save; rescue StandardError; nil; end
1632
+ begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1457
1633
  end
1458
1634
  emit_event(nil, { type: :question_cancelled, id: id, reason: active_cancel_controller.reason.to_s })
1459
1635
  return { error: "cancelled", reason: active_cancel_controller.reason.to_s, id: id }
@@ -1464,7 +1640,7 @@ module Samagotchi
1464
1640
  @question_mutex.synchronize { @pending_question = nil }
1465
1641
  if @session
1466
1642
  @session.pending_question = nil
1467
- begin; @session.save; rescue StandardError; nil; end
1643
+ begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1468
1644
  end
1469
1645
  if answer
1470
1646
  emit_event(nil, { type: :question_answered, id: id, answer: answer })
@@ -1512,14 +1688,12 @@ module Samagotchi
1512
1688
  @question_answer = answer
1513
1689
  @question_cv.broadcast
1514
1690
  answer
1691
+ end.tap do
1692
+ # A human answered: the session is back in the lists (ArchiveStore).
1693
+ ArchiveStore.user_input(@session&.id, state_dir: session_state_dir)
1515
1694
  end
1516
1695
  end
1517
1696
 
1518
- def strip_wire_tokens(text)
1519
- text.to_s.gsub(/<\|[^|]*\|>/, "").gsub(/<\||\|>/, "").strip
1520
- end
1521
- private :strip_wire_tokens
1522
-
1523
1697
  def set_question_sync_handler(&block)
1524
1698
  @question_sync_handler = block
1525
1699
  end
@@ -1555,10 +1729,15 @@ module Samagotchi
1555
1729
  # prompt prefix, and the server's KV cache for it, stay stable.
1556
1730
  # @param target [HostRegistry::ModelTarget, nil]
1557
1731
  # @return [String]
1732
+ # The native prompt depends on the thinking level too (Gemma's token,
1733
+ # Qwen's turn preamble): a level change builds it again.
1558
1734
  def system_prompt(target = nil)
1559
- chat = (target || @host_registry.resolve(@effective_model_name)).entry.chat?
1735
+ target ||= @host_registry.resolve(@effective_model_name)
1736
+ chat = target.entry.chat?
1737
+ level = chat ? nil : thinking_level(target)
1560
1738
  @system_prompts ||= {}
1561
- @system_prompts[chat] ||= system_prompt_with_index(assist_system_prompt(chat: chat), chat: chat)
1739
+ @system_prompts[[chat, level]] ||= system_prompt_with_index(assist_system_prompt(chat: chat, thinking: level),
1740
+ chat: chat, thinking: level)
1562
1741
  end
1563
1742
 
1564
1743
  # @return [Session] current session (Engine owns create/resume)
@@ -1586,6 +1765,8 @@ module Samagotchi
1586
1765
  @session = session
1587
1766
  # One session per REPL/worker process: its records carry this sid.
1588
1767
  Log.session_id = session.id if session.respond_to?(:id) && session.id
1768
+ # A woken worker's /stats and status line count the turns before it.
1769
+ bind_metrics(session)
1589
1770
  sync_used_memories_from_session(session)
1590
1771
  end
1591
1772
 
@@ -1616,7 +1797,7 @@ module Samagotchi
1616
1797
 
1617
1798
  messages = @activity_mutex.synchronize { @session&.messages } || []
1618
1799
  covered = state[:covered].to_i
1619
- turns_since = Array(messages).drop(covered).count { |m| m.is_a?(Hash) && (m["role"] || m[:role]).to_s == "user" }
1800
+ turns_since = Array(messages).drop(covered).count { |m| Steer.prompt?(m) }
1620
1801
  { text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
1621
1802
  end
1622
1803
 
@@ -1710,9 +1891,12 @@ module Samagotchi
1710
1891
  # stay unchanged for callers that don't pass it.
1711
1892
  with_origin = origin ? ->(event) { event.merge(origin: origin) } : ->(event) { event }
1712
1893
  begin
1894
+ # A Stop cuts the /props probes this thread makes for the turn
1895
+ # (window, served model, vision) instead of waiting their timeout.
1896
+ probe_cancel_before = Client.swap_probe_cancel(effective_controller)
1713
1897
  image_refs, image_error = turn_image_refs(session, continue ? [] : images)
1714
1898
  # Emit turn_started event
1715
- @metrics.session_id = session.id
1899
+ bind_metrics(session)
1716
1900
  turn_started = { type: :turn_started, session_id: session.id, prompt: prompt }
1717
1901
  turn_started[:continue] = true if continue
1718
1902
  turn_started[:images] = image_refs unless image_refs.empty?
@@ -1722,6 +1906,11 @@ module Samagotchi
1722
1906
  # Before anything of the turn is kept or a reminder is used up.
1723
1907
  vision = turn_vision(session)
1724
1908
  @kernel.vision = vision if @kernel.respond_to?(:vision=)
1909
+ @kernel.sampling = turn_sampling if @kernel.respond_to?(:sampling=)
1910
+ thinking_target = @host_registry.resolve(@effective_model_name)
1911
+ @turn_thinking = [thinking_level(thinking_target), thinking_target]
1912
+ @kernel.thinking = @turn_thinking.first if @kernel.respond_to?(:thinking=)
1913
+ announce_thinking_level(*@turn_thinking)
1725
1914
  refuse_images!(vision) unless image_refs.empty?
1726
1915
  announce_guardrail_failures(on_event)
1727
1916
  # Plugins' slow setup that brings tools (an MCP server's first
@@ -1785,7 +1974,7 @@ module Samagotchi
1785
1974
  cancel_controller: effective_controller,
1786
1975
  model_name: bare_for_backend,
1787
1976
  max_tool_output_chars: max_tool_output_chars,
1788
- pending_input: pending_input
1977
+ pending_input: turn_drain(pending_input)
1789
1978
  )
1790
1979
 
1791
1980
  # Persist deduped used memories onto the session for Web + reload.
@@ -1818,24 +2007,31 @@ module Samagotchi
1818
2007
  # placeholder text) is a turn the model should know ended that way.
1819
2008
  empty = !canceled && !resumable &&
1820
2009
  (response.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?))
2010
+ # after_turn hooks run below and may present the answer (the web
2011
+ # holds its pop until it knows).
2012
+ display_pending = !canceled && @hooks.any?(:after_turn)
1821
2013
  synchronize_events do
1822
2014
  if empty
1823
2015
  # The placeholder is for the UIs (a new array: it must not leak
1824
2016
  # into the result); the note is for the model, so it goes on the
1825
2017
  # result's conversation too, which the REPL keeps as-is.
2018
+ # A retry's nudge at the tail goes: this note says it all.
1826
2019
  note = TurnNote.empty
1827
- saved = (conversation || session.messages).dup
2020
+ conversation&.replace(TurnNote.without_trailing(conversation))
2021
+ saved = TurnNote.without_trailing(conversation || session.messages)
1828
2022
  saved << { role: "model", content: "[No response]" } if response.strip.empty?
1829
2023
  replace_session_messages(session, saved + [note])
1830
2024
  conversation << note if conversation
1831
2025
  elsif canceled && conversation
1832
2026
  conversation << TurnNote.cancelled(result.cancellation_reason, seconds: turn_seconds.call,
1833
- shown: TurnNote.interrupted_tail?(conversation))
2027
+ shown: TurnNote.interrupted_tail?(conversation),
2028
+ running_tasks: Tools::TaskRuntime.running_created_in(conversation))
1834
2029
  replace_session_messages(session, conversation)
1835
2030
  elsif conversation
1836
2031
  replace_session_messages(session, conversation)
1837
2032
  end
1838
2033
  session.status = Session::STATUS_IDLE
2034
+ record_last_turn(session, canceled ? "canceled" : "completed", turn_seconds.call, origin)
1839
2035
  if canceled
1840
2036
  emit_event(on_event, with_origin.call({
1841
2037
  type: :turn_canceled,
@@ -1847,16 +2043,22 @@ module Samagotchi
1847
2043
  emit_event(on_event, with_origin.call({
1848
2044
  type: :turn_completed,
1849
2045
  result: result,
1850
- turn_summary: turn_summary(result)
2046
+ turn_summary: turn_summary(result),
2047
+ display_pending: display_pending
1851
2048
  }))
1852
2049
  end
1853
2050
  end
1854
- @metrics.persist
2051
+ @metrics.persist(state_dir: session_state_dir)
1855
2052
 
1856
2053
  # Fire :after_turn hook (runs even on cancel/success), with a read-only
1857
- # copy of the conversation the turn stored.
1858
- @hooks.fire(:after_turn, { type: :after_turn, status: canceled ? "canceled" : "completed",
1859
- messages: hook_messages(session.messages) })
2054
+ # copy of the conversation the turn stored, and event[:present] for
2055
+ # a display version of the answer (AnswerDisplay).
2056
+ display = AnswerDisplay.new(session.messages)
2057
+ after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
2058
+ messages: hook_messages(session.messages) }
2059
+ after_turn[:present] = display.presenter(after_turn)
2060
+ @hooks.fire(:after_turn, after_turn)
2061
+ store_answer_display(session, display, pending: display_pending)
1860
2062
 
1861
2063
  # Fire :session_end after every turn (turn-level lifecycle)
1862
2064
  @hooks.fire(:session_end, { type: :session_end, session_id: session.id })
@@ -1865,11 +2067,18 @@ module Samagotchi
1865
2067
  rescue Interrupt
1866
2068
  effective_controller.cancel!(:ctrl_c)
1867
2069
  synchronize_events do
1868
- replace_session_messages(session, TurnNote.replace_trailing(messages, TurnNote.cancelled(:ctrl_c, seconds: turn_seconds.call))) if messages
2070
+ # Only the pre-turn messages survive here, so tasks this turn
2071
+ # started are missed (plan wait-stop §6).
2072
+ if messages
2073
+ note = TurnNote.cancelled(:ctrl_c, seconds: turn_seconds.call,
2074
+ running_tasks: Tools::TaskRuntime.running_created_in(messages))
2075
+ replace_session_messages(session, TurnNote.replace_trailing(messages, note))
2076
+ end
1869
2077
  session.status = Session::STATUS_IDLE
2078
+ record_last_turn(session, "canceled", turn_seconds.call, origin)
1870
2079
  emit_event(on_event, with_origin.call({ type: :turn_canceled, cancellation_reason: :ctrl_c }))
1871
2080
  end
1872
- @metrics.persist
2081
+ @metrics.persist(state_dir: session_state_dir)
1873
2082
  raise
1874
2083
  rescue StandardError => e
1875
2084
  # Keep what the turn got to (the prompt plus the loop's completed
@@ -1885,14 +2094,15 @@ module Samagotchi
1885
2094
  end
1886
2095
  replace_session_messages(session, kept) if kept
1887
2096
  session.status = Session::STATUS_IDLE
1888
- begin; session.save; rescue StandardError; nil; end
2097
+ record_last_turn(session, "failed", turn_seconds.call, origin)
2098
+ begin; session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1889
2099
  failed = { type: :turn_failed, error_class: e.class.name, message: e.message }
1890
2100
  # A provider error says what kind it is, for one line per kind in the UIs.
1891
2101
  if e.is_a?(LLM::ProviderError)
1892
2102
  failed.merge!(error_kind: e.kind, retryable: e.retryable?, host: e.host, summary: e.summary)
1893
2103
  end
1894
2104
  emit_event(on_event, with_origin.call(failed))
1895
- @metrics.persist
2105
+ @metrics.persist(state_dir: session_state_dir)
1896
2106
  raise
1897
2107
  ensure
1898
2108
  # A completed turn is activity: release the turn flag and advance the
@@ -1900,10 +2110,13 @@ module Samagotchi
1900
2110
  # treats the just-finished turn as activity and re-arms its window.
1901
2111
  # Always runs, even if an exception occurred.
1902
2112
  set_turn_running(false)
1903
- @activity_mutex.synchronize do
2113
+ Client.swap_probe_cancel(probe_cancel_before)
2114
+ left = @activity_mutex.synchronize do
1904
2115
  @active_cancel_controller = nil
1905
2116
  @turn_event_sink = nil
2117
+ @steers.tap { @steers = [] }
1906
2118
  end
2119
+ log_dropped_steers(left, "turn_ended")
1907
2120
  record_activity
1908
2121
  # Clear hooks so they remain turn-scoped and never leak into the next turn.
1909
2122
  clear_hooks
@@ -1925,7 +2138,84 @@ module Samagotchi
1925
2138
  def turn_vision(session)
1926
2139
  target = @host_registry.resolve(@effective_model_name)
1927
2140
  VisionContext.new(session_dir: Session.session_dir(session.id, state_dir: session_state_dir),
1928
- capability: -> { VisionSupport.for(target, profile: profile, adapter: vision_adapter(target)) })
2141
+ capability: lambda {
2142
+ VisionSupport.for(target, profile: profile, adapter: vision_adapter(target), names: model_lookup_names(target))
2143
+ })
2144
+ end
2145
+
2146
+ # The effective model's request parameters (hosts: and models:
2147
+ # sampling), read per turn: /model can change the model between turns.
2148
+ def turn_sampling
2149
+ target = @host_registry.resolve(@effective_model_name)
2150
+ SamplingSettings.for(target, names: model_lookup_names(target))
2151
+ rescue StandardError
2152
+ SamplingSettings::EMPTY
2153
+ end
2154
+
2155
+ # The effective model's thinking level (Thinking.resolve), read each turn.
2156
+ def turn_thinking
2157
+ thinking_level(@host_registry.resolve(@effective_model_name))
2158
+ rescue StandardError
2159
+ Thinking::DEFAULT
2160
+ end
2161
+
2162
+ # An effort on a native host, which has no knob for it: said once per
2163
+ # session and host.
2164
+ def announce_thinking_level(level, target)
2165
+ return if target.entry.chat? || Thinking.native(level, profile).honoured
2166
+
2167
+ thinking_notice_once(:unsupported, target, :info,
2168
+ "#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
2169
+ "thinking stays as the model has it")
2170
+ rescue StandardError
2171
+ nil
2172
+ end
2173
+
2174
+ # Thinking off, and the model thought anyway: logged each time, said
2175
+ # once per session and host.
2176
+ def check_thinking_honoured(event)
2177
+ level, target = @turn_thinking
2178
+ chars = event[:thinking_chars].to_i
2179
+ return unless level == :off && chars.positive? && target
2180
+
2181
+ Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
2182
+ thinking_notice_once(:not_honoured, target, :warn,
2183
+ "off wasn't honoured by #{target.bare_model} on #{target.entry.name} " \
2184
+ "(#{chars} chars of thinking); a sampling: override on the host or model may turn it off " \
2185
+ "(see Thinking in docs/configuration.md)")
2186
+ rescue StandardError
2187
+ nil
2188
+ end
2189
+
2190
+ # The host refused the level's request fields (gpt-oss can't turn
2191
+ # thinking off) and the chat loop sent the request without them: said
2192
+ # once per session and host, standing in for the not-honoured notice.
2193
+ def thinking_refused(event)
2194
+ _level, target = @turn_thinking
2195
+ return unless target
2196
+
2197
+ Log.warn(:model, "thinking_refused", level: event[:level], host: target.entry.name, model: event[:model],
2198
+ detail: event[:detail])
2199
+ (@thinking_notices ||= Set.new) << [@session&.id, target.entry.name, :not_honoured]
2200
+ thinking_notice_once(:refused, target, :warn,
2201
+ "#{target.entry.name} refused thinking: #{event[:level]} for #{event[:model]} (#{event[:detail]}); " \
2202
+ "sent without it, so thinking stays as the model has it")
2203
+ rescue StandardError
2204
+ nil
2205
+ end
2206
+
2207
+ def thinking_notice_once(kind, target, level, text)
2208
+ key = [@session&.id, target.entry.name, kind]
2209
+ return unless (@thinking_notices ||= Set.new).add?(key)
2210
+
2211
+ hook_notify(text, level, "thinking")
2212
+ end
2213
+
2214
+ # +target+'s thinking level (Thinking.resolve).
2215
+ def thinking_level(target)
2216
+ Thinking.resolve(target, names: model_lookup_names(target)).first
2217
+ rescue StandardError
2218
+ Thinking::DEFAULT
1929
2219
  end
1930
2220
 
1931
2221
  def vision_adapter(target)
@@ -1987,29 +2277,6 @@ module Samagotchi
1987
2277
  replace_session_messages(@session, clone_messages(checkpoint))
1988
2278
  end
1989
2279
 
1990
- # Backward-compatible: runs a prompt through the kernel loop without event forwarding.
1991
- # @param session [Session]
1992
- # @param prompt [String]
1993
- # @return [String] model response text
1994
- def process_prompt_through_kernel(session, prompt)
1995
- result = run_turn(session, prompt)
1996
- response = result.respond_to?(:text) ? result.text.to_s : result.to_s
1997
- if response.strip.empty?
1998
- session.messages << { role: "model", content: "[No response]" }
1999
- "[No response]"
2000
- else
2001
- response
2002
- end
2003
- end
2004
-
2005
- # Public entrypoint for background session workers.
2006
- # @param session [Session]
2007
- # @param prompt [String]
2008
- # @return [String] model response
2009
- def process_background_prompt(session:, prompt:)
2010
- process_prompt_through_kernel(session, prompt)
2011
- end
2012
-
2013
2280
  # Clone a messages array (shallow dup of each element).
2014
2281
  # @param messages [Array<Hash>]
2015
2282
  # @return [Array<Hash>]
@@ -2050,6 +2317,12 @@ module Samagotchi
2050
2317
  require_relative "memory_bundle/provenance"
2051
2318
  settings = bundle_settings
2052
2319
  MemoryBundle::Provenance.each_installed_holding_hooks do |bundle_name, data|
2320
+ if data[:error]
2321
+ Log.warn(:hooks, "bundle_manifest_invalid", echo: "[samagotchi:hooks] bundle '#{bundle_name}': #{data[:error]}; its hooks are not loaded",
2322
+ bundle: bundle_name)
2323
+ @guardrail_failures.add("hooks (bundle #{bundle_name})", data[:error], required: false)
2324
+ next
2325
+ end
2053
2326
  bundle_dir = File.join(MemoryBundle::Provenance.bundles_dir, bundle_name)
2054
2327
  hooks_dir = File.join(bundle_dir, "hooks")
2055
2328
  if (data[:trust_level] || "experimental").to_s == "experimental"
@@ -2161,11 +2434,14 @@ module Samagotchi
2161
2434
  (task = current_init_task) ? task.cancelled? : @activity_mutex && active_cancel_controller&.cancelled?
2162
2435
  },
2163
2436
  card: ->(**card) { show_card(**card) },
2437
+ steer: ->(text, source) { steer(text, source: source) },
2438
+ stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
2164
2439
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
2165
2440
  ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
2166
2441
  },
2167
2442
  model_name: -> { @session&.model_name || @effective_model_name },
2168
- state_dir: -> { session_state_dir }
2443
+ state_dir: -> { session_state_dir },
2444
+ scratch: -> { @scratch }
2169
2445
  )
2170
2446
  end
2171
2447
 
@@ -2175,7 +2451,7 @@ module Samagotchi
2175
2451
  # with the event log held, so a turn is in exactly one of the two.
2176
2452
  def plugin_messages
2177
2453
  synchronize_events do
2178
- messages = messages_checkpoint || []
2454
+ messages = AnswerDisplay.strip_all(messages_checkpoint)
2179
2455
  first = messages.first
2180
2456
  messages = messages.drop(1) if first && first[:role].to_s == "system" && first[:kind].to_s.empty?
2181
2457
  running = turn_running? && @running_turn_messages ? Array(@running_turn_messages.call) : []
@@ -2394,13 +2670,18 @@ module Samagotchi
2394
2670
  event = event.merge(text: delta[:text], thinking: delta[:thinking]) if enrich == :always
2395
2671
  end
2396
2672
  end
2673
+ # The chat loop asked again without the thinking fields: a notice,
2674
+ # not an event of its own.
2675
+ next thinking_refused(event) if event[:type] == :thinking_refused
2676
+
2397
2677
  emit_event(on_event, event)
2678
+ check_thinking_honoured(event) if event[:type] == :generation_completed
2398
2679
  end
2399
2680
  end
2400
2681
 
2401
2682
  def emit_event(on_event, event)
2402
2683
  # Capture used memories synchronously in the turn thread.
2403
- begin
2684
+ read_names = begin
2404
2685
  capture_used_memory_from_event(event)
2405
2686
  rescue StandardError
2406
2687
  nil
@@ -2419,6 +2700,11 @@ module Samagotchi
2419
2700
  # Persistent subscribers: receive a copy with a locally-monotonic
2420
2701
  # `event_seq`, fan out with per-subscriber error isolation.
2421
2702
  @session_observer.notify(event)
2703
+ # Every memory read, after its tool_call_started: the whole list for
2704
+ # the UIs' memory line, and the names this call read (read_names).
2705
+ return unless read_names
2706
+
2707
+ emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names, read_names: read_names })
2422
2708
  end
2423
2709
 
2424
2710
  # The window as the target's loop would see it (see ChatLoop#context_window:
@@ -2462,10 +2748,14 @@ module Samagotchi
2462
2748
  end
2463
2749
 
2464
2750
  target = @host_registry.resolve(@effective_model_name)
2465
- # As typed (maybe an alias), the part after a host prefix, alias-resolved, bare.
2751
+ ModelProfile.resolve(names: model_lookup_names(target), entry: target.entry, client: target.client, bare_model: target.bare_model)
2752
+ end
2753
+
2754
+ # The names a models: entry may be under: as typed (maybe an alias), the
2755
+ # part after a host prefix, alias-resolved, bare.
2756
+ def model_lookup_names(target)
2466
2757
  typed = @model_lookup_names.first
2467
- names = @model_lookup_names + [@host_registry.parse_qualified_model(typed).last, target.bare_model]
2468
- ModelProfile.resolve(names: names.compact, entry: target.entry, client: target.client, bare_model: target.bare_model)
2758
+ (@model_lookup_names + [@host_registry.parse_qualified_model(typed).last, target.bare_model]).compact
2469
2759
  end
2470
2760
 
2471
2761
  # Everything that holds a profile follows the resolution: the kernel's
@@ -2510,9 +2800,11 @@ module Samagotchi
2510
2800
 
2511
2801
  # Only Qwen has an explicit thinking-close marker, so only Qwen can
2512
2802
  # reliably have this preamble parsed back out of its thinking block.
2513
- def turn_preamble_instruction
2803
+ # With thinking off there is no thinking to begin with it.
2804
+ def turn_preamble_instruction(thinking = nil)
2514
2805
  return "" unless profile.name == "qwen36"
2515
2806
  return "" if Samagotchi::Config.get("thinking.turn_preamble") == false
2807
+ return "" if (thinking || turn_thinking) == :off
2516
2808
 
2517
2809
  "\nTurn preamble: as the very first line of your thinking, write \"TURN: \" followed by a short present-tense action phrase (max 8 words) describing what you are about to do, e.g. \"TURN: reading project config\". Then continue reasoning normally.\n"
2518
2810
  end
@@ -2521,12 +2813,13 @@ module Samagotchi
2521
2813
 
2522
2814
  # @param chat [Boolean] for the chat loop: no tool declarations, call
2523
2815
  # syntax or turn preamble (its tools go as schemas with each request)
2524
- def assist_system_prompt(chat: false)
2816
+ # @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
2817
+ def assist_system_prompt(chat: false, thinking: nil)
2525
2818
  return chat_system_prompt if chat
2526
2819
 
2527
2820
  declarations = tool_declarations
2528
2821
  hint = tool_call_hint
2529
- turn_preamble = turn_preamble_instruction
2822
+ turn_preamble = turn_preamble_instruction(thinking)
2530
2823
 
2531
2824
  <<~SYS
2532
2825
  You are Chi (pronounced "chee"), the friendly name for the Samagotchi assistant harness. You have access to the following tools:
@@ -2606,15 +2899,12 @@ module Samagotchi
2606
2899
 
2607
2900
  # @param chat [Boolean] no Gemma thinking token (the chat API's template
2608
2901
  # decides about thinking)
2609
- def system_prompt_with_index(base, chat: false)
2902
+ # @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
2903
+ def system_prompt_with_index(base, chat: false, thinking: nil)
2610
2904
  project_index = read_memory_index("project")
2611
2905
  system_index = read_memory_index("system")
2612
2906
  project_description = project_specific_description
2613
- thinking_token = if !chat && profile.name == "gemma4" && ENV["THINKING_MODE"] != "false"
2614
- "<|think|>\n"
2615
- else
2616
- ""
2617
- end
2907
+ thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
2618
2908
  memory_sections = [
2619
2909
  "Project memories:\n#{project_index}",
2620
2910
  "System memories:\n#{system_index}"
@@ -2661,6 +2951,8 @@ module Samagotchi
2661
2951
  end
2662
2952
 
2663
2953
  merged = Array(baseline).dup
2954
+ # For the warning when one can't be loaded: it names where it came from.
2955
+ @config_memories = merged.dup
2664
2956
  Array(cli_memories).each do |raw|
2665
2957
  raw.to_s.split(",").map(&:strip).reject(&:empty?).each do |name|
2666
2958
  merged << name unless merged.include?(name)
@@ -2693,7 +2985,8 @@ module Samagotchi
2693
2985
  scope, actual_name = split_memory_scope(name)
2694
2986
  body = Tools::MemoryRead.call(actual_name, scope: scope)
2695
2987
  if body.start_with?("Error:")
2696
- Log.warn(:memory, "preload_failed", echo: "Warning: --memory '#{name}' could not be loaded (#{body})", memory: name)
2988
+ source = Array(@config_memories).include?(raw) ? "memory '#{name}' (from config memories:)" : "--memory '#{name}'"
2989
+ Log.warn(:memory, "preload_failed", echo: "Warning: #{source} could not be loaded (#{body})", memory: name)
2697
2990
  next
2698
2991
  end
2699
2992
  # Record activated names so the UI can echo them in the sticky
@@ -2716,8 +3009,9 @@ module Samagotchi
2716
3009
  @activated_memory_names ||= []
2717
3010
  end
2718
3011
 
2719
- # System-prompt builders the TerminalUI seeds its conversation from.
2720
- public :tool_call_hint, :assist_system_prompt, :system_prompt_with_index, :activated_memory_names
3012
+ # The base prompt (specs, plugins' declarations) and the --memory names
3013
+ # the TerminalUI mirrors into its status line.
3014
+ public :assist_system_prompt, :activated_memory_names
2721
3015
 
2722
3016
  def split_memory_scope(raw)
2723
3017
  value = raw.to_s.strip