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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Samagotchi
8
+ # The single-owner lock of a session: whichever process runs the session's
9
+ # Engine (a SessionManager worker or the in-process TUI) holds an exclusive
10
+ # flock on `<session_dir>/owner.lock` for its lifetime. A second would-be
11
+ # owner backs off, so one session never gets two Engines.
12
+ #
13
+ # flock locks belong to the open file description: the kernel releases them
14
+ # when the owner exits (however it dies), Ruby opens files close-on-exec so a
15
+ # spawned grandchild never inherits one, and probing from another descriptor
16
+ # (even in the owner's own process) never releases the owner's lock.
17
+ class OwnerLock
18
+ FILE = "owner.lock"
19
+ DEFAULT_WAIT = 2.0
20
+ RETRY_INTERVAL = 0.05
21
+ OWNER_READ_ATTEMPTS = 10
22
+
23
+ # Take the lock, retrying for up to +wait+ seconds (another process's
24
+ # #owner probe holds it for a moment). On success the owner's pid and kind
25
+ # are written into the lock file for #owner to report.
26
+ # @param session_dir [String]
27
+ # @param kind [String] "worker" or "tui"
28
+ # @return [OwnerLock, nil] nil when another owner holds it
29
+ def self.acquire(session_dir, kind:, wait: DEFAULT_WAIT)
30
+ FileUtils.mkdir_p(session_dir)
31
+ file = File.open(path(session_dir), File::RDWR | File::CREAT, 0o644)
32
+ deadline = monotonic_now + wait.to_f
33
+ until file.flock(File::LOCK_EX | File::LOCK_NB)
34
+ if monotonic_now >= deadline
35
+ file.close
36
+ return nil
37
+ end
38
+ sleep(RETRY_INTERVAL)
39
+ end
40
+ new(file, kind: kind)
41
+ end
42
+
43
+ # The current owner, read without disturbing it.
44
+ # @param session_dir [String]
45
+ # @return [Hash, nil] {"pid", "kind", "started_at"} while held (values may
46
+ # be missing for a moment right after acquisition), nil when free
47
+ def self.owner(session_dir)
48
+ File.open(path(session_dir), File::RDONLY) do |file|
49
+ if file.flock(File::LOCK_SH | File::LOCK_NB)
50
+ file.flock(File::LOCK_UN)
51
+ return nil
52
+ end
53
+ # A new owner truncates, then writes: retry briefly rather than
54
+ # report an owner of unknown kind.
55
+ data = {}
56
+ OWNER_READ_ATTEMPTS.times do
57
+ data = parse(File.read(file.path))
58
+ break unless data.empty?
59
+
60
+ sleep(RETRY_INTERVAL / 5)
61
+ end
62
+ data
63
+ end
64
+ rescue Errno::ENOENT
65
+ nil
66
+ end
67
+
68
+ # @return [Boolean] whether this session has ever had a lock-taking owner
69
+ # (workers from before the lock only left a pid file)
70
+ def self.lock_file?(session_dir)
71
+ File.exist?(path(session_dir))
72
+ end
73
+
74
+ def self.path(session_dir)
75
+ File.join(session_dir, FILE)
76
+ end
77
+
78
+ def self.parse(raw)
79
+ data = JSON.parse(raw.to_s)
80
+ data.is_a?(Hash) ? data : {}
81
+ rescue JSON::ParserError
82
+ {}
83
+ end
84
+ private_class_method :parse
85
+
86
+ def self.monotonic_now
87
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
+ end
89
+ private_class_method :monotonic_now
90
+
91
+ attr_reader :kind
92
+
93
+ def initialize(file, kind:)
94
+ @file = file
95
+ @kind = kind.to_s
96
+ @file.truncate(0)
97
+ @file.rewind
98
+ @file.write(JSON.generate("pid" => Process.pid, "kind" => @kind, "started_at" => Time.now.iso8601(3)))
99
+ @file.flush
100
+ end
101
+
102
+ # Release the lock (process exit releases it too).
103
+ def release
104
+ return if @file.closed?
105
+
106
+ @file.flock(File::LOCK_UN)
107
+ @file.close
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Thread-safe FIFO of user steering messages submitted while a turn is
5
+ # running. UIs (TUI, web, background workers) are push-only producers;
6
+ # KernelLoop drains the queue at iteration boundaries and injects the
7
+ # messages into the conversation. True mid-stream injection is impossible
8
+ # with llama.cpp's /completion API, so draining only happens between full
9
+ # LLM responses / tool dispatches.
10
+ #
11
+ # Usage:
12
+ # queue = PendingInputQueue.new
13
+ # queue.push("please also check the specs")
14
+ # kernel.run(messages, pending_input: queue.method(:drain))
15
+ class PendingInputQueue
16
+ def initialize
17
+ @mutex = Mutex.new
18
+ @messages = []
19
+ end
20
+
21
+ # Append a message to the queue. Nil/empty strings are ignored.
22
+ def push(text)
23
+ text = text.to_s
24
+ return if text.empty?
25
+
26
+ @mutex.synchronize { @messages << text }
27
+ nil
28
+ end
29
+
30
+ # Remove and return all queued messages in FIFO order. Non-blocking.
31
+ # @return [Array<String>]
32
+ def drain
33
+ @mutex.synchronize do
34
+ drained = @messages
35
+ @messages = []
36
+ drained
37
+ end
38
+ end
39
+
40
+ def empty?
41
+ @mutex.synchronize { @messages.empty? }
42
+ end
43
+
44
+ def size
45
+ @mutex.synchronize { @messages.size }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../log"
4
+ require_relative "../tools/args"
5
+ require_relative "service"
6
+ require_relative "tool_result"
7
+
8
+ module Samagotchi
9
+ module Plugin
10
+ # What a plugin's #register(chi) gets (docs/plugins.md). Each call
11
+ # stages a registration and checks it; Loader commits them all once
12
+ # #register returns, so a plugin that raises halfway adds nothing.
13
+ class Api
14
+ COMMAND_NAME = %r{\A/[a-z][a-z0-9_-]{0,31}\z}
15
+ TOOL_NAME = /\A[a-z][a-z0-9_]{0,47}\z/
16
+ PARAM_NAME = /\A[a-z_][a-z0-9_]*\z/i
17
+
18
+ # @param bundle [String]
19
+ # @param label [String] "<file> (bundle <name>)": what hooks and
20
+ # failures are named by
21
+ # @param registries [Registries]
22
+ # @param context [Context, nil] what handlers get as ctx
23
+ def initialize(bundle:, label:, registries:, context: nil)
24
+ @bundle = bundle
25
+ @label = label
26
+ @registries = registries
27
+ @context = context
28
+ @hooks = []
29
+ @commands = []
30
+ @tools = []
31
+ @services = []
32
+ @inits = []
33
+ @committed = false
34
+ end
35
+
36
+ # The Context the plugin's handlers get, for #register itself: its
37
+ # settings, log, data_dir, and ctx.notify / ctx.card, which, shown
38
+ # while chi starts, wait for the first turn (beside the plugins' load
39
+ # warnings).
40
+ # @return [Context, nil]
41
+ def ctx = @context
42
+
43
+ # A slash command the session runs: the block gets the text after the
44
+ # name (stripped, "" for none) and the Context, and returns what to
45
+ # show (a String) or nil for nothing. A raise is shown as an error.
46
+ # @param name [String] "/name"; a name the session already has
47
+ # (built-in or another bundle's) is a load error
48
+ # @param anytime [Boolean] may run while a turn runs (from P2; today
49
+ # it runs as other commands do)
50
+ def command(name, description, anytime: false, &block)
51
+ raise ArgumentError, "command #{name.inspect} needs a block" unless block
52
+ name = name.to_s
53
+ raise ArgumentError, "command name #{name.inspect} must look like /name (a-z, 0-9, _ and -)" unless name.match?(COMMAND_NAME)
54
+ if (taken = @registries.commands.entries.find { |entry| entry.name == name })
55
+ raise ArgumentError, "command #{name} is already registered (#{taken.source})"
56
+ end
57
+ raise ArgumentError, "command #{name} is registered twice" if @commands.any? { |cmd| cmd[:name] == name }
58
+
59
+ @commands << { name: name, description: description.to_s, anytime: anytime ? true : false, block: block }
60
+ nil
61
+ end
62
+
63
+ # A tool the model can call: the block gets the call's arguments (a
64
+ # frozen Hash, string keys, typed by the schema: see Tools::Args) and
65
+ # the Context, and returns the result text ("Error: …" marks a
66
+ # failure). A raise is the model's "Error: <message>".
67
+ # @param name [String] a-z, 0-9 and _; a name the session already has
68
+ # is a load error
69
+ # @param params [Hash] name => a JSON Schema property ({type:,
70
+ # description:, enum:, items:, properties:, …}) plus required: true
71
+ # @param schema [Hash, nil] instead of params: the parameters as one
72
+ # JSON Schema object ({type: "object", properties:, required: […]}),
73
+ # an MCP server's inputSchema for example
74
+ # @param label [String, nil] the activity line's action
75
+ # @param preview [#call, nil] args → the activity line's params
76
+ # @param targets [#call, nil] args → what guardrail rules match: a Hash
77
+ # with paths: (absolute or relative to the cwd), command: (a shell
78
+ # command) and cwd:, each optional
79
+ def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
80
+ spec = Api.tool_spec(name, description, params: params, schema: schema, label: label, preview: preview,
81
+ targets: targets, &block)
82
+ if (taken = @registries.tools[spec[:name]])
83
+ raise ArgumentError, "tool #{spec[:name]} is already registered (#{taken.source})"
84
+ end
85
+ raise ArgumentError, "tool #{spec[:name]} is registered twice" if @tools.any? { |tool| tool[:name] == spec[:name] }
86
+
87
+ @tools << spec
88
+ nil
89
+ end
90
+
91
+ # The plugin's whole tool set, after #register (a plugin whose tools
92
+ # are known only later: an MCP server that listed them). The block
93
+ # gets a set whose #tool takes #tool's arguments; what it declares
94
+ # replaces the plugin's tools from the next turn on: its tools not in
95
+ # the set go, new or changed ones are registered, and the system
96
+ # prompts are built again if anything changed. Safe from any thread:
97
+ # the set is staged, and the turn thread applies it before the
98
+ # turn's first model request. A name another bundle (or chi) has is
99
+ # left out, with a notice.
100
+ # @raise [ArgumentError] a bad tool (as #tool), or called in #register
101
+ def replace_tools
102
+ raise ArgumentError, "replace_tools needs a block" unless block_given?
103
+ raise ArgumentError, "replace_tools is for after register (use chi.tool there)" unless @committed
104
+
105
+ set = ToolSet.new
106
+ yield set
107
+ if (stage = @registries.stage_tools)
108
+ stage.call(@bundle, set.specs, @context)
109
+ elsif Api.apply_tools(@registries.tools, @bundle, set.specs, @context)[:changed]
110
+ tools_changed!
111
+ end
112
+ nil
113
+ end
114
+
115
+ # What #replace_tools' block declares tools on.
116
+ class ToolSet
117
+ # @return [Array<Hash>]
118
+ attr_reader :specs
119
+
120
+ def initialize
121
+ @specs = []
122
+ end
123
+
124
+ # As Api#tool.
125
+ def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
126
+ spec = Api.tool_spec(name, description, params: params, schema: schema, label: label, preview: preview,
127
+ targets: targets, &block)
128
+ raise ArgumentError, "tool #{spec[:name]} is registered twice" if @specs.any? { |s| s[:name] == spec[:name] }
129
+
130
+ @specs << spec
131
+ nil
132
+ end
133
+ end
134
+
135
+ # Run the block on a hook event (docs/hooks.md: :before_turn,
136
+ # :after_turn, :before_tool_call, …), like a bundle's hooks/*.rb:
137
+ # the block gets the event hash, with event[:notify] and the other
138
+ # helpers, and the Context (a block may take the event alone). A
139
+ # block that raises is logged (not shown) and skipped.
140
+ # @param priority [Integer] lower runs first among bundle hooks
141
+ def on(event, priority: 100, &block)
142
+ raise ArgumentError, "on(#{event.inspect}) needs a block" unless block
143
+ raise ArgumentError, "on: the event must be a Symbol or String" unless event.is_a?(Symbol) || event.is_a?(String)
144
+
145
+ @hooks << { event: event.to_sym, priority: Integer(priority), block: block }
146
+ nil
147
+ end
148
+
149
+ # A long-lived thing the plugin keeps (a server process): the block
150
+ # starts it and returns what #value gives; in it, svc.on_stop { }
151
+ # says how to stop it. It starts on first svc.value, or now with
152
+ # eager: true (a raise then fails the plugin's load, unless the
153
+ # plugin rescues it). The Engine stops its services when it shuts
154
+ # down (the REPL or the session's worker exits), newest first.
155
+ # @param name [String, Symbol] unique in the plugin
156
+ # @return [Service]
157
+ def service(name, eager: false, &block)
158
+ raise ArgumentError, "service #{name.inspect} needs a block" unless block
159
+ name = "#{@bundle}:#{name}"
160
+ raise ArgumentError, "service #{name} is registered twice" if @services.any? { |svc| svc.name == name }
161
+ raise ArgumentError, "this chi has no services (plugins: false)" unless @registries.services
162
+
163
+ service = @registries.services.add(Service.new(name, &block))
164
+ @services << service
165
+ service.start if eager
166
+ service
167
+ end
168
+
169
+ # Slow setup (downloading a model, indexing a repo, logging in, an
170
+ # MCP server's first start) that must not hold chi's start: the block
171
+ # runs on its own thread once the session's UI can show it, not in
172
+ # #register. It gets the Context (ctx.cancelled? says chi is shutting
173
+ # down) and returns a short summary ("3 tools") or raises (the UIs
174
+ # show a warn card). Every UI shows it running (the label) and done.
175
+ # @param label [String] what it does, shown while it runs
176
+ # @param provides_tools [Boolean] it registers tools (chi.replace_tools):
177
+ # a turn sent meanwhile waits for it before its first model request,
178
+ # up to +timeout+; a Ctrl-C ends the wait
179
+ # @param quiet [Boolean] shown only if it fails (a background refresh)
180
+ # @param timeout [Numeric, nil] seconds a turn waits for it (default 60)
181
+ # @param failed [String, nil] the warn card's short title if it raises
182
+ # ("chrome didn't start"; default "setup failed", the label then
183
+ # leads the card's body)
184
+ def init(label, provides_tools: false, quiet: false, timeout: nil, failed: nil, &block)
185
+ raise ArgumentError, "init needs a block" unless block
186
+ raise ArgumentError, "init needs a label" if label.to_s.strip.empty?
187
+ raise ArgumentError, "this chi runs no init tasks (plugins: false)" unless @registries.init
188
+
189
+ @inits << { label: label.to_s.strip, provides_tools: provides_tools ? true : false, quiet: quiet ? true : false,
190
+ timeout: timeout && Float(timeout), failed: failed.to_s.strip.empty? ? nil : failed.to_s.strip,
191
+ block: block }
192
+ nil
193
+ end
194
+
195
+ # Stop the services the plugin started: its load failed after all.
196
+ def abort!
197
+ @services.reverse_each(&:stop)
198
+ nil
199
+ end
200
+
201
+ # Say the session's tools changed after #register (a plugin that
202
+ # registers tools late): the system prompts are built again for the
203
+ # next turn, so the model sees the new set. That costs the server its
204
+ # cached prompt prefix once, so call it only when the set changed.
205
+ def tools_changed!
206
+ @registries.tools_changed&.call
207
+ nil
208
+ end
209
+
210
+ # Register what was staged. Called by Loader after #register.
211
+ def commit!
212
+ context = @context
213
+ @commands.each do |cmd|
214
+ block = cmd[:block]
215
+ @registries.commands.register(cmd[:name], cmd[:description], anytime: cmd[:anytime], source: @bundle) do |args|
216
+ block.call(args, context)
217
+ end
218
+ end
219
+ @tools.each { |tool| @registries.tools.register(tool[:name], source: @bundle, **Api.entry_fields(tool, context)) }
220
+ @hooks.each do |hook|
221
+ bundle = @bundle
222
+ label = @label
223
+ block = hook[:block]
224
+ context = @context
225
+ @registries.hooks.register_bundle(bundle, hook[:event], hook_name: label.split(" ").first,
226
+ priority: hook[:priority]) do |event|
227
+ block.call(event, context)
228
+ rescue StandardError => e
229
+ # Logged only: a turn's live region is on screen.
230
+ Log.warn(:plugins, "plugin_hook_failed", bundle: bundle, event: hook[:event].to_s, error: e.class.name,
231
+ msg: "#{label} #{hook[:event]} hook failed: #{e.message}")
232
+ end
233
+ end
234
+ @inits.each do |init|
235
+ block = init[:block]
236
+ @registries.init.call(@bundle, init[:label], @label, provides_tools: init[:provides_tools], quiet: init[:quiet],
237
+ timeout: init[:timeout], failed: init[:failed]) do
238
+ block.call(context)
239
+ end
240
+ end
241
+ @committed = true
242
+ end
243
+
244
+ # @return [Hash] how many of each it registered (for the log)
245
+ def counts = { commands: @commands.size, tools: @tools.size, hooks: @hooks.size, services: @services.size,
246
+ inits: @inits.size }
247
+
248
+ # A checked tool declaration (#tool's arguments).
249
+ # @return [Hash] {name:, schema:, label:, preview:, targets:, block:}
250
+ def self.tool_spec(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
251
+ raise ArgumentError, "tool #{name.inspect} needs a block" unless block
252
+ name = name.to_s
253
+ raise ArgumentError, "tool name #{name.inspect} must be a-z, 0-9 and _ (at most 48)" unless name.match?(TOOL_NAME)
254
+ raise ArgumentError, "tool #{name}: preview must respond to #call" if preview && !preview.respond_to?(:call)
255
+ raise ArgumentError, "tool #{name}: targets must respond to #call" if targets && !targets.respond_to?(:call)
256
+
257
+ parameters = schema ? schema_parameters(name, schema) : params_schema(name, params)
258
+ { name: name, schema: { name: name, description: description.to_s, parameters: parameters },
259
+ label: label&.to_s, preview: preview, targets: targets, block: block }
260
+ end
261
+
262
+ # Make +bundle+'s tools in +registry+ the +specs+: its tools not in
263
+ # them go; a new one, or one whose schema or label changed, is
264
+ # registered (a changed one again, at the end); an unchanged one is
265
+ # kept as it is. A name another source has is left out. Called on
266
+ # the turn thread (Engine#apply_staged_tools!).
267
+ # @return [Hash] {changed: Boolean, skipped: [String] (why)}
268
+ def self.apply_tools(registry, bundle, specs, context)
269
+ wanted = specs.to_h { |spec| [spec[:name], spec] }
270
+ changed = false
271
+ registry.entries.each do |entry|
272
+ next unless entry.source == bundle
273
+ next if (spec = wanted[entry.name]) && spec[:schema] == entry.schema && spec[:label] == entry.label
274
+
275
+ registry.unregister(entry.name)
276
+ changed = true
277
+ end
278
+ skipped = []
279
+ specs.each do |spec|
280
+ if (taken = registry[spec[:name]])
281
+ skipped << "tool #{spec[:name]} is already registered (#{taken.source})" unless taken.source == bundle
282
+ next
283
+ end
284
+
285
+ registry.register(spec[:name], source: bundle, **entry_fields(spec, context))
286
+ changed = true
287
+ end
288
+ { changed: changed, skipped: skipped }
289
+ end
290
+
291
+ # Registry#register's keywords for a tool declaration: its block
292
+ # wrapped as a handler (typed args, the Context), preview, targets.
293
+ def self.entry_fields(spec, context)
294
+ block = spec[:block]
295
+ preview = spec[:preview]
296
+ targets = spec[:targets]
297
+ parameters = spec[:schema][:parameters]
298
+ {
299
+ schema: spec[:schema], label: spec[:label],
300
+ handler: lambda { |call, _kctx|
301
+ result = block.call(Api.args_of(call, parameters), context)
302
+ # A String (a ToolResult too, with its images) as it is.
303
+ result.is_a?(String) ? result : result.to_s
304
+ },
305
+ preview: preview && ->(call) { preview.call(Api.args_of(call, parameters))&.to_s },
306
+ targets: targets && ->(call) { targets.call(Api.args_of(call, parameters)) }
307
+ }
308
+ end
309
+
310
+ # A tool call's arguments as a plugin sees them: the parsers' args:
311
+ # (a call built without one, in a spec say: the call without its
312
+ # name), string keys, typed by +parameters+, frozen.
313
+ def self.args_of(call, parameters = nil)
314
+ given = call[:args].is_a?(Hash) ? call[:args] : call.reject { |key, _| key == :name || key == :args }
315
+ Tools::Args.coerce(given, parameters).freeze
316
+ end
317
+
318
+ # A JSON Schema with symbol keys (property names included), as
319
+ # ToolDeclarations::TOOL_SCHEMAS has them.
320
+ def self.symbolize(value)
321
+ case value
322
+ when Hash then value.to_h { |key, item| [key.to_sym, symbolize(item)] }
323
+ when Array then value.map { |item| symbolize(item) }
324
+ else value
325
+ end
326
+ end
327
+
328
+ # The parameters object from params: (name => property spec).
329
+ def self.params_schema(name, params)
330
+ raise ArgumentError, "tool #{name}: params must be a Hash" unless params.is_a?(Hash)
331
+
332
+ required = []
333
+ properties = params.each_with_object({}) do |(param, spec), acc|
334
+ param = param.to_s
335
+ raise ArgumentError, "tool #{name}: parameter name #{param.inspect} is not a plain word" unless param.match?(PARAM_NAME)
336
+ raise ArgumentError, "tool #{name}: parameter #{param} must be a Hash {type:, description:}" unless spec.is_a?(Hash)
337
+
338
+ spec = Api.symbolize(spec)
339
+ required << param if spec.delete(:required) == true
340
+ acc[param.to_sym] = { type: (spec.delete(:type) || "string").to_s, description: spec.delete(:description).to_s }.merge(spec)
341
+ end
342
+ { type: "object", properties: properties, required: required }
343
+ end
344
+ private_class_method :params_schema
345
+
346
+ # The parameters object from schema: (a JSON Schema object).
347
+ def self.schema_parameters(name, schema)
348
+ raise ArgumentError, "tool #{name}: schema must be a Hash" unless schema.is_a?(Hash)
349
+
350
+ schema = Api.symbolize(schema)
351
+ raise ArgumentError, "tool #{name}: schema must be {type: \"object\", properties: {…}}" unless schema.fetch(:type, "object").to_s == "object"
352
+
353
+ properties = schema[:properties] || {}
354
+ raise ArgumentError, "tool #{name}: schema properties must be a Hash" unless properties.is_a?(Hash)
355
+
356
+ { type: "object", properties: properties, required: Array(schema[:required]).map(&:to_s) }
357
+ .merge(schema.except(:type, :properties, :required))
358
+ end
359
+ private_class_method :schema_parameters
360
+ end
361
+ end
362
+ end