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
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
require_relative "config"
|
|
6
|
+
require_relative "model_profile"
|
|
7
|
+
require_relative "served_model"
|
|
8
|
+
require_relative "turn_flow"
|
|
9
|
+
require_relative "tools/execute"
|
|
10
|
+
require_relative "commands/registry"
|
|
11
|
+
|
|
12
|
+
module Samagotchi
|
|
13
|
+
# The session commands a REPL and a session worker both run: /model,
|
|
14
|
+
# /models, /guardrails, !rollback, !cmd and the answer to a continue offer. They act on
|
|
15
|
+
# the Engine and its TurnFlow; the host prints the result's output and
|
|
16
|
+
# runs a continue turn when asked to (#run never runs a turn).
|
|
17
|
+
#
|
|
18
|
+
# /stats, /recap and /exit are the UI's own: they are in the registry
|
|
19
|
+
# (local: for Tab and help) but #run never runs them.
|
|
20
|
+
class SessionCommands
|
|
21
|
+
MODEL_COMMAND = "/model"
|
|
22
|
+
MODELS_COMMAND = "/models"
|
|
23
|
+
GUARDRAILS_COMMAND = "/guardrails"
|
|
24
|
+
HELP_COMMAND = "/help"
|
|
25
|
+
# A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
|
|
26
|
+
# shows this many per host; /models <text> lists every match.
|
|
27
|
+
MODELS_PER_HOST = 20
|
|
28
|
+
ROLLBACK_COMMAND = "!rollback"
|
|
29
|
+
CONTINUE_COMMAND = TurnFlow::CONTINUE_COMMAND
|
|
30
|
+
SHELL_BANG_PREFIX = "!"
|
|
31
|
+
RESERVED_MODEL_ARGS = %w[clear default none off].freeze
|
|
32
|
+
ALIAS_USAGE = "usage /model <model> --alias <name> [--default]"
|
|
33
|
+
|
|
34
|
+
# @!attribute status [Symbol] :ok, or :error when the line was refused
|
|
35
|
+
# @!attribute output [String, nil] what to tell the user
|
|
36
|
+
# @!attribute changed [Array<Symbol>] :messages and/or :model
|
|
37
|
+
# @!attribute model_name [String] the Engine's model after the command
|
|
38
|
+
# @!attribute resume [Boolean] the host must now run the continue turn
|
|
39
|
+
# @!attribute shell [Boolean] output is a !cmd's own output
|
|
40
|
+
# @!attribute decision [Symbol, nil] an answer to the continue offer:
|
|
41
|
+
# :resume, :abort, :abort_with_reason or :invalid
|
|
42
|
+
Result = Struct.new(:status, :output, :changed, :model_name, :resume, :shell, :decision, keyword_init: true)
|
|
43
|
+
|
|
44
|
+
# Puts the built-in commands into +registry+, in lookup order (!rollback
|
|
45
|
+
# before !cmd): the ones #run runs, then the UIs' own (local: Tab and
|
|
46
|
+
# help only).
|
|
47
|
+
def self.register_builtins(registry)
|
|
48
|
+
registry.register(ROLLBACK_COMMAND, "discard the interrupted turn and restore the pre-turn state",
|
|
49
|
+
id: :rollback) { |_text| rollback }
|
|
50
|
+
registry.register("!", "run a shell command; its output goes into the conversation",
|
|
51
|
+
id: :shell, match: ->(text) { text.match?(/\A!\s*\S/) }) { |text| shell(text) }
|
|
52
|
+
registry.register(CONTINUE_COMMAND, "answer the continue offer (yes, no or no, <reason>)") do |text|
|
|
53
|
+
next reply("nothing to continue") unless @turn_flow.awaiting_continue?
|
|
54
|
+
|
|
55
|
+
answer = text.delete_prefix(CONTINUE_COMMAND).strip
|
|
56
|
+
continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
|
|
57
|
+
end
|
|
58
|
+
registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)") do |text|
|
|
59
|
+
reply(models_listing(text.delete_prefix(MODELS_COMMAND).strip))
|
|
60
|
+
end
|
|
61
|
+
registry.register(GUARDRAILS_COMMAND, "list the guardrail rules and approvals (/guardrails revoke N)") do |text|
|
|
62
|
+
guardrails(text.delete_prefix(GUARDRAILS_COMMAND).strip)
|
|
63
|
+
end
|
|
64
|
+
registry.register(MODEL_COMMAND, "show or switch the model",
|
|
65
|
+
match: ->(text) { text.match?(/\A\/model(?:\s+.*)?\z/) }) { |text| model(text) }
|
|
66
|
+
registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
|
|
67
|
+
registry.register("/stats", "show the session's stats", local: true)
|
|
68
|
+
registry.register("/recap", "show the session's recap", local: true)
|
|
69
|
+
registry.register("/exit", "leave (--delete also deletes the session)", local: true)
|
|
70
|
+
registry.register("/quit", "leave, like /exit", local: true, uis: [:attached])
|
|
71
|
+
registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached])
|
|
72
|
+
registry
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The built-ins alone, for callers without an Engine (an attached TUI
|
|
76
|
+
# before its snapshot names the session's commands, specs).
|
|
77
|
+
# @return [Commands::Registry] frozen
|
|
78
|
+
def self.builtin_registry
|
|
79
|
+
@builtin_registry ||= register_builtins(Commands::Registry.new).freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [String] the model /model clear goes back to
|
|
83
|
+
attr_reader :default_model
|
|
84
|
+
|
|
85
|
+
# @param default_model [String] the config default, which /model clear
|
|
86
|
+
# restores and /model names (the Engine may have started elsewhere, e.g.
|
|
87
|
+
# on a resumed session's model)
|
|
88
|
+
# @param save [#call] saves a session (a worker passes its state dir)
|
|
89
|
+
# @param registry [Commands::Registry] the commands #run looks lines up in
|
|
90
|
+
def initialize(engine:, turn_flow:, default_model:, save: ->(session) { session.save },
|
|
91
|
+
registry: self.class.builtin_registry)
|
|
92
|
+
@engine = engine
|
|
93
|
+
@turn_flow = turn_flow
|
|
94
|
+
@default_model = default_model
|
|
95
|
+
@save = save
|
|
96
|
+
@registry = registry
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Commands::Registry]
|
|
100
|
+
attr_reader :registry
|
|
101
|
+
|
|
102
|
+
# /help: every command in the registry, the UIs' own and the bundles'
|
|
103
|
+
# too: the session's commands first, then the bundles', then the UIs'
|
|
104
|
+
# own (marked by UI), each group by name.
|
|
105
|
+
# @return [String]
|
|
106
|
+
def help_listing
|
|
107
|
+
entries = @registry.entries.sort_by { |entry| [help_group(entry), entry.name] }
|
|
108
|
+
width = entries.map { |entry| self.class.display_name(entry).length }.max
|
|
109
|
+
lines = entries.map do |entry|
|
|
110
|
+
notes = []
|
|
111
|
+
notes << entry.source unless entry.source == "core"
|
|
112
|
+
notes << "mid-turn too" if entry.anytime
|
|
113
|
+
notes << (entry.uis ? "#{entry.uis.join(" and ")} only" : "terminal only") if entry.local
|
|
114
|
+
line = " #{self.class.display_name(entry).ljust(width)} #{entry.description}"
|
|
115
|
+
notes.empty? ? line : "#{line} (#{notes.join("; ")})"
|
|
116
|
+
end
|
|
117
|
+
"commands:\n#{lines.join("\n")}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# How help and errors name an entry (the shell's "!" is "!<cmd>").
|
|
121
|
+
def self.display_name(entry) = entry.name == SHELL_BANG_PREFIX ? "#{SHELL_BANG_PREFIX}<cmd>" : entry.name
|
|
122
|
+
|
|
123
|
+
# @return [Result, nil] nil when +line+ is not one of these commands
|
|
124
|
+
def run(line)
|
|
125
|
+
text = line.to_s.strip
|
|
126
|
+
entry = @registry.lookup(text)
|
|
127
|
+
return nil unless entry&.handler
|
|
128
|
+
return run_bundle_command(entry, text) unless entry.source == "core"
|
|
129
|
+
|
|
130
|
+
instance_exec(text, &entry.handler)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Answer the pending continue offer (yes / no / no, <reason>).
|
|
134
|
+
# @return [Result] resume: true on yes
|
|
135
|
+
def continue_answer(text)
|
|
136
|
+
decision, reason = TurnFlow.continue_decision(text)
|
|
137
|
+
result = case decision
|
|
138
|
+
when :resume then Result.new(status: :ok, changed: [], model_name: model_name, resume: true, shell: false)
|
|
139
|
+
when :abort
|
|
140
|
+
@turn_flow.abort_continue!
|
|
141
|
+
save_session
|
|
142
|
+
reply("interrupted turn cancelled; enter your next prompt", changed: [:messages])
|
|
143
|
+
when :abort_with_reason
|
|
144
|
+
@turn_flow.abort_continue!(reason: reason)
|
|
145
|
+
save_session
|
|
146
|
+
reply("interrupted turn cancelled; noted your explanation", changed: [:messages])
|
|
147
|
+
else
|
|
148
|
+
reply("answer yes, no, or no, <reason>", status: :error)
|
|
149
|
+
end
|
|
150
|
+
result.decision = decision
|
|
151
|
+
result
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def model_name = @engine.effective_model_name
|
|
157
|
+
|
|
158
|
+
# A bundle plugin's command: its handler gets the text after the name;
|
|
159
|
+
# what it returns is the output (nil: nothing to show), and a raise is
|
|
160
|
+
# an error result.
|
|
161
|
+
def run_bundle_command(entry, text)
|
|
162
|
+
output = entry.handler.call(text.delete_prefix(entry.name).strip)
|
|
163
|
+
reply(output.nil? ? nil : output.to_s)
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
reply("#{entry.name}: #{e.class}: #{e.message}", status: :error)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def help_group(entry)
|
|
169
|
+
return 2 if entry.local
|
|
170
|
+
|
|
171
|
+
entry.source == "core" ? 0 : 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def reply(output, status: :ok, changed: [])
|
|
175
|
+
Result.new(status: status, output: output, changed: changed, model_name: model_name, resume: false, shell: false)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Explicit escape hatch after a Ctrl-C: discard the salvaged partial
|
|
179
|
+
# turn and restore the pre-turn checkpoint.
|
|
180
|
+
def rollback
|
|
181
|
+
offered = @turn_flow.awaiting_continue?
|
|
182
|
+
return reply("nothing to rollback") unless @turn_flow.rollback!
|
|
183
|
+
|
|
184
|
+
save_session
|
|
185
|
+
result = reply("salvaged turn discarded; restored pre-turn state", changed: [:messages])
|
|
186
|
+
# It discarded the turn a continue was offered for: that offer's no.
|
|
187
|
+
result.decision = :abort if offered
|
|
188
|
+
result
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The output goes into the conversation for the next turn. Not saved
|
|
192
|
+
# here (as in the REPL, the next turn saves it).
|
|
193
|
+
def shell(text)
|
|
194
|
+
command = text.delete_prefix(SHELL_BANG_PREFIX).strip
|
|
195
|
+
return reply("!: please provide a shell command after '!'", status: :error) if command.empty?
|
|
196
|
+
|
|
197
|
+
output = Samagotchi::Tools::Execute.call(command)
|
|
198
|
+
@engine.append_messages([{ role: "user", content: "!(#{command})\n#{output}" }])
|
|
199
|
+
# Rolling back past this would silently drop the command output.
|
|
200
|
+
@turn_flow.note_conversation_changed
|
|
201
|
+
Result.new(status: :ok, output: output, changed: [:messages], model_name: model_name, resume: false, shell: true)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# /guardrails: the rules (by source), what failed to load, the stored
|
|
205
|
+
# approvals, numbered; /guardrails revoke N removes approval N.
|
|
206
|
+
def guardrails(args)
|
|
207
|
+
return guardrails_revoke(args.delete_prefix("revoke").strip) if args.start_with?("revoke")
|
|
208
|
+
return reply("usage: /guardrails [revoke N]", status: :error) unless args.empty?
|
|
209
|
+
|
|
210
|
+
reply(guardrails_listing)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def guardrails_listing
|
|
214
|
+
rules = @engine.guardrail_rules
|
|
215
|
+
approvals = @engine.guardrail_approvals.entries
|
|
216
|
+
lines = ["guardrails: #{rules.enabled? ? "on" : "off (guardrails.enabled: false; denies still apply)"}"]
|
|
217
|
+
failures = @engine.guardrail_failures.list
|
|
218
|
+
unless failures.empty?
|
|
219
|
+
lines << "failed to load:"
|
|
220
|
+
failures.each do |f|
|
|
221
|
+
lines << " #{f.what}: #{f.reason}#{" (required: every tool call is denied)" if f.required}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
disabled = rules.rules.count { |rule| rules.disabled?(rule) }
|
|
225
|
+
lines << "rules (#{rules.rules.size}#{", #{disabled} disabled" if disabled.positive?}):"
|
|
226
|
+
lines << " (none; add them under guardrails.rules in config.yml, or install a bundle that ships them)" if rules.rules.empty?
|
|
227
|
+
rules.rules.each_with_index do |rule, idx|
|
|
228
|
+
match = [
|
|
229
|
+
("tool #{rule.tools.join(",")}" if rule.tools),
|
|
230
|
+
("command /#{shorten_pattern(rule.command.source)}/" if rule.command),
|
|
231
|
+
("path #{rule.path}" if rule.path)
|
|
232
|
+
].compact.join(", ")
|
|
233
|
+
off = "disabled (guardrails.disable) — " if rules.disabled?(rule)
|
|
234
|
+
lines << " #{idx + 1}. #{rule.id}: #{off}#{rule.verdict} (#{match}) — #{rule.reason} [#{rule.source}]"
|
|
235
|
+
end
|
|
236
|
+
rules.unmatched_disables.each { |entry| lines << " guardrails.disable: #{entry} matches no rule" }
|
|
237
|
+
lines << " core: deny writes to the approval store and installed bundles; ask before editing config.yml or the hooks dir"
|
|
238
|
+
lines << "approvals (#{approvals.size}):"
|
|
239
|
+
lines << " (none)" if approvals.empty?
|
|
240
|
+
approvals.each_with_index { |entry, idx| lines << " #{idx + 1}. #{approval_line(entry)}" }
|
|
241
|
+
lines << "revoke one with /guardrails revoke N" unless approvals.empty?
|
|
242
|
+
lines.join("\n")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
PATTERN_SHOWN = 80
|
|
246
|
+
|
|
247
|
+
# A regex source cut to PATTERN_SHOWN characters, the last one "…".
|
|
248
|
+
def shorten_pattern(source)
|
|
249
|
+
source.length > PATTERN_SHOWN ? "#{source[0, PATTERN_SHOWN - 1]}…" : source
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def approval_line(entry)
|
|
253
|
+
what = entry["key"] ? entry["key"].tr("\n", " ") : "any call rule #{entry["rule"]} asks about"
|
|
254
|
+
where = entry["scope"] == "session" ? "session #{entry["session_id"].to_s[0, 8]}" : "in #{entry["repo_root"]}"
|
|
255
|
+
rule = entry["rule"] ? " (rule #{entry["rule"]}, #{entry["source"]})" : ""
|
|
256
|
+
"#{entry["scope"]}: #{what} — #{where}#{rule}, #{entry["created_at"]}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def guardrails_revoke(arg)
|
|
260
|
+
return reply("usage: /guardrails revoke N (N from /guardrails)", status: :error) unless arg.match?(/\A\d+\z/)
|
|
261
|
+
|
|
262
|
+
removed = @engine.guardrail_approvals.revoke(arg.to_i - 1)
|
|
263
|
+
return reply("no approval #{arg} (see /guardrails)", status: :error) unless removed
|
|
264
|
+
|
|
265
|
+
reply("revoked approval #{arg}: #{approval_line(removed)}")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def save_session
|
|
269
|
+
session = @engine.session
|
|
270
|
+
return unless session
|
|
271
|
+
|
|
272
|
+
session.model_name = model_name
|
|
273
|
+
@save.call(session)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def model(text)
|
|
277
|
+
output, switched = model_command(text)
|
|
278
|
+
reply(output, changed: switched ? [:model] : [])
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# @return [Array(String, Boolean)] the message, and whether the model changed
|
|
282
|
+
def model_command(input)
|
|
283
|
+
suffix = input.delete_prefix(MODEL_COMMAND).strip
|
|
284
|
+
if suffix.empty?
|
|
285
|
+
return ["runtime model: #{model_name}#{model_note}#{served_note}", false] if model_name == @default_model
|
|
286
|
+
|
|
287
|
+
return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}", false]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Parse flags: --default and --alias <name> / --alias=<name> (tolerant order)
|
|
291
|
+
tokens = suffix.split(/\s+/)
|
|
292
|
+
persist_default = false
|
|
293
|
+
alias_name = nil
|
|
294
|
+
alias_seen = false
|
|
295
|
+
model_parts = []
|
|
296
|
+
i = 0
|
|
297
|
+
while i < tokens.length
|
|
298
|
+
tok = tokens[i]
|
|
299
|
+
if tok == "--default"
|
|
300
|
+
persist_default = true
|
|
301
|
+
i += 1
|
|
302
|
+
elsif tok == "--alias"
|
|
303
|
+
return ["multiple --alias flags are not supported: #{ALIAS_USAGE}", false] if alias_seen
|
|
304
|
+
|
|
305
|
+
alias_seen = true
|
|
306
|
+
nxt = tokens[i + 1]
|
|
307
|
+
return ["--alias requires a name: #{ALIAS_USAGE}", false] if nxt.nil? || nxt.strip.empty? || nxt.start_with?("-")
|
|
308
|
+
|
|
309
|
+
alias_name = nxt.strip
|
|
310
|
+
i += 2
|
|
311
|
+
elsif tok.start_with?("--alias=")
|
|
312
|
+
return ["multiple --alias flags are not supported: #{ALIAS_USAGE}", false] if alias_seen
|
|
313
|
+
|
|
314
|
+
alias_seen = true
|
|
315
|
+
val = tok.delete_prefix("--alias=").strip
|
|
316
|
+
return ["--alias requires a name: #{ALIAS_USAGE}", false] if val.empty? || val.start_with?("-")
|
|
317
|
+
|
|
318
|
+
alias_name = val
|
|
319
|
+
i += 1
|
|
320
|
+
else
|
|
321
|
+
model_parts << tok
|
|
322
|
+
i += 1
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
arg = model_parts.join(" ").strip
|
|
327
|
+
|
|
328
|
+
if alias_name
|
|
329
|
+
return ["--alias requires a model name: #{ALIAS_USAGE}", false] if arg.empty?
|
|
330
|
+
return ["--alias cannot be combined with clear/default/none/off", false] if RESERVED_MODEL_ARGS.include?(arg.downcase)
|
|
331
|
+
|
|
332
|
+
# Validate the alias before switching, so a bad one changes nothing.
|
|
333
|
+
invalid = alias_name_error(alias_name, arg)
|
|
334
|
+
return ["invalid alias: #{invalid}", false] if invalid
|
|
335
|
+
|
|
336
|
+
switch_model(arg, persist_default: persist_default)
|
|
337
|
+
|
|
338
|
+
begin
|
|
339
|
+
previous = ConfigFile.write_model_alias!(alias_name, model_name)
|
|
340
|
+
rescue ArgumentError => e
|
|
341
|
+
return ["invalid alias: #{e.message} (runtime model set to #{model_name}#{model_note})", true]
|
|
342
|
+
rescue StandardError => e
|
|
343
|
+
return ["runtime model set to #{model_name}#{model_note} but failed to persist alias: #{e.message}", true]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
key = alias_name.strip.downcase
|
|
347
|
+
warn_prefix = previous ? "warning: overwriting alias '#{key}' (#{previous} -> #{model_name}); " : ""
|
|
348
|
+
base = persist_default ? "runtime model set to #{model_name}#{model_note} and default updated" : "runtime model set to #{model_name}#{model_note}"
|
|
349
|
+
["#{warn_prefix}#{base}; alias '#{key}' -> '#{model_name}' persisted", true]
|
|
350
|
+
else
|
|
351
|
+
return ["--default requires a model name: usage /model --default <name> or /model <name> [--default]", false] if arg.empty?
|
|
352
|
+
|
|
353
|
+
if RESERVED_MODEL_ARGS.include?(arg.downcase)
|
|
354
|
+
return ["--default cannot be combined with clear/default/none/off", false] if persist_default
|
|
355
|
+
|
|
356
|
+
switch_model(@default_model)
|
|
357
|
+
return ["runtime model reset to #{model_name}#{model_note}", true]
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
switch_model(arg, persist_default: persist_default)
|
|
361
|
+
if persist_default
|
|
362
|
+
["runtime model set to #{model_name}#{model_note} and default updated", true]
|
|
363
|
+
else
|
|
364
|
+
["runtime model set to #{model_name}#{model_note}", true]
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Mirrors ConfigFile.write_model_alias!'s checks without writing.
|
|
370
|
+
# @return [String, nil] what is wrong with the alias name
|
|
371
|
+
def alias_name_error(alias_name, target)
|
|
372
|
+
ak = alias_name.strip
|
|
373
|
+
return "alias name is required" if ak.empty?
|
|
374
|
+
|
|
375
|
+
lk = ak.downcase
|
|
376
|
+
return "alias name '#{ak}' is reserved" if RESERVED_MODEL_ARGS.include?(lk)
|
|
377
|
+
return "alias name must not contain whitespace" if ak.match?(/\s/)
|
|
378
|
+
return "alias name must not start with '-'" if ak.start_with?("-")
|
|
379
|
+
return "alias name must not contain '/'" if ak.include?("/")
|
|
380
|
+
return "alias name must match /[a-z0-9][a-z0-9._-]*/i (got '#{ak}')" unless ak.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
|
|
381
|
+
return "alias must not point to itself" if lk == target.strip.downcase
|
|
382
|
+
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Engine owns the switch (alias resolution, profile, kernel, client and
|
|
387
|
+
# the optional default persist); the session keeps the new model.
|
|
388
|
+
def switch_model(name, persist_default: false)
|
|
389
|
+
@engine.switch_model!(name, persist_default: persist_default)
|
|
390
|
+
@default_model = @engine.default_model_name if persist_default
|
|
391
|
+
persist_session_model
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def persist_session_model
|
|
395
|
+
session = @engine.session
|
|
396
|
+
return unless session.respond_to?(:model_name=)
|
|
397
|
+
|
|
398
|
+
session.model_name = model_name
|
|
399
|
+
begin
|
|
400
|
+
@save.call(session)
|
|
401
|
+
rescue StandardError
|
|
402
|
+
nil
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# "; served: <name>" when the server serves another model than asked.
|
|
407
|
+
def served_note
|
|
408
|
+
served, asked = @engine.respond_to?(:served_model) ? @engine.served_model : nil
|
|
409
|
+
ServedModel.differs?(asked, served) ? "; served: #{served}" : ""
|
|
410
|
+
rescue StandardError
|
|
411
|
+
""
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# " (default: x, profile=qwen36, name)": +extra+ and the prompt profile,
|
|
415
|
+
# which a chat host's model doesn't have (its loop uses none); "" when
|
|
416
|
+
# there is neither.
|
|
417
|
+
def model_note(extra = nil)
|
|
418
|
+
parts = [extra, (profile_note unless chat_model?)].compact
|
|
419
|
+
parts.empty? ? "" : " (#{parts.join(", ")})"
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def chat_model?
|
|
423
|
+
@engine.respond_to?(:chat_model?) && @engine.chat_model?
|
|
424
|
+
rescue StandardError
|
|
425
|
+
false
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# "profile=qwen36, server (chat_template)": the profile and where it came from.
|
|
429
|
+
def profile_note
|
|
430
|
+
resolution = @engine.profile_resolution
|
|
431
|
+
"profile=#{resolution.profile.name}, #{resolution.label}"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# @param filter [String] only ids containing it (any case); "" lists
|
|
435
|
+
# up to MODELS_PER_HOST per host
|
|
436
|
+
def models_listing(filter = "")
|
|
437
|
+
needle = filter.downcase
|
|
438
|
+
registry = @engine.host_registry
|
|
439
|
+
# Aggregate across all hosts (lazy discovery, skip-on-error)
|
|
440
|
+
results = registry.list_all_models
|
|
441
|
+
return "no hosts configured" if results.nil? || results.empty?
|
|
442
|
+
|
|
443
|
+
aliases = ConfigFile.model_aliases
|
|
444
|
+
by_model = Hash.new { |h, k| h[k] = [] }
|
|
445
|
+
aliases.each do |alias_name, model_id|
|
|
446
|
+
# normalize bare comparison for orphan detection (strip host prefix if present)
|
|
447
|
+
_, bare = registry.parse_qualified_model(model_id)
|
|
448
|
+
key = (bare.empty? ? model_id : bare).to_s.downcase
|
|
449
|
+
by_model[key] << alias_name
|
|
450
|
+
# also index full ref for exact alias display
|
|
451
|
+
by_model[model_id.downcase] << alias_name unless key == model_id.downcase
|
|
452
|
+
end
|
|
453
|
+
by_model.each_value { |v| v.uniq!; v.sort! }
|
|
454
|
+
|
|
455
|
+
seen = Set.new
|
|
456
|
+
lines = []
|
|
457
|
+
# Sort hosts for deterministic output
|
|
458
|
+
results.keys.sort.each do |hname|
|
|
459
|
+
data = results[hname]
|
|
460
|
+
host_label = "#{hname} (#{data[:host]}:#{data[:port]})"
|
|
461
|
+
if data[:error]
|
|
462
|
+
lines << "#{host_label} — unreachable: #{data[:error]}"
|
|
463
|
+
next
|
|
464
|
+
end
|
|
465
|
+
models = Array(data[:models])
|
|
466
|
+
if models.empty?
|
|
467
|
+
lines << "#{host_label} — no models discovered"
|
|
468
|
+
next
|
|
469
|
+
end
|
|
470
|
+
shown = []
|
|
471
|
+
batch_variants = batch_variant_ids(models, needle)
|
|
472
|
+
models.each do |entry|
|
|
473
|
+
identifier = entry.id.to_s.empty? ? "unknown" : entry.id
|
|
474
|
+
seen << identifier.to_s.downcase
|
|
475
|
+
# also track host-qualified seen for orphan logic
|
|
476
|
+
seen << "#{hname}:#{identifier}".downcase
|
|
477
|
+
seen << "#{hname}/#{identifier}".downcase
|
|
478
|
+
next if batch_variants.include?(identifier)
|
|
479
|
+
|
|
480
|
+
shown << [entry, identifier] if needle.empty? || identifier.to_s.downcase.include?(needle)
|
|
481
|
+
end
|
|
482
|
+
next if shown.empty?
|
|
483
|
+
|
|
484
|
+
lines << "#{host_label}:"
|
|
485
|
+
hidden = needle.empty? ? [shown.size - MODELS_PER_HOST, 0].max : 0
|
|
486
|
+
shown.first(shown.size - hidden).each do |entry, identifier|
|
|
487
|
+
raw_status = entry.raw["status"] || entry.raw[:status]
|
|
488
|
+
status = raw_status.is_a?(Hash) ? (raw_status["value"] || raw_status[:value] || raw_status["status"] || raw_status[:status]) : raw_status
|
|
489
|
+
base = status.to_s.empty? ? " #{identifier}" : " #{identifier} (#{status})"
|
|
490
|
+
alias_list = (by_model[identifier.to_s.downcase] || []) + (by_model["#{hname}:#{identifier}".downcase] || [])
|
|
491
|
+
alias_list.uniq!
|
|
492
|
+
lines << (alias_list.empty? ? base : "#{base} (alias: #{alias_list.join(", ")})")
|
|
493
|
+
end
|
|
494
|
+
batch = batch_variants.count { |id| needle.empty? || id.downcase.include?(needle) }
|
|
495
|
+
batch_note = "#{batch} :batch variant#{"s" unless batch == 1}"
|
|
496
|
+
if hidden.positive?
|
|
497
|
+
more = batch.positive? ? "#{hidden} more, plus #{batch_note}" : "#{hidden} more"
|
|
498
|
+
lines << " … and #{more}; /models <text> lists the ids containing <text>"
|
|
499
|
+
elsif batch.positive?
|
|
500
|
+
lines << " … plus #{batch_note}; /models :batch lists them"
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
# Warnings for unreachable hosts are already in lines; no failover
|
|
504
|
+
orphans = aliases.reject { |_, model_id| seen.include?(model_id.downcase) || seen.include?(registry.bare_name(model_id).downcase) }
|
|
505
|
+
unless orphans.empty? || !needle.empty?
|
|
506
|
+
lines << ""
|
|
507
|
+
lines << "orphan aliases (target not discovered):"
|
|
508
|
+
orphans.sort.each { |alias_name, model_id| lines << " #{alias_name} -> #{model_id}" }
|
|
509
|
+
end
|
|
510
|
+
lines = [needle.empty? ? "no models discovered" : "no model ids contain #{filter.inspect}"] if lines.empty?
|
|
511
|
+
lines.join("\n")
|
|
512
|
+
rescue StandardError => e
|
|
513
|
+
"unable to list models: #{e.message}"
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# OpenRouter lists a "<id>:batch" variant next to many ids (71 of 457 on
|
|
517
|
+
# 2026-09-23), which only repeats the model. The ids of such variants
|
|
518
|
+
# whose plain id is listed too, unless the filter asks for batch.
|
|
519
|
+
def batch_variant_ids(models, needle)
|
|
520
|
+
return Set.new if needle.include?("batch")
|
|
521
|
+
|
|
522
|
+
ids = models.map { |entry| entry.id.to_s }
|
|
523
|
+
plain = ids.to_set
|
|
524
|
+
ids.select { |id| id.end_with?(":batch") && plain.include?(id.delete_suffix(":batch")) }.to_set
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session"
|
|
4
|
+
require_relative "session_manager"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# `chi sessions delete`: remove sessions for good (SessionManager.delete_session),
|
|
8
|
+
# one line per id. A live worker's session is refused unless --force stops
|
|
9
|
+
# the worker first; a chi REPL's never is.
|
|
10
|
+
class SessionDeleteCommand
|
|
11
|
+
USAGE = <<~TEXT
|
|
12
|
+
Usage: chi sessions delete [--force] (ID|PREFIX)...
|
|
13
|
+
Deletes each session: its history, notes, images and queued input.
|
|
14
|
+
This can't be undone.
|
|
15
|
+
-f, --force stop a session's running worker first (without it,
|
|
16
|
+
a live session is refused); a session open in a
|
|
17
|
+
chi REPL is always refused
|
|
18
|
+
TEXT
|
|
19
|
+
PREVIEW_LIMIT = 60
|
|
20
|
+
|
|
21
|
+
# @param argv [Array<String>] the arguments after "sessions delete"
|
|
22
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
|
|
23
|
+
@argv = argv.dup
|
|
24
|
+
@stdout = stdout
|
|
25
|
+
@stderr = stderr
|
|
26
|
+
@state_dir = state_dir || Session.default_state_dir
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Integer] exit status: 0 all deleted, 1 any refused or
|
|
30
|
+
# unknown, 2 usage
|
|
31
|
+
def run
|
|
32
|
+
options = parse or return 2
|
|
33
|
+
return 0 if options[:help]
|
|
34
|
+
|
|
35
|
+
options[:ids].uniq.map { |given| delete(given, force: options[:force]) }.all? ? 0 : 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def parse
|
|
41
|
+
options = { ids: [], force: false }
|
|
42
|
+
until @argv.empty?
|
|
43
|
+
arg = @argv.shift
|
|
44
|
+
case arg
|
|
45
|
+
when "-h", "--help", "help"
|
|
46
|
+
@stdout.puts(USAGE)
|
|
47
|
+
return { help: true }
|
|
48
|
+
when "-f", "--force" then options[:force] = true
|
|
49
|
+
when /\A-/ then return usage_error("unknown option #{arg}")
|
|
50
|
+
else options[:ids] << arg
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
return usage_error("give session ids") if options[:ids].empty?
|
|
54
|
+
|
|
55
|
+
options
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Boolean] whether the session is gone
|
|
59
|
+
def delete(given, force:)
|
|
60
|
+
id = Session.resolve_id(given, state_dir: @state_dir)
|
|
61
|
+
short = id[0, 8]
|
|
62
|
+
preview = preview_of(id)
|
|
63
|
+
result = SessionManager.delete_session(id, state_dir: @state_dir, stop: force)
|
|
64
|
+
@stdout.puts(["#{short} deleted#{" (stopped its worker)" if result[:stopped]}", preview].compact.join(" "))
|
|
65
|
+
true
|
|
66
|
+
rescue SessionManager::OwnedByTUI
|
|
67
|
+
@stdout.puts("#{short} refused: it is open in a chi REPL; close it there first")
|
|
68
|
+
false
|
|
69
|
+
rescue SessionManager::DeleteRefused => e
|
|
70
|
+
line = if e.reason == :still_stopping then "its worker is still shutting down; try again in a moment"
|
|
71
|
+
else "its worker is running (--force stops it first)"
|
|
72
|
+
end
|
|
73
|
+
@stdout.puts("#{short} refused: #{line}")
|
|
74
|
+
false
|
|
75
|
+
rescue ArgumentError => e
|
|
76
|
+
error_line("chi sessions delete: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
|
|
77
|
+
false
|
|
78
|
+
rescue SystemCallError => e
|
|
79
|
+
@stdout.puts("#{short} failed: #{e.message}")
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def preview_of(id)
|
|
84
|
+
text = Session.load(id, state_dir: @state_dir).first_preview.to_s.gsub(/\s+/, " ").strip
|
|
85
|
+
return nil if text.empty?
|
|
86
|
+
|
|
87
|
+
text.length > PREVIEW_LIMIT ? "#{text[0, PREVIEW_LIMIT - 1]}…" : text
|
|
88
|
+
rescue ArgumentError, JSON::ParserError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# stderr isn't buffered, stdout is when it's a pipe: flush the lines
|
|
93
|
+
# already printed, so the output keeps the order of the ids given.
|
|
94
|
+
def error_line(text)
|
|
95
|
+
@stdout.flush
|
|
96
|
+
@stderr.puts(text)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def usage_error(message)
|
|
100
|
+
error_line("chi sessions delete: #{message}")
|
|
101
|
+
@stderr.puts(USAGE)
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|