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
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+
5
+ module Samagotchi
6
+ # How much a model thinks: one level per model, host or run, mapped to each
7
+ # backend's own switch. The one place that knows the mapping.
8
+ #
9
+ # off thinking off where the backend can do it
10
+ # low, medium, high the effort a chat host is asked for
11
+ # default chi sends nothing: the provider's or template's own default
12
+ #
13
+ # Order (#resolve): --thinking / SAMAGOTCHI_THINKING_LEVEL, then
14
+ # models.<key>.thinking, hosts.<name>.thinking, thinking.level in
15
+ # config.yml, then default.
16
+ module Thinking
17
+ LEVELS = %i[off low medium high default].freeze
18
+ EFFORTS = %i[low medium high].freeze
19
+ DEFAULT = :default
20
+
21
+ # Gemma 4 turns thinking on with this token at the start of the system prompt.
22
+ GEMMA_THINK_TOKEN = "<|think|>\n"
23
+ # An empty thought after the Qwen assistant cue: the model answers at once
24
+ # (what the server's own template does with enable_thinking false).
25
+ QWEN_EMPTY_THOUGHT = "<think>\n\n</think>\n\n"
26
+
27
+ # What a native prompt gets for a level: the text before the system
28
+ # prompt, the text after the assistant cue, and whether the level means
29
+ # anything there (low/medium/high have no native knob).
30
+ Native = Data.define(:system_token, :prefill, :honoured)
31
+
32
+ module_function
33
+
34
+ # A configured value as a level; nil when unset or not a level (that
35
+ # warns once, naming +where+). YAML reads an unquoted `off` as false,
36
+ # which is :off; true (`on`) isn't a level.
37
+ # @return [Symbol, nil]
38
+ def level(value, where)
39
+ return nil if value.nil?
40
+ return :off if value == false || value.to_s.strip.downcase == "false"
41
+
42
+ text = value.to_s.strip.downcase
43
+ return text.to_sym if LEVELS.include?(text.to_sym)
44
+
45
+ hint = value == true || %w[true on].include?(text) ? "; `on` isn't one, `default` leaves it to the model" : ""
46
+ ConfigFile.warn_once "Warning: #{where}: thinking must be one of #{LEVELS.join(', ')}#{hint}; ignored"
47
+ nil
48
+ end
49
+
50
+ # The level for +target+ and where it came from ("--thinking",
51
+ # "models: qwen", "hosts.work", "thinking.level"; nil for the default).
52
+ # @param target [HostRegistry::ModelTarget]
53
+ # @param names [Array<String>, nil] the model's lookup names (Engine#model_lookup_names)
54
+ # @param models [Hash, nil] ConfigFile.model_settings (specs)
55
+ # @return [Array(Symbol, String|nil)]
56
+ def resolve(target, names: nil, models: nil)
57
+ global, origin = global_level
58
+ return [global, origin == :cli ? "--thinking" : "SAMAGOTCHI_THINKING_LEVEL"] if global && %i[cli env].include?(origin)
59
+
60
+ models ||= begin
61
+ ConfigFile.model_settings
62
+ rescue StandardError
63
+ {}
64
+ end
65
+ names ||= [target.model, target.bare_model]
66
+ key, value = ConfigFile.model_setting(names, :thinking, models: models)
67
+ return [value, "models: #{key}"] if key
68
+
69
+ host = target.entry.respond_to?(:thinking) ? target.entry.thinking : nil
70
+ return [host, "hosts.#{target.entry.name}"] if host
71
+ return [global, "thinking.level"] if global
72
+
73
+ [DEFAULT, nil]
74
+ end
75
+
76
+ # thinking.level as a level and its origin (:cli, :env, :file), or nil.
77
+ def global_level
78
+ raw, origin = Config.get_with_origin("thinking.level")
79
+ return nil if origin == :default
80
+
81
+ where = { cli: "--thinking", env: "SAMAGOTCHI_THINKING_LEVEL" }.fetch(origin, "thinking.level")
82
+ value = level(raw, where)
83
+ value ? [value, origin] : nil
84
+ rescue StandardError
85
+ nil
86
+ end
87
+
88
+ # Request fields for an OpenAI-style chat host. off sends both switches
89
+ # (the template's enable_thinking and the OpenAI-style reasoning_effort
90
+ # "none"): llama.cpp, Splash and OpenRouter each honour one of them.
91
+ # An effort goes as reasoning_effort (llama.cpp ignores it). default
92
+ # sends nothing.
93
+ # @return [Hash] frozen
94
+ def chat_fields(level)
95
+ case level
96
+ when :off then { chat_template_kwargs: { enable_thinking: false }.freeze, reasoning_effort: "none" }.freeze
97
+ when *EFFORTS then { reasoning_effort: level.to_s }.freeze
98
+ else {}.freeze
99
+ end
100
+ end
101
+
102
+ # The native prompt's switch for +level+ under +profile+.
103
+ # @param profile [ModelProfile]
104
+ # @return [Native]
105
+ def native(level, profile)
106
+ off = level == :off
107
+ honoured = !EFFORTS.include?(level)
108
+ case profile&.name
109
+ when "gemma4" then Native.new(system_token: off ? "" : GEMMA_THINK_TOKEN, prefill: "", honoured: honoured)
110
+ when "qwen36" then Native.new(system_token: "", prefill: off ? QWEN_EMPTY_THOUGHT : "", honoured: honoured)
111
+ else Native.new(system_token: "", prefill: "", honoured: level == DEFAULT)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -4,6 +4,8 @@ require_relative "tool_activity"
4
4
  require_relative "guardrails"
