samagotchi 0.9.0 → 0.10.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +3 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/plugins.md +34 -12
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +29 -29
  11. data/lib/samagotchi/bridge/card_store.rb +4 -4
  12. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  15. data/lib/samagotchi/bridge.rb +59 -112
  16. data/lib/samagotchi/bridge_client.rb +11 -28
  17. data/lib/samagotchi/bundle_command.rb +158 -212
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  20. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  21. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  26. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/skills/plugin.rb +4 -6
  28. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  29. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/cli/command.rb +51 -0
  33. data/lib/samagotchi/cli/flags.rb +116 -0
  34. data/lib/samagotchi/config.rb +70 -55
  35. data/lib/samagotchi/config_text_edit.rb +107 -0
  36. data/lib/samagotchi/desktop_command.rb +24 -18
  37. data/lib/samagotchi/edit_preview.rb +2 -2
  38. data/lib/samagotchi/engine.rb +20 -75
  39. data/lib/samagotchi/events.rb +23 -0
  40. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  41. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  42. data/lib/samagotchi/hooks/loader.rb +23 -33
  43. data/lib/samagotchi/hooks/registry.rb +41 -0
  44. data/lib/samagotchi/hooks.rb +0 -3
  45. data/lib/samagotchi/host_registry.rb +15 -0
  46. data/lib/samagotchi/idle_reminders.rb +0 -7
  47. data/lib/samagotchi/image_store.rb +22 -0
  48. data/lib/samagotchi/kernel_loop.rb +12 -16
  49. data/lib/samagotchi/live_versions.rb +2 -13
  50. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  51. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  52. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  53. data/lib/samagotchi/memory_bundle/installer.rb +1 -27
  54. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  55. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  56. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  57. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  58. data/lib/samagotchi/memory_paths.rb +10 -0
  59. data/lib/samagotchi/note_command.rb +23 -37
  60. data/lib/samagotchi/owner_lock.rb +14 -3
  61. data/lib/samagotchi/plugin/api.rb +11 -17
  62. data/lib/samagotchi/plugin/context.rb +70 -15
  63. data/lib/samagotchi/plugin/loader.rb +2 -3
  64. data/lib/samagotchi/sampling_settings.rb +3 -5
  65. data/lib/samagotchi/self_report.rb +6 -9
  66. data/lib/samagotchi/send_command.rb +33 -52
  67. data/lib/samagotchi/session.rb +88 -92
  68. data/lib/samagotchi/session_archive_command.rb +8 -14
  69. data/lib/samagotchi/session_commands.rb +3 -21
  70. data/lib/samagotchi/session_delete_command.rb +15 -29
  71. data/lib/samagotchi/session_manager.rb +27 -13
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  74. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  75. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  76. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  77. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  78. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  79. data/lib/samagotchi/terminal_ui.rb +156 -234
  80. data/lib/samagotchi/thinking.rb +2 -3
  81. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  82. data/lib/samagotchi/tool_call_parser.rb +56 -292
  83. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/edit.rb +21 -29
  86. data/lib/samagotchi/tools/memory.rb +1 -11
  87. data/lib/samagotchi/tools/send_note.rb +1 -1
  88. data/lib/samagotchi/update_command.rb +32 -22
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/vision_support.rb +4 -5
  91. data/lib/samagotchi/web/app.rb +89 -151
  92. data/lib/samagotchi/web/public/activity.js +1 -11
  93. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  94. data/lib/samagotchi/web/public/app.js +158 -785
  95. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  96. data/lib/samagotchi/web/public/command_list.js +99 -0
  97. data/lib/samagotchi/web/public/data.js +22 -66
  98. data/lib/samagotchi/web/public/format.js +0 -23
  99. data/lib/samagotchi/web/public/index.html +0 -2
  100. data/lib/samagotchi/web/public/model_pick.js +0 -7
  101. data/lib/samagotchi/web/public/notifications.js +169 -0
  102. data/lib/samagotchi/web/public/question_cards.js +352 -0
  103. data/lib/samagotchi/web/public/session_state.js +34 -0
  104. data/lib/samagotchi/web/public/timing.js +1 -1
  105. data/lib/samagotchi/web/public/turn_events.js +11 -2
  106. data/lib/samagotchi/web/public/turn_model.js +0 -14
  107. data/lib/samagotchi/web/session_hub.rb +6 -4
  108. data/lib/samagotchi/web/session_summary.rb +5 -3
  109. data/lib/samagotchi/worker.rb +59 -53
  110. data/lib/samagotchi/worker_sidecar.rb +27 -1
  111. metadata +12 -1
