samagotchi 0.3.0 → 0.5.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Line-based unified diff (like `diff -u` without the file header), used to
5
+ # show what chi's edit/write tools change. Pure: strings in, a hash out.
6
+ #
7
+ # TextDiff.unified("a\nb\n", "a\nc\n")
8
+ # # => {text: "@@ -1,2 +1,2 @@\n a\n-b\n+c", added: 1, removed: 1, truncated: false}
9
+ #
10
+ # The common prefix and suffix are trimmed first (edits are local), then
11
+ # Myers O(ND) runs on the middle. When the edit distance passes
12
+ # MAX_EDIT_DISTANCE it gives up on a minimal diff and shows the whole middle
13
+ # as removed then added: correct, just not minimal. The text is cut at
14
+ # MAX_LINES / MAX_BYTES with a "… N more lines" line; the counts stay exact.
15
+ module TextDiff
16
+ CONTEXT = 3
17
+ MAX_LINES = 120
18
+ MAX_BYTES = 8 * 1024
19
+ MAX_EDIT_DISTANCE = 1000 # D² work: ~0.35 s for a dense 5 000-line edit
20
+ NO_NEWLINE = "\"
21
+
22
+ module_function
23
+
24
+ def unified(before, after, context: CONTEXT, max_lines: MAX_LINES, max_bytes: MAX_BYTES)
25
+ a = clean(before).lines
26
+ b = clean(after).lines
27
+ ops = edit_script(a, b)
28
+ added = ops.count { |op| op[0] == :add }
29
+ removed = ops.count { |op| op[0] == :del }
30
+ text, truncated = render(hunks(ops, context), a, b, max_lines, max_bytes)
31
+ { text: text, added: added, removed: removed, truncated: truncated }
32
+ end
33
+
34
+ def clean(text)
35
+ text.to_s.dup.force_encoding(Encoding::UTF_8).scrub("�")
36
+ end
37
+
38
+ # Ops are [:eq, ai, bi], [:del, ai, nil] or [:add, nil, bi] in file order.
39
+ def edit_script(a, b)
40
+ pre = 0
41
+ pre += 1 while pre < a.size && pre < b.size && a[pre] == b[pre]
42
+ suf = 0
43
+ suf += 1 while suf < a.size - pre && suf < b.size - pre && a[-1 - suf] == b[-1 - suf]
44
+
45
+ ops = (0...pre).map { |i| [:eq, i, i] }
46
+ a_mid = a[pre...(a.size - suf)]
47
+ b_mid = b[pre...(b.size - suf)]
48
+ middle = myers(a_mid, b_mid) ||
49
+ a_mid.each_index.map { |i| [:del, i, nil] } + b_mid.each_index.map { |j| [:add, nil, j] }
50
+ middle.each { |t, i, j| ops << [t, i && i + pre, j && j + pre] }
51
+ (0...suf).each { |s| ops << [:eq, a.size - suf + s, b.size - suf + s] }
52
+ ops
53
+ end
54
+
55
+ # Myers' greedy forward search, keeping one slice of V per round for the
56
+ # backtrack. Returns nil when the edit distance passes max_d.
57
+ def myers(a, b, max_d = MAX_EDIT_DISTANCE)
58
+ ids = {}
59
+ a = a.map { |l| ids[l] ||= ids.size }
60
+ b = b.map { |l| ids[l] ||= ids.size }
61
+ # No line in common (a full rewrite): the fallback is the minimal diff.
62
+ return nil if ids.size == a.uniq.size + b.uniq.size
63
+
64
+ n = a.size
65
+ m = b.size
66
+ off = n + m + 1
67
+ v = Array.new(2 * off + 1, 0)
68
+ trace = []
69
+ (0..(n + m)).each do |d|
70
+ return nil if d > max_d
71
+
72
+ # Round d reads k-1 and k+1 for k in -d..d: keep -(d+1)..d+1.
73
+ trace << v[off - d - 1, 2 * d + 3]
74
+ k = -d
75
+ while k <= d
76
+ x = if k == -d || (k != d && v[off + k - 1] < v[off + k + 1])
77
+ v[off + k + 1]
78
+ else
79
+ v[off + k - 1] + 1
80
+ end
81
+ y = x - k
82
+ while x < n && y < m && a[x] == b[y]
83
+ x += 1
84
+ y += 1
85
+ end
86
+ v[off + k] = x
87
+ return backtrack(trace, n, m) if x >= n && y >= m
88
+
89
+ k += 2
90
+ end
91
+ end
92
+ []
93
+ end
94
+
95
+ def backtrack(trace, x, y)
96
+ ops = []
97
+ (trace.size - 1).downto(0) do |d|
98
+ vd = trace[d]
99
+ at = ->(k) { vd[k + d + 1] }
100
+ k = x - y
101
+ prev_k = k == -d || (k != d && at.(k - 1) < at.(k + 1)) ? k + 1 : k - 1
102
+ prev_x = at.(prev_k)
103
+ prev_y = prev_x - prev_k
104
+ while x > prev_x && y > prev_y
105
+ x -= 1
106
+ y -= 1
107
+ ops << [:eq, x, y]
108
+ end
109
+ break if d.zero?
110
+
111
+ if x == prev_x
112
+ ops << [:add, nil, prev_y]
113
+ else
114
+ ops << [:del, prev_x, nil]
115
+ end
116
+ x = prev_x
117
+ y = prev_y
118
+ end
119
+ ops.reverse
120
+ end
121
+
122
+ # Groups ops into hunks: runs of changes whose gaps are at most 2*context
123
+ # unchanged lines, each padded with up to context lines on both sides.
124
+ def hunks(ops, context)
125
+ changed = ops.each_index.reject { |i| ops[i][0] == :eq }
126
+ return [] if changed.empty?
127
+
128
+ groups = [[changed.first, changed.first]]
129
+ changed.drop(1).each do |i|
130
+ if i - groups.last[1] - 1 <= 2 * context
131
+ groups.last[1] = i
132
+ else
133
+ groups << [i, i]
134
+ end
135
+ end
136
+ groups.map { |first, last| ops[[first - context, 0].max..[last + context, ops.size - 1].min] }
137
+ end
138
+
139
+ def render(hunks, a, b, max_lines, max_bytes)
140
+ lines = []
141
+ hunks.each do |hunk|
142
+ lines << header(hunk)
143
+ hunk.each do |t, i, j|
144
+ line = t == :add ? b[j] : a[i]
145
+ mark = { eq: " ", del: "-", add: "+" }[t]
146
+ # Only the "\n": chomp("\n") would take a CRLF's "\r" too.
147
+ lines << mark + (line.end_with?("\n") ? line[0...-1] : line)
148
+ lines << NO_NEWLINE unless line.end_with?("\n")
149
+ end
150
+ end
151
+ cut(lines, max_lines, max_bytes)
152
+ end
153
+
154
+ # @@ -start,len +start,len @@ with diff -u's conventions: ",1" is left
155
+ # out. A side with no lines in the hunk is an empty file (any other hunk
156
+ # has context lines), shown as 0,0.
157
+ def header(hunk)
158
+ a_lines = hunk.filter_map { |t, i, _| i unless t == :add }
159
+ b_lines = hunk.filter_map { |t, _, j| j unless t == :del }
160
+ "@@ -#{range(a_lines)} +#{range(b_lines)} @@"
161
+ end
162
+
163
+ def range(indexes)
164
+ return "0,0" if indexes.empty?
165
+
166
+ indexes.size == 1 ? (indexes.first + 1).to_s : "#{indexes.first + 1},#{indexes.size}"
167
+ end
168
+
169
+ def cut(lines, max_lines, max_bytes)
170
+ bytes = 0
171
+ lines.each_with_index do |line, i|
172
+ bytes += line.bytesize + 1
173
+ next unless i >= max_lines || bytes > max_bytes
174
+
175
+ rest = lines.size - i
176
+ return [(lines[0, i] + ["… #{rest} more lines"]).join("\n"), true]
177
+ end
178
+ [lines.join("\n"), false]
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,126 @@
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
+ # Whether a chat host's /props (+props+, Client::ServerProps) says its
103
+ # chat template takes no effort (llama.cpp's
104
+ # chat_template_caps.supports_reasoning_effort: false), so +level+'s
105
+ # reasoning_effort is ignored. false when it doesn't say.
106
+ def effort_ignored?(level, props)
107
+ return false unless EFFORTS.include?(level) && props&.answered? && props.body.is_a?(Hash)
108
+
109
+ caps = props.body["chat_template_caps"]
110
+ caps.is_a?(Hash) && caps["supports_reasoning_effort"] == false
111
+ end
112
+
113
+ # The native prompt's switch for +level+ under +profile+.
114
+ # @param profile [ModelProfile]
115
+ # @return [Native]
116
+ def native(level, profile)
117
+ off = level == :off
118
+ honoured = !EFFORTS.include?(level)
119
+ case profile&.name
120
+ when "gemma4" then Native.new(system_token: off ? "" : GEMMA_THINK_TOKEN, prefill: "", honoured: honoured)
121
+ when "qwen36" then Native.new(system_token: "", prefill: off ? QWEN_EMPTY_THOUGHT : "", honoured: honoured)
122
+ else Native.new(system_token: "", prefill: "", honoured: level == DEFAULT)
123
+ end
124
+ end
125
+ end
126
+ end
@@ -31,13 +31,63 @@ module Samagotchi
31
31
  module_function
