samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+ require "shellwords"
7
+ require "time"
8
+
9
+ module Samagotchi
10
+ module Tools
11
+ module TaskRuntime
12
+ TASKS_DIR = File.join("tmp", "tasks")
13
+ METADATA_FILENAME = "task.json"
14
+ OUTPUT_FILENAME = "output.log"
15
+ EXIT_CODE_FILENAME = "exit_code"
16
+ STOP_GRACE_SEC = 3.0
17
+ STOP_POLL_INTERVAL_SEC = 0.1
18
+ METADATA_VERSION = 1
19
+ OUTPUT_TAIL_READ_BYTES = 16 * 1024
20
+ SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
21
+
22
+ module_function
23
+
24
+ def create_task(command, cwd: nil, env: nil)
25
+ normalized_command = command.to_s.strip
26
+ return [nil, "Error: command is required"] if normalized_command.empty?
27
+
28
+ resolved_cwd = normalize_cwd(cwd)
29
+ return [nil, "Error: cwd not found: #{resolved_cwd}"] unless Dir.exist?(resolved_cwd)
30
+
31
+ spawn_env, env_error = spawn_env_for(env)
32
+ return [nil, env_error] if env_error
33
+
34
+ task_id = generate_task_id
35
+ task_dir = task_dir_for(task_id)
36
+ output_path = File.join(task_dir, OUTPUT_FILENAME)
37
+ exit_code_path = File.join(task_dir, EXIT_CODE_FILENAME)
38
+
39
+ FileUtils.mkdir_p(task_dir)
40
+
41
+ output_io = File.open(output_path, "a")
42
+ output_io.sync = true
43
+
44
+ wrapped_command = wrapped_shell_command(normalized_command, exit_code_path)
45
+ # Non-login shell (matches Execute): a login shell re-sources profile
46
+ # files, which can rebuild PATH and shadow the inherited toolchain.
47
+ pid = Process.spawn(
48
+ spawn_env,
49
+ "/bin/sh", "-c", wrapped_command,
50
+ chdir: resolved_cwd,
51
+ out: output_io,
52
+ err: output_io,
53
+ pgroup: true
54
+ )
55
+ Process.detach(pid)
56
+
57
+ now = timestamp
58
+ record = {
59
+ "metadata_version" => METADATA_VERSION,
60
+ "id" => task_id,
61
+ "command" => normalized_command,
62
+ "cwd" => resolved_cwd,
63
+ "workspace_root" => Dir.pwd,
64
+ "status" => "running",
65
+ "pid" => pid,
66
+ "created_at" => now,
67
+ "started_at" => now,
68
+ "finished_at" => nil,
69
+ "exit_code" => nil,
70
+ "stop_reason" => nil,
71
+ "output_path" => output_path,
72
+ "exit_code_path" => exit_code_path
73
+ }
74
+
75
+ write_record(record)
76
+ [record, nil]
77
+ rescue => e
78
+ [nil, "Error: #{e.message}"]
79
+ ensure
80
+ output_io&.close
81
+ end
82
+
83
+ def list_records
84
+ ensure_tasks_dir
85
+ task_dirs = Dir.glob(File.join(TASKS_DIR, "*"))
86
+ .select { |path| File.directory?(path) }
87
+
88
+ task_dirs.map do |task_dir|
89
+ id = File.basename(task_dir)
90
+ record = load_record(id)
91
+ next nil unless record
92
+
93
+ refresh_record(record)
94
+ end.compact.sort_by { |record| record["created_at"].to_s }.reverse
95
+ end
96
+
97
+ def get_record(task_id)
98
+ record = load_record(task_id)
99
+ return [nil, "Error: task not found: #{task_id}"] unless record
100
+
101
+ [refresh_record(record), nil]
102
+ rescue => e
103
+ [nil, "Error: #{e.message}"]
104
+ end
105
+
106
+ def output_tail_lines(path, line_count)
107
+ return "" unless File.file?(path)
108
+
109
+ size = File.size(path)
110
+ offset = [size - OUTPUT_TAIL_READ_BYTES, 0].max
111
+ content = File.open(path, "rb") do |file|
112
+ file.seek(offset)
113
+ file.read
114
+ end
115
+ content = content.split("\n", 2).last.to_s if offset.positive?
116
+ content.lines.last(line_count).join
117
+ end
118
+
119
+ def stop_task(task_id)
120
+ record = load_record(task_id)
121
+ return [nil, "Error: task not found: #{task_id}"] unless record
122
+
123
+ refreshed = refresh_record(record)
124
+ return [refreshed, nil] unless refreshed["status"] == "running"
125
+
126
+ pid = refreshed["pid"].to_i
127
+
128
+ begin
129
+ Process.kill("TERM", -pid)
130
+ rescue Errno::ESRCH
131
+ # Process already exited; status refresh below handles final state.
132
+ end
133
+
134
+ deadline = monotonic_time + STOP_GRACE_SEC
135
+ while process_alive?(pid) && monotonic_time < deadline
136
+ sleep(STOP_POLL_INTERVAL_SEC)
137
+ end
138
+
139
+ if process_alive?(pid)
140
+ begin
141
+ Process.kill("KILL", -pid)
142
+ rescue Errno::ESRCH
143
+ # Process exited between checks.
144
+ end
145
+ end
146
+
147
+ updated = refresh_record(refreshed)
148
+ updated["status"] = "stopped" if updated["status"] == "running"
149
+ updated["finished_at"] ||= timestamp
150
+ updated["stop_reason"] = "stopped_by_user"
151
+ write_record(updated)
152
+
153
+ [updated, nil]
154
+ rescue => e
155
+ [nil, "Error: #{e.message}"]
156
+ end
157
+
158
+ def refresh_record(record)
159
+ return record unless record["status"] == "running"
160
+
161
+ pid = record["pid"].to_i
162
+ return mark_finished_without_exit_code(record) unless process_alive?(pid)
163
+
164
+ record
165
+ end
166
+
167
+ def load_record(task_id)
168
+ path = metadata_path(task_id)
169
+ return nil unless File.exist?(path)
170
+
171
+ JSON.parse(File.read(path))
172
+ rescue JSON::ParserError
173
+ nil
174
+ end
175
+
176
+ def write_record(record)
177
+ ensure_tasks_dir
178
+ dir = task_dir_for(record.fetch("id"))
179
+ FileUtils.mkdir_p(dir)
180
+
181
+ path = File.join(dir, METADATA_FILENAME)
182
+ temp_path = "#{path}.tmp"
183
+ File.write(temp_path, JSON.pretty_generate(record) + "\n")
184
+ File.rename(temp_path, path)
185
+ end
186
+
187
+ def metadata_path(task_id)
188
+ File.join(task_dir_for(task_id), METADATA_FILENAME)
189
+ end
190
+
191
+ def task_dir_for(task_id)
192
+ File.join(TASKS_DIR, task_id.to_s)
193
+ end
194
+
195
+ def ensure_tasks_dir
196
+ FileUtils.mkdir_p(TASKS_DIR)
197
+ end
198
+
199
+ def generate_task_id
200
+ loop do
201
+ candidate = "#{Time.now.utc.strftime("%Y%m%d%H%M%S")}-#{SecureRandom.hex(4)}"
202
+ return candidate unless File.exist?(metadata_path(candidate))
203
+ end
204
+ end
205
+
206
+ def normalize_cwd(cwd)
207
+ value = cwd.to_s.strip
208
+ value.empty? ? Dir.pwd : File.expand_path(value)
209
+ end
210
+
211
+ def wrapped_shell_command(command, exit_code_path)
212
+ escaped_exit_code_path = Shellwords.escape(exit_code_path)
213
+ <<~SH
214
+ #{command}
215
+ status=$?
216
+ printf "%s" "$status" > #{escaped_exit_code_path}
217
+ exit "$status"
218
+ SH
219
+ end
220
+
221
+ def sanitized_spawn_env
222
+ # Running from temporary directories can inherit Bundler-specific env
223
+ # (for example RUBYOPT=-rbundler/setup) that breaks plain `ruby -e`.
224
+ {
225
+ "RUBYOPT" => sanitized_rubyopt,
226
+ "RUBYLIB" => nil,
227
+ "BUNDLE_GEMFILE" => nil,
228
+ "BUNDLE_BIN_PATH" => nil,
229
+ "BUNDLER_VERSION" => nil
230
+ }
231
+ end
232
+
233
+ def spawn_env_for(overrides)
234
+ return [sanitized_spawn_env, nil] if overrides.nil? || overrides.to_s.empty?
235
+
236
+ overrides = JSON.parse(overrides) if overrides.is_a?(String)
237
+ return [nil, "Error: env must be an object"] unless overrides.is_a?(Hash)
238
+
239
+ normalized = {}
240
+ overrides.each do |key, value|
241
+ return [nil, "Error: env keys and values must be strings"] unless key.is_a?(String) && value.is_a?(String)
242
+ return [nil, "Error: env keys and values cannot contain NUL bytes"] if key.include?("\0") || value.include?("\0")
243
+ return [nil, "Error: env key is reserved: #{key}"] if SANITIZED_ENV_KEYS.include?(key)
244
+
245
+ normalized[key] = value
246
+ end
247
+
248
+ [sanitized_spawn_env.merge(normalized), nil]
249
+ rescue JSON::ParserError
250
+ [nil, "Error: env must be a JSON object"]
251
+ end
252
+
253
+ def sanitized_rubyopt
254
+ rubyopt = ENV["RUBYOPT"].to_s
255
+ return nil if rubyopt.empty?
256
+
257
+ filtered = rubyopt.split(/\s+/).reject do |arg|
258
+ arg == "-rbundler/setup" || arg.match?(/\A-r.*bundler\/setup\z/)
259
+ end
260
+ return nil if filtered.empty?
261
+
262
+ filtered.join(" ")
263
+ end
264
+
265
+ def process_alive?(pid)
266
+ Process.kill(0, pid)
267
+ true
268
+ rescue Errno::ESRCH
269
+ false
270
+ rescue Errno::EPERM
271
+ true
272
+ end
273
+
274
+ def mark_finished_without_exit_code(record)
275
+ exit_code = read_exit_code(record["exit_code_path"])
276
+
277
+ if exit_code.nil?
278
+ record["status"] = "failed"
279
+ record["exit_code"] = nil
280
+ record["stop_reason"] ||= "process_ended_without_exit_code"
281
+ elsif exit_code.zero?
282
+ record["status"] = "completed"
283
+ record["exit_code"] = 0
284
+ else
285
+ record["status"] = "failed"
286
+ record["exit_code"] = exit_code
287
+ end
288
+
289
+ record["finished_at"] ||= timestamp
290
+ write_record(record)
291
+ record
292
+ end
293
+
294
+ def read_exit_code(path)
295
+ return nil if path.to_s.empty? || !File.exist?(path)
296
+
297
+ Integer(File.read(path).strip, exception: false)
298
+ rescue => _e
299
+ nil
300
+ end
301
+
302
+ def timestamp
303
+ Time.now.utc.iso8601
304
+ end
305
+
306
+ def monotonic_time
307
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
308
+ end
309
+ end
310
+ end
311
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "task_runtime"
4
+
5
+ module Samagotchi
6
+ module Tools
7
+ class TaskStop
8
+ NAME = "task_stop"
9
+
10
+ def self.name = NAME
11
+
12
+ def self.call(task_id)
13
+ record, error = TaskRuntime.stop_task(task_id.to_s.strip)
14
+ return error if error
15
+
16
+ [
17
+ "task_id: #{record.fetch("id")}",
18
+ "status: #{record.fetch("status")}",
19
+ "exit_code: #{record["exit_code"]}",
20
+ "finished_at: #{record["finished_at"]}",
21
+ "stop_reason: #{record["stop_reason"]}",
22
+ "output_path: #{record.fetch("output_path")}"
23
+ ].join("\n")
24
+ rescue => e
25
+ "Error: #{e.message}"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,104 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "task_runtime"
5
+
6
+ module Samagotchi
7
+ module Tools
8
+ class TaskWait
9
+ NAME = "task_wait"
10
+ TIMEOUT_DEFAULT = 600
11
+ TAIL_LINES_DEFAULT = 10
12
+ TAIL_LINES_MAX = 100
13
+ POLL_INTERVAL = 0.5
14
+
15
+ def self.name = NAME
16
+
17
+ def self.call(task_id, timeout: TIMEOUT_DEFAULT, tail_lines: TAIL_LINES_DEFAULT, done_pattern: nil)
18
+ timeout = TIMEOUT_DEFAULT if blank?(timeout)
19
+ tail_lines = TAIL_LINES_DEFAULT if blank?(tail_lines)
20
+ timeout = timeout.to_i
21
+ return "Error: task_id is required" if task_id.to_s.strip.empty?
22
+
23
+ tail_lines = parse_tail_lines(tail_lines)
24
+ return tail_lines if tail_lines.is_a?(String)
25
+
26
+ completion_pattern = parse_done_pattern(done_pattern)
27
+ return completion_pattern if completion_pattern.is_a?(String)
28
+
29
+ task_id = task_id.to_s.strip
30
+ deadline = monotonic_time + timeout
31
+
32
+ loop do
33
+ record, error = TaskRuntime.get_record(task_id)
34
+ return "Error: #{error}" if error
35
+
36
+ status = record.fetch("status")
37
+ return format_response(record) unless status == "running"
38
+
39
+ if completion_pattern
40
+ output_tail = TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
41
+ return format_response(record, wait_result: "pattern_matched", output_tail: output_tail) if completion_pattern.match?(output_tail)
42
+ end
43
+
44
+ break if monotonic_time > deadline
45
+ sleep(POLL_INTERVAL)
46
+ end
47
+
48
+ # Timeout reached — do one final refresh to capture the latest status
49
+ refreshed, _ = TaskRuntime.get_record(task_id)
50
+ return format_response(refreshed) unless refreshed.fetch("status") == "running"
51
+
52
+ output_tail = TaskRuntime.output_tail_lines(refreshed.fetch("output_path"), tail_lines)
53
+ format_response(refreshed, wait_result: "timeout", output_tail: output_tail)
54
+ rescue => e
55
+ "Error: #{e.message}"
56
+ end
57
+
58
+ def self.format_response(record, wait_result: nil, output_tail: nil)
59
+ lines = [
60
+ "task_id: #{record.fetch("id")}",
61
+ "status: #{record.fetch("status")}",
62
+ "exit_code: #{record["exit_code"]}",
63
+ "stop_reason: #{record["stop_reason"]}",
64
+ "output_path: #{record.fetch("output_path")}"
65
+ ]
66
+ return lines.join("\n") unless wait_result
67
+
68
+ lines << "wait_result: #{wait_result}"
69
+ lines << "output_tail:"
70
+ lines << output_tail.rstrip unless output_tail.empty?
71
+ lines.join("\n")
72
+ end
73
+ private_class_method :format_response
74
+
75
+ def self.parse_tail_lines(value)
76
+ parsed = Integer(value, exception: false)
77
+ return "Error: tail_lines must be a positive integer" unless parsed&.positive?
78
+ return "Error: tail_lines must be at most #{TAIL_LINES_MAX}" if parsed > TAIL_LINES_MAX
79
+
80
+ parsed
81
+ end
82
+ private_class_method :parse_tail_lines
83
+
84
+ def self.parse_done_pattern(value)
85
+ return nil if blank?(value)
86
+
87
+ Regexp.new(value.to_s)
88
+ rescue RegexpError => e
89
+ "Error: invalid done_pattern: #{e.message}"
90
+ end
91
+ private_class_method :parse_done_pattern
92
+
93
+ def self.blank?(value)
94
+ value.nil? || value.to_s.strip.empty?
95
+ end
96
+ private_class_method :blank?
97
+
98
+ def self.monotonic_time
99
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
100
+ end
101
+ end
102
+ end
103
+ end
104
+
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Tools
5
+ # Normalizes a path argument sent by the model: strips surrounding
6
+ # whitespace and expands a leading `~` (Ruby's File APIs treat it
7
+ # literally, which used to create a `./~/` directory in the cwd).
8
+ # Other paths are returned as-is so relative paths stay relative.
9
+ module ToolPath
10
+ module_function
11
+
12
+ def normalize(path)
13
+ path = path.to_s.strip
14
+ path.start_with?("~") ? File.expand_path(path) : path
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,163 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ require "net/http"
5
+ require "uri"
6
+ require "ipaddr"
7
+ require "nokogiri"
8
+ require_relative "../version"
9
+
10
+ module Samagotchi
11
+ module Tools
12
+ # Fetches the content of a URL and returns cleaned text content.
13
+ class WebFetch
14
+ NAME = "web_fetch"
15
+ TIMEOUT_SEC = 15
16
+ MAX_OUTPUT_BYTES = 32 * 1024
17
+
18
+ def self.name = NAME
19
+
20
+ def self.call(url)
21
+ url = url.to_s.strip
22
+ return "Error: URL is required" if url.empty?
23
+
24
+ uri = URI.parse(url)
25
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
26
+ return "Error: invalid URL scheme. Expected http:// or https://"
27
+ end
28
+
29
+ return "Error: SSRF protection blocked access to internal/private host" unless ssrf_protected?(uri)
30
+
31
+ response = fetch_url(uri)
32
+ return handle_http_error(response, uri) unless response.is_a?(Net::HTTPSuccess)
33
+
34
+ content_type = response["content-type"] || ""
35
+ return to_utf8(extract_text_from_html(response.body)) if content_type.include?("text/html")
36
+ return to_utf8(response.body) if content_type.include?("text/")
37
+
38
+ "Error: unsupported content type: #{content_type}"
39
+ rescue URI::InvalidURIError
40
+ "Error: invalid URI format"
41
+ rescue SocketError
42
+ "Error: could not resolve hostname"
43
+ rescue Timeout::Error
44
+ "Error: request timed out after #{TIMEOUT_SEC}s"
45
+ rescue => e
46
+ "Error: #{e.message}"
47
+ end
48
+
49
+ def self.ssrf_protected?(uri)
50
+ return true if uri.host.nil?
51
+
52
+ private_ranges = [
53
+ IPAddr.new("127.0.0.0/8"),
54
+ IPAddr.new("10.0.0.0/8"),
55
+ IPAddr.new("172.16.0.0/12"),
56
+ IPAddr.new("192.168.0.0/16"),
57
+ IPAddr.new("169.254.0.0/16"),
58
+ IPAddr.new("0.0.0.0/8"),
59
+ IPAddr.new("::1/128"),
60
+ IPAddr.new("fc00::/7"),
61
+ IPAddr.new("fe80::/10"),
62
+ IPAddr.new("::ffff:0:0/96"),
63
+ ]
64
+
65
+ # Try to parse host as IP directly
66
+ begin
67
+ ip = IPAddr.new(uri.host)
68
+ private_ranges.each { |range| return false if range.include?(ip) }
69
+ return true
70
+ rescue IPAddr::InvalidAddressError
71
+ # Host is a domain name — resolve and check
72
+ end
73
+
74
+ # Resolve domain to IP and check
75
+ begin
76
+ addr_info = Socket.getaddrinfo(uri.host, 80, :INET, :STREAM)
77
+ ip = addr_info.first[3]
78
+ private_ranges.each { |range| return false if range.include?(ip) }
79
+ rescue SocketError
80
+ # DNS failure — let fetch URL handle it
81
+ end
82
+
83
+ true
84
+ end
85
+
86
+ def self.fetch_url(uri)
87
+ Net::HTTP.start(uri.host, uri.port,
88
+ use_ssl: uri.scheme == "https",
89
+ open_timeout: TIMEOUT_SEC,
90
+ read_timeout: TIMEOUT_SEC) do |http|
91
+ request = Net::HTTP::Get.new(uri.request_uri)
92
+ request["User-Agent"] = Samagotchi::USER_AGENT
93
+ http.request(request)
94
+ end
95
+ end
96
+
97
+ def self.handle_http_error(response, uri)
98
+ case response
99
+ when Net::HTTPNotFound
100
+ "Error: 404 Not Found for #{uri}"
101
+ when Net::HTTPForbidden
102
+ "Error: 403 Forbidden for #{uri}"
103
+ when Net::HTTPUnauthorized
104
+ "Error: 401 Unauthorized for #{uri}"
105
+ when Net::HTTPGatewayTimeout, Net::HTTPBadGateway
106
+ "Error: server error (#{response.code}) for #{uri}"
107
+ else
108
+ "Error: HTTP #{response.code} for #{uri}"
109
+ end
110
+ end
111
+
112
+ # Network bodies come back tagged ASCII-8BIT even when they hold UTF-8
113
+ # bytes (web text is essentially always UTF-8). Relabel as UTF-8 so the
114
+ # result can be safely interpolated into UTF-8 strings (logs, conversation
115
+ # history) without raising
116
+ # "incompatible character encodings: UTF-8 and BINARY (ASCII-8BIT)".
117
+ # Falls back to lossy replacement only if the bytes are genuinely not
118
+ # valid UTF-8.
119
+ def self.to_utf8(str)
120
+ s = str.to_s
121
+ return s if s.encoding == Encoding::UTF_8 && s.valid_encoding?
122
+
123
+ s = s.dup.force_encoding(Encoding::UTF_8)
124
+ return s if s.valid_encoding?
125
+
126
+ s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
127
+ end
128
+
129
+ def self.extract_text_from_html(html)
130
+ return "Error: empty HTML response" if html.nil? || html.empty?
131
+
132
+ doc = Nokogiri::HTML(html)
133
+
134
+ # Extract noscript text before removing it
135
+ doc.css("noscript").each do |node|
136
+ node.replace(node.text) if node.text?
137
+ end
138
+
139
+ # Remove script, style, iframe, svg elements
140
+ doc.css("script, style, iframe, svg").remove
141
+
142
+ # Get text content
143
+ text = doc.text
144
+ text = text.gsub(/\s+/, " ").strip
145
+ text = text.gsub(/\n\s*\n/, "\n\n").strip
146
+
147
+ return "Error: no visible text content found in HTML" if text.empty?
148
+
149
+ # Truncate to max output bytes
150
+ if text.bytesize > MAX_OUTPUT_BYTES
151
+ suffix = "... [TRUNCATED - content too large]"
152
+ budget = MAX_OUTPUT_BYTES - suffix.bytesize
153
+ truncated = text.byteslice(0, budget)
154
+ truncated = truncated.encode("UTF-8", invalid: :replace, undef: :replace)
155
+ text = "#{truncated}#{suffix}"
156
+ end
157
+
158
+ text
159
+ end
160
+ end
161
+ end
162
+ end
163
+
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "tool_path"
5
+
6
+ module Samagotchi
7
+ module Tools
8
+ # Writes content to a file, creating parent directories as needed.
9
+ # The path is passed as an XML attribute:
10
+ # <tool name="write" path="lib/samagotchi/tools/new_tool.rb">content</tool>
11
+ class Write
12
+ NAME = "write"
13
+
14
+ def self.name = NAME
15
+
16
+ def self.call(content, path:)
17
+ path = ToolPath.normalize(path)
18
+ FileUtils.mkdir_p(File.dirname(path))
19
+ File.write(path, content)
20
+ "Written #{content.bytesize} bytes to #{path}"
21
+ rescue => e
22
+ "Error: #{e.message}"
23
+ end
24
+ end
25
+ end
26
+ end