@@ -17,7 +17,8 @@ module Samagotchi
17
17
  # exactly what the Bridge's minimal server expects.
18
18
  class BridgeClient
19
19
  HOST = "127.0.0.1"
20
- PROBE_TIMEOUT = 0.2
20
+ # A reply's status line; its code is group 1.
21
+ STATUS_LINE = %r{\AHTTP/1\.[01] (\d{3})}
21
22
  STREAM_CONNECT_ATTEMPTS = 3
22
23
  # Seconds #stream waits for bytes before it asks `running` again.
23
24
  STREAM_POLL = 0.5
@@ -59,24 +60,7 @@ module Samagotchi
59
60
  # @param session_dir [String]
60
61
  # @return [Integer, nil]
61
62
  def self.sidecar_port(session_dir, host: HOST)
62
- port = WorkerSidecar.read(session_dir)&.port
63
- return nil unless port&.positive?
64
-
65
- # Validate liveness: stale sidecar after worker death causes ECONNREFUSED
66
- # which surfaces as WEBrick ERROR. Probe quickly and clean up if dead.
67
- begin
68
- Socket.tcp(host, port, connect_timeout: PROBE_TIMEOUT).close
69
- rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError, IOError, StandardError
70
- begin
71
- File.unlink(WorkerSidecar.path(session_dir))
72
- rescue StandardError
73
- nil
74
- end
75
- return nil
76
- end
77
- port
78
- rescue StandardError
79
- nil
63
+ WorkerSidecar.live_port(session_dir, unlink: true, host: host)
80
64
  end
81
65
 
82
66
  # @return [BridgeClient, nil] a client for the live Bridge in +session_dir+
@@ -184,10 +168,11 @@ module Samagotchi
184
168
  post("recap", {}, read_body: true)
185
169
  end
186
170
 
187
- # POST /session/:id/cancel. 202 = requested, 409 = no active turn.
188
- # @return [Response] (status only)
171
+ # POST /session/:id/cancel. 202 = requested (body names the reason the
172
+ # Bridge took), 409 = no active turn.
173
+ # @return [Response]
189
174
  def cancel(reason:)
190
- post("cancel", { reason: reason }, read_body: false)
175
+ post("cancel", { reason: reason }, read_body: true)
191
176
  end
192
177
 
193
178
  # GET /session/:id/<path> as JSON.
@@ -197,10 +182,8 @@ module Samagotchi
197
182
  sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nConnection: close\r\n\r\n")
198
183
  response = read_reply(sock, path)
199
184
  sock.close rescue nil
200
- return nil unless response
201
-
202
- status_line = response.lines.first.to_s
203
- return nil unless status_line.include?("200")
185
+ # A 500 whose text has "200" in it is not a 200.
186
+ return nil unless response && response[STATUS_LINE, 1] == "200"
204
187
 
205
188
  body = response.split("\r\n\r\n", 2)[1] || ""
206
189
  JSON.parse(body)
@@ -278,7 +261,7 @@ module Samagotchi
278
261
  sock.write("GET /session/#{@session_id}/stream#{query} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nAccept: text/event-stream\r\n#{last_event_line}Connection: keep-alive\r\n\r\n")
279
262
  raise Errno::ETIMEDOUT if timeout && !sock.wait_readable(timeout)
280
263
 
281
- status = sock.gets.to_s[/\AHTTP\/1\.[01] (\d{3})/, 1].to_i
264
+ status = sock.gets.to_s[STATUS_LINE, 1].to_i
282
265
  while (line = sock.gets)
283
266
  break if line.strip.empty?
284
267
  end
@@ -301,7 +284,7 @@ module Samagotchi
301
284
  sock.write("POST /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nContent-Type: application/json\r\nContent-Length: #{json_body.bytesize}\r\nConnection: close\r\n\r\n#{json_body}")
302
285
  reply = read_reply(sock, path, whole: read_body)
303
286
  body = read_body ? reply.split("\r\n\r\n", 2)[1] : nil
304
- Response.new(status: reply[/\AHTTP\/1\.[01] (\d{3})/, 1].to_i, body: body)
287
+ Response.new(status: reply[STATUS_LINE, 1].to_i, body: body)
305
288
  ensure
306
289
  sock&.close rescue nil
307
290
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cli/flags"
3
4
  require_relative "memory_bundle"
4
5
 
5
6
  module Samagotchi