32
32
 
33
33
  # @param registry [Tools::Registry, nil] the session's tools
34
- def tool_activity_event(tool_name, call, result, registry: nil)
35
- {
34
+ # @param cwd [String, nil] what a file tool's title is relative to (the
35
+ # worker runs in its session's working directory)
36
+ def tool_activity_event(tool_name, call, result, registry: nil, cwd: Dir.pwd)
37
+ event = {
36
38
  action: tool_activity_action(tool_name, registry: registry),
37
39
  tool: tool_name,
38
40
  params: tool_activity_params(tool_name, call, registry: registry),
39
41
  status: tool_activity_status(result, tool_name)
40
42
  }
43
+ title = tool_title(tool_name, call, cwd: cwd)
44
+ event[:title] = title if title
45
+ event
46
+ end
47
+
48
+ # What the call did, for a web row's one line ("edit lib/a.rb", the
49
+ # command without its "cd … &&"): a file tool's path relative to +cwd+
50
+ # when it is under it, a command's first line, a memory's name; nil for
51
+ # the other tools (a plugin's row keeps its preview, the params).
52
+ # +params+ stays the full key=value line (the TUI, the guardrails).
53
+ def tool_title(tool_name, call, cwd: nil)
54
+ text = case tool_name
55
+ when Tools::Read::NAME, Tools::MemoryRead::NAME then call[:content]
56
+ when Tools::Write::NAME, Tools::Edit::NAME, Tools::MemoryWrite::NAME then call[:path]
57
+ when Tools::Execute::NAME, Tools::TaskCreate::NAME then command_title(call[:content])
58
+ end
59
+ text = text.to_s.strip
60
+ return nil if text.empty?
61
+
62
+ case tool_name
63
+ when Tools::Read::NAME, Tools::Write::NAME, Tools::Edit::NAME
64
+ path = relative_path(text, cwd)
65
+ path.length > TOOL_ACTIVITY_PREVIEW_LIMIT ? "…#{path[-(TOOL_ACTIVITY_PREVIEW_LIMIT - 1)..]}" : path
66
+ else
67
+ text = text.gsub(/\s+/, " ")
68
+ text.length > TOOL_ACTIVITY_PREVIEW_LIMIT ? "#{text[0, TOOL_ACTIVITY_PREVIEW_LIMIT - 1]}…" : text
69
+ end
70
+ end
71
+
72
+ # A leading "cd <dir> &&" / "cd <dir>;" goes: the model's habit, and it
73
+ # eats the line.
74
+ LEADING_CD = /\Acd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/
75
+
76
+ def command_title(command)
77
+ line = command.to_s.lines.map(&:strip).find { |l| !l.empty? }.to_s
78
+ line.sub(LEADING_CD, "")
79
+ end
80
+
81
+ # +path+ relative to +cwd+ when it is inside it, compared by path
82
+ # components ("/p/app-2" is not inside "/p/app"); else as given.
83
+ def relative_path(path, cwd)
84
+ return path if cwd.to_s.empty? || !path.start_with?("/")
85
+
86
+ parts = File.expand_path(path).split("/")
87
+ base = File.expand_path(cwd).split("/")
88
+ return path unless parts.length > base.length && parts[0, base.length] == base
89
+
90
+ parts.drop(base.length).join("/")
41
91
  end
42
92
 
43
93
  def tool_activity_action(tool_name, registry: nil)
@@ -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.
@@ -40,12 +48,17 @@ module Samagotchi
40
48
  started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
41
49
  tool: call[:name], call: call.dup, params: params }
