samagotchi 0.3.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +16 -0
  4. data/bin/chi +32 -31
  5. data/docs/cli.md +77 -5
  6. data/docs/configuration.md +104 -2
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +88 -6
  10. data/docs/releasing.md +6 -6
  11. data/docs/sessions.md +19 -17
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +4 -1
  14. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  15. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  16. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  18. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  19. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  20. data/lib/samagotchi/client.rb +9 -6
  21. data/lib/samagotchi/commands/registry.rb +8 -0
  22. data/lib/samagotchi/config.rb +64 -20
  23. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  25. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  26. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  27. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  28. data/lib/samagotchi/desktop/macos.rb +59 -8
  29. data/lib/samagotchi/desktop_command.rb +6 -3
  30. data/lib/samagotchi/edit_preview.rb +82 -0
  31. data/lib/samagotchi/engine.rb +201 -104
  32. data/lib/samagotchi/gem_update.rb +89 -0
  33. data/lib/samagotchi/guardrails/approval.rb +26 -4
  34. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  35. data/lib/samagotchi/host_registry.rb +8 -12
  36. data/lib/samagotchi/idle_client.rb +24 -15
  37. data/lib/samagotchi/idle_reminders.rb +2 -2
  38. data/lib/samagotchi/image_store.rb +10 -6
  39. data/lib/samagotchi/kernel_loop.rb +26 -79
  40. data/lib/samagotchi/live_versions.rb +59 -0
  41. data/lib/samagotchi/llm/api_key.rb +41 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  43. data/lib/samagotchi/llm/errors.rb +21 -7
  44. data/lib/samagotchi/llm/http.rb +15 -4
  45. data/lib/samagotchi/llm/openai_chat.rb +5 -26
  46. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  47. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  49. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  50. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  51. data/lib/samagotchi/model_profile.rb +23 -0
  52. data/lib/samagotchi/prompt.rb +4 -2
  53. data/lib/samagotchi/reminder_store.rb +1 -9
  54. data/lib/samagotchi/self_report.rb +17 -3
  55. data/lib/samagotchi/send_command.rb +107 -12
  56. data/lib/samagotchi/session_commands.rb +38 -8
  57. data/lib/samagotchi/session_manager.rb +1 -16
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
  59. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
  60. data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
  61. data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
  62. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  63. data/lib/samagotchi/terminal_ui.rb +62 -248
  64. data/lib/samagotchi/text_diff.rb +181 -0
  65. data/lib/samagotchi/thinking.rb +115 -0
  66. data/lib/samagotchi/tool_runner.rb +34 -1
  67. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  68. data/lib/samagotchi/tools/edit.rb +23 -9
  69. data/lib/samagotchi/tools/write.rb +4 -0
  70. data/lib/samagotchi/turn_flow.rb +12 -2
  71. data/lib/samagotchi/update_command.rb +308 -0
  72. data/lib/samagotchi/update_hint.rb +59 -0
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/vision_support.rb +6 -4
  75. data/lib/samagotchi/web/app.rb +3 -3
  76. data/lib/samagotchi/web/message_parts.rb +8 -3
  77. data/lib/samagotchi/web/public/activity.js +3 -0
  78. data/lib/samagotchi/web/public/app.js +36 -24
  79. data/lib/samagotchi/web/public/chat_view.js +3 -0
  80. data/lib/samagotchi/web/public/data.js +2 -0
  81. data/lib/samagotchi/web/public/diff_view.js +58 -0
  82. data/lib/samagotchi/web/public/index.html +22 -1
  83. data/lib/samagotchi/web/public/question_card.js +3 -1
  84. data/lib/samagotchi/web/public/turn_events.js +29 -5
  85. data/lib/samagotchi/web/public/turn_view.js +2 -1
  86. data/lib/samagotchi/worker.rb +5 -4
  87. metadata +12 -1
@@ -46,7 +46,9 @@ require_relative "image_store"
46
46
  require_relative "vision_context"
47
47
  require_relative "vision_support"
48
48
  require_relative "sampling_settings"
49
+ require_relative "thinking"
49
50
  require_relative "answer_display"
