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,527 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "config"
6
+ require_relative "model_profile"
7
+ require_relative "served_model"
8
+ require_relative "turn_flow"
9
+ require_relative "tools/execute"
10
+ require_relative "commands/registry"
11
+
12
+ module Samagotchi
13
+ # The session commands a REPL and a session worker both run: /model,
14
+ # /models, /guardrails, !rollback, !cmd and the answer to a continue offer. They act on
15
+ # the Engine and its TurnFlow; the host prints the result's output and
16
+ # runs a continue turn when asked to (#run never runs a turn).
17
+ #
18
+ # /stats, /recap and /exit are the UI's own: they are in the registry
19
+ # (local: for Tab and help) but #run never runs them.
20
+ class SessionCommands
21
+ MODEL_COMMAND = "/model"
22
+ MODELS_COMMAND = "/models"
23
+ GUARDRAILS_COMMAND = "/guardrails"
24
+ HELP_COMMAND = "/help"
25
+ # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
26
+ # shows this many per host; /models <text> lists every match.
27
+ MODELS_PER_HOST = 20
28
+ ROLLBACK_COMMAND = "!rollback"
29
+ CONTINUE_COMMAND = TurnFlow::CONTINUE_COMMAND
30
+ SHELL_BANG_PREFIX = "!"
31
+ RESERVED_MODEL_ARGS = %w[clear default none off].freeze
32
+ ALIAS_USAGE = "usage /model <model> --alias <name> [--default]"
33
+
34
+ # @!attribute status [Symbol] :ok, or :error when the line was refused
35
+ # @!attribute output [String, nil] what to tell the user
36
+ # @!attribute changed [Array<Symbol>] :messages and/or :model
37
+ # @!attribute model_name [String] the Engine's model after the command
38
+ # @!attribute resume [Boolean] the host must now run the continue turn
39
+ # @!attribute shell [Boolean] output is a !cmd's own output
40
+ # @!attribute decision [Symbol, nil] an answer to the continue offer:
41
+ # :resume, :abort, :abort_with_reason or :invalid
42
+ Result = Struct.new(:status, :output, :changed, :model_name, :resume, :shell, :decision, keyword_init: true)
43
+
44
+ # Puts the built-in commands into +registry+, in lookup order (!rollback
45
+ # before !cmd): the ones #run runs, then the UIs' own (local: Tab and
46
+ # help only).
47
+ def self.register_builtins(registry)
48
+ registry.register(ROLLBACK_COMMAND, "discard the interrupted turn and restore the pre-turn state",
49
+ id: :rollback) { |_text| rollback }
50
+ registry.register("!", "run a shell command; its output goes into the conversation",
51
+ id: :shell, match: ->(text) { text.match?(/\A!\s*\S/) }) { |text| shell(text) }
52
+ registry.register(CONTINUE_COMMAND, "answer the continue offer (yes, no or no, <reason>)") do |text|
53
+ next reply("nothing to continue") unless @turn_flow.awaiting_continue?
54
+
55
+ answer = text.delete_prefix(CONTINUE_COMMAND).strip
56
+ continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
57
+ end
58
+ registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)") do |text|
59
+ reply(models_listing(text.delete_prefix(MODELS_COMMAND).strip))
60
+ end
61
+ registry.register(GUARDRAILS_COMMAND, "list the guardrail rules and approvals (/guardrails revoke N)") do |text|
62
+ guardrails(text.delete_prefix(GUARDRAILS_COMMAND).strip)
63
+ end
64
+ registry.register(MODEL_COMMAND, "show or switch the model",
65
+ match: ->(text) { text.match?(/\A\/model(?:\s+.*)?\z/) }) { |text| model(text) }
66
+ registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
67
+ registry.register("/stats", "show the session's stats", local: true)
68
+ registry.register("/recap", "show the session's recap", local: true)
69
+ registry.register("/exit", "leave (--delete also deletes the session)", local: true)
70
+ registry.register("/quit", "leave, like /exit", local: true, uis: [:attached])
71
+ registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached])
72
+ registry
73
+ end
74
+
75
+ # The built-ins alone, for callers without an Engine (an attached TUI
76
+ # before its snapshot names the session's commands, specs).
77
+ # @return [Commands::Registry] frozen
78
+ def self.builtin_registry
79
+ @builtin_registry ||= register_builtins(Commands::Registry.new).freeze
80
+ end
81
+
82
+ # @return [String] the model /model clear goes back to
83
+ attr_reader :default_model
84
+
85
+ # @param default_model [String] the config default, which /model clear
86
+ # restores and /model names (the Engine may have started elsewhere, e.g.
87
+ # on a resumed session's model)
88
+ # @param save [#call] saves a session (a worker passes its state dir)
89
+ # @param registry [Commands::Registry] the commands #run looks lines up in
90
+ def initialize(engine:, turn_flow:, default_model:, save: ->(session) { session.save },
91
+ registry: self.class.builtin_registry)
92
+ @engine = engine
93
+ @turn_flow = turn_flow
94
+ @default_model = default_model
95
+ @save = save
96
+ @registry = registry
97
+ end
98
+
99
+ # @return [Commands::Registry]
100
+ attr_reader :registry
101
+
102
+ # /help: every command in the registry, the UIs' own and the bundles'
103
+ # too: the session's commands first, then the bundles', then the UIs'
104
+ # own (marked by UI), each group by name.
105
+ # @return [String]
106
+ def help_listing
107
+ entries = @registry.entries.sort_by { |entry| [help_group(entry), entry.name] }
108
+ width = entries.map { |entry| self.class.display_name(entry).length }.max
109
+ lines = entries.map do |entry|
110
+ notes = []
111
+ notes << entry.source unless entry.source == "core"
112
+ notes << "mid-turn too" if entry.anytime
113
+ notes << (entry.uis ? "#{entry.uis.join(" and ")} only" : "terminal only") if entry.local
114
+ line = " #{self.class.display_name(entry).ljust(width)} #{entry.description}"
115
+ notes.empty? ? line : "#{line} (#{notes.join("; ")})"
116
+ end
117
+ "commands:\n#{lines.join("\n")}"
118
+ end
119
+
120
+ # How help and errors name an entry (the shell's "!" is "!<cmd>").
121
+ def self.display_name(entry) = entry.name == SHELL_BANG_PREFIX ? "#{SHELL_BANG_PREFIX}<cmd>" : entry.name
122
+
123
+ # @return [Result, nil] nil when +line+ is not one of these commands
124
+ def run(line)
125
+ text = line.to_s.strip
126
+ entry = @registry.lookup(text)
127
+ return nil unless entry&.handler
128
+ return run_bundle_command(entry, text) unless entry.source == "core"
129
+
130
+ instance_exec(text, &entry.handler)
131
+ end
132
+
133
+ # Answer the pending continue offer (yes / no / no, <reason>).
134
+ # @return [Result] resume: true on yes
135
+ def continue_answer(text)
136
+ decision, reason = TurnFlow.continue_decision(text)
137
+ result = case decision
138
+ when :resume then Result.new(status: :ok, changed: [], model_name: model_name, resume: true, shell: false)
139
+ when :abort
140
+ @turn_flow.abort_continue!
141
+ save_session
142
+ reply("interrupted turn cancelled; enter your next prompt", changed: [:messages])
143
+ when :abort_with_reason
144
+ @turn_flow.abort_continue!(reason: reason)
145
+ save_session
146
+ reply("interrupted turn cancelled; noted your explanation", changed: [:messages])
147
+ else
148
+ reply("answer yes, no, or no, <reason>", status: :error)
149
+ end
150
+ result.decision = decision
151
+ result
152
+ end
153
+
154
+ private
155
+
156
+ def model_name = @engine.effective_model_name
157
+
158
+ # A bundle plugin's command: its handler gets the text after the name;
159
+ # what it returns is the output (nil: nothing to show), and a raise is
160
+ # an error result.
161
+ def run_bundle_command(entry, text)
162
+ output = entry.handler.call(text.delete_prefix(entry.name).strip)
163
+ reply(output.nil? ? nil : output.to_s)
164
+ rescue StandardError => e
165
+ reply("#{entry.name}: #{e.class}: #{e.message}", status: :error)
166
+ end
167
+
168
+ def help_group(entry)
169
+ return 2 if entry.local
170
+
171
+ entry.source == "core" ? 0 : 1
172
+ end
173
+
174
+ def reply(output, status: :ok, changed: [])
175
+ Result.new(status: status, output: output, changed: changed, model_name: model_name, resume: false, shell: false)
176
+ end
177
+
178
+ # Explicit escape hatch after a Ctrl-C: discard the salvaged partial
179
+ # turn and restore the pre-turn checkpoint.
180
+ def rollback
181
+ offered = @turn_flow.awaiting_continue?
182
+ return reply("nothing to rollback") unless @turn_flow.rollback!
183
+
184
+ save_session
185
+ result = reply("salvaged turn discarded; restored pre-turn state", changed: [:messages])
186
+ # It discarded the turn a continue was offered for: that offer's no.
187
+ result.decision = :abort if offered
188
+ result
189
+ end
190
+
191
+ # The output goes into the conversation for the next turn. Not saved
192
+ # here (as in the REPL, the next turn saves it).
193
+ def shell(text)
194
+ command = text.delete_prefix(SHELL_BANG_PREFIX).strip
195
+ return reply("!: please provide a shell command after '!'", status: :error) if command.empty?
196
+
197
+ output = Samagotchi::Tools::Execute.call(command)
198
+ @engine.append_messages([{ role: "user", content: "!(#{command})\n#{output}" }])
199
+ # Rolling back past this would silently drop the command output.
200
+ @turn_flow.note_conversation_changed
201
+ Result.new(status: :ok, output: output, changed: [:messages], model_name: model_name, resume: false, shell: true)
202
+ end
203
+
204
+ # /guardrails: the rules (by source), what failed to load, the stored
205
+ # approvals, numbered; /guardrails revoke N removes approval N.
206
+ def guardrails(args)
207
+ return guardrails_revoke(args.delete_prefix("revoke").strip) if args.start_with?("revoke")
208
+ return reply("usage: /guardrails [revoke N]", status: :error) unless args.empty?
209
+
210
+ reply(guardrails_listing)
211
+ end
212
+
213
+ def guardrails_listing
214
+ rules = @engine.guardrail_rules
215
+ approvals = @engine.guardrail_approvals.entries
216
+ lines = ["guardrails: #{rules.enabled? ? "on" : "off (guardrails.enabled: false; denies still apply)"}"]
217
+ failures = @engine.guardrail_failures.list
218
+ unless failures.empty?
219
+ lines << "failed to load:"
220
+ failures.each do |f|
221
+ lines << " #{f.what}: #{f.reason}#{" (required: every tool call is denied)" if f.required}"
222
+ end
223
+ end
224
+ disabled = rules.rules.count { |rule| rules.disabled?(rule) }
225
+ lines << "rules (#{rules.rules.size}#{", #{disabled} disabled" if disabled.positive?}):"
226
+ lines << " (none; add them under guardrails.rules in config.yml, or install a bundle that ships them)" if rules.rules.empty?
227
+ rules.rules.each_with_index do |rule, idx|
228
+ match = [
229
+ ("tool #{rule.tools.join(",")}" if rule.tools),
230
+ ("command /#{shorten_pattern(rule.command.source)}/" if rule.command),
231
+ ("path #{rule.path}" if rule.path)
232
+ ].compact.join(", ")
233
+ off = "disabled (guardrails.disable) — " if rules.disabled?(rule)
234
+ lines << " #{idx + 1}. #{rule.id}: #{off}#{rule.verdict} (#{match}) — #{rule.reason} [#{rule.source}]"
235
+ end
236
+ rules.unmatched_disables.each { |entry| lines << " guardrails.disable: #{entry} matches no rule" }
237
+ lines << " core: deny writes to the approval store and installed bundles; ask before editing config.yml or the hooks dir"
238
+ lines << "approvals (#{approvals.size}):"
239
+ lines << " (none)" if approvals.empty?
240
+ approvals.each_with_index { |entry, idx| lines << " #{idx + 1}. #{approval_line(entry)}" }
241
+ lines << "revoke one with /guardrails revoke N" unless approvals.empty?
242
+ lines.join("\n")
243
+ end
244
+
245
+ PATTERN_SHOWN = 80
246
+
247
+ # A regex source cut to PATTERN_SHOWN characters, the last one "…".
248
+ def shorten_pattern(source)
249
+ source.length > PATTERN_SHOWN ? "#{source[0, PATTERN_SHOWN - 1]}…" : source
250
+ end
251
+
252
+ def approval_line(entry)
253
+ what = entry["key"] ? entry["key"].tr("\n", " ") : "any call rule #{entry["rule"]} asks about"
254
+ where = entry["scope"] == "session" ? "session #{entry["session_id"].to_s[0, 8]}" : "in #{entry["repo_root"]}"
255
+ rule = entry["rule"] ? " (rule #{entry["rule"]}, #{entry["source"]})" : ""
256
+ "#{entry["scope"]}: #{what} — #{where}#{rule}, #{entry["created_at"]}"
257
+ end
258
+
259
+ def guardrails_revoke(arg)
260
+ return reply("usage: /guardrails revoke N (N from /guardrails)", status: :error) unless arg.match?(/\A\d+\z/)
261
+
262
+ removed = @engine.guardrail_approvals.revoke(arg.to_i - 1)
263
+ return reply("no approval #{arg} (see /guardrails)", status: :error) unless removed
264
+
265
+ reply("revoked approval #{arg}: #{approval_line(removed)}")
266
+ end
267
+
268
+ def save_session
269
+ session = @engine.session
270
+ return unless session
271
+
272
+ session.model_name = model_name
273
+ @save.call(session)
274
+ end
275
+
276
+ def model(text)
277
+ output, switched = model_command(text)
278
+ reply(output, changed: switched ? [:model] : [])
279
+ end
280
+
281
+ # @return [Array(String, Boolean)] the message, and whether the model changed
282
+ def model_command(input)
283
+ suffix = input.delete_prefix(MODEL_COMMAND).strip
284
+ if suffix.empty?
285
+ return ["runtime model: #{model_name}#{model_note}#{served_note}", false] if model_name == @default_model
286
+
287
+ return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}", false]
288
+ end
289
+
290
+ # Parse flags: --default and --alias <name> / --alias=<name> (tolerant order)
291
+ tokens = suffix.split(/\s+/)
292
+ persist_default = false
293
+ alias_name = nil
294
+ alias_seen = false
295
+ model_parts = []
296
+ i = 0
297
+ while i < tokens.length
298
+ tok = tokens[i]
299
+ if tok == "--default"
300
+ persist_default = true
301
+ i += 1
302
+ elsif tok == "--alias"
303
+ return ["multiple --alias flags are not supported: #{ALIAS_USAGE}", false] if alias_seen
304
+
305
+ alias_seen = true
306
+ nxt = tokens[i + 1]
307
+ return ["--alias requires a name: #{ALIAS_USAGE}", false] if nxt.nil? || nxt.strip.empty? || nxt.start_with?("-")
308
+
309
+ alias_name = nxt.strip
310
+ i += 2
311
+ elsif tok.start_with?("--alias=")
312
+ return ["multiple --alias flags are not supported: #{ALIAS_USAGE}", false] if alias_seen
313
+
314
+ alias_seen = true
315
+ val = tok.delete_prefix("--alias=").strip
316
+ return ["--alias requires a name: #{ALIAS_USAGE}", false] if val.empty? || val.start_with?("-")
317
+
318
+ alias_name = val
319
+ i += 1
320
+ else
321
+ model_parts << tok
322
+ i += 1
323
+ end
324
+ end
325
+
326
+ arg = model_parts.join(" ").strip
327
+
328
+ if alias_name
329
+ return ["--alias requires a model name: #{ALIAS_USAGE}", false] if arg.empty?
330
+ return ["--alias cannot be combined with clear/default/none/off", false] if RESERVED_MODEL_ARGS.include?(arg.downcase)
331
+
332
+ # Validate the alias before switching, so a bad one changes nothing.
333
+ invalid = alias_name_error(alias_name, arg)
334
+ return ["invalid alias: #{invalid}", false] if invalid
335
+
336
+ switch_model(arg, persist_default: persist_default)
337
+
338
+ begin
339
+ previous = ConfigFile.write_model_alias!(alias_name, model_name)
340
+ rescue ArgumentError => e
341
+ return ["invalid alias: #{e.message} (runtime model set to #{model_name}#{model_note})", true]
342
+ rescue StandardError => e
343
+ return ["runtime model set to #{model_name}#{model_note} but failed to persist alias: #{e.message}", true]
344
+ end
345
+
346
+ key = alias_name.strip.downcase
347
+ warn_prefix = previous ? "warning: overwriting alias '#{key}' (#{previous} -> #{model_name}); " : ""
348
+ base = persist_default ? "runtime model set to #{model_name}#{model_note} and default updated" : "runtime model set to #{model_name}#{model_note}"
349
+ ["#{warn_prefix}#{base}; alias '#{key}' -> '#{model_name}' persisted", true]
350
+ else
351
+ return ["--default requires a model name: usage /model --default <name> or /model <name> [--default]", false] if arg.empty?
352
+
353
+ if RESERVED_MODEL_ARGS.include?(arg.downcase)
354
+ return ["--default cannot be combined with clear/default/none/off", false] if persist_default
355
+
356
+ switch_model(@default_model)
357
+ return ["runtime model reset to #{model_name}#{model_note}", true]
358
+ end
359
+
360
+ switch_model(arg, persist_default: persist_default)
361
+ if persist_default
362
+ ["runtime model set to #{model_name}#{model_note} and default updated", true]
363
+ else
364
+ ["runtime model set to #{model_name}#{model_note}", true]
365
+ end
366
+ end
367
+ end
368
+
369
+ # Mirrors ConfigFile.write_model_alias!'s checks without writing.
370
+ # @return [String, nil] what is wrong with the alias name
371
+ def alias_name_error(alias_name, target)
372
+ ak = alias_name.strip
373
+ return "alias name is required" if ak.empty?
374
+
375
+ lk = ak.downcase
376
+ return "alias name '#{ak}' is reserved" if RESERVED_MODEL_ARGS.include?(lk)
377
+ return "alias name must not contain whitespace" if ak.match?(/\s/)
378
+ return "alias name must not start with '-'" if ak.start_with?("-")
379
+ return "alias name must not contain '/'" if ak.include?("/")
380
+ return "alias name must match /[a-z0-9][a-z0-9._-]*/i (got '#{ak}')" unless ak.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
381
+ return "alias must not point to itself" if lk == target.strip.downcase
382
+
383
+ nil
384
+ end
385
+
386
+ # Engine owns the switch (alias resolution, profile, kernel, client and
387
+ # the optional default persist); the session keeps the new model.
388
+ def switch_model(name, persist_default: false)
389
+ @engine.switch_model!(name, persist_default: persist_default)
390
+ @default_model = @engine.default_model_name if persist_default
391
+ persist_session_model
392
+ end
393
+
394
+ def persist_session_model
395
+ session = @engine.session
396
+ return unless session.respond_to?(:model_name=)
397
+
398
+ session.model_name = model_name
399
+ begin
400
+ @save.call(session)
401
+ rescue StandardError
402
+ nil
403
+ end
404
+ end
405
+
406
+ # "; served: <name>" when the server serves another model than asked.
407
+ def served_note
408
+ served, asked = @engine.respond_to?(:served_model) ? @engine.served_model : nil
409
+ ServedModel.differs?(asked, served) ? "; served: #{served}" : ""
410
+ rescue StandardError
411
+ ""
412
+ end
413
+
414
+ # " (default: x, profile=qwen36, name)": +extra+ and the prompt profile,
415
+ # which a chat host's model doesn't have (its loop uses none); "" when
416
+ # there is neither.
417
+ def model_note(extra = nil)
418
+ parts = [extra, (profile_note unless chat_model?)].compact
419
+ parts.empty? ? "" : " (#{parts.join(", ")})"
420
+ end
421
+
422
+ def chat_model?
423
+ @engine.respond_to?(:chat_model?) && @engine.chat_model?
424
+ rescue StandardError
425
+ false
426
+ end
427
+
428
+ # "profile=qwen36, server (chat_template)": the profile and where it came from.
429
+ def profile_note
430
+ resolution = @engine.profile_resolution
431
+ "profile=#{resolution.profile.name}, #{resolution.label}"
432
+ end
433
+
434
+ # @param filter [String] only ids containing it (any case); "" lists
435
+ # up to MODELS_PER_HOST per host
436
+ def models_listing(filter = "")
437
+ needle = filter.downcase
438
+ registry = @engine.host_registry
439
+ # Aggregate across all hosts (lazy discovery, skip-on-error)
440
+ results = registry.list_all_models
441
+ return "no hosts configured" if results.nil? || results.empty?
442
+
443
+ aliases = ConfigFile.model_aliases
444
+ by_model = Hash.new { |h, k| h[k] = [] }
445
+ aliases.each do |alias_name, model_id|
446
+ # normalize bare comparison for orphan detection (strip host prefix if present)
447
+ _, bare = registry.parse_qualified_model(model_id)
448
+ key = (bare.empty? ? model_id : bare).to_s.downcase
449
+ by_model[key] << alias_name
450
+ # also index full ref for exact alias display
451
+ by_model[model_id.downcase] << alias_name unless key == model_id.downcase
452
+ end
453
+ by_model.each_value { |v| v.uniq!; v.sort! }
454
+
455
+ seen = Set.new
456
+ lines = []
457
+ # Sort hosts for deterministic output
458
+ results.keys.sort.each do |hname|
459
+ data = results[hname]
460
+ host_label = "#{hname} (#{data[:host]}:#{data[:port]})"
461
+ if data[:error]
462
+ lines << "#{host_label} — unreachable: #{data[:error]}"
463
+ next
464
+ end
465
+ models = Array(data[:models])
466
+ if models.empty?
467
+ lines << "#{host_label} — no models discovered"
468
+ next
469
+ end
470
+ shown = []
471
+ batch_variants = batch_variant_ids(models, needle)
472
+ models.each do |entry|
473
+ identifier = entry.id.to_s.empty? ? "unknown" : entry.id
474
+ seen << identifier.to_s.downcase
475
+ # also track host-qualified seen for orphan logic
476
+ seen << "#{hname}:#{identifier}".downcase
477
+ seen << "#{hname}/#{identifier}".downcase
478
+ next if batch_variants.include?(identifier)
479
+
480
+ shown << [entry, identifier] if needle.empty? || identifier.to_s.downcase.include?(needle)
481
+ end
482
+ next if shown.empty?
483
+
484
+ lines << "#{host_label}:"
485
+ hidden = needle.empty? ? [shown.size - MODELS_PER_HOST, 0].max : 0
486
+ shown.first(shown.size - hidden).each do |entry, identifier|
487
+ raw_status = entry.raw["status"] || entry.raw[:status]
488
+ status = raw_status.is_a?(Hash) ? (raw_status["value"] || raw_status[:value] || raw_status["status"] || raw_status[:status]) : raw_status
489
+ base = status.to_s.empty? ? " #{identifier}" : " #{identifier} (#{status})"
490
+ alias_list = (by_model[identifier.to_s.downcase] || []) + (by_model["#{hname}:#{identifier}".downcase] || [])
491
+ alias_list.uniq!
492
+ lines << (alias_list.empty? ? base : "#{base} (alias: #{alias_list.join(", ")})")
493
+ end
494
+ batch = batch_variants.count { |id| needle.empty? || id.downcase.include?(needle) }
495
+ batch_note = "#{batch} :batch variant#{"s" unless batch == 1}"
496
+ if hidden.positive?
497
+ more = batch.positive? ? "#{hidden} more, plus #{batch_note}" : "#{hidden} more"
498
+ lines << " … and #{more}; /models <text> lists the ids containing <text>"
499
+ elsif batch.positive?
500
+ lines << " … plus #{batch_note}; /models :batch lists them"
501
+ end
502
+ end
503
+ # Warnings for unreachable hosts are already in lines; no failover
504
+ orphans = aliases.reject { |_, model_id| seen.include?(model_id.downcase) || seen.include?(registry.bare_name(model_id).downcase) }
505
+ unless orphans.empty? || !needle.empty?
506
+ lines << ""
507
+ lines << "orphan aliases (target not discovered):"
508
+ orphans.sort.each { |alias_name, model_id| lines << " #{alias_name} -> #{model_id}" }
509
+ end
510
+ lines = [needle.empty? ? "no models discovered" : "no model ids contain #{filter.inspect}"] if lines.empty?
511
+ lines.join("\n")
512
+ rescue StandardError => e
513
+ "unable to list models: #{e.message}"
514
+ end
515
+
516
+ # OpenRouter lists a "<id>:batch" variant next to many ids (71 of 457 on
517
+ # 2026-09-23), which only repeats the model. The ids of such variants
518
+ # whose plain id is listed too, unless the filter asks for batch.
519
+ def batch_variant_ids(models, needle)
520
+ return Set.new if needle.include?("batch")
521
+
522
+ ids = models.map { |entry| entry.id.to_s }
523
+ plain = ids.to_set
524
+ ids.select { |id| id.end_with?(":batch") && plain.include?(id.delete_suffix(":batch")) }.to_set
525
+ end
526
+ end
527
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "session_manager"
5
+
6
+ module Samagotchi
7
+ # `chi sessions delete`: remove sessions for good (SessionManager.delete_session),
8
+ # one line per id. A live worker's session is refused unless --force stops
9
+ # the worker first; a chi REPL's never is.
10
+ class SessionDeleteCommand
11
+ USAGE = <<~TEXT
12
+ Usage: chi sessions delete [--force] (ID|PREFIX)...
13
+ Deletes each session: its history, notes, images and queued input.
14
+ This can't be undone.
15
+ -f, --force stop a session's running worker first (without it,
16
+ a live session is refused); a session open in a
17
+ chi REPL is always refused
18
+ TEXT
19
+ PREVIEW_LIMIT = 60
20
+
21
+ # @param argv [Array<String>] the arguments after "sessions delete"
22
+ def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
23
+ @argv = argv.dup
24
+ @stdout = stdout
25
+ @stderr = stderr
26
+ @state_dir = state_dir || Session.default_state_dir
27
+ end
28
+
29
+ # @return [Integer] exit status: 0 all deleted, 1 any refused or
30
+ # unknown, 2 usage
31
+ def run
32
+ options = parse or return 2
33
+ return 0 if options[:help]
34
+
35
+ options[:ids].uniq.map { |given| delete(given, force: options[:force]) }.all? ? 0 : 1
36
+ end
37
+
38
+ private
39
+
40
+ def parse
41
+ options = { ids: [], force: false }
42
+ until @argv.empty?
43
+ arg = @argv.shift
44
+ case arg
45
+ when "-h", "--help", "help"
46
+ @stdout.puts(USAGE)
47
+ return { help: true }
48
+ when "-f", "--force" then options[:force] = true
49
+ when /\A-/ then return usage_error("unknown option #{arg}")
50
+ else options[:ids] << arg
51
+ end
52
+ end
53
+ return usage_error("give session ids") if options[:ids].empty?
54
+
55
+ options
56
+ end
57
+
58
+ # @return [Boolean] whether the session is gone
59
+ def delete(given, force:)
60
+ id = Session.resolve_id(given, state_dir: @state_dir)
61
+ short = id[0, 8]
62
+ preview = preview_of(id)
63
+ result = SessionManager.delete_session(id, state_dir: @state_dir, stop: force)
64
+ @stdout.puts(["#{short} deleted#{" (stopped its worker)" if result[:stopped]}", preview].compact.join(" "))
65
+ true
66
+ rescue SessionManager::OwnedByTUI
67
+ @stdout.puts("#{short} refused: it is open in a chi REPL; close it there first")
68
+ false
69
+ rescue SessionManager::DeleteRefused => e
70
+ line = if e.reason == :still_stopping then "its worker is still shutting down; try again in a moment"
71
+ else "its worker is running (--force stops it first)"
72
+ end
73
+ @stdout.puts("#{short} refused: #{line}")
74
+ false
75
+ rescue ArgumentError => e
76
+ error_line("chi sessions delete: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
77
+ false
78
+ rescue SystemCallError => e
79
+ @stdout.puts("#{short} failed: #{e.message}")
80
+ false
81
+ end
82
+
83
+ def preview_of(id)
84
+ text = Session.load(id, state_dir: @state_dir).first_preview.to_s.gsub(/\s+/, " ").strip
85
+ return nil if text.empty?
86
+
87
+ text.length > PREVIEW_LIMIT ? "#{text[0, PREVIEW_LIMIT - 1]}…" : text
88
+ rescue ArgumentError, JSON::ParserError
89
+ nil
90
+ end
91
+
92
+ # stderr isn't buffered, stdout is when it's a pipe: flush the lines
93
+ # already printed, so the output keeps the order of the ids given.
94
+ def error_line(text)
95
+ @stdout.flush
96
+ @stderr.puts(text)
97
+ end
98
+
99
+ def usage_error(message)
100
+ error_line("chi sessions delete: #{message}")
101
+ @stderr.puts(USAGE)
102
+ nil
103
+ end
104
+ end
105
+ end