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,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry"
4
+ require_relative "../tool_declarations"
5
+ require_relative "execute"
6
+ require_relative "read"
7
+ require_relative "write"
8
+ require_relative "memory"
9
+ require_relative "edit"
10
+ require_relative "task_create"
11
+ require_relative "task_get"
12
+ require_relative "task_list"
13
+ require_relative "task_stop"
14
+ require_relative "task_wait"
15
+ require_relative "web_fetch"
16
+ require_relative "register_reminder"
17
+ require_relative "cancel_reminder"
18
+ require_relative "list_reminders"
19
+ require_relative "list_sessions"
20
+ require_relative "send_note"
21
+ require_relative "delegate"
22
+ require_relative "delegate_result"
23
+ require_relative "ask_user_question"
24
+
25
+ module Samagotchi
26
+ module Tools
27
+ # chi's own tools as Registry entries: each schema from
28
+ # ToolDeclarations::TOOL_SCHEMAS, with a handler (call, kctx) that runs
29
+ # its class. kctx (KernelLoop::ToolContext) gives what some need from the
30
+ # kernel: the reminder store, the peers, the model key, the muted memory
31
+ # read and the ask-user flow.
32
+ module Builtins
33
+ CLASSES = [
34
+ Execute,
35
+ Read,
36
+ Write,
37
+ MemoryRead,
38
+ MemoryWrite,
39
+ Edit,
40
+ TaskCreate,
41
+ TaskGet,
42
+ TaskList,
43
+ TaskStop,
44
+ TaskWait,
45
+ WebFetch,
46
+ RegisterReminder,
47
+ CancelReminder,
48
+ ListReminders,
49
+ ListSessions,
50
+ SendNote,
51
+ Delegate,
52
+ DelegateResult,
53
+ AskUserQuestion
54
+ ].freeze
55
+
56
+ # The handlers that pass more than the call's content.
57
+ HANDLERS = {
58
+ MemoryRead::NAME => ->(call, kctx) { kctx.muted_memory_read(call) },
59
+ MemoryWrite::NAME => lambda do |call, kctx|
60
+ MemoryWrite.call(call[:content], path: call[:path], scope: call[:scope], description: call[:description],
61
+ current_model_only: truthy?(call[:current_model_only]),
62
+ model_key: kctx.model_key)
63
+ end,
64
+ Write::NAME => ->(call, _kctx) { Write.call(call[:content], path: call[:path]) },
65
+ Read::NAME => lambda do |call, _kctx|
66
+ Read.call(call[:content], start_line: call[:start_line], end_line: call[:end_line])
67
+ end,
68
+ Edit::NAME => lambda do |call, _kctx|
69
+ Edit.call(call[:content], path: call[:path], start_line: call[:start_line], end_line: call[:end_line])
70
+ end,
71
+ TaskCreate::NAME => ->(call, _kctx) { TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env]) },
72
+ TaskWait::NAME => lambda do |call, _kctx|
73
+ TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
74
+ done_pattern: call[:done_pattern])
75
+ end,
76
+ Execute::NAME => ->(call, _kctx) { Execute.call(call[:content], cwd: call[:cwd]) },
77
+ RegisterReminder::NAME => lambda do |call, kctx|
78
+ RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
79
+ interval_minutes: call[:interval_minutes])
80
+ end,
81
+ CancelReminder::NAME => ->(call, kctx) { CancelReminder.call(call[:content], reminder_store: kctx.reminder_store) },
82
+ ListReminders::NAME => ->(call, kctx) { ListReminders.call(call[:content], reminder_store: kctx.reminder_store) },
83
+ ListSessions::NAME => ->(call, kctx) { ListSessions.call(call[:content], peers: kctx.peers, cwd: call[:cwd]) },
84
+ SendNote::NAME => ->(call, kctx) { SendNote.call(call[:content], session: call[:session], peers: kctx.peers) },
85
+ Delegate::NAME => lambda do |call, kctx|
86
+ Delegate.call(call[:content], model: call[:model], session: call[:session], wait: call[:wait],
87
+ timeout: call[:timeout], peers: kctx.peers)
88
+ end,
89
+ DelegateResult::NAME => lambda do |call, kctx|
90
+ DelegateResult.call(call[:content], session: call[:session], timeout: call[:timeout], peers: kctx.peers)
91
+ end,
92
+ AskUserQuestion::NAME => ->(call, kctx) { kctx.ask_user_question(call) }
93
+ }.freeze
94
+
95
+ module_function
96
+
97
+ # @return [Registry] a new registry with the built-ins, in
98
+ # TOOL_SCHEMAS order (an Engine's own, which bundles add to)
99
+ def registry
100
+ by_name = CLASSES.to_h { |klass| [klass::NAME, klass] }
101
+ ToolDeclarations::TOOL_SCHEMAS.each_with_object(Registry.new) do |schema, registry|
102
+ klass = by_name.fetch(schema[:name])
103
+ handler = HANDLERS.fetch(klass::NAME) { ->(call, _kctx) { klass.call(call[:content]) } }
104
+ registry.register(klass::NAME, schema: schema, handler: handler)
105
+ end
106
+ end
107
+
108
+ # @return [Registry] the built-ins alone, frozen: a KernelLoop or a
109
+ # ChatLoop without an Engine's registry uses it
110
+ def default
111
+ @default ||= registry.freeze
112
+ end
113
+
114
+ def truthy?(val)
115
+ return true if val == true
116
+ return false if val == false || val.nil?
117
+
118
+ val.to_s.strip.downcase == "true"
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../reminder_store"
4
+
5
+ module Samagotchi
6
+ module Tools
7
+ # Cancels a previously registered reminder by name.
8
+ class CancelReminder
9
+ NAME = "cancel_reminder"
10
+
11
+ def self.name = NAME
12
+
13
+ def self.call(content, reminder_store: nil)
14
+ return "Error: reminder_store not configured" unless reminder_store
15
+
16
+ name = content.to_s.strip
17
+ reminder_store.cancel(name)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../config"
5
+ require_relative "../model_profile"
6
+ require_relative "peers"
7
+ require_relative "delegate_wait"
8
+
9
+ module Samagotchi
10
+ # Loaded on first use: session_manager requires terminal_ui, which
11
+ # requires KernelLoop and so these tools (a require cycle otherwise).
12
+ autoload :SessionManager, File.expand_path("../session_manager", __dir__)
13
+
14
+ module Tools
15
+ # Hand a task to a child session: an ordinary chi session in its own
16
+ # worker, started in this session's folder with the task as its first
17
+ # user message and the `delegated` system memory preloaded. It shows in
18
+ # every list as a child of this one and the user can attach to it. With
19
+ # session:, a follow-up to a child that exists. Only the child's final
20
+ # reply comes back (DelegateWait), never its trace.
21
+ class Delegate
22
+ NAME = "delegate"
23
+ CLIENT_PREFIX = "delegate"
24
+ MAX_CHILDREN_KEY = "session.max_children"
25
+ MAX_CHILDREN_DEFAULT = 4
26
+ # A child spawned this many seconds ago counts as running even before
27
+ # its worker holds the owner lock: two delegate calls in one model
28
+ # message come milliseconds apart, and the first child's worker is
29
+ # still starting when the second call counts.
30
+ STARTING_GRACE_SECONDS = 15
31
+ # The memory every child starts with, scoped so a project memory of
32
+ # the same name cannot shadow it.
33
+ CHILD_MEMORIES = ["system/delegated"].freeze
34
+
35
+ def self.name = NAME
36
+
37
+ # @param content [String] the task (the child's user message, verbatim)
38
+ # @param model [String, nil] the child's model (an alias works); the parent's by default
39
+ # @param session [String, nil] a child's id or prefix: send the task there instead
40
+ # @param wait [Boolean, String, nil] wait for the reply (default true)
41
+ # @param timeout [Integer, String, nil] seconds to wait (default DelegateWait::TIMEOUT_DEFAULT)
42
+ # @param peers [Peers, nil]
43
+ def self.call(content, model: nil, session: nil, wait: nil, timeout: nil, peers: nil)
44
+ return "Error: this session's id is not known here" unless peers&.session_id
45
+
46
+ task = content.to_s.strip
47
+ return "Error: give the task, the child's first message" if task.empty?
48
+
49
+ sd = peers.state_dir || Session.default_state_dir
50
+ parent = Session.load(peers.session_id, state_dir: sd)
51
+ wait = parse_wait(wait)
52
+ timeout = parse_timeout(timeout)
53
+
54
+ child_id = if session.to_s.strip.empty?
55
+ start_child(task, parent: parent, model: model, state_dir: sd)
56
+ else
57
+ follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
58
+ end
59
+ return child_id if child_id.start_with?("Error:")
60
+ return DelegateWait.call(child_id, peers: peers, timeout: timeout) if wait
61
+
62
+ started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
63
+ "session: #{child_id}\nstatus: running\n#{started}; delegate_result waits for its reply. " \
64
+ "It shows in chi sessions list and the web as a child of this session; the user can attach to it."
65
+ rescue ArgumentError => e
66
+ "Error: #{e.message}"
67
+ end
68
+
69
+ # @return [String] the new child's id, or an Error: line
70
+ def self.start_child(task, parent:, model:, state_dir:)
71
+ if parent.parent_id
72
+ return "Error: this session is a delegate of #{parent.parent_id}; delegated sessions don't delegate further"
73
+ end
74
+
75
+ running = running_children(parent.id, state_dir: state_dir)
76
+ max = max_children
77
+ if running.size >= max
78
+ ids = running.map { |s| s[:short_id] }.join(", ")
79
+ return "Error: #{running.size} delegate#{"s" if running.size != 1} of this session #{running.size == 1 ? "is" : "are"} running " \
80
+ "(the most is #{max}, #{MAX_CHILDREN_KEY}): #{ids}. delegate_result waits for one; `chi sessions stop ID` stops one."
81
+ end
82
+
83
+ child = SessionManager.spawn_session(prompt: task, working_directory: parent.working_directory,
84
+ model_name: child_model(model, parent), memories: CHILD_MEMORIES,
85
+ parent_id: parent.id, state_dir: state_dir)
86
+ child.id
87
+ end
88
+ private_class_method :start_child
89
+
90
+ # @return [String] the child's id, or an Error: line
91
+ def self.follow_up(task, session:, parent:, state_dir:)
92
+ id = Session.resolve_id(session, state_dir: state_dir)
93
+ child = begin
94
+ Session.load(id, state_dir: state_dir)
95
+ rescue ArgumentError
96
+ return "Error: no session #{session} (list_sessions shows them)"
97
+ end
98
+ unless child.parent_id == parent.id
99
+ return "Error: #{id[0, 8]} is not a delegate of this session; send_note reaches any session"
100
+ end
101
+
102
+ # Only a reply after this delivery counts as the follow-up's.
103
+ DelegateWait.mark_seen(parent.id, id, state_dir: state_dir)
104
+ delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{CLIENT_PREFIX}:#{parent.id[0, 8]}",
105
+ state_dir: state_dir)
106
+ case delivered[:status]
107
+ when :accepted then id
108
+ when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
109
+ when :timeout then "Error: the worker of session #{id[0, 8]} did not answer in time, so the message was not sent"
110
+ else "Error: the message to session #{id[0, 8]} could not be written"
111
+ end
112
+ rescue SessionManager::OwnedByTUI
113
+ "Error: session #{id[0, 8]} is open in a chi REPL, which can't take a delegated message"
114
+ rescue Session::AmbiguousId => e
115
+ "Error: #{e.message}"
116
+ end
117
+ private_class_method :follow_up
118
+
119
+ # --model as the REPL reads it: an alias resolved, a host prefix kept.
120
+ # No existence check: an unknown model fails the child's first turn.
121
+ def self.child_model(model, parent)
122
+ return parent.model_name if model.to_s.strip.empty?
123
+
124
+ ModelProfile.required_model_name(ConfigFile.resolve_model_alias(model.to_s.strip))
125
+ end
126
+ private_class_method :child_model
127
+
128
+ # The children of +parent_id+ that count against session.max_children
129
+ # (a plugin's ctx.sessions.fork counts them too).
130
+ # @return [Array<Hash>] SessionManager.children_of rows
131
+ def self.running_children(parent_id, state_dir:)
132
+ SessionManager.children_of(parent_id, state_dir: state_dir).select { |s| running?(s) }
133
+ end
134
+
135
+ # busy (a worker runs its turn), or running with a worker on its way.
136
+ def self.running?(summary)
137
+ return true if summary[:busy]
138
+ return false unless summary[:status] == Session::STATUS_RUNNING && !summary[:live]
139
+
140
+ Time.now - Time.iso8601(summary[:updated_at].to_s) < STARTING_GRACE_SECONDS
141
+ rescue ArgumentError
142
+ false
143
+ end
144
+ private_class_method :running?
145
+
146
+ def self.max_children
147
+ value = Integer(Config.get(MAX_CHILDREN_KEY), exception: false)
148
+ value&.positive? ? value : MAX_CHILDREN_DEFAULT
149
+ rescue StandardError
150
+ MAX_CHILDREN_DEFAULT
151
+ end
152
+
153
+ # true unless told otherwise; text-based parsers hand strings over.
154
+ def self.parse_wait(value)
155
+ return true if value.nil? || value.to_s.strip.empty?
156
+ return value if value == true || value == false
157
+
158
+ !%w[false 0 no off].include?(value.to_s.strip.downcase)
159
+ end
160
+
161
+ def self.parse_timeout(value)
162
+ parsed = Integer(value.to_s.strip, exception: false)
163
+ parsed&.positive? ? parsed : DelegateWait::TIMEOUT_DEFAULT
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "peers"
5
+ require_relative "delegate"
6
+ require_relative "delegate_wait"
7
+
8
+ module Samagotchi
9
+ # Loaded on first use: session_manager requires terminal_ui, which
10
+ # requires KernelLoop and so these tools (a require cycle otherwise).
11
+ autoload :SessionManager, File.expand_path("../session_manager", __dir__)
12
+
13
+ module Tools
14
+ # Wait for a delegated session's next reply: the one named, or this
15
+ # session's newest running child.
16
+ class DelegateResult
17
+ NAME = "delegate_result"
18
+
19
+ def self.name = NAME
20
+
21
+ # @param session [String, nil] a child's id or prefix
22
+ # @param timeout [Integer, String, nil] seconds (default DelegateWait::TIMEOUT_DEFAULT)
23
+ # @param peers [Peers, nil]
24
+ def self.call(_content = nil, session: nil, timeout: nil, peers: nil)
25
+ return "Error: this session's id is not known here" unless peers&.session_id
26
+
27
+ sd = peers.state_dir || Session.default_state_dir
28
+ child_id = if session.to_s.strip.empty?
29
+ newest_running_child(peers.session_id, state_dir: sd) ||
30
+ (return "No running delegate; list_sessions shows finished ones.")
31
+ else
32
+ id = Session.resolve_id(session.to_s.strip, state_dir: sd)
33
+ child = Session.load(id, state_dir: sd)
34
+ unless child.parent_id == peers.session_id
35
+ return "Error: #{id[0, 8]} is not a delegate of this session (list_sessions marks them child)"
36
+ end
37
+
38
+ id
39
+ end
40
+ DelegateWait.call(child_id, peers: peers, timeout: Delegate.parse_timeout(timeout))
41
+ rescue Session::AmbiguousId => e
42
+ "Error: #{e.message}"
43
+ rescue ArgumentError
44
+ "Error: no session #{session.to_s.strip} (list_sessions shows them)"
45
+ end
46
+
47
+ def self.newest_running_child(parent_id, state_dir:)
48
+ SessionManager.children_of(parent_id, state_dir: state_dir).find { |s| s[:busy] }&.fetch(:id)
49
+ end
50
+ private_class_method :newest_running_child
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "output_guardrails"
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
+ # Waiting for a delegated session's next reply, shared by delegate and
14
+ # delegate_result. A reply is the child's output/<timestamp>.txt file
15
+ # (the worker writes one per turn that ended with visible text, just
16
+ # before its idle save), so "new" means a filename past the cursor: the
17
+ # newest one this parent was already given. The cursors live in this
18
+ # process (a worker runs one session), keyed by parent and child; a
19
+ # worker respawn loses them, which only repeats the newest reply once.
20
+ module DelegateWait
21
+ TIMEOUT_DEFAULT = 600
22
+ POLL_INTERVAL = 0.5
23
+ # Cut like an execute result: full up to TRUNCATE_AT bytes, else a
24
+ # head-and-tail preview of PREVIEW bytes.
25
+ TRUNCATE_AT_BYTES = OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES
26
+ PREVIEW_BYTES = OutputGuardrails::DEFAULT_PREVIEW_BYTES
27
+
28
+ module_function
29
+
30
+ # @return [Hash{Array(String, String) => String}] [parent id, child id] → newest reply filename given
31
+ def seen
32
+ @seen ||= {}
33
+ end
34
+
35
+ # @param child_id [String] the child's full id
36
+ # @param peers [Peers] the parent (session_id, state_dir, cancelled?)
37
+ # @param timeout [Integer] seconds
38
+ # @param poll_interval [Float]
39
+ # @return [String] the tool result
40
+ def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL)
41
+ sd = peers.state_dir || Session.default_state_dir
42
+ key = [peers.session_id, child_id]
43
+ cursor = seen[key]
44
+ deadline = monotonic + timeout.to_i
45
+ seen_running = false
46
+
47
+ loop do
48
+ session = Session.load(child_id, state_dir: sd)
49
+
50
+ if (file = newest_reply(child_id, state_dir: sd)) && newer?(file, cursor)
51
+ seen[key] = file
52
+ return reply_result(child_id, File.read(File.join(reply_dir(child_id, state_dir: sd), file)))
53
+ end
54
+
55
+ case session.status
56
+ when Session::STATUS_ERROR
57
+ return result(child_id, "error", "the child's worker failed: #{session.last_prompt.to_s.strip}; its session shows what happened")
58
+ when Session::STATUS_STOPPED
59
+ return result(child_id, "stopped", "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
60
+ end
61
+
62
+ if (pending = session.pending_question)
63
+ return result(child_id, "waiting_for_answer", waiting_text(child_id, pending))
64
+ end
65
+
66
+ if peers.respond_to?(:cancelled?) && peers.cancelled?
67
+ return result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
68
+ end
69
+
70
+ if session.status == Session::STATUS_RUNNING
71
+ seen_running = true
72
+ elsif seen_running
73
+ # It ran and is idle again with no new reply: canceled, failed or
74
+ # empty. Before it was ever seen running, idle means a
75
+ # file-delivered follow-up its worker has not picked up yet.
76
+ return result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
77
+ end
78
+
79
+ return timeout_result(child_id, timeout) if monotonic > deadline
80
+
81
+ sleep(poll_interval)
82
+ end
83
+ rescue ArgumentError => e
84
+ "Error: #{e.message}"
85
+ end
86
+
87
+ # @return [String, nil] the newest reply filename (sortable timestamps)
88
+ def newest_reply(child_id, state_dir:)
89
+ dir = reply_dir(child_id, state_dir: state_dir)
90
+ return nil unless Dir.exist?(dir)
91
+
92
+ Dir.children(dir).select { |f| f.end_with?(".txt") }.max
93
+ end
94
+
95
+ # Point the cursor at the newest reply now, so only a later one counts
96
+ # (a follow-up sent to a child that already answered).
97
+ def mark_seen(parent_id, child_id, state_dir:)
98
+ seen[[parent_id, child_id]] = newest_reply(child_id, state_dir: state_dir)
99
+ end
100
+
101
+ def reply_dir(child_id, state_dir:)
102
+ File.join(Session.session_dir(child_id, state_dir: state_dir), SessionManager::OUTPUT_DIR)
103
+ end
104
+
105
+ def newer?(file, cursor)
106
+ cursor.nil? || file > cursor
107
+ end
108
+
109
+ def reply_result(child_id, text)
110
+ "session: #{child_id}\nstatus: done\n---\n#{cut(text)}"
111
+ end
112
+
113
+ def result(child_id, status, text)
114
+ "session: #{child_id}\nstatus: #{status}\n#{text}"
115
+ end
116
+
117
+ def timeout_result(child_id, timeout)
118
+ result(child_id, "running",
119
+ "no reply yet after #{timeout.to_i} s; the child keeps running. delegate_result #{child_id} waits again; " \
120
+ "chi --attach #{child_id} shows it.")
121
+ end
122
+
123
+ def waiting_text(child_id, pending)
124
+ what = pending[:kind].to_s == "approval" ? "an approval" : "a question"
125
+ "Child #{child_id} is waiting for an answer (#{what}); attach with chi --attach #{child_id} or answer it in the web. " \
126
+ "delegate_result #{child_id} waits again once it is answered."
127
+ end
128
+
129
+ def cut(text)
130
+ text = OutputGuardrails.safe_utf8(text)
131
+ return text if text.bytesize <= TRUNCATE_AT_BYTES
132
+
133
+ preview = OutputGuardrails.head_tail_from_string(content: text, preview_bytes: PREVIEW_BYTES)
134
+ [
135
+ "truncated=true",
136
+ "preview_strategy=head_tail",
137
+ "reply_bytes=#{text.bytesize}",
138
+ "returned_preview_bytes=#{preview[:returned_preview_bytes]}",
139
+ "omitted_bytes=#{preview[:omitted_bytes]}",
140
+ "[TRUNCATED_PREVIEW_HEAD]",
141
+ preview[:head],
142
+ "[... omitted #{preview[:omitted_bytes]} bytes ...]",
143
+ "[TRUNCATED_PREVIEW_TAIL]",
144
+ preview[:tail]
145
+ ].join("\n")
146
+ end
147
+
148
+ def monotonic
149
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "output_guardrails"
4
+ require_relative "tool_path"
5
+
6
+ module Samagotchi
7
+ module Tools
8
+ # Replaces an exact block of text in an existing file.
9
+ #
10
+ # XML syntax (used by the model):
11
+ # <tool name="edit" path="path/to/file">
12
+ # <old>exact text to replace</old>
13
+ # <new>replacement text</new>
14
+ # </tool>
15
+ #
16
+ # The <old> block must match exactly once in the file. The tool returns an
17
+ # error if the text is not found or appears more than once.
18
+ class Edit
19
+ NAME = "edit"
20
+
21
+ def self.name = NAME
22
+
23
+ def self.call(content, path:, start_line: nil, end_line: nil)
24
+ path = ToolPath.normalize(path)
25
+
26
+ if range_requested?(start_line, end_line)
27
+ return call_range_mode(content, path: path, start_line: start_line, end_line: end_line)
28
+ end
29
+
30
+ old_text = extract_tag(content, "old")
31
+ new_text = extract_tag(content, "new")
32
+
33
+ return "Error: missing <old>...</old> block" if old_text.nil?
34
+ return "Error: missing <new>...</new> block" if new_text.nil?
35
+ return "Error: <old> block is empty" if old_text.empty?
36
+ return "Error: file not found: #{path}" unless File.exist?(path)
37
+
38
+ source = File.read(path)
39
+ count = count_occurrences(source, old_text)
40
+
41
+ return "Error: old text not found in #{path}" if count == 0
42
+ return "Error: old text matches #{count} times in #{path}; make it unique" if count > 1
43
+
44
+ idx = source.index(old_text)
45
+ updated = source[0, idx] + new_text + source[idx + old_text.length..]
46
+ File.write(path, updated)
47
+ "Edited #{path}: replaced #{old_text.bytesize} bytes with #{new_text.bytesize} bytes"
48
+ rescue => e
49
+ "Error: #{e.message}"
50
+ end
51
+
52
+ def self.call_range_mode(content, path:, start_line:, end_line:)
53
+ return "Error: file not found: #{path}" unless File.exist?(path)
54
+
55
+ new_text = extract_tag(content, "new")
56
+ return "Error: missing <new>...</new> block" if new_text.nil?
57
+
58
+ start_num = parse_positive_line_number(start_line, "start_line")
59
+ return start_num if start_num.is_a?(String)
60
+ return "Error: start_line must be provided for range edits" if start_num.nil?
61
+
62
+ source = File.read(path)
63
+ lines = source.lines
64
+ total_lines = lines.length
65
+
66
+ # Option C: end_line is optional and means "to EOF" when omitted
67
+ # (unless SAMAGOTCHI_EDIT_END_OPTIONAL=false).
68
+ end_provided = !blank?(end_line)
69
+ end_num = end_provided ? parse_positive_line_number(end_line, "end_line") : nil
70
+ return end_num if end_num.is_a?(String)
71
+
72
+ # Option B: an end_line that overshoots EOF is clamped here and flagged
73
+ # in the result so the agent never mutates a different span silently.
74
+ # Set SAMAGOTCHI_EDIT_ALLOW_OOR_END=false to keep the hard error instead.
75
+ clamp_note = ""
76
+ end_value = nil
77
+ if end_provided
78
+ if end_num > total_lines
79
+ if OutputGuardrails.env_bool("SAMAGOTCHI_EDIT_ALLOW_OOR_END", default: true)
80
+ clamp_note = " (end_line #{end_num} exceeds #{total_lines} lines; clamped to line #{total_lines})"
81
+ end_value = total_lines
82
+ else
83
+ return "Error: range out of bounds for #{path}: file has #{total_lines} lines"
84
+ end
85
+ else
86
+ end_value = end_num
87
+ end
88
+ else
89
+ unless OutputGuardrails.env_bool("SAMAGOTCHI_EDIT_END_OPTIONAL", default: true)
90
+ return "Error: start_line and end_line must both be provided for range edits"
91
+ end
92
+ end_value = total_lines
93
+ end
94
+
95
+ # start past EOF is genuinely unusable -> keep a hard error.
96
+ return "Error: start_line #{start_num} out of bounds for #{path}: file has #{total_lines} lines" if start_num > total_lines
97
+ return "Error: start_line must be <= end_line" if start_num > end_value
98
+
99
+ prefix = lines[0, start_num - 1].join
100
+ suffix = lines[end_value..]&.join.to_s
101
+ # Ensure new_text ends with a newline when a suffix follows so that the
102
+ # first suffix line isn't concatenated onto the last replacement line.
103
+ normalized = (!new_text.empty? && !suffix.empty? && !new_text.end_with?("\n")) ? new_text + "\n" : new_text
104
+ updated = prefix + normalized + suffix
105
+ File.write(path, updated)
106
+
107
+ replaced_lines = (end_value - start_num) + 1
108
+ new_line_count = new_text.lines.length
109
+ "Edited #{path}: replaced lines #{start_num}-#{end_value} (#{replaced_lines} lines) with #{new_line_count} lines#{clamp_note}"
110
+ rescue => e
111
+ "Error: #{e.message}"
112
+ end
113
+ private_class_method :call_range_mode
114
+
115
+ # Count non-overlapping literal occurrences of +needle+ in +haystack+.
116
+ def self.count_occurrences(haystack, needle)
117
+ count = 0
118
+ pos = 0
119
+ while (i = haystack.index(needle, pos))
120
+ count += 1
121
+ break if count > 1 # early exit — we only care whether it's 0, 1, or >1
122
+ pos = i + needle.length
123
+ end
124
+ count
125
+ end
126
+ private_class_method :count_occurrences
127
+
128
+ def self.extract_tag(content, tag)
129
+ m = content.match(/<#{tag}>(.*?)<\/#{tag}>/m)
130
+ m ? m[1] : nil
131
+ end
132
+ private_class_method :extract_tag
133
+
134
+ def self.range_requested?(start_line, end_line)
135
+ !blank?(start_line) || !blank?(end_line)
136
+ end
137
+ private_class_method :range_requested?
138
+
139
+ def self.parse_positive_line_number(value, key)
140
+ return nil if blank?(value)
141
+
142
+ integer = Integer(value.to_s.strip, exception: false)
143
+ return "Error: #{key} must be a positive integer" unless integer&.positive?
144
+
145
+ integer
146
+ end
147
+ private_class_method :parse_positive_line_number
148
+
149
+ def self.blank?(value)
150
+ value.nil? || value.to_s.strip.empty?
151
+ end
152
+ private_class_method :blank?
153
+ end
154
+ end
155
+ end