@@ -49,6 +50,38 @@ module Samagotchi
49
50
  TEXT
50
51
  UPGRADE_HELP = "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]"
51
52
  UNINSTALL_HELP = "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
53
+ BUILD_HELP = <<~TEXT
54
+ Usage: chi bundle build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
55
+
56
+ build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
57
+ Build local memories and installed hooks into a shareable bundle (dir or zip).
58
+ --scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
59
+ FILES... optional allowlist of *.md basenames to include (default: all).
60
+
61
+ Examples:
62
+ chi bundle build --scope system
63
+ chi bundle build --scope project --name my-bundle --version 1.0.0 --out bundle.zip
64
+ chi bundle build --scope system --out ./my-bundle/ identity.md work.md
65
+ TEXT
66
+
67
+ # The subcommands' flags: --help only; a word not starting with -- is
68
+ # a positional (install's source, -h included). --scope takes whatever
69
+ # follows it; build's value flags not a --flag.
70
+ INSTALL_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
71
+ f.value "--scope"
72
+ f.switch "--force"
73
+ end
74
+ UNINSTALL_FLAGS = INSTALL_FLAGS
75
+ UPGRADE_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
76
+ f.value "--scope"
77
+ f.switch "--force"
78
+ f.switch "--dry-run"
79
+ f.switch "--agent"
80
+ f.switch "--no-agent", key: :agent, set: false
81
+ end
82
+ BUILD_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/, dash_values: false) do |f|
83
+ %w[--scope --name --version --description --out].each { |name| f.value name }
84
+ end
52
85
 
53
86
  # @param argv [Array<String>] the arguments after "bundle"
54
87
  def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
@@ -76,174 +109,157 @@ module Samagotchi
76
109
  when "build" then build(@argv[1..])
77
110
  else
78
111
  @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build"
79
- return 1
112
+ return 2
80
113
  end
81
114
  end
82
115
 
83
116
  private
84
117
 
85
118
  def install(rest)
86
- parsed = parse_flags(rest, "install", help: INSTALL_HELP, bools: { "--force" => [:force, true] })
119
+ parsed = parse_flags(INSTALL_FLAGS, rest, "install", help: INSTALL_HELP)
87
120
  return parsed if parsed.is_a?(Integer)
88
121
 
89
- opts, source = parsed
122
+ opts, source = parsed.options, parsed.args.last
90
123
  scope = opts[:scope]
91
124
  force = opts.fetch(:force, false)
92
125
  if source.nil? || source.empty?
93
126
  @stderr.puts "Usage: chi bundle install <source> [--scope system|project] [--force]"
94
- return 1
127
+ return 2
95
128
  end
96
129
  expanded_source = expand_source(source)
97
130
  if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
98
131
  return install_profile(expanded_source, scope: scope, force: force, dry_run: false, word: "Install")
99
132
  end
100
- bundle_name = begin
101
- bundle_name_for(expanded_source)
102
- rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError => e
103
- @stderr.puts "Install failed: #{e.message}"
104
- return 1
105
- end
106
- installer = Samagotchi::MemoryBundle::Installer.new(
107
- source: expanded_source,
108
- name: bundle_name,
109
- scope: scope,
110
- force: force,
111
- strict: true
112
- )
113
- begin
114
- _nd, manifest = installer.run
115
- @stdout.puts installer.summary
116
- if manifest && manifest.hooks && !manifest.hooks.empty?
117
- hook_cnt = manifest.hooks.size
118
- @stdout.puts "Hooks: #{hook_cnt} hook(s) (#{manifest.hooks.keys.join(', ')})"
119
- end
120
- @stdout.puts "Plugin: #{manifest.plugin[:file]} (loads at the next chi start)" if manifest&.plugin
121
- @stdout.puts "Provenance written to: #{Samagotchi::MemoryBundle::Provenance.bundles_dir}/#{bundle_name}/" if manifest
122
- return 0
123
- rescue Samagotchi::MemoryBundle::Installer::InstallError => e
124
- @stderr.puts "Install failed: #{e.message}"
125
- return 1
133
+ bundle_name = bundle_name_or_fail(expanded_source, "Install") or return 1
134
+ run_installer(installer_for(expanded_source, bundle_name, scope: scope, force: force), failed: "Install") do |manifest|
135
+ print_hooks_and_plugin(manifest)
136
+ print_provenance(bundle_name, manifest)
126
137
  end
127
138
  end
128
139
 
129
140
  def upgrade(rest)