5
5
  require_relative "vision_context"
6
6
  require_relative "log"
7
+ require_relative "edit_preview"
8
+ require_relative "tools/tool_path"
7
9
 
8
10
  module Samagotchi
9
11
  # The single per-call path both loops use: the tool_call_started and
@@ -17,6 +19,10 @@ module Samagotchi
17
19
  # that returns many screenshots can't flood the context).
18
20
  MAX_IMAGES_PER_RESULT = 4
19
21
 
22
+ # file_before for a call that isn't an edit/write, or won't run.
23
+ NOT_AN_EDIT = Object.new.freeze
24
+ private_constant :NOT_AN_EDIT
25
+
20
26
  # @param kernel [KernelLoop] read lazily: Engine sets its hooks after
21
27
  # the kernel is built.
22
28
  def initialize(kernel)
@@ -29,7 +35,9 @@ module Samagotchi
29
35
  # shown_params: the params line the live row showed, and shown_label:
30
36
  # its label ("chrome: screenshot"), only for a tool that isn't built in
31
37
  # (the loops save them with the result, so a reload without the plugin
32
- # shows the same row)
38
+ # shows the same row), and diff: what an edit/write changed in its
39
+ # file (EditPreview.change; also on tool_call_completed, never in the
40
+ # model's output)
33
41
  def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
34
42
  params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
35
43
  # The gate runs first, so tool_call_started shows the call that runs.
@@ -45,7 +53,9 @@ module Samagotchi
45
53
  # The ask comes after tool_call_started: the UI shows the tool line,
46
54
  # then the approval under it.
47
55
  settle_ask(verdict) if verdict.ask?
56
+ before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
48
57
  result = verdict.deny? ? denied(call, verdict) : dispatch(call)
58
+ diff = file_change(call, before)
49
59
  result = approved(result, verdict) if verdict.allow? && verdict.decided_by
50
60
  result, images = attach_images(call, result) if result[:images]
51
61
 
