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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- 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
|