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,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