samagotchi 0.5.0 → 0.6.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +4 -1
  7. data/docs/desktop.md +3 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +2 -2
  22. data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
  23. data/lib/samagotchi/desktop/macos/Panel.swift +31 -3
  24. data/lib/samagotchi/engine.rb +138 -618
  25. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  26. data/lib/samagotchi/hooks/registry.rb +11 -3
  27. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  28. data/lib/samagotchi/hooks.rb +3 -0
  29. data/lib/samagotchi/kernel_loop.rb +65 -6
  30. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  31. data/lib/samagotchi/llm/openai_chat.rb +16 -4
  32. data/lib/samagotchi/log_subscriber.rb +10 -4
  33. data/lib/samagotchi/plugin/api.rb +8 -0
  34. data/lib/samagotchi/plugin/context.rb +12 -3
  35. data/lib/samagotchi/plugin_tasks.rb +229 -0
  36. data/lib/samagotchi/question_desk.rb +280 -0
  37. data/lib/samagotchi/recap_setup.rb +128 -0
  38. data/lib/samagotchi/reply_wait.rb +9 -0
  39. data/lib/samagotchi/send_command.rb +3 -6
  40. data/lib/samagotchi/session.rb +9 -1
  41. data/lib/samagotchi/sessions_command.rb +255 -0
  42. data/lib/samagotchi/steer.rb +19 -2
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  44. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  45. data/lib/samagotchi/tool_declarations.rb +2 -2
  46. data/lib/samagotchi/tools/delegate.rb +6 -2
  47. data/lib/samagotchi/tools/delegate_wait.rb +26 -8
  48. data/lib/samagotchi/turn_note.rb +20 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +28 -10
  51. data/lib/samagotchi/web/public/app.js +94 -52
  52. data/lib/samagotchi/web/public/card.js +8 -1
  53. data/lib/samagotchi/web/public/format.js +1 -1
  54. data/lib/samagotchi/web/public/index.html +2 -5
  55. data/lib/samagotchi/web/public/stage_model.js +44 -0
  56. data/lib/samagotchi/web/public/stage_view.js +26 -17
  57. data/lib/samagotchi/web/public/timing.js +9 -3
  58. data/lib/samagotchi/web/public/turn_events.js +18 -5
  59. data/lib/samagotchi/web/public/turn_view.js +80 -16
  60. data/lib/samagotchi/web/server.rb +4 -3
  61. metadata +9 -2
  62. data/lib/samagotchi/web/public/chat_view.js +0 -376