130
- parsed = parse_flags(rest, "upgrade", help: UPGRADE_HELP,
131
- bools: { "--force" => [:force, true], "--dry-run" => [:dry_run, true],
132
- "--agent" => [:agent, true], "--no-agent" => [:agent, false] })
141
+ parsed = parse_flags(UPGRADE_FLAGS, rest, "upgrade", help: UPGRADE_HELP)
133
142
  return parsed if parsed.is_a?(Integer)
134
143
 
135
- opts, source = parsed
144
+ opts, source = parsed.options, parsed.args.last
136
145
  scope = opts[:scope]
137
146
  force = opts.fetch(:force, false)
138
147
  dry_run = opts.fetch(:dry_run, false)
139
148
  agent = opts[:agent]
140
149
  if source.nil? || source.empty?
141
150
  @stderr.puts "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run]"
142
- return 1
151
+ return 2
143
152
  end
144
153
  expanded_source = expand_source(source)
145
154
  if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
146
155
  return install_profile(expanded_source, scope: scope, force: force, dry_run: dry_run, word: "Upgrade")
147
156
  end
148
- bundle_name = begin
149
- bundle_name_for(expanded_source)
150
- rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError => e
151
- @stderr.puts "Upgrade failed: #{e.message}"
152
- return 1
153
- end
154
- provenance = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name)
155
- unless provenance.installed?
157
+ bundle_name = bundle_name_or_fail(expanded_source, "Upgrade") or return 1
158
+ unless Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).installed?
156
159
  @stderr.puts "Bundle '#{bundle_name}' not installed — falling back to install"
157
- installer = Samagotchi::MemoryBundle::Installer.new(source: expanded_source, name: bundle_name, scope: scope, force: force,
158
- strict: true, dry_run: dry_run)
159
- begin
160
- _nd, manifest = installer.run
161
- @stdout.puts installer.summary
162
- if dry_run
163
- @stdout.puts "(dry-run: no changes written)"
164
- return 0
165
- end
166
- @stdout.puts "Provenance written to: #{Samagotchi::MemoryBundle::Provenance.bundles_dir}/#{bundle_name}/" if manifest
167
- return 0
168
- rescue Samagotchi::MemoryBundle::Installer::InstallError => e
169
- @stderr.puts "Install failed: #{e.message}"; return 1
160
+ # (no Hooks/Plugin lines here)
161
+ installer = installer_for(expanded_source, bundle_name, scope: scope, force: force, dry_run: dry_run)
162
+ return run_installer(installer, failed: "Install") do |manifest|
163
+ next dry_run_done if dry_run
164
+
165
+ print_provenance(bundle_name, manifest)
170
166
  end
171
167
  end
