samagotchi 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +3 -2
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/plugins.md +34 -12
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +29 -29
- data/lib/samagotchi/bridge/card_store.rb +4 -4
- data/lib/samagotchi/bridge/pending_card.rb +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +22 -28
- data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
- data/lib/samagotchi/bridge.rb +59 -112
- data/lib/samagotchi/bridge_client.rb +11 -28
- data/lib/samagotchi/bundle_command.rb +158 -212
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +4 -6
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +51 -0
- data/lib/samagotchi/cli/flags.rb +116 -0
- data/lib/samagotchi/config.rb +70 -55
- data/lib/samagotchi/config_text_edit.rb +107 -0
- data/lib/samagotchi/desktop_command.rb +24 -18
- data/lib/samagotchi/edit_preview.rb +2 -2
- data/lib/samagotchi/engine.rb +20 -75
- data/lib/samagotchi/events.rb +23 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
- data/lib/samagotchi/hooks/loader.rb +23 -33
- data/lib/samagotchi/hooks/registry.rb +41 -0
- data/lib/samagotchi/hooks.rb +0 -3
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/idle_reminders.rb +0 -7
- data/lib/samagotchi/image_store.rb +22 -0
- data/lib/samagotchi/kernel_loop.rb +12 -16
- data/lib/samagotchi/live_versions.rb +2 -13
- data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +1 -27
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
- data/lib/samagotchi/memory_bundle/status.rb +1 -14
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +23 -37
- data/lib/samagotchi/owner_lock.rb +14 -3
- data/lib/samagotchi/plugin/api.rb +11 -17
- data/lib/samagotchi/plugin/context.rb +70 -15
- data/lib/samagotchi/plugin/loader.rb +2 -3
- data/lib/samagotchi/sampling_settings.rb +3 -5
- data/lib/samagotchi/self_report.rb +6 -9
- data/lib/samagotchi/send_command.rb +33 -52
- data/lib/samagotchi/session.rb +88 -92
- data/lib/samagotchi/session_archive_command.rb +8 -14
- data/lib/samagotchi/session_commands.rb +3 -21
- data/lib/samagotchi/session_delete_command.rb +15 -29
- data/lib/samagotchi/session_manager.rb +27 -13
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
- data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
- data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
- data/lib/samagotchi/terminal_ui.rb +156 -234
- data/lib/samagotchi/thinking.rb +2 -3
- data/lib/samagotchi/thought_stream_splitter.rb +10 -9
- data/lib/samagotchi/tool_call_parser.rb +56 -292
- data/lib/samagotchi/tools/builtin_calls.rb +146 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/edit.rb +21 -29
- data/lib/samagotchi/tools/memory.rb +1 -11
- data/lib/samagotchi/tools/send_note.rb +1 -1
- data/lib/samagotchi/update_command.rb +32 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -5
- data/lib/samagotchi/web/app.rb +89 -151
- data/lib/samagotchi/web/public/activity.js +1 -11
- data/lib/samagotchi/web/public/annotate_bar.js +120 -0
- data/lib/samagotchi/web/public/app.js +158 -785
- data/lib/samagotchi/web/public/chunk_router.js +7 -6
- data/lib/samagotchi/web/public/command_list.js +99 -0
- data/lib/samagotchi/web/public/data.js +22 -66
- data/lib/samagotchi/web/public/format.js +0 -23
- data/lib/samagotchi/web/public/index.html +0 -2
- data/lib/samagotchi/web/public/model_pick.js +0 -7
- data/lib/samagotchi/web/public/notifications.js +169 -0
- data/lib/samagotchi/web/public/question_cards.js +352 -0
- data/lib/samagotchi/web/public/session_state.js +34 -0
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_events.js +11 -2
- data/lib/samagotchi/web/public/turn_model.js +0 -14
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +5 -3
- data/lib/samagotchi/worker.rb +59 -53
- data/lib/samagotchi/worker_sidecar.rb +27 -1
- metadata +12 -1
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "session"
|
|
4
4
|
require_relative "session_inbox"
|
|
5
5
|
require_relative "session_manager"
|
|
6
|
+
require_relative "cli/command"
|
|
7
|
+
require_relative "cli/flags"
|
|
6
8
|
|
|
7
9
|
module Samagotchi
|
|
8
10
|
# `chi note`: push text into sessions as a context note (background the
|
|
@@ -10,6 +12,8 @@ module Samagotchi
|
|
|
10
12
|
# script, e.g. an Automator action:
|
|
11
13
|
# pbpaste | chi note --source slack $(chi sessions list --live --scope=all --format tsv | cut -f1)
|
|
12
14
|
class NoteCommand
|
|
15
|
+
include CLI::Command
|
|
16
|
+
|
|
13
17
|
USAGE = <<~TEXT
|
|
14
18
|
Usage: chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all
|
|
15
19
|
Adds TEXT (or stdin) to each session as a context note: background
|
|
@@ -20,6 +24,12 @@ module Samagotchi
|
|
|
20
24
|
Find ids with: chi sessions list --live [--scope=all] [--format tsv]
|
|
21
25
|
TEXT
|
|
22
26
|
|
|
27
|
+
FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
|
|
28
|
+
f.switch "--all"
|
|
29
|
+
f.value "--source"
|
|
30
|
+
f.value "-m", "--message", key: :text
|
|
31
|
+
end
|
|
32
|
+
|
|
23
33
|
# @param argv [Array<String>] the arguments after "note"
|
|
24
34
|
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil)
|
|
25
35
|
@argv = argv.dup
|
|
@@ -32,14 +42,12 @@ module Samagotchi
|
|
|
32
42
|
# @return [Integer] exit status: 0 all queued, 1 any refused or failed,
|
|
33
43
|
# 2 usage
|
|
34
44
|
def run
|
|
35
|
-
options = parse
|
|
36
|
-
return
|
|
45
|
+
options = parse
|
|
46
|
+
return options if options.is_a?(Integer)
|
|
37
47
|
|
|
38
48
|
text = utf8(options[:text] || read_stdin)
|
|
39
|
-
unless text
|
|
40
|
-
|
|
41
|
-
return 2
|
|
42
|
-
end
|
|
49
|
+
return usage_error("no note text: pass -m TEXT or pipe it in") unless text
|
|
50
|
+
|
|
43
51
|
begin
|
|
44
52
|
text = SessionInbox.checked_text(text)
|
|
45
53
|
rescue SessionInbox::NoteRejected => e
|
|
@@ -64,24 +72,15 @@ module Samagotchi
|
|
|
64
72
|
|
|
65
73
|
private
|
|
66
74
|
|
|
75
|
+
def command_name = "chi note"
|
|
76
|
+
|
|
77
|
+
# @return [Hash, Integer] the options, or the exit status after the
|
|
78
|
+
# help or a usage error
|
|
67
79
|
def parse
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
when "-h", "--help", "help"
|
|
73
|
-
@stdout.puts(USAGE)
|
|
74
|
-
return { help: true }
|
|
75
|
-
when "--all" then options[:all] = true
|
|
76
|
-
when "--source", "-m", "--message"
|
|
77
|
-
value = @argv.shift or return usage_error("#{arg} needs a value")
|
|
78
|
-
options[arg == "--source" ? :source : :text] = value
|
|
79
|
-
when /\A--source=(.*)\z/m then options[:source] = Regexp.last_match(1)
|
|
80
|
-
when /\A--message=(.*)\z/m then options[:text] = Regexp.last_match(1)
|
|
81
|
-
when /\A-/ then return usage_error("unknown option #{arg}")
|
|
82
|
-
else options[:ids] << arg
|
|
83
|
-
end
|
|
84
|
-
end
|
|
80
|
+
parsed = parse_flags(FLAGS, @argv, source: "cli")
|
|
81
|
+
return parsed if parsed.is_a?(Integer)
|
|
82
|
+
|
|
83
|
+
options = parsed.options.merge(ids: parsed.args)
|
|
85
84
|
return usage_error("give session ids or --all") if options[:ids].empty? && !options[:all]
|
|
86
85
|
return usage_error("--all takes no ids") if options[:all] && options[:ids].any?
|
|
87
86
|
|
|
@@ -127,7 +126,7 @@ module Samagotchi
|
|
|
127
126
|
def deliver(id, text, source)
|
|
128
127
|
short = id[0, 8]
|
|
129
128
|
owner = SessionManager.session_owner(id, state_dir: @state_dir)
|
|
130
|
-
if owner
|
|
129
|
+
if owner&.tui?
|
|
131
130
|
@stdout.puts("#{short} refused: it is open in a chi REPL; notes need attached mode")
|
|
132
131
|
return false
|
|
133
132
|
end
|
|
@@ -148,18 +147,5 @@ module Samagotchi
|
|
|
148
147
|
def utf8(text)
|
|
149
148
|
text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
150
149
|
end
|
|
151
|
-
|
|
152
|
-
# stderr isn't buffered, stdout is when it's a pipe: flush the lines
|
|
153
|
-
# already printed, so the output keeps the order of the ids given.
|
|
154
|
-
def error_line(text)
|
|
155
|
-
@stdout.flush
|
|
156
|
-
@stderr.puts(text)
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def usage_error(message)
|
|
160
|
-
error_line("chi note: #{message}")
|
|
161
|
-
@stderr.puts(USAGE)
|
|
162
|
-
nil
|
|
163
|
-
end
|
|
164
150
|
end
|
|
165
151
|
end
|
|
@@ -20,6 +20,18 @@ module Samagotchi
|
|
|
20
20
|
RETRY_INTERVAL = 0.05
|
|
21
21
|
OWNER_READ_ATTEMPTS = 10
|
|
22
22
|
|
|
23
|
+
# Who holds the lock, as .owner reads it from the lock file. Its fields
|
|
24
|
+
# may be nil for a moment right after acquisition.
|
|
25
|
+
# kind: "worker" or "tui"; pid as written (an Integer).
|
|
26
|
+
Owner = Data.define(:pid, :kind, :started_at) do
|
|
27
|
+
def initialize(pid: nil, kind: nil, started_at: nil) = super
|
|
28
|
+
|
|
29
|
+
# The interactive TUI's: it reads no input files and takes no notes.
|
|
30
|
+
def tui? = kind == "tui"
|
|
31
|
+
|
|
32
|
+
def worker? = kind == "worker"
|
|
33
|
+
end
|
|
34
|
+
|
|
23
35
|
# Take the lock, retrying for up to +wait+ seconds (another process's
|
|
24
36
|
# #owner probe holds it for a moment). On success the owner's pid and kind
|
|
25
37
|
# are written into the lock file for #owner to report.
|
|
@@ -42,8 +54,7 @@ module Samagotchi
|
|
|
42
54
|
|
|
43
55
|
# The current owner, read without disturbing it.
|
|
44
56
|
# @param session_dir [String]
|
|
45
|
-
# @return [
|
|
46
|
-
# be missing for a moment right after acquisition), nil when free
|
|
57
|
+
# @return [Owner, nil] while held, nil when free
|
|
47
58
|
def self.owner(session_dir)
|
|
48
59
|
File.open(path(session_dir), File::RDONLY) do |file|
|
|
49
60
|
if file.flock(File::LOCK_SH | File::LOCK_NB)
|
|
@@ -59,7 +70,7 @@ module Samagotchi
|
|
|
59
70
|
|
|
60
71
|
sleep(RETRY_INTERVAL / 5)
|
|
61
72
|
end
|
|
62
|
-
data
|
|
73
|
+
Owner.new(pid: data["pid"], kind: data["kind"], started_at: data["started_at"])
|
|
63
74
|
end
|
|
64
75
|
rescue Errno::ENOENT
|
|
65
76
|
nil
|
|
@@ -135,9 +135,10 @@ module Samagotchi
|
|
|
135
135
|
|
|
136
136
|
# Run the block on a hook event (docs/hooks.md: :before_turn,
|
|
137
137
|
# :after_turn, :before_tool_call, …), like a bundle's hooks/*.rb:
|
|
138
|
-
# the block gets the event hash
|
|
139
|
-
#
|
|
140
|
-
#
|
|
138
|
+
# the block gets the event hash and the Context (a block may take the
|
|
139
|
+
# event alone); while it runs, ctx.notify and the other helpers act
|
|
140
|
+
# as this event's (Context#with_event). A block that raises is
|
|
141
|
+
# logged (not shown) and skipped.
|
|
141
142
|
# @param priority [Integer] lower runs first among bundle hooks
|
|
142
143
|
def on(event, priority: 100, &block)
|
|
143
144
|
raise ArgumentError, "on(#{event.inspect}) needs a block" unless block
|
|
@@ -219,22 +220,15 @@ module Samagotchi
|
|
|
219
220
|
end
|
|
220
221
|
@tools.each { |tool| @registries.tools.register(tool[:name], source: @bundle, **Api.entry_fields(tool, context)) }
|
|
221
222
|
@hooks.each do |hook|
|
|
222
|
-
bundle = @bundle
|
|
223
|
-
label = @label
|
|
224
223
|
block = hook[:block]
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
block.call(event, context)
|
|
231
|
-
rescue StandardError => e
|
|
232
|
-
next unless log_failure.call
|
|
233
|
-
|
|
234
|
-
# Logged only: a turn's live region is on screen.
|
|
235
|
-
Log.warn(:plugins, "plugin_hook_failed", bundle: bundle, event: hook[:event].to_s, error: e.class.name,
|
|
236
|
-
msg: "#{label} #{hook[:event]} hook failed: #{e.message}")
|
|
224
|
+
# Logged only (echo: false): a turn's live region is on screen.
|
|
225
|
+
handler = Hooks.wrap(label: "#{@label} #{hook[:event]} hook", event: hook[:event], policy: :log,
|
|
226
|
+
log: [:plugins, "plugin_hook_failed"], echo: false, fields: { bundle: @bundle }) do |event|
|
|
227
|
+
# ctx's helpers act as this event's while the block runs.
|
|
228
|
+
context ? context.with_event(event) { block.call(event, context) } : block.call(event, context)
|
|
237
229
|
end
|
|
230
|
+
@registries.hooks.register_bundle(@bundle, hook[:event], hook_name: @label.split(" ").first,
|
|
231
|
+
priority: hook[:priority], &handler)
|
|
238
232
|
end
|
|
239
233
|
@inits.each do |init|
|
|
240
234
|
block = init[:block]
|
|
@@ -19,7 +19,7 @@ module Samagotchi
|
|
|
19
19
|
# the answer text.
|
|
20
20
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
21
21
|
# +model_name+ and +state_dir+ are what ctx.sessions forks with.
|
|
22
|
-
# +steer+ takes (text,
|
|
22
|
+
# +steer+ takes (text, label), +stop_turn+ and +stop_generation+
|
|
23
23
|
# (reason, label), each true when it acted on a running turn.
|
|
24
24
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
25
25
|
:ask_model, :model_name, :state_dir, :scratch, :steer, :stop_turn, :stop_generation, keyword_init: true)
|
|
@@ -35,7 +35,17 @@ module Samagotchi
|
|
|
35
35
|
# they run for, the bundle's settings and storage, a log, and the
|
|
36
36
|
# user-facing helpers hooks have. One per plugin, for the Engine's life:
|
|
37
37
|
# each read is of the session now.
|
|
38
|
+
#
|
|
39
|
+
# The helpers (notify, ask_user, steer, stop_turn, stop_generation)
|
|
40
|
+
# are a plugin's one surface for them. Inside one of the plugin's event
|
|
41
|
+
# handlers (#with_event, on the handler's thread) they are that fire's
|
|
42
|
+
# event[:x]: stop_turn in before_tool_call also denies the pending
|
|
43
|
+
# call, and steer, stop_turn and stop_generation do nothing once the
|
|
44
|
+
# turn is over. Anywhere else (a command, an init task, a thread the
|
|
45
|
+
# handler started) they act on the session now, through the Host.
|
|
38
46
|
class Context
|
|
47
|
+
# Thread.current key: {Context => the event its handler runs for}.
|
|
48
|
+
CURRENT_EVENTS = :samagotchi_plugin_current_events
|
|
39
49
|
# Debug-log records tagged plugins, with bundle=<bundle> (tags are a
|
|
40
50
|
# closed list, LogLine::TAGS).
|
|
41
51
|
class Logger
|
|
@@ -104,11 +114,34 @@ module Samagotchi
|
|
|
104
114
|
# a plugin can say what its answer is about.
|
|
105
115
|
def messages_partial? = !!@host.messages_partial&.call
|
|
106
116
|
|
|
107
|
-
#
|
|
108
|
-
#
|
|
117
|
+
# Run +block+ as this plugin's handler for +event+: until it returns,
|
|
118
|
+
# the helpers called on this thread act as the event's own. Nests (a
|
|
119
|
+
# fire inside a handler gets its own event; the outer one is back
|
|
120
|
+
# after it); threads the block starts don't inherit it.
|
|
121
|
+
# @param event [Hash] the fire's event
|
|
122
|
+
def with_event(event)
|
|
123
|
+
events = (Thread.current[CURRENT_EVENTS] ||= {}.compare_by_identity)
|
|
124
|
+
had = events.key?(self)
|
|
125
|
+
previous = events[self]
|
|
126
|
+
events[self] = event
|
|
127
|
+
yield
|
|
128
|
+
ensure
|
|
129
|
+
if had
|
|
130
|
+
events[self] = previous
|
|
131
|
+
else
|
|
132
|
+
events&.delete(self)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# One line to the user, labelled by the plugin (in every UI by its
|
|
137
|
+
# bundle's name). Every UI shows it, during a turn or between turns.
|
|
109
138
|
# @param level [Symbol] :info or :warn
|
|
110
139
|
def notify(text, level: :info)
|
|
111
|
-
|
|
140
|
+
if (helper = event_helper(:notify))
|
|
141
|
+
helper.call(text.to_s, level: level)
|
|
142
|
+
else
|
|
143
|
+
@host.notify.call(text.to_s, level, @label)
|
|
144
|
+
end
|
|
112
145
|
nil
|
|
113
146
|
end
|
|
114
147
|
|
|
@@ -167,11 +200,15 @@ module Samagotchi
|
|
|
167
200
|
@sessions ||= Sessions.new(@host)
|
|
168
201
|
end
|
|
169
202
|
|
|
170
|
-
# A single-select question through the question flow
|
|
171
|
-
#
|
|
203
|
+
# A single-select question through the question flow. Inside a
|
|
204
|
+
# stream hook's handler (generation_progress) it asks no one (nil).
|
|
172
205
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:}, or nil
|
|
173
206
|
# (no one to ask, cancelled, bad options)
|
|
174
207
|
def ask_user(question:, options:, header: nil, allow_freeform: false)
|
|
208
|
+
if (helper = event_helper(:ask_user))
|
|
209
|
+
return helper.call(question: question, options: options, header: header, allow_freeform: allow_freeform)
|
|
210
|
+
end
|
|
211
|
+
|
|
175
212
|
@host.ask_user.call(question: question, options: options, header: header, allow_freeform: allow_freeform,
|
|
176
213
|
hook: @label)
|
|
177
214
|
end
|
|
@@ -184,31 +221,49 @@ module Samagotchi
|
|
|
184
221
|
# conversation as its own user message, shown in every UI as a nudge
|
|
185
222
|
# from this bundle. It never starts a turn. True means queued: if the
|
|
186
223
|
# model answers first, or the turn ends, it is dropped (logged).
|
|
187
|
-
# Callable from a
|
|
188
|
-
# turn) or your own thread.
|
|
224
|
+
# Callable from a handler (false from after_turn / session_end), a
|
|
225
|
+
# command (an anytime one runs beside the turn) or your own thread.
|
|
189
226
|
# @return [Boolean] whether a turn was running and the text queued
|
|
190
227
|
def steer(text)
|
|
191
|
-
|
|
228
|
+
helper = event_helper(:steer)
|
|
229
|
+
return !!helper.call(text.to_s) if helper
|
|
230
|
+
|
|
231
|
+
!!@host.steer&.call(text.to_s, @label)
|
|
192
232
|
end
|
|
193
233
|
|
|
194
|
-
# Stop the running turn, after a notice with +reason
|
|
195
|
-
#
|
|
234
|
+
# Stop the running turn, after a notice with +reason+. From a
|
|
235
|
+
# before_tool_call handler it also denies the pending call; from
|
|
236
|
+
# after_turn / session_end it does nothing (false).
|
|
196
237
|
# @return [Boolean] whether a running turn was stopped now
|
|
197
238
|
def stop_turn(reason)
|
|
239
|
+
helper = event_helper(:stop_turn)
|
|
240
|
+
return !!helper.call(reason.to_s) if helper
|
|
241
|
+
|
|
198
242
|
!!@host.stop_turn&.call(reason.to_s, @label)
|
|
199
243
|
end
|
|
200
244
|
|
|
201
|
-
# Cut the generation that is streaming
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
245
|
+
# Cut the generation that is streaming: the turn goes on and the
|
|
246
|
+
# model is asked again (retry.empty_answer), or with no retry left
|
|
247
|
+
# the turn ends as cancelled (hook). Shows nothing: post your own
|
|
248
|
+
# notice. From after_turn / session_end it does nothing (false).
|
|
205
249
|
# @return [Boolean] whether a streaming generation was cut now
|
|
206
250
|
def stop_generation(reason)
|
|
251
|
+
helper = event_helper(:stop_generation)
|
|
252
|
+
return !!helper.call(reason.to_s) if helper
|
|
253
|
+
|
|
207
254
|
!!@host.stop_generation&.call(reason.to_s, @label)
|
|
208
255
|
end
|
|
209
256
|
|
|
210
257
|
private
|
|
211
258
|
|
|
259
|
+
# The event[:x] helper of the event this plugin's handler runs for on
|
|
260
|
+
# this thread, or nil (not in a handler).
|
|
261
|
+
def event_helper(name)
|
|
262
|
+
event = Thread.current[CURRENT_EVENTS]&.[](self)
|
|
263
|
+
helper = event[name] if event.is_a?(Hash)
|
|
264
|
+
helper.respond_to?(:call) ? helper : nil
|
|
265
|
+
end
|
|
266
|
+
|
|
212
267
|
def deep_freeze(value)
|
|
213
268
|
case value
|
|
214
269
|
when Hash then value.to_h { |k, v| [deep_freeze(k), deep_freeze(v)] }.freeze
|
|
@@ -98,10 +98,9 @@ module Samagotchi
|
|
|
98
98
|
def instantiate(bundle_name, file, settings)
|
|
99
99
|
namespace = fresh_namespace(bundle_name)
|
|
100
100
|
namespace.module_eval(File.read(file), file, 1)
|
|
101
|
-
|
|
102
|
-
klass = namespace.const_get(class_name, false)
|
|
101
|
+
klass = Hooks.class_for(file, namespace)
|
|
103
102
|
plugin = Hooks.build_plugin(klass, settings)
|
|
104
|
-
raise ArgumentError, "#{
|
|
103
|
+
raise ArgumentError, "#{klass.name.split("::").last} does not respond to #register" unless plugin.respond_to?(:register)
|
|
105
104
|
|
|
106
105
|
plugin
|
|
107
106
|
end
|
|
@@ -12,17 +12,16 @@ module Samagotchi
|
|
|
12
12
|
EMPTY = {}.freeze
|
|
13
13
|
|
|
14
14
|
# @param target [HostRegistry::ModelTarget]
|
|
15
|
-
# @param names [Array<String
|
|
16
|
-
# bare (the Engine's lookup names); default: the target's model and bare model
|
|
15
|
+
# @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
|
|
17
16
|
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
18
17
|
# @return [Hash] frozen, symbol keys; may be empty
|
|
19
|
-
def self.for(target, names
|
|
18
|
+
def self.for(target, names:, models: nil)
|
|
20
19
|
resolve(target, names, models).first
|
|
21
20
|
end
|
|
22
21
|
|
|
23
22
|
# "temperature=0.6 presence_penalty=1.5 (hosts.work, models: qwen)" for
|
|
24
23
|
# /model, or nil when nothing is configured.
|
|
25
|
-
def self.summary(target, names
|
|
24
|
+
def self.summary(target, names:, models: nil)
|
|
26
25
|
params, sources = resolve(target, names, models)
|
|
27
26
|
return nil if params.empty?
|
|
28
27
|
|
|
@@ -44,7 +43,6 @@ module Samagotchi
|
|
|
44
43
|
rescue StandardError
|
|
45
44
|
{}
|
|
46
45
|
end
|
|
47
|
-
names ||= [target.model, target.bare_model]
|
|
48
46
|
host = target.entry.sampling || EMPTY
|
|
49
47
|
key, model = ConfigFile.model_setting(names, :sampling, models: models)
|
|
50
48
|
sources = []
|
|
@@ -97,11 +97,7 @@ module Samagotchi
|
|
|
97
97
|
File.exist?(path) ? path : "#{path} (missing)"
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
def hooks_dir(env)
|
|
101
|
-
data = ConfigFile.read_yaml(path: ConfigFile.global_path(env: env))
|
|
102
|
-
configured = data.is_a?(Hash) && data["hooks"].is_a?(Hash) ? data["hooks"]["hooks_dir"] : nil
|
|
103
|
-
Hooks::Loader.expand_path(configured || Hooks::Loader.default_hooks_dir(env), env)
|
|
104
|
-
end
|
|
100
|
+
def hooks_dir(env) = Hooks::Loader.hooks_dir(env)
|
|
105
101
|
|
|
106
102
|
# Offline, so only the fallback: the running server's n_ctx wins at runtime.
|
|
107
103
|
def context_window(env)
|
|
@@ -153,8 +149,7 @@ module Samagotchi
|
|
|
153
149
|
entry, bare = registry.host_for_model(model)
|
|
154
150
|
return "name-based (chat API: only strips thoughts)" if entry&.chat?
|
|
155
151
|
|
|
156
|
-
|
|
157
|
-
names = [model, registry.parse_qualified_model(model).last, ConfigFile.resolve_model_alias(model, env: env), bare]
|
|
152
|
+
names = registry.lookup_names(model)
|
|
158
153
|
override = Config.resolve_with_origin("model.profile", env: env, cli_overrides: Config.cli_overrides)
|
|
159
154
|
result = ModelProfile.resolve(names: names, entry: entry, client: nil, bare_model: bare, override: override,
|
|
160
155
|
models: ConfigFile.model_settings(env: env))
|
|
@@ -172,8 +167,10 @@ module Samagotchi
|
|
|
172
167
|
|
|
173
168
|
# The model's thinking level and where it came from (Thinking.resolve).
|
|
174
169
|
def thinking_for(model, env)
|
|
175
|
-
|
|
176
|
-
|
|
170
|
+
registry = HostRegistry.new(env: env)
|
|
171
|
+
target = registry.resolve(model)
|
|
172
|
+
level, source = Thinking.resolve(target, names: registry.lookup_names(model, target: target),
|
|
173
|
+
models: ConfigFile.model_settings(env: env))
|
|
177
174
|
source ? "#{level} (#{source})" : level.to_s
|
|
178
175
|
rescue StandardError => e
|
|
179
176
|
"(unknown: #{e.message})"
|
|
@@ -9,6 +9,8 @@ require_relative "image_store"
|
|
|
9
9
|
require_relative "host_registry"
|
|
10
10
|
require_relative "model_profile"
|
|
11
11
|
require_relative "vision_support"
|
|
12
|
+
require_relative "cli/command"
|
|
13
|
+
require_relative "cli/flags"
|
|
12
14
|
|
|
13
15
|
module Samagotchi
|
|
14
16
|
# `chi send`: put text into sessions as the user's message, the same as
|
|
@@ -19,13 +21,15 @@ module Samagotchi
|
|
|
19
21
|
# --new starts a session instead, and --wait blocks for the reply and
|
|
20
22
|
# prints it: an agent's one-shot the user can watch in the web.
|
|
21
23
|
class SendCommand
|
|
24
|
+
include CLI::Command
|
|
25
|
+
|
|
22
26
|
CLIENT_ID = "cli:send"
|
|
23
27
|
POLL_INTERVAL = ReplyWait::POLL_INTERVAL
|
|
24
28
|
# No live worker this long while waiting: it died before it could mark
|
|
25
29
|
# the session (a worker takes well under a second to start).
|
|
26
30
|
WORKER_GONE_AFTER = 5
|
|
27
|
-
# The
|
|
28
|
-
MAX_IMAGES =
|
|
31
|
+
# The cap on one turn's images the Bridge checks.
|
|
32
|
+
MAX_IMAGES = ImageStore::MAX_TURN_REFS
|
|
29
33
|
|
|
30
34
|
USAGE = <<~TEXT
|
|
31
35
|
Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
|
|
@@ -59,6 +63,19 @@ module Samagotchi
|
|
|
59
63
|
Find ids with: chi sessions list --live [--scope=all] [--format tsv]
|
|
60
64
|
TEXT
|
|
61
65
|
|
|
66
|
+
FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
|
|
67
|
+
f.value "-m", "--message"
|
|
68
|
+
f.switch "--new"
|
|
69
|
+
f.value "--dir"
|
|
70
|
+
f.value "--model"
|
|
71
|
+
f.value "--image", key: :images, repeat: true
|
|
72
|
+
f.switch "--wait"
|
|
73
|
+
f.value "--timeout"
|
|
74
|
+
# Starting a turn in every live session at once is too easy to do
|
|
75
|
+
# by accident.
|
|
76
|
+
f.refuse "--all", "there is no --all: name the sessions"
|
|
77
|
+
end
|
|
78
|
+
|
|
62
79
|
# Whether +model_name+ (a session's; blank: the configured default)
|
|
63
80
|
# takes images, decided as its worker decides before a turn with images
|
|
64
81
|
# (Engine#turn_vision: VisionSupport, with models.<name>.vision under
|
|
@@ -72,7 +89,7 @@ module Samagotchi
|
|
|
72
89
|
name = ModelProfile.required_model_name(model_name)
|
|
73
90
|
aliased = ConfigFile.resolve_model_alias(name)
|
|
74
91
|
target = registry.resolve(aliased)
|
|
75
|
-
names =
|
|
92
|
+
names = registry.lookup_names(name, target: target)
|
|
76
93
|
entry = target.entry
|
|
77
94
|
return VisionSupport.for(target, adapter: registry.adapter_for(entry), names: names) if entry.chat?
|
|
78
95
|
|
|
@@ -106,22 +123,17 @@ module Samagotchi
|
|
|
106
123
|
# 2 usage; with --wait 0 answered, 3 waiting for an answer, 130
|
|
107
124
|
# Ctrl-C (the turn goes on)
|
|
108
125
|
def run
|
|
109
|
-
options = parse
|
|
110
|
-
return
|
|
126
|
+
options = parse
|
|
127
|
+
return options if options.is_a?(Integer)
|
|
111
128
|
|
|
112
129
|
# With --wait stdout is the answer alone.
|
|
113
130
|
@info = options[:wait] ? @stderr : @stdout
|
|
114
131
|
prompt = compose(utf8(read_stdin), utf8(options[:message]))
|
|
115
132
|
# Before the wait-only case, which would drop the images.
|
|
116
|
-
if prompt.nil? && !options[:images].empty?
|
|
117
|
-
usage_error("--image needs a message: pass -m TEXT or pipe it in")
|
|
118
|
-
return 2
|
|
119
|
-
end
|
|
133
|
+
return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
|
|
120
134
|
return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
|
|
121
|
-
unless prompt
|
|
122
|
-
|
|
123
|
-
return 2
|
|
124
|
-
end
|
|
135
|
+
return usage_error("no message: pass -m TEXT or pipe it in") unless prompt
|
|
136
|
+
|
|
125
137
|
begin
|
|
126
138
|
prompt = SessionInbox.checked_text(prompt, noun: "message")
|
|
127
139
|
rescue SessionInbox::NoteRejected => e
|
|
@@ -150,33 +162,15 @@ module Samagotchi
|
|
|
150
162
|
|
|
151
163
|
private
|
|
152
164
|
|
|
165
|
+
def command_name = "chi send"
|
|
166
|
+
|
|
167
|
+
# @return [Hash, Integer] the options, or the exit status after the
|
|
168
|
+
# help or a usage error
|
|
153
169
|
def parse
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
when "-h", "--help", "help"
|
|
159
|
-
@stdout.puts(USAGE)
|
|
160
|
-
return { help: true }
|
|
161
|
-
when "-m", "--message"
|
|
162
|
-
options[:message] = @argv.shift or return usage_error("#{arg} needs a value")
|
|
163
|
-
when /\A--message=(.*)\z/m then options[:message] = Regexp.last_match(1)
|
|
164
|
-
when "--new" then options[:new] = true
|
|
165
|
-
when "--dir", "--model"
|
|
166
|
-
options[arg.delete_prefix("--").to_sym] = @argv.shift or return usage_error("#{arg} needs a value")
|
|
167
|
-
when /\A--(dir|model)=(.*)\z/m then options[Regexp.last_match(1).to_sym] = Regexp.last_match(2)
|
|
168
|
-
when "--image" then options[:images] << (@argv.shift or return usage_error("#{arg} needs a value"))
|
|
169
|
-
when /\A--image=(.*)\z/m then options[:images] << Regexp.last_match(1)
|
|
170
|
-
when "--wait" then options[:wait] = true
|
|
171
|
-
when "--timeout" then options[:timeout] = @argv.shift or return usage_error("#{arg} needs a value")
|
|
172
|
-
when /\A--timeout=(.*)\z/ then options[:timeout] = Regexp.last_match(1)
|
|
173
|
-
# Starting a turn in every live session at once is too easy to do
|
|
174
|
-
# by accident.
|
|
175
|
-
when "--all" then return usage_error("there is no --all: name the sessions")
|
|
176
|
-
when /\A-/ then return usage_error("unknown option #{arg}")
|
|
177
|
-
else options[:ids] << arg
|
|
178
|
-
end
|
|
179
|
-
end
|
|
170
|
+
parsed = parse_flags(FLAGS, @argv, images: [])
|
|
171
|
+
return parsed if parsed.is_a?(Integer)
|
|
172
|
+
|
|
173
|
+
options = parsed.options.merge(ids: parsed.args)
|
|
180
174
|
if options[:timeout]
|
|
181
175
|
return usage_error("--timeout needs --wait") unless options[:wait]
|
|
182
176
|
|
|
@@ -470,18 +464,5 @@ module Samagotchi
|
|
|
470
464
|
def utf8(text)
|
|
471
465
|
text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
472
466
|
end
|
|
473
|
-
|
|
474
|
-
# stderr isn't buffered, stdout is when it's a pipe: flush the lines
|
|
475
|
-
# already printed, so the output keeps the order of the ids given.
|
|
476
|
-
def error_line(text)
|
|
477
|
-
@stdout.flush
|
|
478
|
-
@stderr.puts(text)
|
|
479
|
-
end
|
|
480
|
-
|
|
481
|
-
def usage_error(message)
|
|
482
|
-
error_line("chi send: #{message}")
|
|
483
|
-
@stderr.puts(USAGE)
|
|
484
|
-
nil
|
|
485
|
-
end
|
|
486
467
|
end
|
|
487
468
|
end
|