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
@@ -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,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
@@ -65,11 +65,13 @@ module Samagotchi
65
65
 
66
66
  # "error" for an "Error:" result, and for an execute whose command
67
67
  # exited non-zero (its last "exit: N" line; no number: killed by a
68
- # signal), so the turn tally's "(N failed)" counts it.
68
+ # signal), so the turn tally's "(N failed)" counts it. "stopped" for a
69
+ # task_wait the user's Stop ended: the task itself runs on.
69
70
  def tool_activity_status(result, tool_name = nil)
70
71
  text = result.to_s
71
72
  return "error" if text.start_with?("Error:")
72
73
  return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
74
+ return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^wait_result: canceled$/)
73
75
 
74
76
  "ok"
75
77
  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
@@ -68,12 +68,16 @@ module Samagotchi
68
68
  Edit::NAME => lambda do |call, _kctx|
69
69
  Edit.call(call[:content], path: call[:path], start_line: call[:start_line], end_line: call[:end_line])
70
70
  end,
71
- TaskCreate::NAME => ->(call, _kctx) { TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env]) },
72
- TaskWait::NAME => lambda do |call, _kctx|
71
+ TaskCreate::NAME => lambda do |call, kctx|
72
+ next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
73
+
74
+ TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env])
75
+ end,
76
+ TaskWait::NAME => lambda do |call, kctx|
73
77
  TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
74
- done_pattern: call[:done_pattern])
78
+ done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx))
75
79
  end,
76
- Execute::NAME => ->(call, _kctx) { Execute.call(call[:content], cwd: call[:cwd]) },
80
+ Execute::NAME => ->(call, kctx) { Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx)) },
77
81
  RegisterReminder::NAME => lambda do |call, kctx|
78
82
  RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
79
83
  interval_minutes: call[:interval_minutes])
@@ -94,6 +98,13 @@ module Samagotchi
94
98
 
95
99
  module_function
96
100
 
101
+ # Stop flips the turn's controller, seen through the Engine's PeerView
102
+ # (as DelegateWait does); a bare kernel has no peers: never cancelled.
103
+ def cancelled_proc(kctx)
104
+ peers = kctx.peers
105
+ -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
106
+ end
107
+
97
108
  # @return [Registry] a new registry with the built-ins, in
98
109
  # TOOL_SCHEMAS order (an Engine's own, which bundles add to)
99
110
  def registry
@@ -1,22 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../session"
4
+ require_relative "../reply_wait"
4
5
  require_relative "output_guardrails"
5
6
  require_relative "peers"
6
7
 
7
8
  module Samagotchi
8
- # Loaded on first use: session_manager requires terminal_ui, which
9
- # requires KernelLoop and so these tools (a require cycle otherwise).
10
- autoload :SessionManager, File.expand_path("../session_manager", __dir__)
11
-
12
9
  module Tools
13
10
  # Waiting for a delegated session's next reply, shared by delegate and
14
- # delegate_result. A reply is the child's output/<timestamp>.txt file
15
- # (the worker writes one per turn that ended with visible text, just
16
- # before its idle save), so "new" means a filename past the cursor: the
17
- # newest one this parent was already given. The cursors live in this
18
- # process (a worker runs one session), keyed by parent and child; a
19
- # worker respawn loses them, which only repeats the newest reply once.
11
+ # delegate_result: ReplyWait in the tools' words, with a cursor per
12
+ # child (the newest reply this parent was already given). The cursors
13
+ # live in this process (a worker runs one session), keyed by parent and
14
+ # child; a worker respawn loses them, which only repeats the newest reply
15
+ # once.
20
16
  module DelegateWait
21
17
  TIMEOUT_DEFAULT = 600
22
18
  POLL_INTERVAL = 0.5
@@ -40,70 +36,34 @@ module Samagotchi
40
36
  def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL)
41
37
  sd = peers.state_dir || Session.default_state_dir
42
38
  key = [peers.session_id, child_id]
43
- cursor = seen[key]
44
- deadline = monotonic + timeout.to_i
45
- seen_running = false
46
-
47
- loop do
48
- session = Session.load(child_id, state_dir: sd)
49
-
50
- if (file = newest_reply(child_id, state_dir: sd)) && newer?(file, cursor)
51
- seen[key] = file
52
- return reply_result(child_id, File.read(File.join(reply_dir(child_id, state_dir: sd), file)))
53
- end
54
-
55
- case session.status
56
- when Session::STATUS_ERROR
57
- return result(child_id, "error", "the child's worker failed: #{session.last_prompt.to_s.strip}; its session shows what happened")
58
- when Session::STATUS_STOPPED
59
- return result(child_id, "stopped", "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
60
- end
61
-
62
- if (pending = session.pending_question)
63
- return result(child_id, "waiting_for_answer", waiting_text(child_id, pending))
64
- end
65
-
66
- if peers.respond_to?(:cancelled?) && peers.cancelled?
67
- return result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
68
- end
69
-
70
- if session.status == Session::STATUS_RUNNING
71
- seen_running = true
72
- elsif seen_running
73
- # It ran and is idle again with no new reply: canceled, failed or
74
- # empty. Before it was ever seen running, idle means a
75
- # file-delivered follow-up its worker has not picked up yet.
76
- return result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
77
- end
78
-
79
- return timeout_result(child_id, timeout) if monotonic > deadline
80
-
81
- sleep(poll_interval)
39
+ cancelled = -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
40
+ wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
41
+ poll_interval: poll_interval, cancelled: cancelled)
42
+ case wait.status
43
+ when :done
44
+ seen[key] = wait.file
45
+ reply_result(child_id, wait.text)
46
+ when :error
47
+ result(child_id, "error", "the child's worker failed: #{wait.text}; its session shows what happened")
48
+ when :stopped
49
+ result(child_id, "stopped", "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
50
+ when :waiting_for_answer
51
+ result(child_id, "waiting_for_answer", waiting_text(child_id, wait.question))
52
+ when :canceled
53
+ result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
54
+ when :no_reply
55
+ result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
56
+ else
57
+ timeout_result(child_id, timeout)
82
58
  end
83
59
  rescue ArgumentError => e
84
60
  "Error: #{e.message}"
85
61
  end
86
62
 
87
- # @return [String, nil] the newest reply filename (sortable timestamps)
88
- def newest_reply(child_id, state_dir:)
89
- dir = reply_dir(child_id, state_dir: state_dir)
90
- return nil unless Dir.exist?(dir)
91
-
92
- Dir.children(dir).select { |f| f.end_with?(".txt") }.max
93
- end
94
-
95
63
  # Point the cursor at the newest reply now, so only a later one counts
96
64
  # (a follow-up sent to a child that already answered).
97
65
  def mark_seen(parent_id, child_id, state_dir:)
98
- seen[[parent_id, child_id]] = newest_reply(child_id, state_dir: state_dir)
99
- end
100
-
101
- def reply_dir(child_id, state_dir:)
102
- File.join(Session.session_dir(child_id, state_dir: state_dir), SessionManager::OUTPUT_DIR)
103
- end
104
-
105
- def newer?(file, cursor)
106
- cursor.nil? || file > cursor
66
+ seen[[parent_id, child_id]] = ReplyWait.newest_reply(child_id, state_dir: state_dir)
107
67
  end
108
68
 
109
69
  def reply_result(child_id, text)
@@ -145,9 +105,6 @@ module Samagotchi
145
105
  ].join("\n")
146
106
  end
147
107
 
148
- def monotonic
149
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
150
- end
151
108
  end
152
109
  end
153
110
  end