samagotchi 0.5.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +4 -1
- data/docs/desktop.md +3 -1
- data/docs/hooks.md +69 -4
- data/docs/plugins.md +98 -3
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/card_store.rb +30 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
- data/lib/samagotchi/bridge/turn_notice.rb +37 -0
- data/lib/samagotchi/bundle_command.rb +652 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cancellation_controller.rb +42 -1
- data/lib/samagotchi/config.rb +2 -2
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
- data/lib/samagotchi/desktop/macos/Panel.swift +15 -0
- data/lib/samagotchi/engine.rb +138 -618
- data/lib/samagotchi/guardrail_wiring.rb +238 -0
- data/lib/samagotchi/hooks/registry.rb +11 -3
- data/lib/samagotchi/hooks/stream_watch.rb +93 -0
- data/lib/samagotchi/hooks.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +65 -6
- data/lib/samagotchi/llm/chat_loop.rb +73 -11
- data/lib/samagotchi/llm/openai_chat.rb +4 -2
- data/lib/samagotchi/log_subscriber.rb +10 -4
- data/lib/samagotchi/plugin/api.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +12 -3
- data/lib/samagotchi/plugin_tasks.rb +229 -0
- data/lib/samagotchi/question_desk.rb +280 -0
- data/lib/samagotchi/recap_setup.rb +128 -0
- data/lib/samagotchi/session.rb +9 -1
- data/lib/samagotchi/sessions_command.rb +255 -0
- data/lib/samagotchi/steer.rb +19 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/turn_note.rb +20 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -8
- data/lib/samagotchi/web/public/app.js +94 -52
- data/lib/samagotchi/web/public/card.js +8 -1
- data/lib/samagotchi/web/public/format.js +1 -1
- data/lib/samagotchi/web/public/index.html +2 -5
- data/lib/samagotchi/web/public/stage_model.js +44 -0
- data/lib/samagotchi/web/public/stage_view.js +26 -17
- data/lib/samagotchi/web/public/timing.js +9 -3
- data/lib/samagotchi/web/public/turn_events.js +18 -5
- data/lib/samagotchi/web/public/turn_view.js +68 -16
- data/lib/samagotchi/web/server.rb +4 -3
- metadata +9 -2
- 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.
|
|
3
|
+
version: 0.2.0
|
|
4
4
|
scope: system
|
|
5
|
-
description: "Breaks
|
|
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:
|
|
10
|
-
requires_chi: ">= 0.
|
|
9
|
+
sha256: sha256:5a0dc653a654e73d881767c8b7b9486d4ac02cfc7823a150132593ffa2cbba04
|
|
10
|
+
requires_chi: ">= 0.6.0"
|