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
data/bin/chi ADDED
@@ -0,0 +1,1140 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
6
+ # No locale at all (an app started from Finder or launchd, `env -i`): files
7
+ # would read as US-ASCII, and chi's session JSON is UTF-8. Workers this
8
+ # process spawns inherit the LANG.
9
+ if Encoding.default_external == Encoding::US_ASCII && %w[LC_ALL LC_CTYPE LANG].none? { |key| ENV[key] }
10
+ ENV["LANG"] = "en_US.UTF-8"
11
+ Encoding.default_external = Encoding::UTF_8
12
+ end
13
+
14
+ require "optparse"
15
+ require "samagotchi/config"
16
+
17
+ Samagotchi::ConfigFile.load_global_env!
18
+ require "samagotchi"
19
+
20
+ # `chi self`: print version, source dir, config/memory/session paths, model and
21
+ # bundles (read-only, offline) — lets the agent find its own code and state.
22
+ if ARGV[0] == "self"
23
+ if %w[-h --help help].include?(ARGV[1])
24
+ puts "Usage: chi self"
25
+ puts " Print version, source dir, config/memory/session paths, model/host and bundles."
26
+ exit 0
27
+ end
28
+ require "samagotchi/self_report"
29
+ puts Samagotchi::SelfReport.text
30
+ exit 0
31
+ end
32
+
33
+ # Early handle `sessions` subcommand before OptionParser (has its own flags like --dry-run)
34
+ if ARGV[0] == "sessions"
35
+ require "samagotchi/session"
36
+ require "samagotchi/session_manager"
37
+ require "samagotchi/project_scope"
38
+ sessions_args = ARGV[1..] || []
39
+ sub = sessions_args[0]
40
+ if sub.nil? || %w[-h --help help].include?(sub)
41
+ puts "Usage: chi sessions <list|stop|delete|prune|clean> [options]"
42
+ puts " list [--sort updated_at|created_at] [--order desc|asc] [--limit N]"
43
+ puts " [--live] [--cwd PATH] [--format text|json|tsv]"
44
+ puts " --live: sessions a worker runs now (the ones chi note reaches), 10 unless --limit"
45
+ puts " --cwd PATH: sessions in PATH or below; json/tsv (id<TAB>description) are for scripts"
46
+ puts " [--scope=all]: every project's sessions; by default only this git project's (all outside a repo)"
47
+ puts " stop ID... # stop each session's worker (IDs or unique prefixes); chi --resume ID then starts a fresh one"
48
+ puts " delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first"
49
+ puts " prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]"
50
+ puts " clean [--dry-run] [--days N] # test sessions (SAMAGOTCHI_ENV=test, CI): all of them, or those older than N days"
51
+ puts "Defaults: days=14 keep=500 keep_status=running (env overrides: SAMAGOTCHI_SESSION_RETENTION_DAYS etc.)"
52
+ exit 0
53
+ end
54
+ dry_run = sessions_args.include?("--dry-run")
55
+ test_only = sessions_args.include?("--test-only") || sessions_args.include?("--test")
56
+ days = nil
57
+ keep = nil
58
+ keep_status = nil
59
+ sort = nil
60
+ order = nil
61
+ limit = nil
62
+ live = sessions_args.include?("--live")
63
+ cwd = nil
64
+ format = nil
65
+ scope = nil
66
+ sessions_args.each_with_index do |arg, i|
67
+ nxt = sessions_args[i+1]
68
+ if arg == "--days" && nxt then days = nxt.to_i
69
+ elsif arg.start_with?("--days=") then days = arg.split("=",2).last.to_i
70
+ elsif arg == "--keep" && nxt then keep = nxt.to_i
71
+ elsif arg.start_with?("--keep=") then keep = arg.split("=",2).last.to_i
72
+ elsif arg == "--keep-status" && nxt then keep_status = nxt
73
+ elsif arg.start_with?("--keep-status=") then keep_status = arg.split("=",2).last
74
+ elsif arg == "--sort" && nxt then sort = nxt
75
+ elsif arg.start_with?("--sort=") then sort = arg.split("=",2).last
76
+ elsif arg == "--order" && nxt then order = nxt
77
+ elsif arg.start_with?("--order=") then order = arg.split("=",2).last
78
+ elsif arg == "--limit" && nxt then limit = nxt.to_i
79
+ elsif arg.start_with?("--limit=") then limit = arg.split("=",2).last.to_i
80
+ elsif arg == "--cwd" && nxt then cwd = nxt
81
+ elsif arg.start_with?("--cwd=") then cwd = arg.split("=",2).last
82
+ elsif arg == "--format" && nxt then format = nxt
83
+ elsif arg.start_with?("--format=") then format = arg.split("=",2).last
84
+ elsif arg == "--scope" && nxt then scope = nxt
85
+ elsif arg.start_with?("--scope=") then scope = arg.split("=",2).last
86
+ end
87
+ end
88
+ case sub
89
+ when "delete"
90
+ require "samagotchi/session_delete_command"
91
+ exit Samagotchi::SessionDeleteCommand.new(sessions_args[1..]).run
92
+ when "list"
93
+ unless format.nil? || %w[text json tsv].include?(format)
94
+ warn "Unknown format #{format.inspect}: use --format text|json|tsv"
95
+ exit 1
96
+ end
97
+ unless scope.nil? || %w[project all].include?(scope)
98
+ warn "Unknown scope #{scope.inspect}: use --scope=project|all"
99
+ exit 1
100
+ end
101
+ # The current git project's sessions (Samagotchi::ProjectScope), like
102
+ # chi web's; --scope=all, a folder in no repo or an explicit --cwd: every
103
+ # project's.
104
+ project = scope == "all" || cwd ? nil : Samagotchi::ProjectScope.root_for(Dir.pwd)
105
+ scope_note = ->(count) { "#{count} session(s) in #{File.basename(project)} (--scope=all: every project)" }
106
+ # The picker (chi note from a script): filters apply before the limit,
107
+ # and test runs stay out.
108
+ if live || cwd || (format && format != "text")
109
+ summaries = Samagotchi::SessionManager.session_summaries(
110
+ live: live, cwd: cwd, limit: limit || (live ? 10 : nil), include_tests: false, project_root: project
111
+ )
112
+ case format
113
+ when "json"
114
+ # owner: "worker", "tui" (a chi REPL: it takes no notes or messages)
115
+ # or nil; recap: the saved recap's first sentence, or nil; project:
116
+ # its git project's root, or nil
117
+ # parent_id: the session that delegated it (the delegate tool), or nil
118
+ keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id]
119
+ puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
120
+ when "tsv"
121
+ summaries.each { |summary| puts "#{summary[:id]}\t#{summary[:desc]}" }
122
+ else
123
+ summaries.each do |summary|
124
+ state = summary[:busy] ? "running" : (summary[:live] ? "live" : summary[:status].to_s)
125
+ # The saved recap's first sentence says more than the last prompt
126
+ # (text only: tsv and json keep desc for pickers).
127
+ desc = summary[:desc]
128
+ if summary[:recap]
129
+ desc = [File.basename(summary[:cwd].to_s), summary[:recap]].reject(&:empty?).join(" · ")
130
+ desc = "#{desc[0, 59]}…" if desc.length > 60
131
+ end
132
+ # A delegated session points at its parent.
133
+ child = summary[:parent_short_id] ? " ↳ #{summary[:parent_short_id]}" : ""
134
+ puts "#{summary[:id]} #{state.ljust(8)} #{summary[:updated_at]} #{desc}#{child}"
135
+ end
136
+ if project
137
+ puts "#{summaries.empty? ? "" : "\n"}#{scope_note.call(summaries.size)}"
138
+ else
139
+ puts summaries.empty? ? "No sessions." : "\n#{summaries.size} session(s)"
140
+ end
141
+ end
142
+ exit 0
143
+ end
144
+ sort ||= "updated_at"
145
+ order ||= "desc"
146
+ sessions = Samagotchi::SessionManager.list_sessions(sort: sort, order: order, limit: limit, project_root: project)
147
+ # The saved recap's first sentence, else the last prompt, cut as before.
148
+ state_dir = Samagotchi::Session.default_state_dir
149
+ list_text = lambda do |s|
150
+ recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
151
+ (recap || Samagotchi::SessionManager.one_line(s.last_prompt))[0, 60]
152
+ end
153
+ # A delegated session points at its parent: ↳ <parent's short id>.
154
+ row = lambda do |s|
155
+ flag = s.test_run ? " [test]" : ""
156
+ child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
157
+ "#{s.id} #{s.status.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
158
+ end
159
+ if project
160
+ sessions.each { |s| puts row.call(s) }
161
+ puts "#{sessions.empty? ? "" : "\n"}#{scope_note.call(sessions.size)}"
162
+ elsif sessions.empty?
163
+ puts "No sessions."
164
+ else
165
+ sessions.each { |s| puts row.call(s) }
166
+ puts "\n#{sessions.size} session(s) (sort=#{sort} order=#{order})"
167
+ end
168
+ exit 0
169
+ when "stop"
170
+ ids = sessions_args[1..]
171
+ if ids.empty? || ids.any? { |arg| arg.start_with?("-") }
172
+ warn "Usage: chi sessions stop ID..."
173
+ exit 1
174
+ end
175
+ # Each id in turn, like chi sessions delete: stdout is flushed before an
176
+ # error line, so the output keeps the order of the ids given.
177
+ ok = ids.uniq.map do |given|
178
+ begin
179
+ id = Samagotchi::Session.resolve_id(given)
180
+ released = Samagotchi::SessionManager.stop_session(id, wait: 10)
181
+ rescue Samagotchi::SessionManager::OwnedByTUI
182
+ $stdout.flush
183
+ warn "session #{id} is open in a chi REPL; close it there first"
184
+ next false
185
+ rescue ArgumentError => e
186
+ $stdout.flush
187
+ warn e.message
188
+ next false
189
+ end
190
+ if released
191
+ puts "Stopped session #{id}. chi --resume #{id} starts a fresh worker."
192
+ else
193
+ $stdout.flush
194
+ warn "Stopped session #{id}, but its worker is still shutting down; try chi --resume #{id} in a moment."
195
+ end
196
+ true
197
+ end
198
+ exit(ok.all? ? 0 : 1)
199
+ when "prune", "clean"
200
+ test_only = true if sub == "clean" && !sessions_args.include?("--all")
201
+ # Test sessions are throwaway: clean takes them whatever their age,
202
+ # unless --days asks for the older ones only. Live workers and
203
+ # keep_status still protect a session.
204
+ any_age = sub == "clean" && test_only && days.nil?
205
+ result = Samagotchi::SessionManager.prune_sessions(days: days, max_count: keep, keep_status: keep_status, dry_run: dry_run,
206
+ test_only: test_only, any_age: any_age)
207
+ mode = dry_run ? "Would delete" : "Deleted"
208
+ puts "#{mode} #{result[:deleted].size} sessions (kept #{result[:kept].size}, skipped #{result[:skipped].size})"
209
+ if result[:deleted].any?
210
+ result[:deleted].each { |id| puts " #{id}" }
211
+ end
212
+ if dry_run && result[:deleted].any?
213
+ puts "\nRun without --dry-run to delete."
214
+ end
215
+ exit 0
216
+ else
217
+ warn "Unknown sessions subcommand: #{sub}. Use: list, stop, delete, prune, clean"
218
+ exit 1
219
+ end
220
+ end
221
+
222
+ # `chi note`: a context note for sessions, before OptionParser (-m, --source, --all are its own)
223
+ if ARGV[0] == "note"
224
+ require "samagotchi/note_command"
225
+ exit Samagotchi::NoteCommand.new(ARGV[1..] || []).run
226
+ end
227
+
228
+ # `chi send`: a message into sessions as the user's, before OptionParser (-m is its own)
229
+ if ARGV[0] == "send"
230
+ require "samagotchi/send_command"
231
+ exit Samagotchi::SendCommand.new(ARGV[1..] || []).run
232
+ end
233
+
234
+ # `chi desktop`: the native "Send to chi" helper, before OptionParser (its own flags)
235
+ if ARGV[0] == "desktop"
236
+ require "samagotchi/desktop_command"
237
+ exit Samagotchi::DesktopCommand.new(ARGV[1..] || []).run
238
+ end
239
+
240
+ # Handle `bundle` subcommand before OptionParser (has its own flags like --scope, --force)
241
+ if ARGV[0] == "bundle"
242
+ require "samagotchi/memory_bundle"
243
+ bundle_args = ARGV[1..] || []
244
+ sub = bundle_args[0]
245
+ if sub.nil? || %w[-h --help help].include?(sub)
246
+ puts "Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build> [options]"
247
+ puts ""
248
+ puts " install <source> [--scope system|project] [--force]"
249
+ puts " Install a memory bundle from a directory, zip, tar archive, or git URL."
250
+ puts " Source is the path/URL to the bundle (dir/zip/tar.gz/git)."
251
+ puts " --force overwrites existing entries; default skips them."
252
+ puts ""
253
+ puts " upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]"
254
+ puts " Upgrade a bundle (3-way merge: auto-merge if not edited, conflict otherwise)."
255
+ puts " --dry-run shows what would change. --agent launches interactive session on conflict."
256
+ puts ""
257
+ puts " uninstall <bundle> [--scope system|project] [--force]"
258
+ puts " Remove a bundle and its index entries (--force if locally edited)."
259
+ puts ""
260
+ puts " status [<bundle>]"
261
+ puts " Show provenance + modification status for bundles."
262
+ puts ""
263
+ puts " diff <bundle> [file]"
264
+ puts " Show diffs between base/current/incoming for a bundle."
265
+ puts ""
266
+ puts " list"
267
+ puts " List installed bundles and the ones shipped with chi that aren't installed."
268
+ puts ""
269
+ puts " build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]"
270
+ puts " Build local memories and installed hooks (and plugin) into a shareable bundle (dir or zip)."
271
+ puts " --scope selects source dir (default: system). --out inferred from extension; default <name>.zip."
272
+ puts " FILES... optional allowlist of *.md basenames to include (default: all)."
273
+ exit 0
274
+ end
275
+
276
+ # helpers for bundle_name derivation
277
+ def chi_expand_source(src)
278
+ return src if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(src)
279
+ Samagotchi::MemoryBundle::SourceNormalizer.shipped_bundle_dir(src) || File.expand_path(src)
280
+ end
281
+
282
+ def chi_bundle_name_for(expanded_src)
283
+ # Try to derive from manifest if possible (skip git clone for speed if possible)
284
+ if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(expanded_src)
285
+ # For git URLs, avoid double clone: derive name from URL basename if no manifest yet
286
+ # Let Installer do the real manifest read; fallback to URL basename
287
+ base = expanded_src.split("/").last.to_s.split("#").first.to_s
288
+ base = base.sub(/\.git\z/i, "")
289
+ return base.empty? ? "bundle" : base
290
+ end
291
+ require "yaml"
292
+ nd = nil
293
+ owned = false
294
+ begin
295
+ nd, owned = Samagotchi::MemoryBundle::SourceNormalizer.normalize(expanded_src)
296
+ m = Samagotchi::MemoryBundle::Manifest.read(dir: nd)
297
+ return m.name
298
+ rescue Samagotchi::MemoryBundle::Manifest::ValidationError
299
+ # Fallback to source basename without archive extensions
300
+ src = expanded_src
301
+ name = File.basename(src)
302
+ name = name.sub(/\.tar\.gz\z/i, "").sub(/\.tgz\z/i, "").sub(/\.zip\z/i, "").sub(/\.tar\z/i, "")
303
+ return name.empty? ? File.basename(nd || src) : name
304
+ ensure
305
+ Samagotchi::MemoryBundle::SourceNormalizer.cleanup(nd) if owned
306
+ end
307
+ end
308
+
309
+ def chi_build_conflict_prompt(bundle_name, conflicts, expanded_source)
310
+ lines = []
311
+ lines << "Memory bundle upgrade has conflicts that need interactive resolution."
312
+ lines << "Bundle: #{bundle_name}"
313
+ lines << "Source: #{expanded_source}"
314
+ lines << "Conflicts (#{conflicts.size} file(s)):"
315
+ conflicts.each do |file_key, info|
316
+ lines << " - #{file_key}"
317
+ lines << " base: #{info[:base]} (provenance snapshot)"
318
+ lines << " current: #{info[:current]} (on-disk, has local edits)"
319
+ lines << " incoming: #{info[:incoming]} (new bundle version)"
320
+ begin
321
+ if File.exist?(info[:current]) && File.exist?(info[:incoming])
322
+ base_content = File.exist?(info[:base]) ? File.read(info[:base]) : ""
323
+ cur = File.read(info[:current])
324
+ inc = File.read(info[:incoming])
325
+ lines << " --- current vs incoming preview ---"
326
+ # simple line diff preview (first 40 lines)
327
+ cur_lines = cur.lines
328
+ inc_lines = inc.lines
329
+ max = [cur_lines.size, inc_lines.size].max
330
+ preview = []
331
+ max.times do |i|
332
+ break if preview.size >= 30
333
+ c = cur_lines[i]
334
+ n = inc_lines[i]
335
+ if c != n
336
+ preview << "- #{c.chomp}" if c
337
+ preview << "+ #{n.chomp}" if n
338
+ end
339
+ end
340
+ lines.concat(preview)
341
+ lines << " --- end preview ---"
342
+ end
343
+ rescue => _e
344
+ end
345
+ end
346
+ lines << ""
347
+ lines << "Your task: resolve each conflict by editing the file in place using the memory_write tool."
348
+ lines << "Use memory_read to inspect current content if needed. The correct scope for memory_write is the bundle's scope."
349
+ lines << "When all conflicts are resolved, type /exit to finish. If you abort, the upgrade will not be applied."
350
+ lines.join("\n")
351
+ end
352
+
353
+ case sub
354
+ when "install"
355
+ rest = bundle_args[1..]
356
+ source = nil
357
+ scope = nil
358
+ force = false
359
+ i = 0
360
+ while i < rest.size
361
+ arg = rest[i]
362
+ if arg.start_with?("--")
363
+ nxt = rest[i + 1]
364
+ if %w[-h --help help].include?(arg)
365
+ puts "Usage: chi bundle install <source> [--scope system|project] [--force]"
366
+ puts ""
367
+ puts " install <source> [--scope system|project] [--force]"
368
+ puts " Install a memory bundle from a directory, zip, tar archive, or git URL,"
369
+ puts " or a bundle shipped with chi by name (see: chi bundle list)."
370
+ puts " --force overwrites existing entries; default skips them."
371
+ exit 0
372
+ elsif arg == "--scope" && nxt
373
+ scope = nxt
374
+ i += 2
375
+ elsif arg.start_with?("--scope=")
376
+ scope = arg.split("=", 2).last
377
+ i += 1
378
+ elsif arg == "--force"
379
+ force = true
380
+ i += 1
381
+ else
382
+ warn "Unknown bundle install flag: #{arg}"
383
+ exit 1
384
+ end
385
+ else
386
+ source = arg
387
+ i += 1
388
+ end
389
+ end
390
+ if source.nil? || source.empty?
391
+ warn "Usage: chi bundle install <source> [--scope system|project] [--force]"
392
+ exit 1
393
+ end
394
+ expanded_source = chi_expand_source(source)
395
+ bundle_name = chi_bundle_name_for(expanded_source)
396
+ installer = Samagotchi::MemoryBundle::Installer.new(
397
+ source: expanded_source,
398
+ name: bundle_name,
399
+ scope: scope,
400
+ force: force,
401
+ strict: true
402
+ )
403
+ begin
404
+ _nd, manifest = installer.run
405
+ puts installer.summary
406
+ if manifest && manifest.hooks && !manifest.hooks.empty?
407
+ hook_cnt = manifest.hooks.size
408
+ puts "Hooks: #{hook_cnt} hook(s) (#{manifest.hooks.keys.join(', ')})"
409
+ end
410
+ puts "Plugin: #{manifest.plugin[:file]} (loads at the next chi start)" if manifest&.plugin
411
+ puts "Provenance written to: #{Samagotchi::MemoryBundle::Provenance.bundles_dir}/#{bundle_name}/" if manifest
412
+ exit 0
413
+ rescue Samagotchi::MemoryBundle::Installer::InstallError => e
414
+ warn "Install failed: #{e.message}"
415
+ exit 1
416
+ end
417
+
418
+ when "upgrade"
419
+ rest = bundle_args[1..]
420
+ source = nil
421
+ scope = nil
422
+ force = false
423
+ dry_run = false
424
+ agent = nil
425
+ i = 0
426
+ while i < rest.size
427
+ arg = rest[i]
428
+ if arg.start_with?("--")
429
+ nxt = rest[i + 1]
430
+ if %w[-h --help help].include?(arg)
431
+ puts "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]"
432
+ exit 0
433
+ elsif arg == "--scope" && nxt
434
+ scope = nxt; i += 2
435
+ elsif arg.start_with?("--scope=")
436
+ scope = arg.split("=", 2).last; i += 1
437
+ elsif arg == "--force"
438
+ force = true; i += 1
439
+ elsif arg == "--dry-run"
440
+ dry_run = true; i += 1
441
+ elsif arg == "--agent"
442
+ agent = true; i += 1
443
+ elsif arg == "--no-agent"
444
+ agent = false; i += 1
445
+ else
446
+ warn "Unknown bundle upgrade flag: #{arg}"
447
+ exit 1
448
+ end
449
+ else
450
+ source = arg; i += 1
451
+ end
452
+ end
453
+ if source.nil? || source.empty?
454
+ warn "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run]"
455
+ exit 1
456
+ end
457
+ expanded_source = chi_expand_source(source)
458
+ bundle_name = chi_bundle_name_for(expanded_source)
459
+ provenance = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name)
460
+ unless provenance.installed?
461
+ warn "Bundle '#{bundle_name}' not installed — falling back to install"
462
+ installer = Samagotchi::MemoryBundle::Installer.new(source: expanded_source, name: bundle_name, scope: scope, force: force, strict: true)
463
+ begin
464
+ _nd, manifest = installer.run
465
+ puts installer.summary
466
+ puts "Provenance written to: #{Samagotchi::MemoryBundle::Provenance.bundles_dir}/#{bundle_name}/" if manifest
467
+ exit 0
468
+ rescue Samagotchi::MemoryBundle::Installer::InstallError => e
469
+ warn "Install failed: #{e.message}"; exit 1
470
+ end
471
+ end
472
+ installer = Samagotchi::MemoryBundle::Installer.new(
473
+ source: expanded_source,
474
+ name: bundle_name,
475
+ scope: scope,
476
+ force: force,
477
+ strict: true,
478
+ upgrade: true,
479
+ dry_run: dry_run
480
+ )
481
+ begin
482
+ _nd, manifest = installer.run
483
+ puts installer.summary
484
+ if manifest && manifest.hooks && !manifest.hooks.empty?
485
+ puts "Hooks: #{manifest.hooks.size} hook(s) (#{manifest.hooks.keys.join(', ')})"
486
+ end
487
+ puts "Plugin: #{manifest.plugin[:file]} (loads at the next chi start)" if manifest&.plugin && !dry_run
488
+ if dry_run
489
+ puts "(dry-run: no changes written)"
490
+ exit 0
491
+ end
492
+ if installer.conflicts.any? && !force
493
+ puts "\n#{installer.conflicts.size} conflict(s) need resolution."
494
+ installer.conflicts.each { |k, _| puts " conflict: #{k}" }
495
+ # Decide whether to launch agent
496
+ launch = false
497
+ if agent == false
498
+ launch = false
499
+ elsif agent == true
500
+ launch = true
501
+ elsif STDIN.tty?
502
+ print "Conflicts detected — launch interactive agent to resolve? [y/N] "
503
+ ans = begin; $stdin.gets; rescue => _e; nil; end
504
+ launch = ans && ans.strip.downcase.start_with?("y")
505
+ else
506
+ puts "Non-interactive terminal — aborting. Re-run with --force to overwrite or --agent in a TTY."
507
+ exit 2
508
+ end
509
+ if launch
510
+ prompt = chi_build_conflict_prompt(bundle_name, installer.conflicts, expanded_source)
511
+ puts "Launching interactive session for conflict resolution… (/exit when done)"
512
+ require "samagotchi/terminal_ui"
513
+ Samagotchi::TerminalUI.new(prompt: prompt).run
514
+ # After session, re-check if conflicts resolved by re-reading current files vs incoming
515
+ # Simple heuristic: if user edited files, current != base now but we assume resolved if file exists
516
+ # Re-run upgrade without conflicts? User already edited in place, so we need to write provenance now
517
+ # Update provenance to reflect resolved state
518
+ if manifest
519
+ # Re-collect provenance files from target dir for this bundle
520
+ prov_files = {}
521
+ target_scope = scope || provenance.read[:scope]&.to_s || "system"
522
+ target_dir = Samagotchi::MemoryBundle::Installer.new(source: expanded_source, name: bundle_name, scope: target_scope).send(:resolve_target_dir, target_scope)
523
+ # Actually reuse provenance write with current target files for bundle keys
524
+ bundle_files = installer.conflicts.keys + installer.results.select { |_, r| %w[installed updated].include?(r[:status]) }.keys
525
+ # Fallback: collect all md that exist and were in installer results
526
+ all_keys = installer.results.keys
527
+ all_keys.each do |k|
528
+ p = File.join(target_dir, k)
529
+ prov_files[k] = p if File.exist?(p)
530
+ end
531
+ if prov_files.any?
532
+ Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).write(
533
+ files: prov_files,
534
+ scope: target_scope,
535
+ version: manifest.version,
536
+ source_path: expanded_source
537
+ )
538
+ puts "Provenance updated after interactive resolution."
539
+ end
540
+ end
541
+ puts "Upgrade resolved interactively."
542
+ exit 0
543
+ else
544
+ puts "Upgrade aborted due to conflicts. Resolve manually or re-run with --force."
545
+ exit 2
546
+ end
547
+ end
548
+ puts "Provenance written to: #{Samagotchi::MemoryBundle::Provenance.bundles_dir}/#{bundle_name}/" if manifest
549
+ exit 0
550
+ rescue Samagotchi::MemoryBundle::Installer::InstallError => e
551
+ warn "Upgrade failed: #{e.message}"; exit 1
552
+ end
553
+
554
+ when "uninstall"
555
+ rest = bundle_args[1..]
556
+ bundle_name = nil
557
+ scope = nil
558
+ force = false
559
+ i = 0
560
+ while i < rest.size
561
+ arg = rest[i]
562
+ if arg.start_with?("--")
563
+ nxt = rest[i + 1]
564
+ if %w[-h --help help].include?(arg)
565
+ puts "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
566
+ exit 0
567
+ elsif arg == "--scope" && nxt
568
+ scope = nxt; i += 2
569
+ elsif arg.start_with?("--scope=")
570
+ scope = arg.split("=", 2).last; i += 1
571
+ elsif arg == "--force"
572
+ force = true; i += 1
573
+ else
574
+ warn "Unknown bundle uninstall flag: #{arg}"; exit 1
575
+ end
576
+ else
577
+ bundle_name = arg; i += 1
578
+ end
579
+ end
580
+ if bundle_name.nil? || bundle_name.empty?
581
+ warn "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
582
+ exit 1
583
+ end
584
+ # Propagate overrides
585
+ Samagotchi::MemoryBundle::Uninstaller.system_dir_override = Samagotchi::MemoryBundle::Installer.system_dir_override
586
+ Samagotchi::MemoryBundle::Uninstaller.project_dir_base_override = Samagotchi::MemoryBundle::Installer.project_dir_base_override
587
+ uninstaller = Samagotchi::MemoryBundle::Uninstaller.new(name: bundle_name, scope: scope, force: force)
588
+ begin
589
+ uninstaller.run
590
+ puts "Uninstalled bundle '#{bundle_name}'"
591
+ puts "Removed: #{uninstaller.removed_files.join(', ')}" unless uninstaller.removed_files.empty?
592
+ hook_removed = uninstaller.removed_files.count { |f| f.start_with?("hooks/") }
593
+ puts "Hooks removed: #{hook_removed}" if hook_removed > 0
594
+ uninstaller.warnings.each { |w| puts w }
595
+ exit 0
596
+ rescue Samagotchi::MemoryBundle::Uninstaller::UninstallError => e
597
+ warn "Uninstall failed: #{e.message}"; exit 1
598
+ end
599
+
600
+ when "status"
601
+ rest = bundle_args[1..]
602
+ name = rest.find { |a| !a.start_with?("--") }
603
+ require "json"
604
+ if name
605
+ st = Samagotchi::MemoryBundle::Status.bundle_status(name)
606
+ unless st
607
+ puts "Bundle '#{name}' not installed."
608
+ exit 0
609
+ end
610
+ puts "Bundle: #{name} v#{st[:provenance][:version]} scope=#{st[:scope]} installed=#{st[:provenance][:installed_at]}"
611
+ puts "Target: #{st[:target_dir]}"
612
+ st[:files].each do |k, info|
613
+ mods = []
614
+ mods << "modified" if info[:modified]
615
+ mods << "missing" if info[:missing]
616
+ mods << "no-index" unless info[:index_present]
617
+ label = mods.empty? ? "ok" : mods.join(",")
618
+ puts " #{k}: #{label}"
619
+ end
620
+ # Hooks
621
+ hooks = st[:provenance][:hooks] || {}
622
+ trust = st[:provenance][:trust_level] || "experimental"
623
+ commit = st[:provenance][:source_commit]
624
+ puts " trust_level: #{trust}" if hooks.any? || trust
625
+ puts " source_commit: #{commit}" if commit
626
+ if hooks.any?
627
+ puts " Hooks (#{hooks.size}):"
628
+ hooks.each do |hk, meta|
629
+ m = meta.is_a?(Hash) ? meta.transform_keys(&:to_s) : {}
630
+ event = m["event"] || ""
631
+ on_error = m["on_error"] || "skip"
632
+ prio = m["priority"] || 100
633
+ hook_path = File.join(Samagotchi::MemoryBundle::Provenance.new(name: name).hooks_dir, hk.to_s)
634
+ exists = File.exist?(hook_path) ? "ok" : "missing"
635
+ puts " #{hk}: event=#{event} on_error=#{on_error} priority=#{prio} [#{exists}]"
636
+ end
637
+ end
638
+ if (plugin = st[:plugin])
639
+ note = plugin[:state] == "modified" ? " (edited after install: not loaded; reinstall the bundle)" : ""
640
+ puts " Plugin: #{plugin[:file]} [#{plugin[:state]}]#{note}"
641
+ puts " requires_chi: #{plugin[:requires_chi]}" if plugin[:requires_chi]
642
+ puts " not loaded: #{plugin[:requires_failure]}" if plugin[:requires_failure]
643
+ end
644
+ st[:needs].each { |need| puts " #{Samagotchi::MemoryBundle::Status.need_line(need)}" }
645
+ else
646
+ bundles_dir = Samagotchi::MemoryBundle::Provenance.bundles_dir
647
+ unless File.directory?(bundles_dir)
648
+ puts "No installed bundles."; exit 0
649
+ end
650
+ entries = Dir.entries(bundles_dir).reject { |e| e.start_with?(".") }
651
+ if entries.empty?
652
+ puts "No installed bundles."
653
+ else
654
+ entries.sort.each do |bname|
655
+ st = Samagotchi::MemoryBundle::Status.bundle_status(bname)
656
+ next unless st
657
+ mods = st[:files].values.count { |v| v[:modified] || v[:missing] }
658
+ hooks_count = (st[:provenance][:hooks] || {}).size
659
+ hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
660
+ plugin = st[:plugin]
661
+ mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
662
+ plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
663
+ puts " #{bname} v#{st[:provenance][:version]} scope=#{st[:scope]} files=#{st[:files].size}#{hook_info}#{plugin_info} issues=#{mods}"
664
+ end
665
+ end
666
+ end
667
+ exit 0
668
+
669
+ when "diff"
670
+ rest = bundle_args[1..]
671
+ args = rest.reject { |a| a.start_with?("--") }
672
+ bname = args[0]
673
+ file_arg = args[1]
674
+ if bname.nil? || bname.empty?
675
+ warn "Usage: chi bundle diff <bundle> [file]"; exit 1
676
+ end
677
+ prov = Samagotchi::MemoryBundle::Provenance.new(name: bname)
678
+ data = prov.read
679
+ unless data
680
+ warn "Bundle '#{bname}' not installed"; exit 1
681
+ end
682
+ scope = data[:scope]&.to_s || "system"
683
+ target_dir = Samagotchi::MemoryBundle::Installer.new(source: ".", name: bname, scope: scope).send(:resolve_target_dir, scope)
684
+ files = data[:files] || {}
685
+ hooks = data[:hooks] || {}
686
+ plugin_path = prov.plugin_path(data)
687
+ plugin_file = plugin_path && File.basename(plugin_path)
688
+ show_plugin = lambda do
689
+ puts "=== plugin/#{plugin_file} ==="
690
+ puts "--- base (provenance) ---"
691
+ base = prov.plugin_base_path(plugin_file)
692
+ puts File.exist?(base) ? File.read(base) : "(no base)"
693
+ puts "--- current (on-disk) ---"
694
+ puts File.exist?(plugin_path) ? File.read(plugin_path) : "(missing)"
695
+ puts "--- metadata: sha256=#{data[:plugin][:sha256]}#{data[:requires_chi] ? " requires_chi=#{data[:requires_chi]}" : ""}"
696
+ end
697
+ if file_arg
698
+ # Try file first, then hook
699
+ if files.key?(file_arg.to_sym) || files.key?(file_arg)
700
+ base = prov.base_path(file_arg)
701
+ cur = File.join(target_dir, file_arg)
702
+ puts "=== #{file_arg} ==="
703
+ puts "--- base (provenance) ---"
704
+ puts File.exist?(base) ? File.read(base) : "(no base)"
705
+ puts "--- current (on-disk) ---"
706
+ puts File.exist?(cur) ? File.read(cur) : "(missing)"
707
+ elsif hooks.key?(file_arg.to_sym) || hooks.key?(file_arg)
708
+ base = prov.base_path(file_arg)
709
+ cur = File.join(prov.hooks_dir, file_arg)
710
+ puts "=== hooks/#{file_arg} ==="
711
+ puts "--- base (provenance) ---"
712
+ puts File.exist?(base) ? File.read(base) : "(no base)"
713
+ puts "--- current (on-disk) ---"
714
+ puts File.exist?(cur) ? File.read(cur) : "(missing)"
715
+ # Show hook metadata
716
+ meta = hooks[file_arg.to_sym] || hooks[file_arg]
717
+ if meta
718
+ m = meta.transform_keys(&:to_s)
719
+ puts "--- metadata: event=#{m['event']} on_error=#{m['on_error']} priority=#{m['priority']} sha256=#{m['sha256']}"
720
+ end
721
+ elsif plugin_file && [plugin_file, "plugin/#{plugin_file}"].include?(file_arg)
722
+ show_plugin.call
723
+ else
724
+ puts "=== #{file_arg} ==="
725
+ puts "(not found in bundle)"
726
+ end
727
+ puts ""
728
+ else
729
+ keys = files.keys.map(&:to_s)
730
+ keys.each do |k|
731
+ base = prov.base_path(k)
732
+ cur = File.join(target_dir, k)
733
+ puts "=== #{k} ==="
734
+ puts "--- base (provenance) ---"
735
+ puts File.exist?(base) ? File.read(base) : "(no base)"
736
+ puts "--- current (on-disk) ---"
737
+ puts File.exist?(cur) ? File.read(cur) : "(missing)"
738
+ puts ""
739
+ end
740
+ # Hooks diff
741
+ hooks.keys.map(&:to_s).each do |k|
742
+ base = prov.base_path(k)
743
+ cur = File.join(prov.hooks_dir, k)
744
+ puts "=== hooks/#{k} ==="
745
+ puts "--- base (provenance) ---"
746
+ puts File.exist?(base) ? File.read(base) : "(no base)"
747
+ puts "--- current (on-disk) ---"
748
+ puts File.exist?(cur) ? File.read(cur) : "(missing)"
749
+ meta = hooks[k.to_sym] || hooks[k]
750
+ if meta
751
+ m = meta.transform_keys(&:to_s)
752
+ puts "--- metadata: event=#{m['event']} on_error=#{m['on_error']} priority=#{m['priority']} sha256=#{m['sha256']}"
753
+ end
754
+ puts ""
755
+ end
756
+ if plugin_file
757
+ show_plugin.call
758
+ puts ""
759
+ end
760
+ end
761
+ exit 0
762
+
763
+ when "list"
764
+ listing = Samagotchi::MemoryBundle::Listing
765
+ shipped = listing.shipped
766
+ installed = listing.installed(shipped: shipped)
767
+ available = listing.available(shipped: shipped, installed: installed)
768
+
769
+ # One column width for both sections, so the names line up.
770
+ name_w = (installed.map { |b| b.name.length } + available.map { |s| s.source.length }).max.to_i
771
+ ver_w = (installed.map { |b| (b.version || "?").to_s.length } + available.map { |s| s.version.length }).max.to_i + 1
772
+
773
+ if installed.empty?
774
+ puts "No installed bundles."
775
+ else
776
+ puts "Installed:"
777
+ installed.each do |b|
778
+ next puts(" #{b.name.ljust(name_w)} (#{b.error})") if b.error
779
+ line = " #{b.name.ljust(name_w)} #{"v#{b.version || "?"}".ljust(ver_w)} scope=#{b.scope || "?"} files=#{b.files} installed=#{b.installed_at || "?"}"
780
+ line += " (shipped v#{b.upgrade.version}: chi bundle upgrade #{b.upgrade.source})" if b.upgrade
781
+ puts line
782
+ end
783
+ end
784
+ unless available.empty?
785
+ puts ""
786
+ puts "Available (shipped with chi, install with: chi bundle install <name>):"
787
+ available.each do |s|
788
+ puts " #{s.source.ljust(name_w)} #{"v#{s.version}".ljust(ver_w)} #{s.description}".rstrip
789
+ end
790
+ end
791
+
792
+ when "build"
793
+ rest = bundle_args[1..]
794
+ scope = nil
795
+ name = nil
796
+ version = nil
797
+ description = ""
798
+ out = nil
799
+ filter_files = []
800
+ i = 0
801
+ while i < rest.size
802
+ arg = rest[i]
803
+ if arg.start_with?("--")
804
+ nxt = rest[i + 1]
805
+ if %w[-h --help help].include?(arg)
806
+ puts "Usage: chi bundle build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]"
807
+ puts ""
808
+ puts " build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]"
809
+ puts " Build local memories and installed hooks into a shareable bundle (dir or zip)."
810
+ puts " --scope selects source dir (default: system). --out inferred from extension; default <name>.zip."
811
+ puts " FILES... optional allowlist of *.md basenames to include (default: all)."
812
+ puts ""
813
+ puts " Examples:"
814
+ puts " chi bundle build --scope system"
815
+ puts " chi bundle build --scope project --name my-bundle --version 1.0.0 --out bundle.zip"
816
+ puts " chi bundle build --scope system --out ./my-bundle/ identity.md work.md"
817
+ exit 0
818
+ elsif arg == "--scope" && nxt && !nxt.start_with?("--")
819
+ scope = nxt; i += 2
820
+ elsif arg.start_with?("--scope=")
821
+ scope = arg.split("=", 2).last; i += 1
822
+ elsif arg == "--name" && nxt && !nxt.start_with?("--")
823
+ name = nxt; i += 2
824
+ elsif arg.start_with?("--name=")
825
+ name = arg.split("=", 2).last; i += 1
826
+ elsif arg == "--version" && nxt && !nxt.start_with?("--")
827
+ version = nxt; i += 2
828
+ elsif arg.start_with?("--version=")
829
+ version = arg.split("=", 2).last; i += 1
830
+ elsif arg == "--description" && nxt && !nxt.start_with?("--")
831
+ description = nxt; i += 2
832
+ elsif arg.start_with?("--description=")
833
+ description = arg.split("=", 2).last; i += 1
834
+ elsif arg == "--out" && nxt && !nxt.start_with?("--")
835
+ out = nxt; i += 2
836
+ elsif arg.start_with?("--out=")
837
+ out = arg.split("=", 2).last; i += 1
838
+ else
839
+ warn "Unknown bundle build flag: #{arg}"
840
+ exit 1
841
+ end
842
+ else
843
+ filter_files << arg
844
+ i += 1
845
+ end
846
+ end
847
+ # Validate scope if given
848
+ if scope && !%w[system project].include?(scope.to_s.strip.downcase)
849
+ warn "Invalid scope '#{scope}', expected system or project"
850
+ exit 1
851
+ end
852
+ filter_files = nil if filter_files.empty?
853
+ begin
854
+ builder = Samagotchi::MemoryBundle::Builder.new(
855
+ scope: scope,
856
+ name: name,
857
+ version: version,
858
+ description: description,
859
+ out: out,
860
+ files: filter_files
861
+ )
862
+ result = builder.run
863
+ puts "Built #{result[:files].size} file(s) to #{result[:out_path]}"
864
+ puts "Bundle: #{result[:name]} v#{result[:version]} scope=#{result[:scope]}"
865
+ puts "Files: #{result[:files].join(', ')}" unless result[:files].empty?
866
+ unless result[:placeholder_warnings].empty?
867
+ puts "Placeholders:"
868
+ result[:placeholder_warnings].each { |w| puts " #{w}" }
869
+ end
870
+ exit 0
871
+ rescue Samagotchi::MemoryBundle::Builder::BuildError => e
872
+ warn "Build failed: #{e.message}"
873
+ exit 1
874
+ end
875
+
876
+ else
877
+ warn "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build"
878
+ exit 1
879
+ end
880
+
881
+ exit 0
882
+ end
883
+
884
+ options = { verbose: false, no_interrupt: false, non_interactive: false, no_default_input: false, web: false, web_port: nil, web_open: false, web_markdown: nil, web_turn_view: nil }
885
+ cli_overrides = {}
886
+
887
+ if ARGV.any? { |arg| arg == "--backend" || arg.start_with?("--backend=") }
888
+ warn "Error: --backend was removed. A host's api: in config.yml picks the loop " \
889
+ "(api: openai for the chat API; see docs/configuration.md)."
890
+ exit 1
891
+ end
892
+
893
+ # Strict kebab enforcement: --recap_base_url is unknown (OptionParser would otherwise accept _ as -)
894
+ ARGV.each do |arg|
895
+ next unless arg.start_with?("--") && arg.include?("_")
896
+ # Allow --no-interrupt etc. already dash; underscore is always rejected for config flags
897
+ # We check raw ARGV before OptionParser normalizes
898
+ base = arg.split("=", 2).first
899
+ # If dashed version is a known flag (registry or hardcoded), reject underscore variant
900
+ dashed = base.tr("_", "-")
901
+ known = %w[--prompt --resume --attach --shared --no-shared --model --memory --mute --no-interrupt --non-interactive --verbose --no-default-input --port --open] + Samagotchi::Config.cli_entries.map(&:cli_flag)
902
+ if known.include?(dashed) || known.include?(base)
903
+ warn "Unknown option: #{arg} (did you mean #{dashed}?)"
904
+ exit 1
905
+ end
906
+ end
907
+
908
+ parser = OptionParser.new do |opts|
909
+ opts.banner = <<~BANNER
910
+ Usage: chi [options]
911
+ chi web [--port PORT] [--open] [--scope=all] the web UI
912
+ chi sessions <list|stop|delete|prune|clean>
913
+ chi note [--source NAME] [-m TEXT] ID...|--all background context for sessions
914
+ chi send [-m TEXT] ID... a message into sessions, as if typed there
915
+ chi bundle <install|upgrade|uninstall|status|diff|list|build>
916
+ chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
917
+ chi self version, paths, model and bundles
918
+ (each subcommand takes --help)
919
+
920
+ BANNER
921
+ opts.on("-p", "--prompt PROMPT", "Run PROMPT as the first turn, then stay in the REPL") { |p| options[:prompt] = p }
922
+ opts.on("--resume SESSION_ID", "Resume a previous session by its ID (or a unique prefix of it)") { |s| options[:resume] = s }
923
+ opts.on("--attach SESSION_ID", "Attach to a session's worker, waking one if it has exited; ID or a unique prefix") { |s| options[:attach] = s }
924
+ opts.on("--[no-]shared", "Run the session in a background worker and attach to it, so `chi web` can share it (the default, config session.shared; with --resume: that session); --no-shared runs a plain in-process REPL") { |v| options[:shared] = v }
925
+ opts.on("--model NAME", "Model selector (runtime only, overrides --resume) [alias for --default-model]") { |m| options[:model] = m; cli_overrides["default.model"] = m }
926
+ opts.on("--profile NAME", Samagotchi::ModelProfile::NAMES,
927
+ "Prompt profile for every model in this run (#{Samagotchi::ModelProfile::NAMES.join('|')}) [alias for --model-profile]") { |p| cli_overrides["model.profile"] = p }
928
+ opts.on("--memory NAME", "Preload a memory entry into the system prompt (repeatable; a comma list too)") { |m| (options[:memories] ||= []) << m }
929
+ opts.on("--mute NAME", "Hide a memory from this session: not in the prompt, refused by memory_read (repeatable; a comma list too)") { |m| (options[:muted] ||= []) << m }
930
+ opts.on("--no-interrupt", "Raise the tool call limit to 1000 iterations (useful for long tasks)") { options[:no_interrupt] = true; cli_overrides["no_interrupt"] = true }
931
+ opts.on("--non-interactive", "Run a single prompt and exit (no REPL, implies --no-interrupt)") { options[:non_interactive] = true }
932
+ opts.on("-v", "--verbose", "Print raw LLM responses and tool calls/results (runs a plain REPL, not attached)") { options[:verbose] = true }
933
+ opts.on("--no-default-input", "Skip default REPL input prefill") { options[:no_default_input] = true; cli_overrides["no_default_input"] = true }
934
+ opts.on("--port PORT", "Port for `chi web` (default: 4567, env SAMAGOTCHI_WEB_PORT)") { |p| options[:web_port] = p.to_i; cli_overrides["web.port"] = p.to_s }
935
+ opts.on("--open", "Open browser for `chi web`") { options[:web_open] = true }
936
+ opts.on("--scope VALUE", %w[project all],
937
+ "Which sessions `chi web` and `chi sessions list` show: project (the default: this folder's git project; " \
938
+ "every session outside a repo) or all") { |v| options[:scope] = v }
939
+ # Accepted values of string entries that the registry doesn't type as enums.
940
+ value_hints = { "thinking.ui" => "spinner|off", "status.line" => "on|off", "status.width_mode" => "terminal_cap|fixed",
941
+ "web.host" => "127.0.0.1|::1|localhost", "recap.sentences" => "N|N-M" }
942
+ # Universal config flags (implicit convention: ENV SAMAGOTCHI_* ↔ YAML dotted ↔ CLI --kebab)
943
+ # Generated from Samagotchi::Config registry — Option A: snake leaf in YAML, kebab in CLI.
944
+ Samagotchi::Config.cli_entries.each do |entry|
945
+ next if %w[no_interrupt no_default_input].include?(entry.key)
946
+ next if entry.key == "web.port" # already handled as --port
947
+ next if entry.key == "default.model" # alias --model above
948
+ next if %w[web.markdown web.turn_view].include?(entry.key) # handled below with explicit inverse flags
949
+ case entry.type
950
+ when :bool
951
+ # --[no-]: a bool that defaults to on is only useful switched off.
952
+ opts.on("--[no-]#{entry.cli_flag.delete_prefix('--')}", "Set #{entry.key} (bool, default #{entry.default ? 'on' : 'off'}) [env #{entry.env_key}]") { |v| cli_overrides[entry.key] = v }
953
+ when :integer, :float, :string, :enum
954
+ desc = "Set #{entry.key} [env #{entry.env_key}]"
955
+ desc += " (#{entry.enum_values.join('|')})" if entry.type == :enum
956
+ desc += " (#{value_hints[entry.key]})" if value_hints.key?(entry.key)
957
+ desc += "; only without a hosts: config" if %w[server.host server.port].include?(entry.key)
958
+ desc += "; hosts: entries without a transport use it" if entry.key == "server.transport"
959
+ if entry.type == :enum
960
+ opts.on("#{entry.cli_flag} VALUE", entry.enum_values, desc) { |v| cli_overrides[entry.key] = v }
961
+ else
962
+ opts.on("#{entry.cli_flag} VALUE", desc) { |v| cli_overrides[entry.key] = v }
963
+ end
964
+ end
965
+ end
966
+ opts.on("--web-markdown", "Render finalized LLM Markdown in `chi web` (requires optional commonmarker gem)") { options[:web_markdown] = true; cli_overrides["web.markdown"] = true }
967
+ opts.on("--no-web-markdown", "Disable Markdown rendering in `chi web`") { options[:web_markdown] = false; cli_overrides["web.markdown"] = false }
968
+ opts.on("--web-turn-view", "Show each turn in `chi web` as one block of steps (the live one at the bottom; the default); ?view=turn|chat overrides per page") { options[:web_turn_view] = true; cli_overrides["web.turn_view"] = true }
969
+ opts.on("--no-web-turn-view", "Show turns in `chi web` as the classic row of bubbles instead of the turn view") { options[:web_turn_view] = false; cli_overrides["web.turn_view"] = false }
970
+ # Strict underscore rejection: --recap_base_url etc. are not registered and will raise OptionParser::InvalidOption
971
+ opts.on("--version", "Print chi's version") { puts "chi #{Samagotchi::VERSION}"; exit }
972
+ opts.on("-h", "--help", "Show help") { puts opts; exit }
973
+ end
974
+ begin
975
+ parser.parse!
976
+ rescue OptionParser::ParseError => e
977
+ warn "Error: #{e.message} (see chi --help)"
978
+ exit 1
979
+ end
980
+
981
+ # The subcommands with their own parsers ran above; what parse! leaves is
982
+ # `web` or nothing. Anything else is a typo, not a prompt: refuse it rather
983
+ # than start a session.
984
+ stray = ARGV.first == "web" ? ARGV[1] : ARGV.first
985
+ if stray
986
+ warn "Error: #{ARGV.first == "web" ? "unexpected argument" : "unknown command"} #{stray} (see chi --help)"
987
+ exit 1
988
+ end
989
+
990
+ # Apply CLI precedence: CLI > ENV > file
991
+ unless cli_overrides.empty?
992
+ Samagotchi::Config.reload!(cli_overrides: cli_overrides)
993
+ # Keep ENV in sync for legacy readers still using ENV directly
994
+ cli_overrides.each do |k, v|
995
+ entry = Samagotchi::Config.find_by_key(k)
996
+ next unless entry&.env_exposed?
997
+ ENV[entry.env_key] = v.to_s
998
+ end
999
+ end
1000
+
1001
+ # The debug log (Samagotchi::LogPath, level log.level) for every command.
1002
+ # -v (the plain REPL only): debug records, each mirrored to stderr.
1003
+ require "samagotchi/log"
1004
+ Samagotchi::Log.configure(level: options[:verbose] ? :debug : nil, mirror: options[:verbose])
1005
+
1006
+ # Handle subcommands (support `chi web --port 4567 --open --web-markdown`)
1007
+ web_args = ARGV.dup
1008
+ case ARGV[0]
1009
+ when "web"
1010
+ options[:web] = true
1011
+ # consume subcommand and re-parse web-specific flags that may follow it
1012
+ web_args.shift
1013
+ web_args.each_with_index do |arg, i|
1014
+ if arg == "--port" && web_args[i + 1]
1015
+ options[:web_port] = web_args[i + 1].to_i
1016
+ elsif arg == "--open"
1017
+ options[:web_open] = true
1018
+ elsif arg == "--web-markdown"
1019
+ options[:web_markdown] = true
1020
+ elsif arg == "--no-web-markdown"
1021
+ options[:web_markdown] = false
1022
+ elsif arg == "--web-turn-view"
1023
+ options[:web_turn_view] = true
1024
+ elsif arg == "--no-web-turn-view"
1025
+ options[:web_turn_view] = false
1026
+ elsif arg.start_with?("--port=")
1027
+ options[:web_port] = arg.split("=", 2).last.to_i
1028
+ end
1029
+ end
1030
+ end
1031
+
1032
+ if options[:web]
1033
+ require "samagotchi/web/server"
1034
+ port = options[:web_port] || Samagotchi::Config.get("web.port") || 4567
1035
+ # legacy env fallback
1036
+ port = ENV.fetch("SAMAGOTCHI_WEB_PORT", port.to_s).to_i rescue port.to_i
1037
+ markdown = options[:web_markdown]
1038
+ markdown = Samagotchi::Config.get("web.markdown") if markdown.nil?
1039
+ turn_view = options[:web_turn_view]
1040
+ turn_view = Samagotchi::Config.get("web.turn_view") if turn_view.nil?
1041
+ exit Samagotchi::Web::Server.launch(port: port, scope: options[:scope] || "project", open_browser: options[:web_open],
1042
+ markdown: markdown, turn_view: turn_view)
1043
+ end
1044
+
1045
+ # --resume and --attach take a unique id prefix too, like git.
1046
+ begin
1047
+ %i[resume attach].each { |key| options[key] &&= Samagotchi::Session.resolve_id(options[key]) }
1048
+ rescue Samagotchi::Session::AmbiguousId => e
1049
+ warn "Error: #{e.message}"
1050
+ exit 1
1051
+ end
1052
+
1053
+ # --memory and --mute names are checked here, before the launch: a worker's
1054
+ # stderr goes nowhere, so its engine's warnings would not be seen. Warnings
1055
+ # only; the session starts either way.
1056
+ if options[:memories] || options[:muted]
1057
+ require "samagotchi/muted_memories"
1058
+ require "samagotchi/memory_bundle/system_bundle"
1059
+ begin
1060
+ Samagotchi::MemoryBundle::SystemBundle.ensure!
1061
+ rescue StandardError
1062
+ nil
1063
+ end
1064
+ memory_exists = lambda do |raw|
1065
+ scope, name = raw.split("/", 2)
1066
+ scope, name = nil, raw unless name && Samagotchi::Tools::VALID_SCOPES.include?(scope)
1067
+ !Samagotchi::Tools::MemoryRead.call(name, scope: scope).start_with?("Error:")
1068
+ end
1069
+ cli_memories = (options[:memories] || []).flat_map { |m| m.split(",") }.map(&:strip).reject(&:empty?)
1070
+ cli_memories.each { |m| warn "Warning: --memory '#{m}' not found" unless memory_exists.call(m) }
1071
+ muted = Samagotchi::MutedMemories.normalize_list(options[:muted] || [])
1072
+ baseline = Samagotchi::ConfigFile.preloaded_memories
1073
+ muted.each do |name|
1074
+ if cli_memories.any? { |m| Samagotchi::MutedMemories.normalize(m) == name }
1075
+ warn "Warning: '#{name}' is both --memory and --mute; muted"
1076
+ elsif baseline.any? { |m| Samagotchi::MutedMemories.normalize(m) == name }
1077
+ warn "Warning: '#{name}' is in config.yml memories: and --mute; muted for this session"
1078
+ elsif !memory_exists.call(name)
1079
+ warn "Warning: --mute '#{name}' matches no memory"
1080
+ end
1081
+ end
1082
+ end
1083
+
1084
+ require "samagotchi/launch_mode"
1085
+ launch, launch_note = Samagotchi::LaunchMode.resolve(options, shared_config: Samagotchi::Config.get("session.shared"))
1086
+ warn launch_note if launch_note
1087
+
1088
+ if launch == :attached
1089
+ conflict = { non_interactive: "--non-interactive", verbose: "--verbose" }
1090
+ .find { |key, _flag| options[key] }
1091
+ error = if options[:attach] && options[:shared]
1092
+ "use either --attach ID or --shared"
1093
+ elsif options[:attach] && options[:resume]
1094
+ "--attach takes the session id; --resume goes with --shared"
1095
+ elsif conflict
1096
+ "--attach/--shared can't be combined with #{conflict.last}"
1097
+ end
1098
+ if error
1099
+ warn "Error: #{error}"
1100
+ exit 1
1101
+ end
1102
+
1103
+ require "samagotchi/terminal_ui/attach_launcher"
1104
+ begin
1105
+ ended = Samagotchi::TerminalUI::AttachLauncher.run(attach: options[:attach], shared: !options[:attach], resume: options[:resume],
1106
+ prompt: options[:prompt], model: options[:model],
1107
+ no_interrupt: !!options[:no_interrupt],
1108
+ default_input: !options[:no_default_input],
1109
+ memories: options[:memories] || [],
1110
+ muted_memories: options[:muted] || [])
1111
+ rescue Samagotchi::TerminalUI::AttachLauncher::Error => e
1112
+ warn "Error: #{e.message}"
1113
+ exit 1
1114
+ end
1115
+ exit(ended == :detached ? 0 : 1)
1116
+ end
1117
+
1118
+ begin
1119
+ Samagotchi::TerminalUI.new(
1120
+ prompt: options[:prompt],
1121
+ session_id: options[:resume],
1122
+ no_interrupt: options[:no_interrupt] || options[:non_interactive],
1123
+ no_default_input: options[:no_default_input],
1124
+ memories: options[:memories] || [],
1125
+ muted_memories: options[:muted] || [],
1126
+ non_interactive: options[:non_interactive],
1127
+ model_name: options[:model]
1128
+ ).run
1129
+ rescue Samagotchi::TerminalUI::SessionBusy, Samagotchi::TerminalUI::SessionNotFound, Samagotchi::ModelProfile::MissingModel => e
1130
+ warn "Error: #{e.message}"
1131
+ exit 1
1132
+ rescue Samagotchi::LLM::ProviderError => e
1133
+ # -p --non-interactive: the turn failed; its prompt is in the session.
1134
+ warn "Error: #{e.summary}"
1135
+ exit 1
1136
+ rescue Samagotchi::ImageStore::Error => e
1137
+ # -p --non-interactive: an @path image that can't be used.
1138
+ warn "Error: #{e.message}"
1139
+ exit 1
1140
+ end