@@ -0,0 +1,652 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "memory_bundle"
4
+
5
+ module Samagotchi
6
+ # `chi bundle`: install, upgrade, uninstall, status, diff, list and build
7
+ # memory bundles (bin/chi dispatches here before OptionParser; the
8
+ # subcommands have their own flags).
9
+ class BundleCommand
10
+ USAGE = <<~TEXT
11
+ Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build> [options]
12
+
13
+ install <source> [--scope system|project] [--force]
14
+ Install a memory bundle from a directory, zip, tar archive, or git URL.
15
+ Source is the path/URL to the bundle (dir/zip/tar.gz/git).
16
+ --force overwrites existing entries; default skips them.
17
+
18
+ upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]
19
+ Upgrade a bundle (3-way merge: auto-merge if not edited, conflict otherwise).
20
+ --dry-run shows what would change. --agent launches interactive session on conflict.
21
+
22
+ uninstall <bundle> [--scope system|project] [--force]
23
+ Remove a bundle and its index entries (--force if locally edited).
24
+
25
+ status [<bundle>]
26
+ Show provenance + modification status for bundles.
27
+
28
+ diff <bundle> [file]
29
+ Show diffs between base/current/incoming for a bundle.
30
+
31
+ list
32
+ List installed bundles and the ones shipped with chi that aren't installed.
33
+
34
+ build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
35
+ Build local memories and installed hooks (and plugin) into a shareable bundle (dir or zip).
36
+ --scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
37
+ FILES... optional allowlist of *.md basenames to include (default: all).
38
+ TEXT
39
+
40
+ # Each subcommand's --help (and only --help: -h and help are taken as
41
+ # the source or name).
42
+ INSTALL_HELP = <<~TEXT
43
+ Usage: chi bundle install <source> [--scope system|project] [--force]
44
+
45
+ install <source> [--scope system|project] [--force]
46
+ Install a memory bundle from a directory, zip, tar archive, or git URL,
47
+ or a bundle shipped with chi by name (see: chi bundle list).
48
+ --force overwrites existing entries; default skips them.
49
+ TEXT
50
+ UPGRADE_HELP = "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]"
51
+ UNINSTALL_HELP = "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
52
+
53
+ # @param argv [Array<String>] the arguments after "bundle"
54
+ def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
55
+ @argv = argv.dup
56
+ @stdin = stdin
57
+ @stdout = stdout
58
+ @stderr = stderr
59
+ end
60
+
61
+ # @return [Integer] exit status
62
+ def run
63
+ sub = @argv[0]
64
+ if sub.nil? || %w[-h --help help].include?(sub)
65
+ @stdout.puts USAGE
66
+ return 0
67
+ end
68
+
69
+ case sub
70
+ when "install" then install(@argv[1..])
71
+ when "upgrade" then upgrade(@argv[1..])
72
+ when "uninstall" then uninstall(@argv[1..])
73
+ when "status" then status(@argv[1..])
74
+ when "diff" then diff(@argv[1..])
75
+ when "list" then list
76
+ when "build" then build(@argv[1..])
77
+ else
78
+ @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build"
79
+ return 1
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def install(rest)
86
+ parsed = parse_flags(rest, "install", help: INSTALL_HELP, bools: { "--force" => [:force, true] })
87
+ return parsed if parsed.is_a?(Integer)
88
+
89
+ opts, source = parsed
90
+ scope = opts[:scope]
91
+ force = opts.fetch(:force, false)
92
+ if source.nil? || source.empty?
93
+ @stderr.puts "Usage: chi bundle install <source> [--scope system|project] [--force]"
94
+ return 1
95
+ end
96
+ expanded_source = expand_source(source)
97
+ bundle_name = begin
98
+ bundle_name_for(expanded_source)
99
+ rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError => e
100
+ @stderr.puts "Install failed: #{e.message}"
101
+ return 1
102
+ end
103
+ installer = Samagotchi::MemoryBundle::Installer.new(
104
+ source: expanded_source,
105
+ name: bundle_name,
106
+ scope: scope,
107
+ force: force,
108
+ strict: true
109
+ )
110
+ begin
111
+ _nd, manifest = installer.run
112
+ @stdout.puts installer.summary
113
+ if manifest && manifest.hooks && !manifest.hooks.empty?
114
+ hook_cnt = manifest.hooks.size
115
+ @stdout.puts "Hooks: #{hook_cnt} hook(s) (#{manifest.hooks.keys.join(', ')})"
116
+ end
117
+ @stdout.puts "Plugin: #{manifest.plugin[:file]} (loads at the next chi start)" if manifest&.plugin
118
+ @stdout.puts "Provenance written to: #{Samagotchi::MemoryBundle::Provenance.bundles_dir}/#{bundle_name}/" if manifest
119
+ return 0
120
+ rescue Samagotchi::MemoryBundle::Installer::InstallError => e
121
+ @stderr.puts "Install failed: #{e.message}"
122
+ return 1
123
+ end
124
+ end
125
+
126
+ def upgrade(rest)
127
+ parsed = parse_flags(rest, "upgrade", help: UPGRADE_HELP,
128
+ bools: { "--force" => [:force, true], "--dry-run" => [:dry_run, true],
129
+ "--agent" => [:agent, true], "--no-agent" => [:agent, false] })
130
+ return parsed if parsed.is_a?(Integer)
131
+
132
+ opts, source = parsed
133
+ scope = opts[:scope]
134
+ force = opts.fetch(:force, false)
135
+ dry_run = opts.fetch(:dry_run, false)
136
+ agent = opts[:agent]
137
+ if source.nil? || source.empty?
138
+ @stderr.puts "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run]"
139
+ return 1
140
+ end
141
+ expanded_source = expand_source(source)
142
+ bundle_name = begin
143
+ bundle_name_for(expanded_source)
144
+ rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError => e
145
+ @stderr.puts "Upgrade failed: #{e.message}"
146
+ return 1
147
+ end
148
+ provenance = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name)
149
+ unless provenance.installed?
150
+ @stderr.puts "Bundle '#{bundle_name}' not installed — falling back to install"
151
+ installer = Samagotchi::MemoryBundle::Installer.new(source: expanded_source, name: bundle_name, scope: scope, force: force,
152
+ strict: true, dry_run: dry_run)
153
+ begin
154
+ _nd, manifest = installer.run
155
+ @stdout.puts installer.summary
156
+ if dry_run
157
+ @stdout.puts "(dry-run: no changes written)"
158
+ return 0
159
+ end
160
+ @stdout.puts "Provenance written to: #{Samagotchi::MemoryBundle::Provenance.bundles_dir}/#{bundle_name}/" if manifest
161
+ return 0
162
+ rescue Samagotchi::MemoryBundle::Installer::InstallError => e
163
+ @stderr.puts "Install failed: #{e.message}"; return 1
164
+ end
165
+ end
166
+ installer = Samagotchi::MemoryBundle::Installer.new(
167
+ source: expanded_source,
168
+ name: bundle_name,
169
+ scope: scope,
170
+ force: force,
171
+ strict: true,
172
+ upgrade: true,
173
+ dry_run: dry_run
174
+ )
175
+ begin
176
+ _nd, manifest = installer.run
177
+ @stdout.puts installer.summary
178
+ if manifest && manifest.hooks && !manifest.hooks.empty?
179
+ @stdout.puts "Hooks: #{manifest.hooks.size} hook(s) (#{manifest.hooks.keys.join(', ')})"
180
+ end
181
+ @stdout.puts "Plugin: #{manifest.plugin[:file]} (loads at the next chi start)" if manifest&.plugin && !dry_run
182
+ if dry_run
183
+ @stdout.puts "(dry-run: no changes written)"
184
+ return 0
185
+ end
186
+ if installer.conflicts.any? && !force
187
+ @stdout.puts "\n#{installer.conflicts.size} conflict(s) need resolution."
188
+ installer.conflicts.each { |k, _| @stdout.puts " conflict: #{k}" }
189
+ # Decide whether to launch agent
190
+ launch = false
191
+ if agent == false
192
+ launch = false
193
+ elsif agent == true
194
+ launch = true
195
+ elsif @stdin.tty?
196
+ @stdout.print "Conflicts detected — launch interactive agent to resolve? [y/N] "
197
+ ans = begin; @stdin.gets; rescue => _e; nil; end
198
+ launch = ans && ans.strip.downcase.start_with?("y")
199
+ else
200
+ @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."
201
+ return 2
202
+ end
203
+ if launch
204
+ prompt = build_conflict_prompt(bundle_name, installer.conflicts, expanded_source)
205
+ @stdout.puts "Launching interactive session for conflict resolution… (/exit when done)"
206
+ require "samagotchi/terminal_ui"
207
+ Samagotchi::TerminalUI.new(prompt: prompt).run
208
+ # The installer already recorded the upgrade (conflicted files
209
+ # kept their old base); the resolved files now start from the
210
+ # bundle's version.
211
+ Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).resolve_conflicts(installer.conflicts)
212
+ @stdout.puts "Provenance updated after interactive resolution."
213
+ @stdout.puts "Upgrade resolved interactively."
214
+ return 0
215
+ else
216
+ @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."
217
+ return 2
218
+ end
219
+ end
220
+ @stdout.puts "Provenance written to: #{Samagotchi::MemoryBundle::Provenance.bundles_dir}/#{bundle_name}/" if manifest
221
+ return 0
222
+ rescue Samagotchi::MemoryBundle::Installer::InstallError => e
223
+ @stderr.puts "Upgrade failed: #{e.message}"; return 1
224
+ end
225
+ end
226
+
227
+ def uninstall(rest)
228
+ parsed = parse_flags(rest, "uninstall", help: UNINSTALL_HELP, bools: { "--force" => [:force, true] })
229
+ return parsed if parsed.is_a?(Integer)
230
+
231
+ opts, bundle_name = parsed
232
+ scope = opts[:scope]
233
+ force = opts.fetch(:force, false)
234
+ if bundle_name.nil? || bundle_name.empty?
235
+ @stderr.puts "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
236
+ return 1
237
+ end
238
+ # Propagate overrides
239
+ Samagotchi::MemoryBundle::Uninstaller.system_dir_override = Samagotchi::MemoryBundle::Installer.system_dir_override
240
+ Samagotchi::MemoryBundle::Uninstaller.project_dir_base_override = Samagotchi::MemoryBundle::Installer.project_dir_base_override
241
+ uninstaller = Samagotchi::MemoryBundle::Uninstaller.new(name: bundle_name, scope: scope, force: force)
242
+ begin
243
+ uninstaller.run
244
+ @stdout.puts "Uninstalled bundle '#{bundle_name}'"
245
+ @stdout.puts "Removed: #{uninstaller.removed_files.join(', ')}" unless uninstaller.removed_files.empty?
246
+ hook_removed = uninstaller.removed_files.count { |f| f.start_with?("hooks/") }
247
+ @stdout.puts "Hooks removed: #{hook_removed}" if hook_removed > 0
248
+ uninstaller.warnings.each { |w| @stdout.puts w }
249
+ return 0
250
+ rescue Samagotchi::MemoryBundle::Uninstaller::UninstallError => e
251
+ @stderr.puts "Uninstall failed: #{e.message}"; return 1
252
+ end
253
+ end
254
+
255
+ def status(rest)
256
+ name = rest.find { |a| !a.start_with?("--") }
257
+ require "json"
258
+ if name
259
+ st = Samagotchi::MemoryBundle::Status.bundle_status(name)
260
+ unless st
261
+ @stdout.puts "Bundle '#{name}' not installed."
262
+ return 0
263
+ end
264
+ @stdout.puts "Bundle: #{name} v#{st[:provenance][:version]} scope=#{st[:scope]} installed=#{st[:provenance][:installed_at]}"
265
+ @stdout.puts "Target: #{st[:target_dir]}"
266
+ st[:files].each do |k, info|
267
+ mods = []
268
+ mods << "conflict (kept your edits over v#{st[:provenance][:version]}: chi bundle diff #{name} #{k})" if info[:conflict]
269
+ mods << "modified" if info[:modified]
270
+ mods << "missing" if info[:missing]
271
+ mods << "no-index" unless info[:index_present]
272
+ label = mods.empty? ? "ok" : mods.join(",")
273
+ @stdout.puts " #{k}: #{label}"
274
+ end
275
+ # Hooks
276
+ hooks = st[:provenance][:hooks] || {}
277
+ trust = st[:provenance][:trust_level] || "experimental"
278
+ commit = st[:provenance][:source_commit]
279
+ @stdout.puts " trust_level: #{trust}" if hooks.any? || trust
280
+ @stdout.puts " source_commit: #{commit}" if commit
281
+ if hooks.any?
282
+ @stdout.puts " Hooks (#{hooks.size}):"
283
+ hooks.each do |hk, meta|
284
+ m = meta.is_a?(Hash) ? meta.transform_keys(&:to_s) : {}
285
+ event = m["event"] || ""
286
+ on_error = m["on_error"] || "skip"
287
+ prio = m["priority"] || 100
288
+ hook_path = File.join(Samagotchi::MemoryBundle::Provenance.new(name: name).hooks_dir, hk.to_s)
289
+ exists = File.exist?(hook_path) ? "ok" : "missing"
290
+ @stdout.puts " #{hk}: event=#{event} on_error=#{on_error} priority=#{prio} [#{exists}]"
291
+ end
292
+ end
293
+ if (plugin = st[:plugin])
294
+ note = plugin[:state] == "modified" ? " (edited after install: not loaded; reinstall the bundle)" : ""
295
+ @stdout.puts " Plugin: #{plugin[:file]} [#{plugin[:state]}]#{note}"
296
+ @stdout.puts " requires_chi: #{plugin[:requires_chi]}" if plugin[:requires_chi]
297
+ @stdout.puts " not loaded: #{plugin[:requires_failure]}" if plugin[:requires_failure]
298
+ end
299
+ st[:needs].each { |need| @stdout.puts " #{Samagotchi::MemoryBundle::Status.need_line(need)}" }
300
+ else
301
+ bundles_dir = Samagotchi::MemoryBundle::Provenance.bundles_dir
302
+ unless File.directory?(bundles_dir)
303
+ @stdout.puts "No installed bundles."; return 0
304
+ end
305
+ entries = Dir.entries(bundles_dir).reject { |e| e.start_with?(".") }
306
+ if entries.empty?
307
+ @stdout.puts "No installed bundles."
308
+ else
309
+ entries.sort.each do |bname|
310
+ st = Samagotchi::MemoryBundle::Status.bundle_status(bname)
311
+ next unless st
312
+ mods = st[:files].values.count { |v| v[:modified] || v[:missing] }
313
+ hooks_count = (st[:provenance][:hooks] || {}).size
314
+ hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
315
+ plugin = st[:plugin]
316
+ mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
317
+ plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
318
+ @stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{st[:scope]} files=#{st[:files].size}#{hook_info}#{plugin_info} issues=#{mods}"
319
+ end
320
+ end
321
+ end
322
+ return 0
323
+ end
324
+
325
+ def diff(rest)
326
+ args = rest.reject { |a| a.start_with?("--") }
327
+ bname = args[0]
328
+ file_arg = args[1]
329
+ if bname.nil? || bname.empty?
330
+ @stderr.puts "Usage: chi bundle diff <bundle> [file]"; return 1
331
+ end
332
+ prov = Samagotchi::MemoryBundle::Provenance.new(name: bname)
333
+ data = prov.read
334
+ unless data
335
+ @stderr.puts "Bundle '#{bname}' not installed"; return 1
336
+ end
337
+ scope = data[:scope]&.to_s || "system"
338
+ target_dir = Samagotchi::MemoryBundle::Installer.new(source: ".", name: bname, scope: scope).send(:resolve_target_dir, scope)
339
+ files = data[:files] || {}
340
+ hooks = data[:hooks] || {}
341
+ plugin_path = prov.plugin_path(data)
342
+ plugin_file = plugin_path && File.basename(plugin_path)
343
+ show_plugin = lambda do
344
+ @stdout.puts "=== plugin/#{plugin_file} ==="
345
+ @stdout.puts "--- base (provenance) ---"
346
+ base = prov.plugin_base_path(plugin_file)
347
+ @stdout.puts File.exist?(base) ? File.read(base) : "(no base)"
348
+ @stdout.puts "--- current (on-disk) ---"
349
+ @stdout.puts File.exist?(plugin_path) ? File.read(plugin_path) : "(missing)"
350
+ @stdout.puts "--- metadata: sha256=#{data[:plugin][:sha256]}#{data[:requires_chi] ? " requires_chi=#{data[:requires_chi]}" : ""}"
351
+ end
352
+ if file_arg
353
+ # Try file first, then hook
354
+ if files.key?(file_arg.to_sym) || files.key?(file_arg)
355
+ base = prov.base_path(file_arg)
356
+ cur = File.join(target_dir, file_arg)
357
+ @stdout.puts "=== #{file_arg} ==="
358
+ @stdout.puts "--- base (provenance) ---"
359
+ @stdout.puts File.exist?(base) ? File.read(base) : "(no base)"
360
+ @stdout.puts "--- current (on-disk) ---"
361
+ @stdout.puts File.exist?(cur) ? File.read(cur) : "(missing)"
362
+ elsif hooks.key?(file_arg.to_sym) || hooks.key?(file_arg)
363
+ base = prov.base_path(file_arg)
364
+ cur = File.join(prov.hooks_dir, file_arg)
365
+ @stdout.puts "=== hooks/#{file_arg} ==="
366
+ @stdout.puts "--- base (provenance) ---"
367
+ @stdout.puts File.exist?(base) ? File.read(base) : "(no base)"
368
+ @stdout.puts "--- current (on-disk) ---"
369
+ @stdout.puts File.exist?(cur) ? File.read(cur) : "(missing)"
370
+ # Show hook metadata
371
+ meta = hooks[file_arg.to_sym] || hooks[file_arg]
372
+ if meta
373
+ m = meta.transform_keys(&:to_s)
374
+ @stdout.puts "--- metadata: event=#{m['event']} on_error=#{m['on_error']} priority=#{m['priority']} sha256=#{m['sha256']}"
375
+ end
376
+ elsif plugin_file && [plugin_file, "plugin/#{plugin_file}"].include?(file_arg)
377
+ show_plugin.call
378
+ else
379
+ @stdout.puts "=== #{file_arg} ==="
380
+ @stdout.puts "(not found in bundle)"
381
+ end
382
+ @stdout.puts ""
383
+ else
384
+ keys = files.keys.map(&:to_s)
385
+ keys.each do |k|
386
+ base = prov.base_path(k)
387
+ cur = File.join(target_dir, k)
388
+ @stdout.puts "=== #{k} ==="
389
+ @stdout.puts "--- base (provenance) ---"
390
+ @stdout.puts File.exist?(base) ? File.read(base) : "(no base)"
391
+ @stdout.puts "--- current (on-disk) ---"
392
+ @stdout.puts File.exist?(cur) ? File.read(cur) : "(missing)"
393
+ @stdout.puts ""
394
+ end
395
+ # Hooks diff
396
+ hooks.keys.map(&:to_s).each do |k|
397
+ base = prov.base_path(k)
398
+ cur = File.join(prov.hooks_dir, k)
399
+ @stdout.puts "=== hooks/#{k} ==="
400
+ @stdout.puts "--- base (provenance) ---"
401
+ @stdout.puts File.exist?(base) ? File.read(base) : "(no base)"
402
+ @stdout.puts "--- current (on-disk) ---"
403
+ @stdout.puts File.exist?(cur) ? File.read(cur) : "(missing)"
404
+ meta = hooks[k.to_sym] || hooks[k]
405
+ if meta
406
+ m = meta.transform_keys(&:to_s)
407
+ @stdout.puts "--- metadata: event=#{m['event']} on_error=#{m['on_error']} priority=#{m['priority']} sha256=#{m['sha256']}"
408
+ end
409
+ @stdout.puts ""
410
+ end
411
+ if plugin_file
412
+ show_plugin.call
413
+ @stdout.puts ""
414
+ end
415
+ end
416
+ return 0
417
+ end
418
+
419
+ def list
420
+ listing = Samagotchi::MemoryBundle::Listing
421
+ shipped = listing.shipped
422
+ installed = listing.installed(shipped: shipped)
423
+ available = listing.available(shipped: shipped, installed: installed)
424
+
425
+ # One column width for both sections, so the names line up.
426
+ name_w = (installed.map { |b| b.name.length } + available.map { |s| s.source.length }).max.to_i
427
+ ver_w = (installed.map { |b| (b.version || "?").to_s.length } + available.map { |s| s.version.length }).max.to_i + 1
428
+
429
+ if installed.empty?
430
+ @stdout.puts "No installed bundles."
431
+ else
432
+ @stdout.puts "Installed:"
433
+ installed.each do |b|
434
+ next @stdout.puts(" #{b.name.ljust(name_w)} (#{b.error})") if b.error
435
+ line = " #{b.name.ljust(name_w)} #{"v#{b.version || "?"}".ljust(ver_w)} scope=#{b.scope || "?"} files=#{b.files} installed=#{b.installed_at || "?"}"
436
+ line += " (shipped v#{b.upgrade.version}: chi bundle upgrade #{b.upgrade.source})" if b.upgrade
437
+ @stdout.puts line
438
+ end
439
+ @stdout.puts " (or all at once: chi update)" if installed.any?(&:upgrade)
440
+ end
441
+ unless available.empty?
442
+ @stdout.puts ""
443
+ @stdout.puts "Available (shipped with chi, install with: chi bundle install <name>):"
444
+ available.each do |s|
445
+ @stdout.puts " #{s.source.ljust(name_w)} #{"v#{s.version}".ljust(ver_w)} #{s.description}".rstrip
446
+ end
447
+ end
448
+ return 0
449
+ end
450
+
451
+ def build(rest)
452
+ scope = nil
453
+ name = nil
454
+ version = nil
455
+ description = ""
456
+ out = nil
457
+ filter_files = []
458
+ i = 0
459
+ while i < rest.size
460
+ arg = rest[i]
461
+ if arg.start_with?("--")
462
+ nxt = rest[i + 1]
463
+ if %w[-h --help help].include?(arg)
464
+ @stdout.puts "Usage: chi bundle build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]"
465
+ @stdout.puts ""
466
+ @stdout.puts " build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]"
467
+ @stdout.puts " Build local memories and installed hooks into a shareable bundle (dir or zip)."
468
+ @stdout.puts " --scope selects source dir (default: system). --out inferred from extension; default <name>.zip."
469
+ @stdout.puts " FILES... optional allowlist of *.md basenames to include (default: all)."
470
+ @stdout.puts ""
471
+ @stdout.puts " Examples:"
472
+ @stdout.puts " chi bundle build --scope system"
473
+ @stdout.puts " chi bundle build --scope project --name my-bundle --version 1.0.0 --out bundle.zip"
474
+ @stdout.puts " chi bundle build --scope system --out ./my-bundle/ identity.md work.md"
475
+ return 0
476
+ elsif arg == "--scope" && nxt && !nxt.start_with?("--")
477
+ scope = nxt; i += 2
478
+ elsif arg.start_with?("--scope=")
479
+ scope = arg.split("=", 2).last; i += 1
480
+ elsif arg == "--name" && nxt && !nxt.start_with?("--")
481
+ name = nxt; i += 2
482
+ elsif arg.start_with?("--name=")
483
+ name = arg.split("=", 2).last; i += 1
484
+ elsif arg == "--version" && nxt && !nxt.start_with?("--")
485
+ version = nxt; i += 2
486
+ elsif arg.start_with?("--version=")
487
+ version = arg.split("=", 2).last; i += 1
488
+ elsif arg == "--description" && nxt && !nxt.start_with?("--")
489
+ description = nxt; i += 2
490
+ elsif arg.start_with?("--description=")
491
+ description = arg.split("=", 2).last; i += 1
492
+ elsif arg == "--out" && nxt && !nxt.start_with?("--")
493
+ out = nxt; i += 2
494
+ elsif arg.start_with?("--out=")
495
+ out = arg.split("=", 2).last; i += 1
496
+ else
497
+ @stderr.puts "Unknown bundle build flag: #{arg}"
498
+ return 1
499
+ end
500
+ else
501
+ filter_files << arg
502
+ i += 1
503
+ end
504
+ end
505
+ # Validate scope if given
506
+ if scope && !%w[system project].include?(scope.to_s.strip.downcase)
507
+ @stderr.puts "Invalid scope '#{scope}', expected system or project"
508
+ return 1
509
+ end
510
+ filter_files = nil if filter_files.empty?
511
+ begin
512
+ builder = Samagotchi::MemoryBundle::Builder.new(
513
+ scope: scope,
514
+ name: name,
515
+ version: version,
516
+ description: description,
517
+ out: out,
518
+ files: filter_files
519
+ )
520
+ result = builder.run
521
+ @stdout.puts "Built #{result[:files].size} file(s) to #{result[:out_path]}"
522
+ @stdout.puts "Bundle: #{result[:name]} v#{result[:version]} scope=#{result[:scope]}"
523
+ @stdout.puts "Files: #{result[:files].join(', ')}" unless result[:files].empty?
524
+ unless result[:placeholder_warnings].empty?
525
+ @stdout.puts "Placeholders:"
526
+ result[:placeholder_warnings].each { |w| @stdout.puts " #{w}" }
527
+ end
528
+ return 0
529
+ rescue Samagotchi::MemoryBundle::Builder::BuildError => e
530
+ @stderr.puts "Build failed: #{e.message}"
531
+ return 1
532
+ end
533
+ end
534
+
535
+ # The install/upgrade/uninstall flags, in argv order, stopping at the
536
+ # first --help or unknown --flag: --scope V (whatever V is) or
537
+ # --scope=V, and the +bools+ ("--flag" => [key, value]). Anything not
538
+ # starting with -- is the positional; the last one wins.
539
+ #
540
+ # @return [Array(Hash, String), Integer] the flags and the positional
541
+ # (nil when none), or the exit status after --help or an unknown flag
542
+ def parse_flags(rest, sub, help:, bools:)
543
+ opts = {}
544
+ positional = nil
545
+ i = 0
546
+ while i < rest.size
547
+ arg = rest[i]
548
+ if arg.start_with?("--")
549
+ nxt = rest[i + 1]
550
+ if arg == "--help"
551
+ @stdout.puts help
552
+ return 0
553
+ elsif arg == "--scope" && nxt
554
+ opts[:scope] = nxt
555
+ i += 2
556
+ elsif arg.start_with?("--scope=")
557
+ opts[:scope] = arg.split("=", 2).last
558
+ i += 1
559
+ elsif bools.key?(arg)
560
+ key, value = bools[arg]
561
+ opts[key] = value
562
+ i += 1
563
+ else
564
+ @stderr.puts "Unknown bundle #{sub} flag: #{arg}"
565
+ return 1
566
+ end
567
+ else
568
+ positional = arg
569
+ i += 1
570
+ end
571
+ end
572
+ [opts, positional]
573
+ end
574
+
575
+ # helpers for bundle_name derivation
576
+ def expand_source(src)
577
+ return src if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(src)
578
+ Samagotchi::MemoryBundle::SourceNormalizer.shipped_bundle_dir(src) || File.expand_path(src)
579
+ end
580
+
581
+ def bundle_name_for(expanded_src)
582
+ # Try to derive from manifest if possible (skip git clone for speed if possible)
583
+ if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(expanded_src)
584
+ # For git URLs, avoid double clone: derive name from URL basename if no manifest yet
585
+ # Let Installer do the real manifest read; fallback to URL basename
586
+ base = expanded_src.split("/").last.to_s.split("#").first.to_s
587
+ base = base.sub(/\.git\z/i, "")
588
+ return base.empty? ? "bundle" : base
589
+ end
590
+ require "yaml"
591
+ nd = nil
592
+ owned = false
593
+ begin
594
+ nd, owned = Samagotchi::MemoryBundle::SourceNormalizer.normalize(expanded_src)
595
+ m = Samagotchi::MemoryBundle::Manifest.read(dir: nd)
596
+ return m.name
597
+ rescue Samagotchi::MemoryBundle::Manifest::ValidationError
598
+ # Fallback to source basename without archive extensions
599
+ src = expanded_src
600
+ name = File.basename(src)
601
+ name = name.sub(/\.tar\.gz\z/i, "").sub(/\.tgz\z/i, "").sub(/\.zip\z/i, "").sub(/\.tar\z/i, "")
602
+ return name.empty? ? File.basename(nd || src) : name
603
+ ensure
604
+ Samagotchi::MemoryBundle::SourceNormalizer.cleanup(nd) if owned
605
+ end
606
+ end
607
+
608
+ def build_conflict_prompt(bundle_name, conflicts, expanded_source)
609
+ lines = []
610
+ lines << "Memory bundle upgrade has conflicts that need interactive resolution."
611
+ lines << "Bundle: #{bundle_name}"
612
+ lines << "Source: #{expanded_source}"
613
+ lines << "Conflicts (#{conflicts.size} file(s)):"
614
+ conflicts.each do |file_key, info|
615
+ lines << " - #{file_key}"
616
+ lines << " base: #{info[:base]} (provenance snapshot)"
617
+ lines << " current: #{info[:current]} (on-disk, has local edits)"
618
+ lines << " incoming: #{info[:incoming]} (new bundle version)"
619
+ begin
620
+ if File.exist?(info[:current]) && File.exist?(info[:incoming])
621
+ _base_content = File.exist?(info[:base]) ? File.read(info[:base]) : ""
622
+ cur = File.read(info[:current])
623
+ inc = File.read(info[:incoming])
624
+ lines << " --- current vs incoming preview ---"
625
+ # simple line diff preview (first 40 lines)
626
+ cur_lines = cur.lines
627
+ inc_lines = inc.lines
628
+ max = [cur_lines.size, inc_lines.size].max
629
+ preview = []
630
+ max.times do |i|
631
+ break if preview.size >= 30
632
+ c = cur_lines[i]
633
+ n = inc_lines[i]
634
+ if c != n
635
+ preview << "- #{c.chomp}" if c
636
+ preview << "+ #{n.chomp}" if n
637
+ end
638
+ end
639
+ lines.concat(preview)
640
+ lines << " --- end preview ---"
641
+ end
642
+ rescue => _e
643
+ end
644
+ end
645
+ lines << ""
646
+ lines << "Your task: resolve each conflict by editing the file in place using the memory_write tool."
647
+ lines << "Use memory_read to inspect current content if needed. The correct scope for memory_write is the bundle's scope."
648
+ lines << "When all conflicts are resolved, type /exit to finish. If you stop early, the files stay as they are (the rest of the upgrade is already applied)."
649
+ lines.join("\n")
650
+ end
651
+ end
652
+ end
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: loop-guard
3
- version: 0.1.0
3
+ version: 0.2.0
4
4
  scope: system
5
- description: "Breaks tool-call loops: the same call with the same result again and again in one turn is denied with advice, and after a few denies the turn stops with a card"
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:f9e66f289f3bb92ba53e6a16ea563c16e7b814accb607a34f20585cf563b7ef5
10
- requires_chi: ">= 0.1.30"
9
+ sha256: sha256:5a0dc653a654e73d881767c8b7b9486d4ac02cfc7823a150132593ffa2cbba04
10
+ requires_chi: ">= 0.6.0"