51
+ require_relative "edit_preview"
50
52
 
51
53
  module Samagotchi
52
54
  # Engine owns the core agent logic: system prompt construction, tool
@@ -92,9 +94,13 @@ module Samagotchi
92
94
  @scratch = scratch
93
95
  @chat_backend = nil
94
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!).
95
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?
96
103
  @effective_model_name = @default_model_name
97
- @host_registry = host_registry || HostRegistry.new
98
104
  # An injected client (specs) stands in for every host's client.
99
105
  @host_registry.client_override = client if client
100
106
  @client = @host_registry.resolve(@effective_model_name).client
@@ -116,6 +122,7 @@ module Samagotchi
116
122
  # to load is announced, and a required guardrail's failure denies
117
123
  # every tool call. Rules load now too, so their errors are announced.
118
124
  @guardrail_failures = Guardrails::LoadFailures.new
125
+ @guardrail_rules_mutex = Mutex.new
119
126
  # Plugins that failed to load: announced apart, as plugins (not
120
127
  # guardrails: no tool call is denied for them).
121
128
  @plugin_failures = Guardrails::LoadFailures.new
@@ -847,7 +854,7 @@ module Samagotchi
847
854
  def switch_model!(model_name, persist_default: false)
848
855
  # Resolve alias first (alias may point to qualified ref)
849
856
  aliased = ConfigFile.resolve_model_alias(model_name)
850
- resolved = ModelProfile.required_model_name(aliased)
857
+ resolved = ModelProfile.check_host!(ModelProfile.required_model_name(aliased), hosts: @host_registry.entries)
851
858
  @effective_model_name = resolved
852
859
  bare = bare_model_name(resolved)
853
860
  @model_lookup_names = [model_name, aliased, resolved]
@@ -867,10 +874,6 @@ module Samagotchi
867
874
  resolved
868
875
  end
869
876
 
870
- def reset_model!
871
- switch_model!(@default_model_name)
872
- end
873
-
874
877
  # ── Hooks API ──────────────────────────────────────────────────────────────
875
878
 
876
879
  # Register a hook callback for a named lifecycle event.
@@ -941,8 +944,6 @@ module Samagotchi
941
944
  clear_due_reminder_names!
942
945
  due
943
946
  end
944
- # Alias for backward compatibility.
945
- alias maybe_inject_reminders collect_due_reminders
946
947
 
947
948
  # @return [Boolean] whether any reminder is due now (the store's view,
948
949
  # which #collect_due_reminders would inject), regardless of the REPL queue
@@ -1003,6 +1004,16 @@ module Samagotchi
1003
1004
  nil
1004
1005
  end
1005
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
+
1006
1017
  # The model the server serves for the current model, and the name asked
1007
1018
  # for: what the last generation of that name reported, else llama.cpp's
1008
1019
  # model_alias (/props, one short cached probe; not with probe: false),
@@ -1128,6 +1139,9 @@ module Samagotchi
1128
1139
  base.empty? ? nil : base
1129
1140
  end
1130
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
1131
1145
  def capture_used_memory_from_event(event)
1132
1146
  return unless event.is_a?(Hash) && event[:type] == :tool_call_started
1133
1147
 
@@ -1138,6 +1152,7 @@ module Samagotchi
1138
1152
  return if names.empty?
1139
1153
 
1140
1154
  add_used_memory_names(names)
1155
+ names
1141
1156
  end
1142
1157
 
1143
1158
  # ── Guardrails ─────────────────────────────────────────────────────────────
@@ -1193,28 +1208,54 @@ module Samagotchi
1193
1208
 
1194
1209
  # The YAML rules: config.yml's `guardrails:` section (rules, disable) and
1195
1210
  # installed bundles'. One that doesn't parse is a required load failure
1196
- # (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.
1197
1213
  # @return [Guardrails::Rules]
1198
1214
  def guardrail_rules