172
- installer = Samagotchi::MemoryBundle::Installer.new(
173
- source: expanded_source,
174
- name: bundle_name,
175
- scope: scope,
176
- force: force,
177
- strict: true,
178
- upgrade: true,
179
- dry_run: dry_run
180
- )
181
- begin
182
- _nd, manifest = installer.run
183
- @stdout.puts installer.summary
184
- if manifest && manifest.hooks && !manifest.hooks.empty?
185
- @stdout.puts "Hooks: #{manifest.hooks.size} hook(s) (#{manifest.hooks.keys.join(', ')})"
186
- end
187
- @stdout.puts "Plugin: #{manifest.plugin[:file]} (loads at the next chi start)" if manifest&.plugin && !dry_run
188
- if dry_run
189
- @stdout.puts "(dry-run: no changes written)"
190
- return 0
191
- end
192
- if installer.conflicts.any? && !force
193
- @stdout.puts "\n#{installer.conflicts.size} conflict(s) need resolution."
194
- installer.conflicts.each { |k, _| @stdout.puts " conflict: #{k}" }
195
- # Decide whether to launch agent
196
- launch = false
197
- if agent == false
198
- launch = false
199
- elsif agent == true
200
- launch = true
201
- elsif @stdin.tty?
202
- @stdout.print "Conflicts detected — launch interactive agent to resolve? [y/N] "
203
- ans = begin; @stdin.gets; rescue => _e; nil; end
204
- launch = ans && ans.strip.downcase.start_with?("y")
205
- else
206
- @stdout.puts "Non-interactive terminal: kept your edits in the file(s) above; the rest is upgraded. Re-run with --force to take the bundle's version, or --agent in a TTY to merge."
207
- return 2
208
- end
209
- if launch
210
- prompt = build_conflict_prompt(bundle_name, installer.conflicts, expanded_source)
211
- @stdout.puts "Launching interactive session for conflict resolution… (/exit when done)"
212
- require "samagotchi/terminal_ui"
213
- Samagotchi::TerminalUI.new(prompt: prompt).run
214
- # The installer already recorded the upgrade (conflicted files
215
- # kept their old base); the resolved files now start from the
216
- # bundle's version.
217
- Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).resolve_conflicts(installer.conflicts)
218
- @stdout.puts "Provenance updated after interactive resolution."
219
- @stdout.puts "Upgrade resolved interactively."
220
- return 0
221
- else
222
- @stdout.puts "Kept your edits in the file(s) above; the rest is upgraded. chi bundle diff #{bundle_name} FILE shows the base; re-run with --force to take the bundle's version."
223
- return 2
224
- end
225
- end
226
- @stdout.puts "Provenance written to: #{Samagotchi::MemoryBundle::Provenance.bundles_dir}/#{bundle_name}/" if manifest
227
- return 0
228
- rescue Samagotchi::MemoryBundle::Installer::InstallError => e
229
- @stderr.puts "Upgrade failed: #{e.message}"; return 1
168
+ installer = installer_for(expanded_source, bundle_name, scope: scope, force: force, upgrade: true, dry_run: dry_run)
169
+ run_installer(installer, failed: "Upgrade") do |manifest|
170
+ print_hooks_and_plugin(manifest, plugin: !dry_run)
171
+ next dry_run_done if dry_run
172
+ next upgrade_conflicts(installer, bundle_name, expanded_source, agent) if installer.conflicts.any? && !force
173
+
174
+ print_provenance(bundle_name, manifest)
175
+ end
176
+ end
177
+
178
+ # The bundle's name, or nil after "<word> failed: …".
179
+ def bundle_name_or_fail(expanded_source, word)
180
+ bundle_name_for(expanded_source)
181
+ rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError => e
182
+ @stderr.puts "#{word} failed: #{e.message}"
183
+ nil
184
+ end
185
+
186
+ def installer_for(source, name, scope:, force:, **options)
187
+ Samagotchi::MemoryBundle::Installer.new(source: source, name: name, scope: scope, force: force, strict: true, **options)
188
+ end
189
+
190
+ # Runs +installer+, prints its summary, then yields the manifest.
191
+ # @return [Integer] the block's exit status, or 1 after "<failed> failed: …"
192
+ def run_installer(installer, failed:)
193
+ _nd, manifest = installer.run
194
+ @stdout.puts installer.summary
195
+ yield manifest
196
+ rescue Samagotchi::MemoryBundle::Installer::InstallError => e
197
+ @stderr.puts "#{failed} failed: #{e.message}"
198
+ 1
199
+ end
200
+
201
+ def print_hooks_and_plugin(manifest, plugin: true)
202
+ @stdout.puts "Hooks: #{manifest.hooks.size} hook(s) (#{manifest.hooks.keys.join(', ')})" if manifest&.hooks&.any?
203
+ @stdout.puts "Plugin: #{manifest.plugin[:file]} (loads at the next chi start)" if plugin && manifest&.plugin
204
+ end
205
+
206
+ # @return [Integer] 0
207
+ def print_provenance(bundle_name, manifest)
208
+ @stdout.puts "Provenance written to: #{Samagotchi::MemoryBundle::Provenance.bundles_dir}/#{bundle_name}/" if manifest
209
+ 0
210
+ end
211
+
212
+ # @return [Integer] 0
213
+ def dry_run_done
214
+ @stdout.puts "(dry-run: no changes written)"
215
+ 0
216
+ end
217
+
218
+ # An upgrade that kept edited files: launch an agent to merge them
219
+ # (--agent, or yes on a terminal), or say how to take the bundle's.
220
+ # @return [Integer] 0 resolved, 2 kept the edits
221
+ def upgrade_conflicts(installer, bundle_name, expanded_source, agent)
222
+ @stdout.puts "\n#{installer.conflicts.size} conflict(s) need resolution."
223
+ installer.conflicts.each { |k, _| @stdout.puts " conflict: #{k}" }
224
+ launch = if agent.nil? && @stdin.tty?
225
+ @stdout.print "Conflicts detected — launch interactive agent to resolve? [y/N] "
226
+ ans = begin; @stdin.gets; rescue => _e; nil; end
227
+ ans && ans.strip.downcase.start_with?("y")
228
+ elsif agent.nil?
229
+ @stdout.puts "Non-interactive terminal: kept your edits in the file(s) above; the rest is upgraded. Re-run with --force to take the bundle's version, or --agent in a TTY to merge."
230
+ return 2
231
+ else
232
+ agent
233
+ end
234
+ unless launch
235
+ @stdout.puts "Kept your edits in the file(s) above; the rest is upgraded. chi bundle diff #{bundle_name} FILE shows the base; re-run with --force to take the bundle's version."
236
+ return 2
230
237
  end