@@ -61,17 +71,40 @@ module Samagotchi
61
71
  completed = { type: :tool_call_completed, iteration: iteration, call_count: call_count, call_index: call_index,
62
72
  tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
63
73
  completed[:images] = images if images&.any?
74
+ completed[:diff] = diff if diff
64
75
  emit(on_stream_event, completed)
65
76
 
66
77
  run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
67
78
  run[:images] = images if images&.any?
68
79
  run[:shown_params] = params if params && plugin_tool?(call[:name])
69
80
  run[:shown_label] = label if label
81
+ run[:diff] = diff if diff
70
82
  run
71
83
  end
72
84
 
73
85
  private
74
86
 
87
+ # edit/write only: the file just before the call runs. The row diffs it
88
+ # with the file after, whatever the result says, so an edit that fails
89
+ # after writing still shows its change and one that wrote nothing shows
90
+ # none.
91
+ def file_before(call)
92
+ return NOT_AN_EDIT unless EditPreview.tool?(call[:name])
93
+
94
+ EditPreview.snapshot(Tools::ToolPath.normalize(call[:path]))
95
+ rescue StandardError
96
+ NOT_AN_EDIT
97
+ end
98
+
99
+ def file_change(call, before)
100
+ return nil if before.equal?(NOT_AN_EDIT)
101
+
102
+ EditPreview.change(before, EditPreview.snapshot(Tools::ToolPath.normalize(call[:path])))
103
+ rescue StandardError => e
104
+ Log.warn(:turn, "edit_diff_failed", tool: call[:name], error: "#{e.class}: #{e.message}")
105
+ nil
106
+ end
107
+
75
108
  # A tool's output can hold bytes that aren't UTF-8 (`printf '\xff'`, a
76
109
  # binary file). They become "?" here, before the output reaches the
77
110
  # conversation, the events and the saved session: JSON.generate raises
@@ -19,30 +19,50 @@ module Samagotchi
19
19
 
20
20
  def self.name = NAME
21
21
 
22
- # Direct invocation (used in specs / headless fallback).
23
- # When a blocking handler is not injected, return an instructional error so the
24
- # model falls back to plain text rather than hanging.
25
- def self.call(question, options: nil, header: nil, multi_select: nil, allow_freeform: nil)
26
- question = question.to_s.strip
27
- return "Error: question is required" if question.empty?
28
-
29
- opts = normalize_options(options)
30
- return "Error: options must be an array of 2-8 non-empty strings" if opts.nil?
31
-
32
- header = header.to_s.strip
33
- header = nil if header.empty?
34
- ms = to_bool(multi_select)
35
- af = to_bool(allow_freeform)
36
-
37
- payload = {
22
+ QUESTION_REQUIRED_ERROR = "Error: ask_user_question requires 'question'"
23
+
24
+ # The tool result for a wrong number of options.
25
+ def self.options_count_error(count)
26
+ "Error: ask_user_question requires 2-8 options (got #{count}). Provide e.g. options=[\"Cats\",\"Dogs\"]"
27
+ end
28
+
29
+ # The one check of a model's ask_user_question call (the kernel runs it
30
+ # before the question opens). Dumb-model tolerant: options may be a JSON
31
+ # string or bracket noise, a single option is salvaged, missing options
32
+ # are read from the call's content, booleans may be strings; wire control
33
+ # tokens are stripped from the question and header.
34
+ # @param call [Hash] the tool call (question:/content:, options:, header:,
35
+ # multi_select:, allow_freeform:)
36
+ # @return [Hash, String] the payload {question:, options:, header:,
37
+ # multi_select:, allow_freeform:} (no header key when empty), or the
38
+ # plain-text error the model gets as the tool result
39
+ def self.validate(call)
40
+ question = strip_wire_tokens(call[:question] || call[:content])
41
+ return QUESTION_REQUIRED_ERROR if question.empty?
42
+
43
+ options = normalize_options_lenient(call[:options])
44
+ options = normalize_options_lenient(call[:content].to_s) if options.empty? && call[:content]
45
+ return options_count_error(options.size) if options.empty? || options.size > 8
46
+
47
+ header = strip_wire_tokens(call[:header])
48
+ {
38
49
  question: question,
39
- options: opts,
40
- header: header,
41
- multi_select: ms,
42
- allow_freeform: af
50
+ options: options,
51
+ header: (header unless header.empty?),
52
+ multi_select: to_bool(call[:multi_select]),
53
+ allow_freeform: to_bool(call[:allow_freeform])
43
54
  }.compact
55
+ end
56
+
57
+ # Booleans the way models send them (Gemma passes "true"/"false").
58
+ def self.to_bool(value)
59
+ return value if value == true || value == false
44
60
 
45
- JSON.pretty_generate(payload)
61
+ %w[1 true yes on].include?(value.to_s.strip.downcase)
62
+ end
63
+
64
+ def self.strip_wire_tokens(text)
65
+ text.to_s.gsub(/<\|[^|]*\|>/, "").gsub(/<\||\|>/, "").strip
46
66
  end
47
67
 
48
68
  # Normalize options param: accept Array or JSON string; strip, reject empty.
@@ -135,18 +155,6 @@ module Samagotchi
135
155
 
136
156
  arr.map { |v| sanitize_option(v) }.reject { |v| v.nil? || v.empty? }
137
157
  end
138
-
139
- def self.to_bool(v)
140
- return nil if v.nil?
141
- return v if v == true || v == false
142
-
143
- s = v.to_s.strip.downcase
144
- return true if %w[1 true yes on].include?(s)
145
- return false if %w[0 false no off].include?(s)
146
-
147
- nil
148
- end
149
- private_class_method :to_bool
150
158
  end
151
159
  end
152
160
  end
@@ -22,9 +22,25 @@ module Samagotchi
22
22
 
23
23
  def self.call(content, path:, start_line: nil, end_line: nil)
24
24
  path = ToolPath.normalize(path)
25
+ result = apply(content, path: path, start_line: start_line, end_line: end_line)
26
+ return result if result.is_a?(String)
27
+
28
+ updated, message = result
29
+ File.write(path, updated)
30
+ message
31
+ rescue => e
32
+ "Error: #{e.message}"
33
+ end
34
+
35
+ # The edit without the write: [updated, message] or "Error: …", with
36
+ # exactly the strings call returns. +read+ reads the file, so a dry run
37
+ # (EditPreview) can reuse a copy it already has; each mode keeps its own
38
+ # check order (exact: tags first; range: file not found first).
39
+ def self.apply(content, path:, start_line: nil, end_line: nil, read: ->(p) { File.read(p) })
40
+ path = ToolPath.normalize(path)
25
41
 
26
42
  if range_requested?(start_line, end_line)
27
- return call_range_mode(content, path: path, start_line: start_line, end_line: end_line)
43
+ return apply_range_mode(content, path: path, start_line: start_line, end_line: end_line, read: read)
28
44
  end
29
45
 
30
46
  old_text = extract_tag(content, "old")
@@ -35,7 +51,7 @@ module Samagotchi
35
51
  return "Error: <old> block is empty" if old_text.empty?
36
52
  return "Error: file not found: #{path}" unless File.exist?(path)
37
53
 
38
- source = File.read(path)
54
+ source = read.(path)
39
55
  count = count_occurrences(source, old_text)
40
56
 
41
57
  return "Error: old text not found in #{path}" if count == 0
@@ -43,13 +59,12 @@ module Samagotchi
43
59
 
44
60
  idx = source.index(old_text)
45
61
  updated = source[0, idx] + new_text + source[idx + old_text.length..]
46
- File.write(path, updated)
47
- "Edited #{path}: replaced #{old_text.bytesize} bytes with #{new_text.bytesize} bytes"
62
+ [updated, "Edited #{path}: replaced #{old_text.bytesize} bytes with #{new_text.bytesize} bytes"]
48
63
  rescue => e
49
64
  "Error: #{e.message}"
50
65
  end
51
66
 
52
- def self.call_range_mode(content, path:, start_line:, end_line:)
67
+ def self.apply_range_mode(content, path:, start_line:, end_line:, read:)
53
68
  return "Error: file not found: #{path}" unless File.exist?(path)
54
69
 
55
70
  new_text = extract_tag(content, "new")
@@ -59,7 +74,7 @@ module Samagotchi
59
74
  return start_num if start_num.is_a?(String)
60
75
  return "Error: start_line must be provided for range edits" if start_num.nil?
61
76
 
62
- source = File.read(path)
77
+ source = read.(path)
63
78
  lines = source.lines
64
79
  total_lines = lines.length
65
80
 
@@ -102,15 +117,14 @@ module Samagotchi
102
117
  # first suffix line isn't concatenated onto the last replacement line.
103
118
  normalized = (!new_text.empty? && !suffix.empty? && !new_text.end_with?("\n")) ? new_text + "\n" : new_text
104
119
  updated = prefix + normalized + suffix
105
- File.write(path, updated)
106
120
 
107
121
  replaced_lines = (end_value - start_num) + 1
108
122
  new_line_count = new_text.lines.length
109
- "Edited #{path}: replaced lines #{start_num}-#{end_value} (#{replaced_lines} lines) with #{new_line_count} lines#{clamp_note}"
123
+ [updated, "Edited #{path}: replaced lines #{start_num}-#{end_value} (#{replaced_lines} lines) with #{new_line_count} lines#{clamp_note}"]
110
124
  rescue => e
111
125
  "Error: #{e.message}"
112
126
  end
113
- private_class_method :call_range_mode
127
+ private_class_method :apply_range_mode
114
128
 
115
129
  # Count non-overlapping literal occurrences of +needle+ in +haystack+.
116
130
  def self.count_occurrences(haystack, needle)
@@ -14,6 +14,10 @@ module Samagotchi
14
14
  def self.name = NAME
15
15
 
16
16
  def self.call(content, path:)
17
+ # A native call without content/text arrives as nil; check before
18
+ # touching the file, or File.write(path, nil) would empty it.
19
+ return "Error: missing content" unless content.is_a?(String)
20
+
17
21
  path = ToolPath.normalize(path)
18
22
  FileUtils.mkdir_p(File.dirname(path))
19
23
  File.write(path, content)
@@ -143,9 +143,19 @@ module Samagotchi
143
143
  @checkpoint = nil
144
144
  end
145
145
 
146
- # A reminder turn ran. The checkpoint stays only for a pending offer.
146
+ # A reminder turn is about to run: a pending continue offer goes, as for
147
+ # a new prompt (the partial turn stays), so a later "no" can't roll the
148
+ # reminder's exchange back with it.
149
+ # @return [Boolean] whether an offer was pending
150
+ def before_reminder_turn
151
+ offered = awaiting_continue?
152
+ @offer = nil
153
+ offered
154
+ end
155
+
156
+ # A reminder turn ran: the rollback window closes.
147
157
  def after_reminder_turn
148
- @checkpoint = nil unless awaiting_continue?
158
+ @checkpoint = nil
149
159
  end
150
160
 
151
161
  private