samagotchi 0.2.0 → 0.3.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 +116 -1
- data/README.md +40 -4
- data/bin/chi +86 -19
- data/docs/cli.md +108 -5
- data/docs/configuration.md +229 -45
- data/docs/desktop.md +6 -0
- data/docs/hooks.md +126 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +18 -8
- data/docs/sessions.md +30 -4
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +72 -13
- data/lib/samagotchi/config.rb +196 -36
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
- data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +233 -36
- data/lib/samagotchi/guardrails/approval.rb +9 -0
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +4 -3
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/kernel_loop.rb +47 -15
- data/lib/samagotchi/llm/chat_loop.rb +59 -20
- data/lib/samagotchi/llm/errors.rb +21 -3
- data/lib/samagotchi/llm/http.rb +42 -13
- data/lib/samagotchi/llm/openai_chat.rb +12 -4
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +1 -0
- data/lib/samagotchi/send_command.rb +153 -7
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +11 -2
- data/lib/samagotchi/session_manager.rb +114 -9
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +77 -4
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -6
- data/lib/samagotchi/web/app.rb +88 -4
- data/lib/samagotchi/web/public/activity.js +10 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +437 -88
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +10 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +21 -6
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +38 -2
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +46 -0
- data/lib/samagotchi/web/public/turn_view.js +47 -7
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +11 -0
- metadata +20 -1
|
@@ -27,8 +27,8 @@ module Samagotchi
|
|
|
27
27
|
question_requested: [],
|
|
28
28
|
question_answered: %i[id],
|
|
29
29
|
question_cancelled: %i[id reason],
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
generation_cancelled: %i[iteration],
|
|
31
|
+
empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars]
|
|
32
32
|
}.freeze
|
|
33
33
|
|
|
34
34
|
# @param session_id [#call] the session the events are about (the
|
|
@@ -71,6 +71,15 @@ module Samagotchi
|
|
|
71
71
|
exhausted: summary[:exhausted] || nil, **origin(event))
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# A plugin's steers by count and source, never their text.
|
|
75
|
+
def on_pending_input_merged(event)
|
|
76
|
+
steers = Array(event[:steers])
|
|
77
|
+
log(:info, :pending_input_merged, **event.slice(:iteration, :count),
|
|
78
|
+
steers: steers.empty? ? nil : steers.size,
|
|
79
|
+
steer_sources: steers.empty? ? nil : steers.map { |s| s[:source] }.uniq.join(","),
|
|
80
|
+
**origin(event))
|
|
81
|
+
end
|
|
82
|
+
|
|
74
83
|
def on_turn_canceled(event)
|
|
75
84
|
log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason], **origin(event))
|
|
76
85
|
end
|
|
@@ -92,7 +101,7 @@ module Samagotchi
|
|
|
92
101
|
ms: since(@generation_started_at.delete(event[:iteration])),
|
|
93
102
|
served_model: event[:served_model], requested_model: event[:requested_model],
|
|
94
103
|
content_length: event[:content_length],
|
|
95
|
-
thinking_chars: event[:thinking_chars])
|
|
104
|
+
thinking_chars: event[:thinking_chars], finish_reason: event[:finish_reason])
|
|
96
105
|
end
|
|
97
106
|
|
|
98
107
|
def on_generation_retrying(event)
|
|
@@ -125,6 +134,12 @@ module Samagotchi
|
|
|
125
134
|
log(level, :hook_notice, hook: event[:hook], msg: event[:text].to_s[0, 300])
|
|
126
135
|
end
|
|
127
136
|
|
|
137
|
+
# after_turn hooks presented the answer (AnswerDisplay): its size only
|
|
138
|
+
# (none for the display: nil that says nothing was presented).
|
|
139
|
+
def on_answer_display(event)
|
|
140
|
+
log(:info, :answer_display, chars: event[:display].to_s.length) if event[:display]
|
|
141
|
+
end
|
|
142
|
+
|
|
128
143
|
# A card shown to the user (Engine#show_card): whose, which, and its
|
|
129
144
|
# title (the body is the plugin's text; not logged).
|
|
130
145
|
def on_card(event)
|
|
@@ -139,7 +139,7 @@ module Samagotchi
|
|
|
139
139
|
"~/.config/samagotchi/config.yml"
|
|
140
140
|
end
|
|
141
141
|
"no model configured: set default.model in #{path} to the model id your server serves " \
|
|
142
|
-
"(or #{MODEL_ENV}, or pass --model ID); see docs/configuration.md"
|
|
142
|
+
"(or #{MODEL_ENV}, or pass --model ID); see docs/configuration.md; or run: chi bootstrap HOST[:PORT]"
|
|
143
143
|
end
|
|
144
144
|
|
|
145
145
|
def self.from_model_name(model_name)
|
|
@@ -18,8 +18,10 @@ module Samagotchi
|
|
|
18
18
|
# the answer text.
|
|
19
19
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
20
20
|
# +model_name+ and +state_dir+ are what ctx.sessions forks with.
|
|
21
|
+
# +steer+ takes (text, source) and +stop_turn+ (reason, label), each
|
|
22
|
+
# true when it acted on a running turn.
|
|
21
23
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
22
|
-
:ask_model, :model_name, :state_dir, keyword_init: true)
|
|
24
|
+
:ask_model, :model_name, :state_dir, :scratch, :steer, :stop_turn, keyword_init: true)
|
|
23
25
|
|
|
24
26
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
25
27
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -178,6 +180,25 @@ module Samagotchi
|
|
|
178
180
|
# Whether the running turn was cancelled (a long tool should stop).
|
|
179
181
|
def cancelled? = !!@host.cancelled.call
|
|
180
182
|
|
|
183
|
+
# Put +text+ into the running turn, as a user's steering does: at the
|
|
184
|
+
# loop's next boundary (after the tool calls in flight) it joins the
|
|
185
|
+
# conversation as its own user message, shown in every UI as a nudge
|
|
186
|
+
# from this bundle. It never starts a turn. True means queued: if the
|
|
187
|
+
# model answers first, or the turn ends, it is dropped (logged).
|
|
188
|
+
# Callable from a hook, a command (an anytime one runs beside the
|
|
189
|
+
# turn) or your own thread.
|
|
190
|
+
# @return [Boolean] whether a turn was running and the text queued
|
|
191
|
+
def steer(text)
|
|
192
|
+
!!@host.steer&.call(text.to_s, @bundle)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Stop the running turn, after a notice with +reason+, as a hook's
|
|
196
|
+
# event[:stop_turn] does (for commands and threads).
|
|
197
|
+
# @return [Boolean] whether a running turn was stopped now
|
|
198
|
+
def stop_turn(reason)
|
|
199
|
+
!!@host.stop_turn&.call(reason.to_s, @label)
|
|
200
|
+
end
|
|
201
|
+
|
|
181
202
|
private
|
|
182
203
|
|
|
183
204
|
def deep_freeze(value)
|
|
@@ -33,9 +33,11 @@ module Samagotchi
|
|
|
33
33
|
# the first user message, by default)
|
|
34
34
|
# @param prompt [String, nil] the child's first turn
|
|
35
35
|
# @return [String] the child's id
|
|
36
|
-
# @raise [Error] no session yet,
|
|
36
|
+
# @raise [Error] no session yet, a scratch session (the child would
|
|
37
|
+
# outlive it), or too many children running
|
|
37
38
|
def fork(messages:, title: nil, prompt: nil)
|
|
38
39
|
parent_id = @host.session_id.call or raise Error, "this session has no id yet"
|
|
40
|
+
raise Error, "a scratch session starts no other sessions: they would outlive it" if @host.scratch&.call
|
|
39
41
|
state_dir = self.state_dir
|
|
40
42
|
prompt = prompt.to_s.strip.empty? ? nil : prompt.to_s
|
|
41
43
|
check_children(parent_id, state_dir) if prompt
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# Loaded on first use: session_manager requires terminal_ui, which
|
|
7
|
+
# requires KernelLoop and so the tools that use this (a require cycle
|
|
8
|
+
# otherwise).
|
|
9
|
+
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
10
|
+
|
|
11
|
+
# Waiting for a session's next reply. A reply is the worker's
|
|
12
|
+
# output/<timestamp>.txt file (one per turn that ended with visible text,
|
|
13
|
+
# written just before its idle save), so "next" means a filename past the
|
|
14
|
+
# cursor the caller holds: the newest one it already has, or nil for any.
|
|
15
|
+
# The delegate tools and `chi send --wait` wrap it in their own words.
|
|
16
|
+
module ReplyWait
|
|
17
|
+
POLL_INTERVAL = 0.5
|
|
18
|
+
|
|
19
|
+
# @!attribute status [Symbol] :done, :waiting_for_answer, :no_reply,
|
|
20
|
+
# :error, :stopped, :canceled, :timeout or :worker_gone
|
|
21
|
+
# @!attribute text [String, nil] the reply (:done) or the worker's error
|
|
22
|
+
# (:error)
|
|
23
|
+
# @!attribute file [String, nil] the reply's filename, the next cursor
|
|
24
|
+
# @!attribute question [Hash, nil] the pending question
|
|
25
|
+
# (:waiting_for_answer)
|
|
26
|
+
Result = Struct.new(:status, :text, :file, :question, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# @param id [String] the session's full id
|
|
31
|
+
# @param state_dir [String]
|
|
32
|
+
# @param cursor [String, nil] the newest reply filename already had
|
|
33
|
+
# @param timeout [Numeric, nil] seconds; nil waits with no limit
|
|
34
|
+
# @param poll_interval [Float]
|
|
35
|
+
# @param cancelled [#call] true stops the wait (:canceled)
|
|
36
|
+
# @param baseline [Hash, nil] {messages:, question_id:} as the session
|
|
37
|
+
# was before the message went in. With it, a turn that grew the
|
|
38
|
+
# messages (a failed, canceled or empty one leaves its note) and went
|
|
39
|
+
# idle again ends the wait even if it was never seen running, and a
|
|
40
|
+
# question already pending then is not the answer's. A nil
|
|
41
|
+
# messages: skips the count.
|
|
42
|
+
# @param owner_grace [Numeric, nil] seconds with no live worker before
|
|
43
|
+
# :worker_gone (one that died before its rescue leaves it running)
|
|
44
|
+
# @return [Result]
|
|
45
|
+
# @raise [ArgumentError] no such session
|
|
46
|
+
def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
|
|
47
|
+
baseline: nil, owner_grace: nil)
|
|
48
|
+
deadline = timeout && (monotonic + timeout.to_f)
|
|
49
|
+
seen_running = false
|
|
50
|
+
gone_since = nil
|
|
51
|
+
|
|
52
|
+
loop do
|
|
53
|
+
session = Session.load(id, state_dir: state_dir)
|
|
54
|
+
|
|
55
|
+
if (reply = reply_past(id, state_dir: state_dir, cursor: cursor))
|
|
56
|
+
return reply
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
case session.status
|
|
60
|
+
when Session::STATUS_ERROR
|
|
61
|
+
return Result.new(status: :error, text: session.last_prompt.to_s.strip)
|
|
62
|
+
when Session::STATUS_STOPPED
|
|
63
|
+
return Result.new(status: :stopped)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
pending = session.pending_question
|
|
67
|
+
if pending && !(baseline && pending[:id] == baseline[:question_id])
|
|
68
|
+
return Result.new(status: :waiting_for_answer, question: pending)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return Result.new(status: :canceled) if cancelled.call
|
|
72
|
+
|
|
73
|
+
if session.status == Session::STATUS_RUNNING
|
|
74
|
+
seen_running = true
|
|
75
|
+
elsif seen_running || (baseline&.dig(:messages) && session.messages.size > baseline[:messages])
|
|
76
|
+
# It ran and is idle again with no new reply: canceled, failed or
|
|
77
|
+
# empty. Before it was ever seen running, idle means a
|
|
78
|
+
# file-delivered message its worker has not picked up yet.
|
|
79
|
+
# The worker writes the reply before its idle save, so the read
|
|
80
|
+
# above has it; one more look costs nothing should that change.
|
|
81
|
+
return reply_past(id, state_dir: state_dir, cursor: cursor) || Result.new(status: :no_reply)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if owner_grace
|
|
85
|
+
if SessionManager.session_owner(id, state_dir: state_dir)
|
|
86
|
+
gone_since = nil
|
|
87
|
+
elsif monotonic - (gone_since ||= monotonic) > owner_grace
|
|
88
|
+
return Result.new(status: :worker_gone)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
return Result.new(status: :timeout) if deadline && monotonic > deadline
|
|
93
|
+
|
|
94
|
+
sleep(poll_interval)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Result, nil] :done with the newest reply past the cursor
|
|
99
|
+
def reply_past(id, state_dir:, cursor:)
|
|
100
|
+
file = newest_reply(id, state_dir: state_dir)
|
|
101
|
+
return nil unless file && newer?(file, cursor)
|
|
102
|
+
|
|
103
|
+
Result.new(status: :done, text: File.read(File.join(reply_dir(id, state_dir: state_dir), file)), file: file)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [String, nil] the newest reply filename (sortable timestamps)
|
|
107
|
+
def newest_reply(id, state_dir:)
|
|
108
|
+
dir = reply_dir(id, state_dir: state_dir)
|
|
109
|
+
return nil unless Dir.exist?(dir)
|
|
110
|
+
|
|
111
|
+
Dir.children(dir).select { |f| f.end_with?(".txt") }.max
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def reply_dir(id, state_dir:)
|
|
115
|
+
File.join(Session.session_dir(id, state_dir: state_dir), SessionManager::OUTPUT_DIR)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def newer?(file, cursor)
|
|
119
|
+
cursor.nil? || file > cursor
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def monotonic
|
|
123
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "config"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# The request parameters (temperature, penalties, …) a model's generations
|
|
8
|
+
# go out with: hosts.<name>.sampling merged with models.<key>.sampling, the
|
|
9
|
+
# model's keys winning per key (a host can set a penalty and a model move
|
|
10
|
+
# only the temperature). Recap and side clients keep their own options.
|
|
11
|
+
module SamplingSettings
|
|
12
|
+
EMPTY = {}.freeze
|
|
13
|
+
|
|
14
|
+
# @param target [HostRegistry::ModelTarget]
|
|
15
|
+
# @param names [Array<String>, nil] the model as typed, alias-resolved,
|
|
16
|
+
# bare (the Engine's lookup names); default: the target's model and bare model
|
|
17
|
+
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
18
|
+
# @return [Hash] frozen, symbol keys; may be empty
|
|
19
|
+
def self.for(target, names: nil, models: nil)
|
|
20
|
+
resolve(target, names, models).first
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# "temperature=0.6 presence_penalty=1.5 (hosts.work, models: qwen)" for
|
|
24
|
+
# /model, or nil when nothing is configured.
|
|
25
|
+
def self.summary(target, names: nil, models: nil)
|
|
26
|
+
params, sources = resolve(target, names, models)
|
|
27
|
+
return nil if params.empty?
|
|
28
|
+
|
|
29
|
+
pairs = params.map { |key, value| "#{key}=#{value.nil? ? "(not sent)" : value.inspect}" }
|
|
30
|
+
"#{pairs.join(" ")} (#{sources.join(", ")})"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Request fields as the http log line shows them:
|
|
34
|
+
# "temperature=0.6 presence_penalty=1.5", nil for none.
|
|
35
|
+
def self.log_text(fields)
|
|
36
|
+
return nil if fields.nil? || fields.empty?
|
|
37
|
+
|
|
38
|
+
fields.map { |key, value| "#{key}=#{value.is_a?(String) ? value : JSON.generate(value)}" }.join(" ")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.resolve(target, names, models)
|
|
42
|
+
models ||= begin
|
|
43
|
+
ConfigFile.model_settings
|
|
44
|
+
rescue StandardError
|
|
45
|
+
{}
|
|
46
|
+
end
|
|
47
|
+
names ||= [target.model, target.bare_model]
|
|
48
|
+
host = target.entry.sampling || EMPTY
|
|
49
|
+
key, model = ConfigFile.model_setting(names, :sampling, models: models)
|
|
50
|
+
sources = []
|
|
51
|
+
sources << "hosts.#{target.entry.name}" unless host.empty?
|
|
52
|
+
sources << "models: #{key}" if key
|
|
53
|
+
[host.merge(model || EMPTY).freeze, sources]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private_class_method :resolve
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -116,6 +116,7 @@ module Samagotchi
|
|
|
116
116
|
client = registry.client_for(entry)
|
|
117
117
|
props = client.server_props(model: bare) if client.respond_to?(:server_props)
|
|
118
118
|
return "reported per turn (the server has no /props)" if props.nil?
|
|
119
|
+
return "unknown (the server didn't answer; is it running?)" if props.status == :network_error
|
|
119
120
|
|
|
120
121
|
served = ServedModel.from_props(props)
|
|
121
122
|
return "unknown (no answer from the server's /props)" unless served
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "session"
|
|
4
4
|
require_relative "session_manager"
|
|
5
5
|
require_relative "context_quote"
|
|
6
|
+
require_relative "reply_wait"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# `chi send`: put text into sessions as the user's message, the same as
|
|
@@ -10,15 +11,36 @@ module Samagotchi
|
|
|
10
11
|
# `chi note`. Fire and forget: it returns once the message is queued, and
|
|
11
12
|
# the answer shows in whatever is attached. For a script:
|
|
12
13
|
# pbpaste | chi send -m "is this the same bug?" 3fa2
|
|
14
|
+
# --new starts a session instead, and --wait blocks for the reply and
|
|
15
|
+
# prints it: an agent's one-shot the user can watch in the web.
|
|
13
16
|
class SendCommand
|
|
14
17
|
CLIENT_ID = "cli:send"
|
|
18
|
+
POLL_INTERVAL = ReplyWait::POLL_INTERVAL
|
|
19
|
+
# No live worker this long while waiting: it died before it could mark
|
|
20
|
+
# the session (a worker takes well under a second to start).
|
|
21
|
+
WORKER_GONE_AFTER = 5
|
|
15
22
|
|
|
16
23
|
USAGE = <<~TEXT
|
|
17
24
|
Usage: chi send [-m TEXT] (ID|PREFIX)...
|
|
25
|
+
chi send --new [--dir DIR] [--model M] [-m TEXT]
|
|
26
|
+
chi send --wait [--timeout S] [-m TEXT] (--new | ID)
|
|
27
|
+
chi send --wait [--timeout S] ID
|
|
18
28
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
19
29
|
or a running one picks it up. A stopped session's worker starts.
|
|
20
|
-
-m TEXT
|
|
21
|
-
|
|
30
|
+
-m TEXT the message; stdin, when piped too, goes above it as a
|
|
31
|
+
quote (context); without -m, stdin is the message
|
|
32
|
+
--new start a new session with the message instead, as the
|
|
33
|
+
web does, and print its id
|
|
34
|
+
--dir DIR (--new) its folder, the project it belongs to; default
|
|
35
|
+
the current one
|
|
36
|
+
--model M (--new) its model; default the configured one
|
|
37
|
+
--wait wait for the answer and print it (one session); the
|
|
38
|
+
other lines go to stderr. Exit 3: it waits for an
|
|
39
|
+
answer from you (chi --attach ID or the web). With
|
|
40
|
+
no message (no -m, nothing piped) nothing is sent: it
|
|
41
|
+
waits for the session's next reply, a running turn's
|
|
42
|
+
too (after exit 3 or 130, wait again this way)
|
|
43
|
+
--timeout S (--wait) give up after S seconds; default no limit
|
|
22
44
|
Only sessions on this machine. Answers show in the attached TUI
|
|
23
45
|
or web page, not here.
|
|
24
46
|
Find ids with: chi sessions list --live [--scope=all] [--format tsv]
|
|
@@ -34,12 +56,16 @@ module Samagotchi
|
|
|
34
56
|
end
|
|
35
57
|
|
|
36
58
|
# @return [Integer] exit status: 0 all sent, 1 any refused or failed,
|
|
37
|
-
# 2 usage
|
|
59
|
+
# 2 usage; with --wait 0 answered, 3 waiting for an answer, 130
|
|
60
|
+
# Ctrl-C (the turn goes on)
|
|
38
61
|
def run
|
|
39
62
|
options = parse or return 2
|
|
40
63
|
return 0 if options[:help]
|
|
41
64
|
|
|
65
|
+
# With --wait stdout is the answer alone.
|
|
66
|
+
@info = options[:wait] ? @stderr : @stdout
|
|
42
67
|
prompt = compose(utf8(read_stdin), utf8(options[:message]))
|
|
68
|
+
return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
|
|
43
69
|
unless prompt
|
|
44
70
|
usage_error("no message: pass -m TEXT or pipe it in")
|
|
45
71
|
return 2
|
|
@@ -51,6 +77,9 @@ module Samagotchi
|
|
|
51
77
|
return 1
|
|
52
78
|
end
|
|
53
79
|
|
|
80
|
+
return run_new(prompt, options) if options[:new]
|
|
81
|
+
return run_wait(prompt, options) if options[:wait]
|
|
82
|
+
|
|
54
83
|
# One id at a time, so each line follows the order of the ids given.
|
|
55
84
|
seen = {}
|
|
56
85
|
results = options[:ids].uniq.map do |given|
|
|
@@ -77,6 +106,13 @@ module Samagotchi
|
|
|
77
106
|
when "-m", "--message"
|
|
78
107
|
options[:message] = @argv.shift or return usage_error("#{arg} needs a value")
|
|
79
108
|
when /\A--message=(.*)\z/m then options[:message] = Regexp.last_match(1)
|
|
109
|
+
when "--new" then options[:new] = true
|
|
110
|
+
when "--dir", "--model"
|
|
111
|
+
options[arg.delete_prefix("--").to_sym] = @argv.shift or return usage_error("#{arg} needs a value")
|
|
112
|
+
when /\A--(dir|model)=(.*)\z/m then options[Regexp.last_match(1).to_sym] = Regexp.last_match(2)
|
|
113
|
+
when "--wait" then options[:wait] = true
|
|
114
|
+
when "--timeout" then options[:timeout] = @argv.shift or return usage_error("#{arg} needs a value")
|
|
115
|
+
when /\A--timeout=(.*)\z/ then options[:timeout] = Regexp.last_match(1)
|
|
80
116
|
# Starting a turn in every live session at once is too easy to do
|
|
81
117
|
# by accident.
|
|
82
118
|
when "--all" then return usage_error("there is no --all: name the sessions")
|
|
@@ -84,11 +120,30 @@ module Samagotchi
|
|
|
84
120
|
else options[:ids] << arg
|
|
85
121
|
end
|
|
86
122
|
end
|
|
123
|
+
if options[:timeout]
|
|
124
|
+
return usage_error("--timeout needs --wait") unless options[:wait]
|
|
125
|
+
|
|
126
|
+
options[:timeout] = Float(options[:timeout], exception: false)
|
|
127
|
+
return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
|
|
128
|
+
end
|
|
129
|
+
return usage_error("--wait takes one session") if options[:wait] && options[:ids].uniq.size > 1
|
|
130
|
+
return new_options(options) if options[:new]
|
|
131
|
+
%i[dir model].each { |key| return usage_error("--#{key} needs --new") if options[key] }
|
|
87
132
|
return usage_error("give session ids") if options[:ids].empty?
|
|
88
133
|
|
|
89
134
|
options
|
|
90
135
|
end
|
|
91
136
|
|
|
137
|
+
def new_options(options)
|
|
138
|
+
return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
|
|
139
|
+
|
|
140
|
+
if options[:dir]
|
|
141
|
+
options[:dir] = File.expand_path(options[:dir])
|
|
142
|
+
return usage_error("no folder #{options[:dir]}") unless File.directory?(options[:dir])
|
|
143
|
+
end
|
|
144
|
+
options
|
|
145
|
+
end
|
|
146
|
+
|
|
92
147
|
# With both, stdin is the context quoted above the message; with one,
|
|
93
148
|
# it goes in as is. nil when both are blank.
|
|
94
149
|
def compose(context, message)
|
|
@@ -123,6 +178,97 @@ module Samagotchi
|
|
|
123
178
|
nil
|
|
124
179
|
end
|
|
125
180
|
|
|
181
|
+
# A worker session like the web start page's: saved as running with the
|
|
182
|
+
# message before its worker spawns, so lists and the web show it at
|
|
183
|
+
# once. The full id, so a script can pass it on. The model name isn't
|
|
184
|
+
# checked here (nor in the web): a wrong one fails in the worker.
|
|
185
|
+
# @return [Integer] the exit status
|
|
186
|
+
def run_new(prompt, options)
|
|
187
|
+
begin
|
|
188
|
+
session = SessionManager.spawn_session(prompt: prompt, working_directory: options[:dir],
|
|
189
|
+
model_name: options[:model], state_dir: @state_dir)
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
error_line("chi send: could not start a session: #{e.message}")
|
|
192
|
+
return 1
|
|
193
|
+
end
|
|
194
|
+
@info.puts("#{session.id} started")
|
|
195
|
+
return 0 unless options[:wait]
|
|
196
|
+
|
|
197
|
+
wait_for_reply(session.id, cursor: nil, baseline: { messages: session.messages.size, question_id: nil },
|
|
198
|
+
timeout: options[:timeout])
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# One existing session, then its next reply. The cursor and baseline
|
|
202
|
+
# are taken before the message goes in, so neither an older reply nor a
|
|
203
|
+
# turn that ends before the first look is mistaken for the answer. A
|
|
204
|
+
# running turn is refused: a message that misses it runs next, and the
|
|
205
|
+
# running one's reply would come back as the answer.
|
|
206
|
+
# @return [Integer] the exit status
|
|
207
|
+
def run_wait(prompt, options)
|
|
208
|
+
id = resolve(options[:ids].first) or return 1
|
|
209
|
+
session = Session.load(id, state_dir: @state_dir)
|
|
210
|
+
if session.status == Session::STATUS_RUNNING && SessionManager.session_owner(id, state_dir: @state_dir)
|
|
211
|
+
error_line("#{id[0, 8]} busy: a turn is running; wait or attach")
|
|
212
|
+
return 1
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
|
|
216
|
+
baseline = { messages: session.messages.size, question_id: session.pending_question&.dig(:id) }
|
|
217
|
+
return 1 unless deliver(id, prompt)
|
|
218
|
+
|
|
219
|
+
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# --wait with no message: the session's next reply, sending nothing,
|
|
223
|
+
# so an agent can go back to waiting after exit 3 or 130. A running
|
|
224
|
+
# turn is fine here (nothing joins its queue); a question pending now
|
|
225
|
+
# was already reported. No message count: a note landing between turns
|
|
226
|
+
# grows the messages without a turn. An idle session with no worker
|
|
227
|
+
# waits for whatever wakes one (the web, chi send) rather than calling
|
|
228
|
+
# it gone.
|
|
229
|
+
# @return [Integer] the exit status
|
|
230
|
+
def run_wait_only(options)
|
|
231
|
+
id = resolve(options[:ids].first) or return 1
|
|
232
|
+
session = Session.load(id, state_dir: @state_dir)
|
|
233
|
+
live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
|
|
234
|
+
wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
|
|
235
|
+
baseline: { messages: nil, question_id: session.pending_question&.dig(:id) },
|
|
236
|
+
timeout: options[:timeout], owner_grace: live ? WORKER_GONE_AFTER : nil)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# @return [Integer] the exit status
|
|
240
|
+
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: WORKER_GONE_AFTER)
|
|
241
|
+
result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
|
|
242
|
+
owner_grace: owner_grace, poll_interval: POLL_INTERVAL)
|
|
243
|
+
return reply(result.text) if result.status == :done
|
|
244
|
+
|
|
245
|
+
attach = "chi --attach #{id}"
|
|
246
|
+
line, status = case result.status
|
|
247
|
+
when :waiting_for_answer
|
|
248
|
+
question = result.question[:question].to_s.strip.lines.first.to_s.strip
|
|
249
|
+
["waiting for an answer: #{question}; open it: #{attach} or the web", 3]
|
|
250
|
+
when :no_reply then ["the turn ended without a reply (canceled, failed or empty); #{attach} shows it", 1]
|
|
251
|
+
when :error then ["the worker failed: #{result.text}; #{attach} shows what happened", 1]
|
|
252
|
+
when :worker_gone then ["the worker is gone; #{attach} shows what happened", 1]
|
|
253
|
+
when :stopped then ["the session was stopped (chi sessions stop)", 1]
|
|
254
|
+
else ["still running after #{format("%g", timeout)} s: #{attach}", 1]
|
|
255
|
+
end
|
|
256
|
+
error_line("chi send: #{line}")
|
|
257
|
+
status
|
|
258
|
+
rescue Interrupt
|
|
259
|
+
error_line("chi send: still running: chi --attach #{id}")
|
|
260
|
+
130
|
|
261
|
+
rescue ArgumentError
|
|
262
|
+
error_line("chi send: the session is gone (deleted while waiting)")
|
|
263
|
+
1
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def reply(text)
|
|
267
|
+
@stdout.puts(utf8(text))
|
|
268
|
+
@stdout.flush
|
|
269
|
+
0
|
|
270
|
+
end
|
|
271
|
+
|
|
126
272
|
# @return [Boolean] whether the message was queued
|
|
127
273
|
def deliver(id, prompt)
|
|
128
274
|
short = id[0, 8]
|
|
@@ -130,20 +276,20 @@ module Samagotchi
|
|
|
130
276
|
running = owner && Session.load(id, state_dir: @state_dir).status == Session::STATUS_RUNNING
|
|
131
277
|
result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, state_dir: @state_dir)
|
|
132
278
|
unless result[:status] == :accepted
|
|
133
|
-
@
|
|
279
|
+
@info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
|
|
134
280
|
return false
|
|
135
281
|
end
|
|
136
282
|
|
|
137
283
|
note = if owner.nil? then " (started its worker)"
|
|
138
284
|
elsif running then " (the running turn picks it up)"
|
|
139
285
|
end
|
|
140
|
-
@
|
|
286
|
+
@info.puts("#{short} sent#{note}")
|
|
141
287
|
true
|
|
142
288
|
rescue SessionManager::OwnedByTUI
|
|
143
|
-
@
|
|
289
|
+
@info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
|
|
144
290
|
false
|
|
145
291
|
rescue StandardError => e
|
|
146
|
-
@
|
|
292
|
+
@info.puts("#{short} failed: #{e.message}")
|
|
147
293
|
false
|
|
148
294
|
end
|
|
149
295
|
|