238
+
239
+ prompt = build_conflict_prompt(bundle_name, installer.conflicts, expanded_source)
240
+ @stdout.puts "Launching interactive session for conflict resolution… (/exit when done)"
241
+ require "samagotchi/terminal_ui"
242
+ Samagotchi::TerminalUI.new(prompt: prompt).run
243
+ # The installer already recorded the upgrade (conflicted files kept
244
+ # their old base); the resolved files now start from the bundle's
245
+ # version.
246
+ Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).resolve_conflicts(installer.conflicts)
247
+ @stdout.puts "Provenance updated after interactive resolution."
248
+ @stdout.puts "Upgrade resolved interactively."
249
+ 0
231
250
  end
232
251
 
233
252
  def uninstall(rest)
234
- parsed = parse_flags(rest, "uninstall", help: UNINSTALL_HELP, bools: { "--force" => [:force, true] })
253
+ parsed = parse_flags(UNINSTALL_FLAGS, rest, "uninstall", help: UNINSTALL_HELP)
235
254
  return parsed if parsed.is_a?(Integer)
236
255
 
237
- opts, bundle_name = parsed
256
+ opts, bundle_name = parsed.options, parsed.args.last
238
257
  scope = opts[:scope]
239
258
  force = opts.fetch(:force, false)
240
259
  if bundle_name.nil? || bundle_name.empty?
241
260
  @stderr.puts "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
242
- return 1
261
+ return 2
243
262
  end
244
- # Propagate overrides
245
- Samagotchi::MemoryBundle::Uninstaller.system_dir_override = Samagotchi::MemoryBundle::Installer.system_dir_override
246
- Samagotchi::MemoryBundle::Uninstaller.project_dir_base_override = Samagotchi::MemoryBundle::Installer.project_dir_base_override
247
263
  data = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).read
248
264
  return uninstall_profile(bundle_name, force: force) if Samagotchi::MemoryBundle::Profile.installed_meta?(bundle_name, data)
249
265
 
@@ -377,7 +393,7 @@ module Samagotchi
377
393
  bname = args[0]
378
394
  file_arg = args[1]
379
395
  if bname.nil? || bname.empty?
380
- @stderr.puts "Usage: chi bundle diff <bundle> [file]"; return 1
396
+ @stderr.puts "Usage: chi bundle diff <bundle> [file]"; return 2
381
397
  end
382
398
  prov = Samagotchi::MemoryBundle::Provenance.new(name: bname)
383
399
  data = prov.read
@@ -509,65 +525,17 @@ module Samagotchi
509
525
  end
510
526
 
511
527
  def build(rest)
512
- scope = nil
513
- name = nil
514
- version = nil
515
- description = ""
516
- out = nil
517
- filter_files = []
518
- i = 0
519
- while i < rest.size
520
- arg = rest[i]
521
- if arg.start_with?("--")
522
- nxt = rest[i + 1]
523
- if %w[-h --help help].include?(arg)
524
- @stdout.puts "Usage: chi bundle build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]"
525
- @stdout.puts ""
526
- @stdout.puts " build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]"
527
- @stdout.puts " Build local memories and installed hooks into a shareable bundle (dir or zip)."
528
- @stdout.puts " --scope selects source dir (default: system). --out inferred from extension; default <name>.zip."
529
- @stdout.puts " FILES... optional allowlist of *.md basenames to include (default: all)."
530
- @stdout.puts ""
531
- @stdout.puts " Examples:"
532
- @stdout.puts " chi bundle build --scope system"
533
- @stdout.puts " chi bundle build --scope project --name my-bundle --version 1.0.0 --out bundle.zip"
534
- @stdout.puts " chi bundle build --scope system --out ./my-bundle/ identity.md work.md"
535
- return 0
536
- elsif arg == "--scope" && nxt && !nxt.start_with?("--")
537
- scope = nxt; i += 2
538
- elsif arg.start_with?("--scope=")
539
- scope = arg.split("=", 2).last; i += 1
540
- elsif arg == "--name" && nxt && !nxt.start_with?("--")
541
- name = nxt; i += 2
542
- elsif arg.start_with?("--name=")
543
- name = arg.split("=", 2).last; i += 1
544
- elsif arg == "--version" && nxt && !nxt.start_with?("--")
545
- version = nxt; i += 2
546
- elsif arg.start_with?("--version=")
547
- version = arg.split("=", 2).last; i += 1
548
- elsif arg == "--description" && nxt && !nxt.start_with?("--")
549
- description = nxt; i += 2
550
- elsif arg.start_with?("--description=")
551
- description = arg.split("=", 2).last; i += 1
552
- elsif arg == "--out" && nxt && !nxt.start_with?("--")
553
- out = nxt; i += 2
554
- elsif arg.start_with?("--out=")
555
- out = arg.split("=", 2).last; i += 1
556
- else
557
- @stderr.puts "Unknown bundle build flag: #{arg}"
558
- return 1
559
- end
560
- else
561
- filter_files << arg
562
- i += 1
563
- end
564
- end
528
+ parsed = parse_flags(BUILD_FLAGS, rest, "build", help: BUILD_HELP)
529
+ return parsed if parsed.is_a?(Integer)
530
+
531
+ scope, name, version, out = parsed.options.values_at(:scope, :name, :version, :out)
532
+ description = parsed.options.fetch(:description, "")
533
+ filter_files = parsed.args.empty? ? nil : parsed.args
565
534
  # Validate scope if given
