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,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "output_guardrails"
|
|
4
|
+
require_relative "tool_path"
|
|
5
|
+
require_relative "../image_store"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module Tools
|
|
9
|
+
# Reads a file from disk and returns its contents as a string.
|
|
10
|
+
class Read
|
|
11
|
+
NAME = "read"
|
|
12
|
+
|
|
13
|
+
DEFAULT_HARD_MAX_BYTES = 2 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
def self.name = NAME
|
|
16
|
+
|
|
17
|
+
# An image the tool found (by its magic bytes, whatever its name): the
|
|
18
|
+
# line the model reads, and the file the loop attaches (ToolRunner).
|
|
19
|
+
# It is that line as a String.
|
|
20
|
+
class ImageResult < String
|
|
21
|
+
attr_reader :image_path, :description
|
|
22
|
+
|
|
23
|
+
def initialize(image_path, description)
|
|
24
|
+
@image_path = image_path
|
|
25
|
+
@description = description
|
|
26
|
+
super("Image #{description} attached.")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def images = [{ path: image_path, description: description }]
|
|
30
|
+
|
|
31
|
+
# The text only says the image is attached: when it can't be, the
|
|
32
|
+
# line saying why replaces it.
|
|
33
|
+
def image_only? = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# How far into a file its size is looked for (a JPEG's frame header
|
|
37
|
+
# can follow a large EXIF block).
|
|
38
|
+
IMAGE_HEADER_BYTES = 256 * 1024
|
|
39
|
+
|
|
40
|
+
def self.call(path, start_line: nil, end_line: nil)
|
|
41
|
+
path = ToolPath.normalize(path)
|
|
42
|
+
|
|
43
|
+
if ImageStore.image_file?(path)
|
|
44
|
+
return "Error: #{path} is an image; read it without start_line/end_line" if range_requested?(start_line, end_line)
|
|
45
|
+
|
|
46
|
+
return image_result(path)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if range_requested?(start_line, end_line)
|
|
50
|
+
return read_range(path, start_line: start_line, end_line: end_line)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
size = File.size(path)
|
|
54
|
+
|
|
55
|
+
hard_max_bytes = OutputGuardrails.env_positive_int("SAMAGOTCHI_READ_HARD_MAX_BYTES", DEFAULT_HARD_MAX_BYTES)
|
|
56
|
+
truncate_at_bytes = OutputGuardrails.env_positive_int("SAMAGOTCHI_READ_TRUNCATE_AT_BYTES", OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES)
|
|
57
|
+
preview_bytes = OutputGuardrails.env_positive_int("SAMAGOTCHI_READ_PREVIEW_BYTES", OutputGuardrails::DEFAULT_PREVIEW_BYTES)
|
|
58
|
+
|
|
59
|
+
if size > hard_max_bytes
|
|
60
|
+
return "Error: file too large: #{path} (#{size} bytes, hard limit #{hard_max_bytes} bytes)."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return File.read(path) if size <= truncate_at_bytes
|
|
64
|
+
|
|
65
|
+
build_truncated_preview(path: path, file_size: size, preview_bytes: preview_bytes)
|
|
66
|
+
rescue Errno::ENOENT
|
|
67
|
+
"Error: file not found: #{path}"
|
|
68
|
+
rescue => e
|
|
69
|
+
"Error: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.image_result(path)
|
|
73
|
+
info = ImageHeader.read(File.binread(path, IMAGE_HEADER_BYTES))
|
|
74
|
+
size = info.width && info.height ? "#{info.width}×#{info.height} " : ""
|
|
75
|
+
ImageResult.new(path, "#{File.basename(path)} (#{size}#{info.format.to_s.upcase})")
|
|
76
|
+
end
|
|
77
|
+
private_class_method :image_result
|
|
78
|
+
|
|
79
|
+
def self.build_truncated_preview(path:, file_size:, preview_bytes:)
|
|
80
|
+
preview = OutputGuardrails.head_tail_from_file(path: path, file_size: file_size, preview_bytes: preview_bytes)
|
|
81
|
+
|
|
82
|
+
preview_block = [
|
|
83
|
+
"[TRUNCATED_PREVIEW_HEAD]",
|
|
84
|
+
preview[:head],
|
|
85
|
+
"[... omitted #{preview[:omitted_bytes]} bytes ...]",
|
|
86
|
+
"[TRUNCATED_PREVIEW_TAIL]",
|
|
87
|
+
preview[:tail]
|
|
88
|
+
].join("\n")
|
|
89
|
+
|
|
90
|
+
metadata_lines = [
|
|
91
|
+
"truncated=true",
|
|
92
|
+
"path=#{path}",
|
|
93
|
+
"preview_strategy=head_tail",
|
|
94
|
+
"file_bytes=#{file_size}",
|
|
95
|
+
"returned_preview_bytes=#{preview[:returned_preview_bytes]}",
|
|
96
|
+
"omitted_bytes=#{preview[:omitted_bytes]}"
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
telemetry = telemetry_lines_for(preview_block)
|
|
100
|
+
metadata_lines.concat(telemetry) unless telemetry.empty?
|
|
101
|
+
|
|
102
|
+
[metadata_lines.join("\n"), "", preview_block].join("\n")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.telemetry_lines_for(content)
|
|
106
|
+
OutputGuardrails.telemetry_lines_for(
|
|
107
|
+
content: content,
|
|
108
|
+
threshold_env: "SAMAGOTCHI_READ_TELEMETRY_THRESHOLD_PCT",
|
|
109
|
+
threshold_default: OutputGuardrails::DEFAULT_TELEMETRY_THRESHOLD_PCT,
|
|
110
|
+
token_key: "estimated_tokens_for_preview",
|
|
111
|
+
pct_key: "estimated_window_pct_for_preview"
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.range_requested?(start_line, end_line)
|
|
116
|
+
!blank?(start_line) || !blank?(end_line)
|
|
117
|
+
end
|
|
118
|
+
private_class_method :range_requested?
|
|
119
|
+
|
|
120
|
+
def self.read_range(path, start_line:, end_line:)
|
|
121
|
+
lines = File.readlines(path, chomp: false)
|
|
122
|
+
total_lines = lines.length
|
|
123
|
+
return "Error: range out of bounds for #{path}: file is empty (0 lines)" if total_lines.zero?
|
|
124
|
+
|
|
125
|
+
start_num = parse_positive_line_number(start_line, "start_line")
|
|
126
|
+
return start_num if start_num.is_a?(String)
|
|
127
|
+
return "Error: start_line must be provided for range reads" if start_num.nil?
|
|
128
|
+
|
|
129
|
+
end_provided = !blank?(end_line)
|
|
130
|
+
end_num = end_provided ? parse_positive_line_number(end_line, "end_line") : nil
|
|
131
|
+
return end_num if end_num.is_a?(String)
|
|
132
|
+
|
|
133
|
+
# Option C: end_line is optional and means "to EOF" when omitted
|
|
134
|
+
# (unless SAMAGOTCHI_READ_END_OPTIONAL=false).
|
|
135
|
+
unless end_provided
|
|
136
|
+
return "Error: start_line and end_line must both be provided for range reads" unless OutputGuardrails.env_bool("SAMAGOTCHI_READ_END_OPTIONAL", default: true)
|
|
137
|
+
|
|
138
|
+
end_num = total_lines
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Option B: an end_line that overshoots EOF is clamped silently by
|
|
142
|
+
# Ruby's slice; when it also exceeds the file we trim here so the agent
|
|
143
|
+
# sees the real span. Set SAMAGOTCHI_READ_ALLOW_OOR_END=false to keep
|
|
144
|
+
# the historical hard error instead.
|
|
145
|
+
clamp_note = ""
|
|
146
|
+
if end_provided && end_num > total_lines
|
|
147
|
+
if OutputGuardrails.env_bool("SAMAGOTCHI_READ_ALLOW_OOR_END", default: true)
|
|
148
|
+
clamp_note = "\n[read: end_line #{end_num} exceeds #{total_lines} lines; returning lines #{start_num}-#{total_lines}]"
|
|
149
|
+
end_num = total_lines
|
|
150
|
+
else
|
|
151
|
+
return "Error: range out of bounds for #{path}: file has #{total_lines} lines"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# A start past EOF is genuinely unusable -> keep a hard error. A slice
|
|
156
|
+
# whose start is within the file never yields nil, so this also guards
|
|
157
|
+
# against the previous ".join on nil" crash.
|
|
158
|
+
return "Error: start_line #{start_num} out of bounds for #{path}: file has #{total_lines} lines" if start_num > total_lines
|
|
159
|
+
return "Error: start_line must be <= end_line" if start_num > end_num
|
|
160
|
+
|
|
161
|
+
content = lines[(start_num - 1)..(end_num - 1)].join
|
|
162
|
+
clamp_note.empty? ? content : content + clamp_note
|
|
163
|
+
end
|
|
164
|
+
private_class_method :read_range
|
|
165
|
+
|
|
166
|
+
def self.parse_positive_line_number(value, key)
|
|
167
|
+
return nil if blank?(value)
|
|
168
|
+
|
|
169
|
+
integer = Integer(value.to_s.strip, exception: false)
|
|
170
|
+
return "Error: #{key} must be a positive integer" unless integer&.positive?
|
|
171
|
+
|
|
172
|
+
integer
|
|
173
|
+
end
|
|
174
|
+
private_class_method :parse_positive_line_number
|
|
175
|
+
|
|
176
|
+
def self.blank?(value)
|
|
177
|
+
value.nil? || value.to_s.strip.empty?
|
|
178
|
+
end
|
|
179
|
+
private_class_method :blank?
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../reminder_store"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Tools
|
|
7
|
+
# Registers a periodic reminder. The harness will inject a [SYSTEM:] message
|
|
8
|
+
# into the next idle turn's conversation.
|
|
9
|
+
#
|
|
10
|
+
# The agent can use this to schedule checks (API health, file changes, etc.)
|
|
11
|
+
# without blocking the main turn. The reminder fires when the agent is idle
|
|
12
|
+
# and a new turn is about to start.
|
|
13
|
+
class RegisterReminder
|
|
14
|
+
NAME = "register_reminder"
|
|
15
|
+
|
|
16
|
+
def self.name = NAME
|
|
17
|
+
|
|
18
|
+
def self.call(content, reminder_store: nil, description: nil, interval_minutes: nil)
|
|
19
|
+
return "Error: reminder_store not configured" unless reminder_store
|
|
20
|
+
|
|
21
|
+
# Parse params from content string (JSON-like)
|
|
22
|
+
params = parse_params(content)
|
|
23
|
+
# Merge explicit params from kernel normalizer (overrides parsed content)
|
|
24
|
+
unless content.to_s.strip.empty?
|
|
25
|
+
params[:name] ||= content.to_s.strip
|
|
26
|
+
end
|
|
27
|
+
params[:description] ||= description if description
|
|
28
|
+
params[:interval_minutes] ||= interval_minutes if interval_minutes
|
|
29
|
+
reminder_store.register(params)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.parse_params(content)
|
|
33
|
+
return {} if content.to_s.strip.empty?
|
|
34
|
+
|
|
35
|
+
# Try JSON first
|
|
36
|
+
begin
|
|
37
|
+
parsed = JSON.parse(content.to_s)
|
|
38
|
+
return parsed if parsed.is_a?(Hash)
|
|
39
|
+
rescue JSON::ParserError
|
|
40
|
+
# fall through to key=value parsing
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Fallback: key=value pairs
|
|
44
|
+
params = {}
|
|
45
|
+
content.to_s.split(",").each do |pair|
|
|
46
|
+
key, value = pair.split(":", 2).map(&:strip)
|
|
47
|
+
params[key] = value if key && !value.nil?
|
|
48
|
+
end
|
|
49
|
+
params
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Tools
|
|
5
|
+
# The tools a session offers the model, in declaration order: the
|
|
6
|
+
# built-ins (Tools::Builtins), and later the ones bundles add. The
|
|
7
|
+
# native prompts and the chat path's tools: are rendered from #schemas,
|
|
8
|
+
# and KernelLoop#dispatch runs a call through its entry's handler.
|
|
9
|
+
class Registry
|
|
10
|
+
# @!attribute name [String]
|
|
11
|
+
# @!attribute schema [Hash] {name:, description:, parameters:}, as in
|
|
12
|
+
# ToolDeclarations::TOOL_SCHEMAS
|
|
13
|
+
# @!attribute handler [#call] (call, kctx) → the result (a String;
|
|
14
|
+
# "Error: …" marks a failure); kctx is the KernelLoop's ToolContext
|
|
15
|
+
# @!attribute label [String, nil] the activity line's action ("calling
|
|
16
|
+
# tool" when nil); ToolActivity knows the built-ins' own
|
|
17
|
+
# @!attribute preview [#call, nil] call → the activity line's params
|
|
18
|
+
# @!attribute targets [#call, nil] call → what guardrail rules match
|
|
19
|
+
# @!attribute source [String] "core", or the bundle that added it
|
|
20
|
+
Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :source, keyword_init: true) do
|
|
21
|
+
def core? = source == "core"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@entries = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Entry]
|
|
29
|
+
def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, source: "core")
|
|
30
|
+
raise ArgumentError, "tool #{name} is already registered" if @entries.key?(name)
|
|
31
|
+
|
|
32
|
+
@entries[name] = Entry.new(name: name, schema: schema, handler: handler, label: label, preview: preview,
|
|
33
|
+
targets: targets, source: source)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Remove a tool (a plugin's tool set changed after load).
|
|
37
|
+
# @return [Entry, nil] the removed entry
|
|
38
|
+
def unregister(name) = @entries.delete(name.to_s)
|
|
39
|
+
|
|
40
|
+
# @return [Entry, nil]
|
|
41
|
+
def [](name) = @entries[name.to_s]
|
|
42
|
+
|
|
43
|
+
def key?(name) = @entries.key?(name.to_s)
|
|
44
|
+
|
|
45
|
+
# @return [Array<String>] in declaration order
|
|
46
|
+
def names = @entries.keys
|
|
47
|
+
|
|
48
|
+
# @return [Array<Entry>] in declaration order
|
|
49
|
+
def entries = @entries.values
|
|
50
|
+
|
|
51
|
+
# @return [Array<Hash>] the schemas, in declaration order
|
|
52
|
+
def schemas = @entries.values.map(&:schema)
|
|
53
|
+
|
|
54
|
+
def freeze
|
|
55
|
+
@entries.freeze
|
|
56
|
+
super
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "../context_note"
|
|
5
|
+
require_relative "peers"
|
|
6
|
+
|
|
7
|
+
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
|
+
module Tools
|
|
13
|
+
# Tell another chi session something: a context note it sees on its next
|
|
14
|
+
# turn as background from this session. It never starts a turn there.
|
|
15
|
+
class SendNote
|
|
16
|
+
NAME = "send_note"
|
|
17
|
+
|
|
18
|
+
def self.name = NAME
|
|
19
|
+
|
|
20
|
+
# @param content [String] the note
|
|
21
|
+
# @param session [String] the target's id or a unique prefix
|
|
22
|
+
# @param peers [Peers, nil]
|
|
23
|
+
def self.call(content, session: nil, peers: nil)
|
|
24
|
+
return "Error: this session's id is not known here" unless peers&.session_id
|
|
25
|
+
return "Error: give the target session's id (list_sessions shows them)" if session.to_s.strip.empty?
|
|
26
|
+
|
|
27
|
+
id = Session.resolve_id(session.to_s.strip, state_dir: peers.state_dir)
|
|
28
|
+
Session.load(id, state_dir: peers.state_dir)
|
|
29
|
+
return "Error: #{id[0, 8]} is this session; send_note is for other sessions" if id == peers.session_id
|
|
30
|
+
|
|
31
|
+
owner = SessionManager.session_owner(id, state_dir: peers.state_dir)
|
|
32
|
+
if owner && owner["kind"] == "tui"
|
|
33
|
+
return "Error: session #{id[0, 8]} is open in a chi REPL, which can't take notes"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
SessionManager.write_note(id, text: content, source: "session", from_session: peers.session_id,
|
|
37
|
+
from_cwd: peers.cwd, state_dir: peers.state_dir)
|
|
38
|
+
where = owner ? "its worker adds it before its next turn" : "it has no worker now, so it waits for its next start"
|
|
39
|
+
"Queued a note for session #{id[0, 8]}: #{where}. It does not start a turn there."
|
|
40
|
+
rescue Session::AmbiguousId => e
|
|
41
|
+
"Error: #{e.message}"
|
|
42
|
+
rescue SessionManager::NoteRejected => e
|
|
43
|
+
"Error: #{e.message}"
|
|
44
|
+
rescue ArgumentError
|
|
45
|
+
"Error: no session #{session.to_s.strip} (list_sessions shows them)"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
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 TaskCreate
|
|
8
|
+
NAME = "task_create"
|
|
9
|
+
|
|
10
|
+
def self.name = NAME
|
|
11
|
+
|
|
12
|
+
def self.call(command, cwd: nil, env: nil)
|
|
13
|
+
record, error = TaskRuntime.create_task(command, cwd: cwd, env: env)
|
|
14
|
+
return error if error
|
|
15
|
+
|
|
16
|
+
[
|
|
17
|
+
"task_id: #{record.fetch("id")}",
|
|
18
|
+
"status: #{record.fetch("status")}",
|
|
19
|
+
"pid: #{record.fetch("pid")}",
|
|
20
|
+
"created_at: #{record.fetch("created_at")}",
|
|
21
|
+
"output_path: #{record.fetch("output_path")}",
|
|
22
|
+
"cwd: #{record.fetch("cwd")}"
|
|
23
|
+
].join("\n")
|
|
24
|
+
rescue => e
|
|
25
|
+
"Error: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "task_runtime"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Tools
|
|
7
|
+
class TaskGet
|
|
8
|
+
NAME = "task_get"
|
|
9
|
+
|
|
10
|
+
def self.name = NAME
|
|
11
|
+
|
|
12
|
+
def self.call(task_id)
|
|
13
|
+
record, error = TaskRuntime.get_record(task_id.to_s.strip)
|
|
14
|
+
return error if error
|
|
15
|
+
|
|
16
|
+
format_record(record)
|
|
17
|
+
rescue => e
|
|
18
|
+
"Error: #{e.message}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.format_record(record)
|
|
22
|
+
[
|
|
23
|
+
"task_id: #{record.fetch("id")}",
|
|
24
|
+
"status: #{record.fetch("status")}",
|
|
25
|
+
"command: #{record.fetch("command")}",
|
|
26
|
+
"cwd: #{record.fetch("cwd")}",
|
|
27
|
+
"pid: #{record.fetch("pid")}",
|
|
28
|
+
"created_at: #{record.fetch("created_at")}",
|
|
29
|
+
"started_at: #{record.fetch("started_at")}",
|
|
30
|
+
"finished_at: #{record["finished_at"]}",
|
|
31
|
+
"exit_code: #{record["exit_code"]}",
|
|
32
|
+
"stop_reason: #{record["stop_reason"]}",
|
|
33
|
+
"output_path: #{record.fetch("output_path")}"
|
|
34
|
+
].join("\n")
|
|
35
|
+
end
|
|
36
|
+
private_class_method :format_record
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "task_runtime"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Tools
|
|
7
|
+
class TaskList
|
|
8
|
+
NAME = "task_list"
|
|
9
|
+
COMMAND_PREVIEW_LIMIT = 80
|
|
10
|
+
|
|
11
|
+
def self.name = NAME
|
|
12
|
+
|
|
13
|
+
def self.call(_content = nil)
|
|
14
|
+
records = TaskRuntime.list_records
|
|
15
|
+
return "No tasks found." if records.empty?
|
|
16
|
+
|
|
17
|
+
records.map { |record| format_entry(record) }.join("\n\n")
|
|
18
|
+
rescue => e
|
|
19
|
+
"Error: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.format_entry(record)
|
|
23
|
+
[
|
|
24
|
+
"task_id: #{record.fetch("id")}",
|
|
25
|
+
"status: #{record.fetch("status")}",
|
|
26
|
+
"pid: #{record.fetch("pid")}",
|
|
27
|
+
"created_at: #{record.fetch("created_at")}",
|
|
28
|
+
"command: #{preview(record.fetch("command"))}",
|
|
29
|
+
"output_path: #{record.fetch("output_path")}"
|
|
30
|
+
].join("\n")
|
|
31
|
+
end
|
|
32
|
+
private_class_method :format_entry
|
|
33
|
+
|
|
34
|
+
def self.preview(value)
|
|
35
|
+
text = value.to_s.gsub(/\s+/, " ").strip
|
|
36
|
+
return "" if text.length <= COMMAND_PREVIEW_LIMIT
|
|
37
|
+
|
|
38
|
+
text[0, COMMAND_PREVIEW_LIMIT - 1] + "…"
|
|
39
|
+
end
|
|
40
|
+
private_class_method :preview
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|