1199
- @guardrail_rules ||= begin
1200
- section = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
1201
- section = section["guardrails"] if section.is_a?(Hash)
1202
- rules = []
1203
- disable = []
1204
- begin
1205
- raise Guardrails::Rules::ParseError, "guardrails must be a mapping" unless section.nil? || section.is_a?(Hash)
1206
-
1207
- rules = Guardrails::Rules.parse(section && section["rules"], source: "config")
1208
- disable = Guardrails::Rules.parse_disable(section && section["disable"])
1209
- rescue Guardrails::Rules::ParseError => e
1210
- Log.warn(:guardrails, "config_rules_invalid", echo: "[samagotchi:guardrails] config.yml guardrails rules: #{e.message}")
1211
- @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
1212
1221
  end
1213
- Guardrails::Rules.new(rules + bundle_guardrail_rules, disable: disable,
1214
- enabled: Samagotchi::Config.get("guardrails.enabled") != false)
1222
+ @guardrail_rules
1223
+ end
1224
+ end
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]
1215
1237
  end
1216
1238
  end
1217
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
+
1218
1259
  # Installed bundles' guardrails/*.yml, by bundle name then file name.
1219
1260
  # A file that is missing, changed since install (sha256) or doesn't
1220
1261
  # parse is a required load failure.
@@ -1224,7 +1265,7 @@ module Samagotchi
1224
1265
  MemoryBundle::Provenance.each_installed_with_guardrails do |bundle_name, data|
1225
1266
  if data[:error]
1226
1267
  Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{data[:error]}", bundle: bundle_name)
1227
- @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)
1228
1269
  next
1229
1270
  end
1230
1271
  dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
@@ -1246,14 +1287,14 @@ module Samagotchi
1246
1287
  rules.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
1247
1288
  rescue Guardrails::Rules::ParseError, Psych::Exception => e
1248
1289
  Log.warn(:guardrails, "rules_file_invalid", echo: "[samagotchi:guardrails] #{what}: #{e.message}", bundle: bundle_name, file: basename.to_s)
1249
- @guardrail_failures.add(what, e.message, required: true)
1290
+ @guardrail_failures.add(what, e.message, required: true, group: :rules)
1250
1291
  end
1251
1292
  end
1252
1293
  end
1253
1294
  rules
1254
1295
  rescue StandardError => e
1255
1296
  Log.error(:guardrails, "bundle_rules_failed", echo: "[samagotchi:guardrails] failed to read installed bundles' rules: #{e.class}: #{e.message}", error: e.class.name)
1256
- @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)
1257
1298
  rules || []
1258
1299
  end
1259
1300
 
@@ -1327,10 +1368,21 @@ module Samagotchi
1327
1368
 
1328
1369
  # A plugin tool is asked about by its label, as its row shows it.
1329
1370
  label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools)
1330
- 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))
1331
1373
  Guardrails::Approval.settle(verdict, open_question(payload), payload[:approval][:scopes])
1332
1374
  end
1333
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
+
1334
1386
  # The conversation as a hook may read it: a frozen array of copied
1335
1387
  # messages, so a hook cannot change what the turn sends or stores.
1336
1388
  def hook_messages(messages)
@@ -1466,45 +1518,24 @@ module Samagotchi
1466
1518
  @question_mutex.synchronize { @pending_question&.dup }
1467
1519
  end
1468
1520
 
1469
- # Request a structured question from the user. Called from KernelLoop's
1470
- # turn thread (via dispatch): validates and cleans the model's payload,
1471
- # then #open_question. Returns a normalized JSON string for the
1472
- # 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.
1473
1525
  # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
1474
1526
  # @return [String] normalized answer JSON
1475
1527
  def request_question(payload)
