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