566
535
  if scope && !%w[system project].include?(scope.to_s.strip.downcase)
567
536
  @stderr.puts "Invalid scope '#{scope}', expected system or project"
568
- return 1
537
+ return 2
569
538
  end
570
- filter_files = nil if filter_files.empty?
571
539
  begin
572
540
  builder = Samagotchi::MemoryBundle::Builder.new(
573
541
  scope: scope,
@@ -592,44 +560,22 @@ module Samagotchi
592
560
  end
593
561
  end
594
562
 
595
- # The install/upgrade/uninstall flags, in argv order, stopping at the
596
- # first --help or unknown --flag: --scope V (whatever V is) or
597
- # --scope=V, and the +bools+ ("--flag" => [key, value]). Anything not
598
- # starting with -- is the positional; the last one wins.
563
+ # +rest+ through +flags+, stopping at the first --help or error.
599
564
  #
600
- # @return [Array(Hash, String), Integer] the flags and the positional
601
- # (nil when none), or the exit status after --help or an unknown flag
602
- def parse_flags(rest, sub, help:, bools:)
603
- opts = {}
604
- positional = nil
605
- i = 0
606
- while i < rest.size
607
- arg = rest[i]
608
- if arg.start_with?("--")
609
- nxt = rest[i + 1]
610
- if arg == "--help"
611
- @stdout.puts help
612
- return 0
613
- elsif arg == "--scope" && nxt
614
- opts[:scope] = nxt
615
- i += 2
616
- elsif arg.start_with?("--scope=")
617
- opts[:scope] = arg.split("=", 2).last
618
- i += 1
619
- elsif bools.key?(arg)
620
- key, value = bools[arg]
621
- opts[key] = value
622
- i += 1
623
- else
624
- @stderr.puts "Unknown bundle #{sub} flag: #{arg}"
625
- return 1
626
- end
627
- else
628
- positional = arg
629
- i += 1
630
- end
565
+ # @return [CLI::Flags::Result, Integer] the result, or the exit status
566
+ # after the help or an unknown flag (a value flag without its value
567
+ # reads as one)
568
+ def parse_flags(flags, rest, sub, help:)
569
+ parsed = flags.parse(rest)
570
+ if parsed.help
571
+ @stdout.puts help
572
+ return 0
573
+ end
574
+ if parsed.error
575
+ @stderr.puts "Unknown bundle #{sub} flag: #{parsed.error.arg}"
576
+ return 2
631
577
  end
632
- [opts, positional]
578
+ parsed
633
579
  end
634
580
 
635
581
  # helpers for bundle_name derivation
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: check-in
3
- version: 0.1.1
3
+ version: 0.1.3
4
4
  scope: system
5
5
  description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:b2ca10c949734ef3d31df9ff775cf136d7f86f185e875a4c16259e8f871bfb82
9
+ sha256: sha256:c5aa4c49989723ec2370184cee36f4c8515d9df818b2c45422a5efb6c4ba31a7
10
10
  requires_chi: ">= 0.3.0"
@@ -32,7 +32,6 @@ class Plugin
32
32
  USAGE = "usage: /checkin [on|off|<calls>|mode ask|nudge|notify|nudge|later|stop]"
33
33
 
34
34
  def initialize(settings = {})
35
- settings = {} unless settings.is_a?(Hash)
36
35
  @after = positive(settings["after"]) || 50
37
36
  @every = positive(settings["every"]) || @after
38
37
  @mode = MODES.include?(settings["mode"].to_s) ? settings["mode"].to_s : "ask"
@@ -85,13 +84,13 @@ class Plugin
85
84
  @next_at = @count + @every
86
85
  [@count, @mode]
87
86
  end
88
- check_in(event, ctx, *due) if due
87
+ check_in(ctx, *due) if due
89
88
  end
90
89
 
91
- def check_in(event, ctx, count, mode)
90
+ def check_in(ctx, count, mode)
92
91
  case mode
93
92
  when "nudge"
94
- ctx.notify("nudged the model after #{count} tool calls") if event[:steer]&.call(message(count))
93
+ ctx.notify("nudged the model after #{count} tool calls") if ctx.steer(message(count))
95
94
  when "notify"
96
95
  ctx.notify("#{count} tool calls in this turn, no answer yet")
97
96
  else
@@ -25,7 +25,6 @@ class KnownNames
25
25
  ASK_OPTIONS = ["Correct it and run", "Run as is", "Deny"].freeze
26
26
 
27
27
  def initialize(settings = {})
28
- settings = {} unless settings.is_a?(Hash)
29
28
  @names = list(settings["names"])
30
29
  @ignore = list(settings["ignore"]).map(&:downcase)
31
30
  @mode = MODES.include?(settings["mode"].to_s) ? settings["mode"].to_s : "reject"
@@ -175,13 +174,18 @@ class KnownNames
175
174
  event[:notify]&.call("rejected #{tool}: \"#{hit[:miss]}\" looks like \"#{hit[:name]}\"")
176
175
  end
177
176
 
177
+ # Tools whose content is file text: it is left as written.
178
+ FILE_TEXT_TOOLS = %w[write memory_write].freeze
179
+
178
180
  # The same call with the near miss replaced (whole tokens, everywhere it
179
- # appears in the content, path and cwd).
181
+ # appears in the command or read path (content), the path and the cwd).
182
+ # Not in file text: a write's content, an edit's old and new text.
180
183
  def correct(event, hit)
181
184
  call = event[:call]
182
185
  pattern = /(?<![A-Za-z0-9])#{Regexp.escape(hit[:miss])}(?![A-Za-z0-9])/
183
186
  fixed = call.dup
184
- %i[content path cwd].each do |key|
187
+ keys = FILE_TEXT_TOOLS.include?(call[:name].to_s) ? %i[path cwd] : %i[content path cwd]
188
+ keys.each do |key|
185
189
  next unless fixed[key].is_a?(String)
186
190
 
187
191
  fixed[key] = fixed[key].gsub(pattern, hit[:name])
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: known-names
3
- version: 0.1.1
3
+ version: 0.1.2
4
4
  scope: system
5
5
  description: Catches a near-miss of a protected name (home folder, login, git name, repo name, names from config) in a tool call and rejects it with the right spelling; mode ask / correct optional
6
6
  trust_level: reviewed
@@ -8,7 +8,7 @@ files:
8
8
  known_names.md: sha256:d5348b95b6c655017b541910b36e384220da280818160201f6ec9a50e1e4287c
9
9
  hooks:
10
10
  known_names.rb:
11
- sha256: sha256:2ccfe9124133d6d12499cb41ad7bdfd4ba59492c067efbf60eaa1400a7cc2504
11
+ sha256: sha256:f62bc72a64bb8b36d03374b0e9152a0492cc066e04f76129ae50d8cda9467f15
12
12
  event: before_tool_call
13
13
  on_error: log
14
14
  priority: 50
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: loop-guard
3
- version: 0.2.0
3
+ version: 0.2.2
4
4
  scope: system
5
5
  description: "Breaks loops: the same tool call with the same result again and again in one turn is denied with advice (after a few denies the turn stops with a card), and thinking that repeats itself while it streams is cut and the model asked again"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:5a0dc653a654e73d881767c8b7b9486d4ac02cfc7823a150132593ffa2cbba04
9
+ sha256: sha256:e62c68d1a2280adf55b150488d0d85bbbc35ffce54dfed387a86adceb85e4d34
10
10
  requires_chi: ">= 0.6.0"