1476
- # Strip wire control tokens (<|...|> / stray <|,|>) that can bleed into the
1477
- # question text when the model wraps the tool call in markup.
1478
- question = strip_wire_tokens(payload[:question])
1479
- options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(payload[:options])
1480
- # Fallback for string JSON that lenient missed
1481
- if options.empty? && payload[:options].is_a?(String)
1482
- options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(payload[:options].to_s)
1483
- end
1484
- if question.empty? || options.empty?
1485
- return JSON.generate({ error: "invalid question", detail: "question and 2-8 options required (got #{options.size})" })
1486
- end
1487
- # Dumb-model salvage: allow single option (don't hard error, just render what we have)
1488
- if options.size == 1
1489
- # keep as is
1490
- elsif options.size < 2
1491
- return JSON.generate({ error: "invalid question", detail: "question and 2-8 options required (got #{options.size})" })
1492
- end
1493
- if options.size > 8
1494
- options = options.first(8)
1495
- end
1496
-
1497
- clean_header = strip_wire_tokens(payload[:header])
1498
- result = open_question(
1499
- question: question,
1500
- options: options,
1501
- header: clean_header.empty? ? nil : clean_header,
1502
- multi_select: !!payload[:multi_select],
1503
- allow_freeform: !!payload[:allow_freeform]
1504
- )
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"
1505
1533
  result.is_a?(String) ? result : JSON.generate(result)
1506
1534
  end
1507
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
+
1508
1539
  # Open a question for the UIs and wait for its answer. Emits
1509
1540
  # :question_requested, persists it to the session, and BLOCKS until
1510
1541
  # answer_question / cancel_question wakes it (or the turn is cancelled).
@@ -1663,11 +1694,6 @@ module Samagotchi
1663
1694
  end
1664
1695
  end
1665
1696
 
1666
- def strip_wire_tokens(text)
1667
- text.to_s.gsub(/<\|[^|]*\|>/, "").gsub(/<\||\|>/, "").strip
1668
- end
1669
- private :strip_wire_tokens
1670
-
1671
1697
  def set_question_sync_handler(&block)
1672
1698
  @question_sync_handler = block
1673
1699
  end
@@ -1703,10 +1729,15 @@ module Samagotchi
1703
1729
  # prompt prefix, and the server's KV cache for it, stay stable.
1704
1730
  # @param target [HostRegistry::ModelTarget, nil]
1705
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.
1706
1734
  def system_prompt(target = nil)
1707
- 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)
1708
1738
  @system_prompts ||= {}
1709
- @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)
1710
1741
  end
1711
1742
 
1712
1743
  # @return [Session] current session (Engine owns create/resume)
@@ -1876,6 +1907,10 @@ module Samagotchi
1876
1907
  vision = turn_vision(session)
1877
1908
  @kernel.vision = vision if @kernel.respond_to?(:vision=)
1878
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)
1879
1914
  refuse_images!(vision) unless image_refs.empty?
1880
1915
  announce_guardrail_failures(on_event)
1881
1916
  # Plugins' slow setup that brings tools (an MCP server's first
@@ -2103,7 +2138,9 @@ module Samagotchi
2103
2138
  def turn_vision(session)
2104
2139
  target = @host_registry.resolve(@effective_model_name)
2105
2140
  VisionContext.new(session_dir: Session.session_dir(session.id, state_dir: session_state_dir),
2106
- 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
+ })
2107
2144
  end
2108
2145
 
2109
2146
  # The effective model's request parameters (hosts: and models:
@@ -2115,6 +2152,72 @@ module Samagotchi
2115
2152
  SamplingSettings::EMPTY
2116
2153
  end
2117
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
2219
+ end
2220
+
2118
2221
  def vision_adapter(target)
2119
2222
  target.entry.chat? ? @host_registry.adapter_for(target.entry) : nil
2120
2223
  rescue StandardError
@@ -2174,29 +2277,6 @@ module Samagotchi
2174
2277
  replace_session_messages(@session, clone_messages(checkpoint))
2175
2278
  end
2176
2279
 
2177
- # Backward-compatible: runs a prompt through the kernel loop without event forwarding.
2178
- # @param session [Session]
2179
- # @param prompt [String]
2180
- # @return [String] model response text
2181
- def process_prompt_through_kernel(session, prompt)
2182
- result = run_turn(session, prompt)
2183
- response = result.respond_to?(:text) ? result.text.to_s : result.to_s
2184
- if response.strip.empty?
2185
- session.messages << { role: "model", content: "[No response]" }
2186
- "[No response]"
2187
- else
2188
- response
2189
- end
2190
- end
2191
-
2192
- # Public entrypoint for background session workers.
2193
- # @param session [Session]
2194
- # @param prompt [String]
2195
- # @return [String] model response
2196
- def process_background_prompt(session:, prompt:)
2197
- process_prompt_through_kernel(session, prompt)
2198
- end
2199
-
2200
2280
  # Clone a messages array (shallow dup of each element).