42
50
  started[:label] = label if label
51
+ # The worker runs in its session's working directory.
52
+ title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
53
+ started[:title] = title if title
43
54
  emit(on_stream_event, started)
44
55
 
45
56
  # The ask comes after tool_call_started: the UI shows the tool line,
46
57
  # then the approval under it.
47
58
  settle_ask(verdict) if verdict.ask?
59
+ before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
48
60
  result = verdict.deny? ? denied(call, verdict) : dispatch(call)
61
+ diff = file_change(call, before)
49
62
  result = approved(result, verdict) if verdict.allow? && verdict.decided_by
50
63
  result, images = attach_images(call, result) if result[:images]
51
64
 
@@ -61,17 +74,40 @@ module Samagotchi
61
74
  completed = { type: :tool_call_completed, iteration: iteration, call_count: call_count, call_index: call_index,
62
75
  tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
63
76
  completed[:images] = images if images&.any?
77
+ completed[:diff] = diff if diff
64
78
  emit(on_stream_event, completed)
65
79
 
66
80
  run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
67
81
  run[:images] = images if images&.any?
68
82
  run[:shown_params] = params if params && plugin_tool?(call[:name])
69
83
  run[:shown_label] = label if label
84
+ run[:diff] = diff if diff
70
85
  run
71
86
  end
72
87
 
73
88
  private
74
89
 
90
+ # edit/write only: the file just before the call runs. The row diffs it
91
+ # with the file after, whatever the result says, so an edit that fails
92
+ # after writing still shows its change and one that wrote nothing shows
93
+ # none.
94
+ def file_before(call)
95
+ return NOT_AN_EDIT unless EditPreview.tool?(call[:name])
96
+
97
+ EditPreview.snapshot(Tools::ToolPath.normalize(call[:path]))
98
+ rescue StandardError
99
+ NOT_AN_EDIT
100
+ end
101
+
102
+ def file_change(call, before)
103
+ return nil if before.equal?(NOT_AN_EDIT)
104
+
105
+ EditPreview.change(before, EditPreview.snapshot(Tools::ToolPath.normalize(call[:path])))
106
+ rescue StandardError => e
107
+ Log.warn(:turn, "edit_diff_failed", tool: call[:name], error: "#{e.class}: #{e.message}")
108
+ nil
109
+ end
110
+
75
111
  # A tool's output can hold bytes that aren't UTF-8 (`printf '\xff'`, a
76
112
  # binary file). They become "?" here, before the output reaches the
77
113
  # 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)
@@ -195,8 +195,8 @@ module Samagotchi
195
195
  def self.output_block(label, content)
196
196
  return nil if content.nil? || content.empty?
197
197
 
198
- truncate_at_bytes = OutputGuardrails.env_positive_int("SAMAGOTCHI_EXECUTE_TRUNCATE_AT_BYTES", OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES)
199
- preview_bytes = OutputGuardrails.env_positive_int("SAMAGOTCHI_EXECUTE_PREVIEW_BYTES", OutputGuardrails::DEFAULT_PREVIEW_BYTES)
198
+ truncate_at_bytes = OutputGuardrails.config_positive_int("execute.truncate_at_bytes", OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES)
199
+ preview_bytes = OutputGuardrails.config_positive_int("execute.preview_bytes", OutputGuardrails::DEFAULT_PREVIEW_BYTES)
200
200
  bytes = content.bytesize
201
201
  return "#{label}:\n#{content}" if bytes <= truncate_at_bytes
202
202
 
@@ -220,7 +220,7 @@ module Samagotchi
220
220
  def self.telemetry_lines_for(content)
221
221
  OutputGuardrails.telemetry_lines_for(
222
222
  content: content,
223
- threshold_env: "SAMAGOTCHI_EXECUTE_TELEMETRY_THRESHOLD_PCT",
223
+ threshold_key: "execute.telemetry_threshold_pct",
224
224
  threshold_default: OutputGuardrails::DEFAULT_TELEMETRY_THRESHOLD_PCT,
225
225
  token_key: "estimated_tokens_for_command_output",
226
226
  pct_key: "estimated_window_pct_for_command_output"