2201
2281
  # @param messages [Array<Hash>]
2202
2282
  # @return [Array<Hash>]
@@ -2237,6 +2317,12 @@ module Samagotchi
2237
2317
  require_relative "memory_bundle/provenance"
2238
2318
  settings = bundle_settings
2239
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
2240
2326
  bundle_dir = File.join(MemoryBundle::Provenance.bundles_dir, bundle_name)
2241
2327
  hooks_dir = File.join(bundle_dir, "hooks")
2242
2328
  if (data[:trust_level] || "experimental").to_s == "experimental"
@@ -2584,13 +2670,18 @@ module Samagotchi
2584
2670
  event = event.merge(text: delta[:text], thinking: delta[:thinking]) if enrich == :always
2585
2671
  end
2586
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
+
2587
2677
  emit_event(on_event, event)
2678
+ check_thinking_honoured(event) if event[:type] == :generation_completed
2588
2679
  end
2589
2680
  end
2590
2681
 
2591
2682
  def emit_event(on_event, event)
2592
2683
  # Capture used memories synchronously in the turn thread.
2593
- begin
2684
+ read_names = begin
2594
2685
  capture_used_memory_from_event(event)
2595
2686
  rescue StandardError
2596
2687
  nil
@@ -2609,6 +2700,11 @@ module Samagotchi
2609
2700
  # Persistent subscribers: receive a copy with a locally-monotonic
2610
2701
  # `event_seq`, fan out with per-subscriber error isolation.
2611
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 })
2612
2708
  end
2613
2709
 
2614
2710
  # The window as the target's loop would see it (see ChatLoop#context_window:
@@ -2704,9 +2800,11 @@ module Samagotchi
2704
2800
 
2705
2801
  # Only Qwen has an explicit thinking-close marker, so only Qwen can
2706
2802
  # reliably have this preamble parsed back out of its thinking block.
2707
- def turn_preamble_instruction
2803
+ # With thinking off there is no thinking to begin with it.
2804
+ def turn_preamble_instruction(thinking = nil)
2708
2805
  return "" unless profile.name == "qwen36"
2709
2806
  return "" if Samagotchi::Config.get("thinking.turn_preamble") == false
2807
+ return "" if (thinking || turn_thinking) == :off
2710
2808
 
2711
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"
2712
2810
  end
@@ -2715,12 +2813,13 @@ module Samagotchi
2715
2813
 
2716
2814
  # @param chat [Boolean] for the chat loop: no tool declarations, call
2717
2815
  # syntax or turn preamble (its tools go as schemas with each request)
2718
- 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)
2719
2818
  return chat_system_prompt if chat
2720
2819
 
2721
2820
  declarations = tool_declarations
2722
2821
  hint = tool_call_hint
2723
- turn_preamble = turn_preamble_instruction
2822
+ turn_preamble = turn_preamble_instruction(thinking)
2724
2823
 
2725
2824
  <<~SYS
2726
2825
  You are Chi (pronounced "chee"), the friendly name for the Samagotchi assistant harness. You have access to the following tools:
@@ -2800,15 +2899,12 @@ module Samagotchi
2800
2899
 
2801
2900
  # @param chat [Boolean] no Gemma thinking token (the chat API's template
2802
2901
  # decides about thinking)
2803
- 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)
2804
2904
  project_index = read_memory_index("project")
2805
2905
  system_index = read_memory_index("system")
2806
2906
  project_description = project_specific_description
2807
- thinking_token = if !chat && profile.name == "gemma4" && ENV["THINKING_MODE"] != "false"
2808
- "<|think|>\n"
2809
- else
2810
- ""
2811
- end
2907
+ thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
2812
2908
  memory_sections = [
2813
2909
  "Project memories:\n#{project_index}",
2814
2910
  "System memories:\n#{system_index}"
@@ -2913,8 +3009,9 @@ module Samagotchi
2913
3009
  @activated_memory_names ||= []
2914
3010
  end
2915
3011
 
2916
- # System-prompt builders the TerminalUI seeds its conversation from.
2917
- 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
2918
3015
 
2919
3016
  def split_memory_scope(raw)
2920
3017
  value = raw.to_s.strip
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "open3"
6
+ require "rbconfig"
7
+ require "rubygems/package"
8
+ require_relative "version"
9
+
10
+ module Samagotchi
11
+ # The gem part of `chi update`: the newest samagotchi on rubygems, and a
12
+ # `gem install` of it through this Ruby's own gem command (the real
13
+ # interpreter's bindir, not a shim: the same under mise, rbenv and asdf).
14
+ #
15
+ # SAMAGOTCHI_UPDATE_GEM_FILE=<path to a .gem> (hidden, for smokes) makes that
16
+ # file the newest version and installs it instead.
17
+ class GemUpdate
18
+ Error = Class.new(StandardError)
19
+
20
+ LATEST_URL = URI("https://rubygems.org/api/v1/versions/samagotchi/latest.json")
21
+ # Not SAMAGOTCHI_UPDATE_GEM: that is the env form of config update.gem.
22
+ LOCAL_ENV = "SAMAGOTCHI_UPDATE_GEM_FILE"
23
+ TIMEOUT = 5
24
+
25
+ # source is what `gem install` takes: the gem's name, or a .gem file.
26
+ Latest = Struct.new(:version, :source, keyword_init: true)
27
+
28
+ # Runs a command without a shell; [output, success].
29
+ class Runner
30
+ def run(argv)
31
+ output, status = Open3.capture2e(*argv)
32
+ [output, status.success?]
33
+ rescue SystemCallError => e
34
+ [e.message, false]
35
+ end
36
+ end
37
+
38
+ # @param fetcher [#call, nil] → the latest.json body (specs)
39
+ def initialize(env: ENV, fetcher: nil, runner: Runner.new, gem_bin: File.join(RbConfig::CONFIG["bindir"], "gem"))
40
+ @env = env
41
+ @fetcher = fetcher || method(:fetch)
42
+ @runner = runner
43
+ @gem_bin = gem_bin
44
+ end
45
+
46
+ # @return [Latest]
47
+ # @raise [Error] when it can't be found out (offline, a bad answer)
48
+ def latest
49
+ local = @env[LOCAL_ENV].to_s
50
+ return Latest.new(version: Gem::Package.new(local).spec.version.to_s, source: local) unless local.empty?
51
+
52
+ version = JSON.parse(@fetcher.call.to_s)["version"].to_s
53
+ raise Error, "rubygems.org answered without a version" if version.empty? || !Gem::Version.correct?(version)
54
+
55
+ Latest.new(version: version, source: "samagotchi")
56
+ rescue Error
57
+ raise
58
+ rescue StandardError => e
59
+ raise Error, "#{e.class}: #{e.message}".lines.first.strip
60
+ end
61
+
62
+ def newer?(latest, current = VERSION)
63
+ Gem::Version.new(latest.version) > Gem::Version.new(current)
64
+ end
65
+
66
+ def install_argv(latest)
67
+ argv = [@gem_bin, "install", latest.source, "--no-document"]
68
+ latest.source == "samagotchi" ? argv + ["-v", latest.version] : argv
69
+ end
70
+
71
+ # @return [Array(String, Boolean)] gem's output, success
72
+ def install(latest)
73
+ @runner.run(install_argv(latest))
74
+ end
75
+
76
+ private
77
+
78
+ def fetch
79
+ Net::HTTP.start(LATEST_URL.host, LATEST_URL.port, use_ssl: true, open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
80
+ request = Net::HTTP::Get.new(LATEST_URL)
81
+ request["User-Agent"] = USER_AGENT
82
+ response = http.request(request)
83
+ raise Error, "rubygems.org answered #{response.code}" unless response.code == "200"
84
+
85
+ response.body
86
+ end
87
+ end
88
+ end
89
+ end