samagotchi 0.101.0 → 0.103.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 +67 -1
- data/bin/chi +31 -196
- data/docs/architecture.md +62 -6
- data/docs/cli.md +154 -7
- data/docs/configuration.md +32 -5
- data/docs/guardrails.md +2 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +25 -1
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/remote-sessions.md +303 -0
- data/docs/internals/working-set.md +174 -15
- data/docs/plugins.md +32 -5
- data/docs/sessions.md +38 -14
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +7 -0
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +33 -7
- data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +22 -38
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +43 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +89 -53
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/engine.rb +138 -15
- data/lib/samagotchi/idle_recap.rb +7 -1
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/launch_mode.rb +22 -0
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +24 -8
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +233 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +186 -118
- data/lib/samagotchi/session.rb +48 -16
- data/lib/samagotchi/session_access/contract.rb +129 -0
- data/lib/samagotchi/session_access/dispatch.rb +133 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +349 -0
- data/lib/samagotchi/session_access/remote.rb +638 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
- data/lib/samagotchi/session_access.rb +141 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +53 -6
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_manager.rb +7 -2
- data/lib/samagotchi/session_setup.rb +15 -10
- data/lib/samagotchi/sessions_command.rb +42 -16
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +54 -18
- data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +82 -76
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +387 -79
- data/lib/samagotchi/web/public/app.js +2 -0
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +6 -4
- data/lib/samagotchi/web/public/index.html +37 -3
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +7 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/working_set.js +247 -24
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +198 -25
- data/lib/samagotchi/web/session_access_routes.rb +343 -0
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +132 -20
- data/lib/samagotchi/working_set_command.rb +222 -0
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +414 -0
- metadata +23 -2
- data/lib/samagotchi/bundle_session_settings.rb +0 -251
|
@@ -187,6 +187,9 @@ module Samagotchi
|
|
|
187
187
|
mark[:staged] = true unless edit.applied?
|
|
188
188
|
if edit.kind == :stale
|
|
189
189
|
mark[:note] = edit.note[/superseded by .*\z/] || edit.note
|
|
190
|
+
elsif edit.kind == LLMContextEdit::DROP
|
|
191
|
+
# The user's: no note, no grouping (a forget's key is its note).
|
|
192
|
+
mark[:by] = edit.by
|
|
190
193
|
else
|
|
191
194
|
key = [edit.note, edit.by, edit.staged_at]
|
|
192
195
|
if first.key?(key)
|
|
@@ -201,12 +204,14 @@ module Samagotchi
|
|
|
201
204
|
mark
|
|
202
205
|
end
|
|
203
206
|
|
|
204
|
-
# +output+'s ✂ mark (+marks+ by +id+); an applied stale stub
|
|
205
|
-
# what it frees: its output, ~chars/4.
|
|
207
|
+
# +output+'s ✂ mark (+marks+ by +id+); an applied stale stub or a
|
|
208
|
+
# user's drop says what it frees: its output, ~chars/4.
|
|
209
|
+
WHOLE_STUBS = %w[stale drop].freeze
|
|
210
|
+
|
|
206
211
|
def mark_of(id, marks, output)
|
|
207
212
|
mark = marks[id]
|
|
208
213
|
return nil unless mark
|
|
209
|
-
return mark unless mark[:kind]
|
|
214
|
+
return mark unless WHOLE_STUBS.include?(mark[:kind]) && !mark[:staged] && output
|
|
210
215
|
|
|
211
216
|
mark.merge(tokens: (output.length / TokenUsage::CHARS_PER_TOKEN).ceil)
|
|
212
217
|
end
|
|
@@ -6,6 +6,7 @@ require_relative "note_delivery"
|
|
|
6
6
|
require_relative "session_manager"
|
|
7
7
|
require_relative "cli/command"
|
|
8
8
|
require_relative "cli/flags"
|
|
9
|
+
require_relative "session_access/local"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
# `chi note`: push text into sessions as a context note (background the
|
|
@@ -33,12 +34,15 @@ module Samagotchi
|
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
# @param argv [Array<String>] the arguments after "note"
|
|
36
|
-
|
|
37
|
+
# @param access [SessionAccess::Local, nil] where the sessions are;
|
|
38
|
+
# default this machine's, in +state_dir+
|
|
39
|
+
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, access: nil)
|
|
37
40
|
@argv = argv.dup
|
|
38
41
|
@stdin = stdin
|
|
39
42
|
@stdout = stdout
|
|
40
43
|
@stderr = stderr
|
|
41
44
|
@state_dir = state_dir || Session.default_state_dir
|
|
45
|
+
@access = access || SessionAccess::Local.new(state_dir: @state_dir)
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
# @return [Integer] exit status: 0 all queued, 1 any refused or failed,
|
|
@@ -93,8 +97,7 @@ module Samagotchi
|
|
|
93
97
|
|
|
94
98
|
# --all: every live session. @return [Integer] exit status
|
|
95
99
|
def deliver_to_live(text, source)
|
|
96
|
-
ids =
|
|
97
|
-
state_dir: @state_dir).map { |s| s[:id] }
|
|
100
|
+
ids = @access.summaries(live: true, include_tests: Session.test_session_env?).map { |s| s[:id] }
|
|
98
101
|
if ids.empty?
|
|
99
102
|
error_line("chi note: no live sessions (chi sessions list --live --scope=all)")
|
|
100
103
|
return 1
|
|
@@ -104,9 +107,7 @@ module Samagotchi
|
|
|
104
107
|
end
|
|
105
108
|
|
|
106
109
|
def resolve(given)
|
|
107
|
-
|
|
108
|
-
Session.load(id, state_dir: @state_dir)
|
|
109
|
-
id
|
|
110
|
+
@access.resolve(given)
|
|
110
111
|
rescue ArgumentError => e
|
|
111
112
|
message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
|
|
112
113
|
error_line("chi note: #{message}")
|
|
@@ -115,7 +116,7 @@ module Samagotchi
|
|
|
115
116
|
|
|
116
117
|
# @return [Boolean] whether the note was queued
|
|
117
118
|
def deliver(id, text, source)
|
|
118
|
-
result =
|
|
119
|
+
result = @access.note(id, text: text, source: source)
|
|
119
120
|
@stdout.puts("#{id[0, 8]} #{outcome(result)}")
|
|
120
121
|
result.delivered?
|
|
121
122
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "delegate"
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
|
+
require_relative "chi_invocation"
|
|
6
7
|
require_relative "cli/exit"
|
|
7
8
|
require_relative "pending_question"
|
|
8
9
|
require_relative "session"
|
|
@@ -81,7 +82,9 @@ module Samagotchi
|
|
|
81
82
|
# @return [Hash] the --format json object
|
|
82
83
|
# @param strategy [Guardrails::ParentPolicy::Strategy, nil] what a parent
|
|
83
84
|
# may pick on the waited-for session (#strategy_for); nil: config.yml's
|
|
84
|
-
|
|
85
|
+
# @param via [ChiInvocation] how the printed commands are spelled (this
|
|
86
|
+
# machine's chi, or chi --remote NAME); each function here takes it
|
|
87
|
+
def json(result, session_id:, timeout: nil, extra: {}, strategy: nil, via: ChiInvocation::HERE)
|
|
85
88
|
report = { status: status(result), session_id: session_id }
|
|
86
89
|
report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
|
|
87
90
|
case report[:status]
|
|
@@ -89,10 +92,10 @@ module Samagotchi
|
|
|
89
92
|
when "question"
|
|
90
93
|
question = question(result.question, strategy: strategy)
|
|
91
94
|
report[:question] = question
|
|
92
|
-
report[:answer_with] = answer_with(session_id, question)
|
|
95
|
+
report[:answer_with] = answer_with(session_id, question, via: via)
|
|
93
96
|
else
|
|
94
97
|
report.merge!(stop_facts(result))
|
|
95
|
-
report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy)
|
|
98
|
+
report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy, via: via)
|
|
96
99
|
end
|
|
97
100
|
report.merge(extra)
|
|
98
101
|
end
|
|
@@ -110,8 +113,8 @@ module Samagotchi
|
|
|
110
113
|
end
|
|
111
114
|
|
|
112
115
|
# @return [String] the JSON object as one line
|
|
113
|
-
def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil)
|
|
114
|
-
JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
|
|
116
|
+
def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil, via: ChiInvocation::HERE)
|
|
117
|
+
JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy, via: via))
|
|
115
118
|
end
|
|
116
119
|
|
|
117
120
|
# Print a wait's end the way the commands do: the JSON object on
|
|
@@ -120,17 +123,19 @@ module Samagotchi
|
|
|
120
123
|
# @param command [String] "chi send", "chi answer"
|
|
121
124
|
# @param extra [Hash] more keys for the JSON object (#json)
|
|
122
125
|
# @return [Integer] the exit status
|
|
123
|
-
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil
|
|
126
|
+
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil,
|
|
127
|
+
via: ChiInvocation::HERE)
|
|
124
128
|
if json
|
|
125
|
-
stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
|
|
129
|
+
stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy, via: via))
|
|
126
130
|
elsif result.status == :done
|
|
127
131
|
stdout.puts(result.text)
|
|
128
132
|
else
|
|
129
133
|
stdout.flush
|
|
130
134
|
text = if result.status == :waiting_for_answer
|
|
131
|
-
question_text(result.question, session_id: session_id, strategy: strategy)
|
|
135
|
+
question_text(result.question, session_id: session_id, strategy: strategy, via: via)
|
|
132
136
|
else
|
|
133
|
-
"#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy
|
|
137
|
+
"#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy,
|
|
138
|
+
via: via)}\n"
|
|
134
139
|
end
|
|
135
140
|
stderr.print("#{command}: #{text}")
|
|
136
141
|
end
|
|
@@ -177,16 +182,22 @@ module Samagotchi
|
|
|
177
182
|
# lists an Allow once a parent may give). A step-limit question's
|
|
178
183
|
# continues the turn, or stops it where a parent may not continue (its
|
|
179
184
|
# other form is in #question_text).
|
|
180
|
-
def answer_with(session_id, question)
|
|
181
|
-
return
|
|
182
|
-
return stop_command(session_id, question) if continue?(question) && !may_continue?(question)
|
|
183
|
-
return
|
|
185
|
+
def answer_with(session_id, question, via: ChiInvocation::HERE)
|
|
186
|
+
return answer_command(session_id, question, "Deny --text WHY", via: via) if approval?(question)
|
|
187
|
+
return stop_command(session_id, question, via: via) if continue?(question) && !may_continue?(question)
|
|
188
|
+
return answer_command(session_id, question, "Continue", via: via) if continue?(question)
|
|
184
189
|
|
|
185
190
|
# The one option a parent may pick (cost-cap's Stop under hard).
|
|
186
191
|
only = Array(question[:allowed_options])
|
|
187
|
-
return
|
|
192
|
+
return answer_command(session_id, question, option_arg(only.first), via: via) if only.size == 1
|
|
188
193
|
|
|
189
|
-
|
|
194
|
+
answer_command(session_id, question, "N", via: via)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# chi answer ID --question QID --option +option+ (its words after
|
|
198
|
+
# --option: a label, maybe --text).
|
|
199
|
+
def answer_command(session_id, question, option, via: ChiInvocation::HERE)
|
|
200
|
+
via.command("answer #{session_id} --question #{question[:id]} --option #{option}")
|
|
190
201
|
end
|
|
191
202
|
|
|
192
203
|
# What a parent does with an approval it may not allow.
|
|
@@ -239,17 +250,18 @@ module Samagotchi
|
|
|
239
250
|
question.fetch(:allowed_options, question[:options]).include?(Guardrails::ParentPolicy::CONTINUE)
|
|
240
251
|
end
|
|
241
252
|
|
|
242
|
-
def stop_command(session_id, question)
|
|
243
|
-
|
|
253
|
+
def stop_command(session_id, question, via: ChiInvocation::HERE)
|
|
254
|
+
answer_command(session_id, question, "Stop --text WHY", via: via)
|
|
244
255
|
end
|
|
245
256
|
|
|
246
257
|
def option_arg(label) = label.match?(/\A[\w.-]+\z/) ? label : label.inspect
|
|
247
258
|
|
|
248
259
|
# The other way: the user answers it in chi (the web's bell and badge
|
|
249
260
|
# say it waits), and a wait with no message waits past it.
|
|
250
|
-
def leave_open(session_id)
|
|
251
|
-
|
|
252
|
-
|
|
261
|
+
def leave_open(session_id, via: ChiInvocation::HERE)
|
|
262
|
+
web = via.remote? ? via.web : "chi web"
|
|
263
|
+
"or leave it open: tell your user it waits in #{web} (session #{session_id[0, 8]}); " \
|
|
264
|
+
"#{via.command("send --wait --format json #{session_id}")} waits until they answer"
|
|
253
265
|
end
|
|
254
266
|
|
|
255
267
|
# @param question [PendingQuestion, Hash, nil] a pending question, or a
|
|
@@ -267,13 +279,13 @@ module Samagotchi
|
|
|
267
279
|
end
|
|
268
280
|
|
|
269
281
|
# What a message does to a step-limit question.
|
|
270
|
-
def message_drops(session_id)
|
|
271
|
-
"a message instead (
|
|
282
|
+
def message_drops(session_id, via: ChiInvocation::HERE)
|
|
283
|
+
"a message instead (#{via.command("send #{session_id} -m …")}) drops it and starts a new turn"
|
|
272
284
|
end
|
|
273
285
|
|
|
274
286
|
# What a wait that ended without an answer says, one line.
|
|
275
|
-
def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil)
|
|
276
|
-
|
|
287
|
+
def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil, via: ChiInvocation::HERE)
|
|
288
|
+
shows = via.shows(session_id)
|
|
277
289
|
case result.status
|
|
278
290
|
when :waiting_for_answer
|
|
279
291
|
q = question(result.question, strategy: strategy)
|
|
@@ -281,20 +293,20 @@ module Samagotchi
|
|
|
281
293
|
"waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q, strategy: strategy)}"
|
|
282
294
|
elsif continue?(q) && !may_continue?(q)
|
|
283
295
|
"waiting at the step limit: #{first_line(q[:text])}; a parent may only stop it here " \
|
|
284
|
-
"(#{policy(strategy).label}): #{stop_command(session_id, q)}, or tell your user it waits"
|
|
296
|
+
"(#{policy(strategy).label}): #{stop_command(session_id, q, via: via)}, or tell your user it waits"
|
|
285
297
|
elsif continue?(q)
|
|
286
298
|
"waiting at the step limit: #{first_line(q[:text])}; " \
|
|
287
|
-
"answer Continue or Stop:
|
|
299
|
+
"answer Continue or Stop: #{answer_command(session_id, q, "Continue", via: via)}"
|
|
288
300
|
else
|
|
289
|
-
"waiting for an answer: #{first_line(q[:text])}; open it: #{
|
|
301
|
+
"waiting for an answer: #{first_line(q[:text])}; open it: #{via.open_it(session_id)}"
|
|
290
302
|
end
|
|
291
|
-
when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy)}; #{
|
|
292
|
-
when :error then "the worker failed: #{result.text}; #{
|
|
293
|
-
when :worker_gone then "the worker is gone; #{
|
|
303
|
+
when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy, via: via)}; #{shows} shows it"
|
|
304
|
+
when :error then "the worker failed: #{result.text}; #{shows} shows what happened"
|
|
305
|
+
when :worker_gone then "the worker is gone; #{shows} shows what happened"
|
|
294
306
|
when :stopped then "the session was stopped (chi sessions stop)"
|
|
295
307
|
else
|
|
296
|
-
wait_again = "
|
|
297
|
-
hint = wait_again ||
|
|
308
|
+
wait_again = via.command("send --wait --turn #{turn_id} #{session_id}") unless turn_id.to_s.empty?
|
|
309
|
+
hint = wait_again || shows
|
|
298
310
|
timeout ? "still running after #{format("%g", timeout)} s: #{hint}" : "still running: #{hint}"
|
|
299
311
|
end
|
|
300
312
|
end
|
|
@@ -302,7 +314,7 @@ module Samagotchi
|
|
|
302
314
|
# The whole question for stderr: header, text, numbered options, what
|
|
303
315
|
# else it takes, and the commands that answer it.
|
|
304
316
|
# @return [String] lines, newline-terminated
|
|
305
|
-
def question_text(pending, session_id:, strategy: nil)
|
|
317
|
+
def question_text(pending, session_id:, strategy: nil, via: ChiInvocation::HERE)
|
|
306
318
|
q = question(pending, strategy: strategy)
|
|
307
319
|
lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
|
|
308
320
|
text_lines = q[:text].strip.lines.map(&:rstrip)
|
|
@@ -315,31 +327,31 @@ module Samagotchi
|
|
|
315
327
|
lines << " allowing it is up to your user: #{DENY_AND_TELL}"
|
|
316
328
|
else
|
|
317
329
|
lines << " #{approval_advice(q, strategy: strategy)}"
|
|
318
|
-
allows.each { |label| lines << " allow:
|
|
330
|
+
allows.each { |label| lines << " allow: #{answer_command(session_id, q, option_arg(label), via: via)}" }
|
|
319
331
|
end
|
|
320
|
-
lines << " deny: #{answer_with(session_id, q)}"
|
|
321
|
-
lines << " #{leave_open(session_id)}"
|
|
332
|
+
lines << " deny: #{answer_with(session_id, q, via: via)}"
|
|
333
|
+
lines << " #{leave_open(session_id, via: via)}"
|
|
322
334
|
elsif continue?(q)
|
|
323
335
|
lines << if may_continue?(q)
|
|
324
|
-
" continue: #{answer_with(session_id, q)}"
|
|
336
|
+
" continue: #{answer_with(session_id, q, via: via)}"
|
|
325
337
|
else
|
|
326
338
|
" continuing it is up to your user (#{policy(strategy).label}): stop it, or tell your user it waits"
|
|
327
339
|
end
|
|
328
|
-
lines << " stop: #{stop_command(session_id, q)} (the text is optional; the model reads it)"
|
|
329
|
-
lines << " #{message_drops(session_id)}"
|
|
340
|
+
lines << " stop: #{stop_command(session_id, q, via: via)} (the text is optional; the model reads it)"
|
|
341
|
+
lines << " #{message_drops(session_id, via: via)}"
|
|
330
342
|
else
|
|
331
343
|
lines << " more than one allowed: repeat --option" if q[:multi_select]
|
|
332
344
|
lines << " free text allowed: --text" if q[:allow_freeform]
|
|
333
345
|
if q[:allowed_options]
|
|
334
346
|
lines << " a parent may pick #{q[:allowed_options].map { |o| option_arg(o) }.join(", ")}; the others are up to your user"
|
|
335
347
|
end
|
|
336
|
-
lines << " answer: #{answer_with(session_id, q)}"
|
|
337
|
-
lines << " or open it:
|
|
348
|
+
lines << " answer: #{answer_with(session_id, q, via: via)}"
|
|
349
|
+
lines << " or open it: #{via.open_it(session_id)}"
|
|
338
350
|
end
|
|
339
351
|
lines.map { |line| "#{line}\n" }.join
|
|
340
352
|
end
|
|
341
353
|
|
|
342
|
-
def no_reply_line(result, session_id = nil, strategy: nil)
|
|
354
|
+
def no_reply_line(result, session_id = nil, strategy: nil, via: ChiInvocation::HERE)
|
|
343
355
|
return command_line(result) if result.origin == TurnCheckpoint::COMMAND_ORIGIN
|
|
344
356
|
|
|
345
357
|
case result.outcome
|
|
@@ -354,7 +366,7 @@ module Samagotchi
|
|
|
354
366
|
ran_out = "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered"
|
|
355
367
|
strategy = policy(strategy)
|
|
356
368
|
if strategy.allow?(Guardrails::ParentPolicy::SPEND)
|
|
357
|
-
"#{ran_out};
|
|
369
|
+
"#{ran_out}; #{via.command("send #{session_id} -m '/continue yes'")} continues it, a message drops it"
|
|
358
370
|
else
|
|
359
371
|
# No /continue yes hint: a parent may not continue it here.
|
|
360
372
|
"#{ran_out}; continuing it is up to your user (#{strategy.label}), a message drops it"
|
|
@@ -104,7 +104,7 @@ module Samagotchi
|
|
|
104
104
|
|
|
105
105
|
# config.yml `bundles: <bundle>:` with the session's own values for
|
|
106
106
|
# the bundle laid over it (chi --set BUNDLE.KEY=VALUE, through the
|
|
107
|
-
# Host's setup: SessionSetup#
|
|
107
|
+
# Host's setup: SessionSetup#settings's bundles), string keys, frozen.
|
|
108
108
|
# Plugin init and chi.ctx run before the session is bound: with no
|
|
109
109
|
# session id yet it is the config alone, and it is built again once
|
|
110
110
|
# per session id (the session's values never change after it starts).
|
|
@@ -473,7 +473,7 @@ module Samagotchi
|
|
|
473
473
|
# The session's own values for this bundle ({} without any; nil when
|
|
474
474
|
# the Host couldn't tell them, so #settings can retry).
|
|
475
475
|
def session_values(id)
|
|
476
|
-
values = @host.setup&.call&.
|
|
476
|
+
values = @host.setup&.call&.settings&.bundles
|
|
477
477
|
values.is_a?(Hash) ? values.fetch(@bundle, {}) : {}
|
|
478
478
|
rescue StandardError => e
|
|
479
479
|
unless (@session_settings_unread ||= {}).key?(id)
|
data/lib/samagotchi/prompt.rb
CHANGED
|
@@ -27,7 +27,7 @@ module Samagotchi
|
|
|
27
27
|
# - Generation cue: <|im_start|>assistant\n
|
|
28
28
|
module Prompt
|
|
29
29
|
# Tail system messages whose text isn't the harness's own.
|
|
30
|
-
ESCAPED_KINDS = %w[note turn_note context].freeze
|
|
30
|
+
ESCAPED_KINDS = %w[note turn_note context working_set].freeze
|
|
31
31
|
|
|
32
32
|
# @param messages [Array<Hash>] each element has :role and :content.
|
|
33
33
|
# Recognised roles: "system", "user", "model", "tool_response".
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# /recap on|off|default, the setting form of /recap (the bare /recap,
|
|
7
|
+
# which shows the recap, is the UIs' own): this session's own
|
|
8
|
+
# recap.enabled (Engine#recap_override=), the same value
|
|
9
|
+
# `chi --set recap.enabled=…` saves, applied at once (the idle recap job
|
|
10
|
+
# is rebuilt or dropped). `default` removes it, and config.yml (or env)
|
|
11
|
+
# decides again. The session's value beats config.yml's: `/recap on`
|
|
12
|
+
# turns it on under `recap: false` too. It doesn't save: its host does
|
|
13
|
+
# on the reply's changed: [:recap] (Worker#run_command).
|
|
14
|
+
class RecapCommand
|
|
15
|
+
NAME = "/recap"
|
|
16
|
+
CHOICES = { "on" => true, "off" => false, "default" => nil }.freeze
|
|
17
|
+
USAGE = "usage: #{NAME} [on|off|default]".freeze
|
|
18
|
+
|
|
19
|
+
# @param engine [Engine] #session, #recap_override=, #recap
|
|
20
|
+
def initialize(engine:)
|
|
21
|
+
@engine = engine
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param args [String] what follows /recap
|
|
25
|
+
# @return [Array(String, Boolean, Symbol)] the reply, whether this
|
|
26
|
+
# session's recap setting changed, and :ok or :error
|
|
27
|
+
def run(args)
|
|
28
|
+
word = args.to_s.strip.downcase
|
|
29
|
+
return [USAGE, false, :error] unless CHOICES.key?(word)
|
|
30
|
+
return ["no session yet", false, :error] unless @engine.session
|
|
31
|
+
|
|
32
|
+
value = CHOICES.fetch(word)
|
|
33
|
+
changed = @engine.recap_override != value
|
|
34
|
+
@engine.recap_override = value if changed
|
|
35
|
+
[status_line(value), changed, :ok]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# "recap: off (this session; config.yml: on)", "recap: on (config.yml)".
|
|
39
|
+
# @param own [Boolean, nil] the session's own value, nil for none
|
|
40
|
+
def status_line(own)
|
|
41
|
+
from_config = self.class.config_status
|
|
42
|
+
line = if own.nil?
|
|
43
|
+
"recap: #{word(from_config[:on])} (#{from_config[:source]})"
|
|
44
|
+
else
|
|
45
|
+
"recap: #{word(own)} (this session; #{from_config[:source]}: #{word(from_config[:on])})"
|
|
46
|
+
end
|
|
47
|
+
# On by its own word, yet no job: a pinned recap host or model that
|
|
48
|
+
# doesn't resolve (warned in the log), or an embedder's recap: false.
|
|
49
|
+
on = own.nil? ? from_config[:on] : own
|
|
50
|
+
on && @engine.recap.nil? ? "#{line}; no recap is written: its host or model doesn't resolve (see the log)" : line
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Whether the recap is on without the session's own value: off by a
|
|
54
|
+
# scalar `recap: false`, `recap: {enabled: false}` or
|
|
55
|
+
# SAMAGOTCHI_RECAP_ENABLED=false (ConfigFile.recap_config sees all
|
|
56
|
+
# three), else on (the default).
|
|
57
|
+
# @return [Hash] {on: Boolean, source: "config.yml" or "env"}
|
|
58
|
+
def self.config_status
|
|
59
|
+
on = ConfigFile.recap_config != false
|
|
60
|
+
_value, origin = Config.get_with_origin("recap.enabled")
|
|
61
|
+
{ on: on, source: origin == :env ? "env" : "config.yml" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def word(enabled) = enabled ? "on" : "off"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -7,8 +7,10 @@ require_relative "idle_target"
|
|
|
7
7
|
require_relative "recap_store"
|
|
8
8
|
|
|
9
9
|
module Samagotchi
|
|
10
|
-
# Builds (or disables) an Engine's idle recap job from the `recap:` kwarg
|
|
11
|
-
# and the Config registry.
|
|
10
|
+
# Builds (or disables) an Engine's idle recap job from the `recap:` kwarg,
|
|
11
|
+
# the session's own settings and the Config registry. The Engine builds
|
|
12
|
+
# one at start, and again when it binds a session whose own recap values
|
|
13
|
+
# differ (Engine#session=).
|
|
12
14
|
module RecapSetup
|
|
13
15
|
# Build (or disable) the idle recap job. On by default: with no recap
|
|
14
16
|
# host or model configured it asks the session's current model on its
|
|
@@ -18,9 +20,10 @@ module Samagotchi
|
|
|
18
20
|
# --model goes; an incomplete one warns and leaves recap off.
|
|
19
21
|
#
|
|
20
22
|
# Single precedence path: explicit `recap:` kwarg > Config registry
|
|
21
|
-
# (CLI > ENV > file > default). An explicit disable
|
|
22
|
-
# the kwarg or in the config file, `recap: {enabled:
|
|
23
|
-
# SAMAGOTCHI_RECAP_ENABLED=false)
|
|
23
|
+
# (session > CLI > ENV > file > default). An explicit disable
|
|
24
|
+
# (`recap: false` as the kwarg or in the config file, `recap: {enabled:
|
|
25
|
+
# false}`, or SAMAGOTCHI_RECAP_ENABLED=false) wins, but over a session's
|
|
26
|
+
# own recap.enabled (chi --set recap.enabled=true) only as the kwarg.
|
|
24
27
|
#
|
|
25
28
|
# @param recap [Hash, false, nil] the Engine's `recap:` kwarg
|
|
26
29
|
# @param engine [Engine] the recap job's engine
|
|
@@ -28,13 +31,22 @@ module Samagotchi
|
|
|
28
31
|
# @param session_target [#call] → the session's current model as a recap target
|
|
29
32
|
# @param session_id [#call] → String, nil
|
|
30
33
|
# @param state_dir [#call] → String
|
|
34
|
+
# @param session_config [#call] → the session's own core settings
|
|
35
|
+
# (SessionConfig::Settings#core), read once here
|
|
31
36
|
# @return [IdleRecap, nil]
|
|
32
|
-
def self.build(recap, engine:, host_registry:, session_target:, session_id:, state_dir:)
|
|
37
|
+
def self.build(recap, engine:, host_registry:, session_target:, session_id:, state_dir:, session_config: -> { {} })
|
|
33
38
|
return nil if recap == false
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
|
|
40
|
+
session = session_config.call || {}
|
|
41
|
+
# The session's own recap.enabled replaces both checks below.
|
|
42
|
+
if session.key?("recap.enabled")
|
|
43
|
+
return nil unless session["recap.enabled"]
|
|
44
|
+
else
|
|
45
|
+
# The REPL and a worker pass nothing: the config is read here alone
|
|
46
|
+
# (a scalar `recap: false` is only seen this way).
|
|
47
|
+
return nil if recap.nil? && ConfigFile.recap_config == false
|
|
48
|
+
return nil if Samagotchi::Config.get("recap.enabled") == false
|
|
49
|
+
end
|
|
38
50
|
|
|
39
51
|
# An explicit kwarg (a spec, an embedder) over the registry.
|
|
40
52
|
kwarg_config = recap.is_a?(Hash) ? recap : {}
|
|
@@ -64,7 +76,7 @@ module Samagotchi
|
|
|
64
76
|
inactivity: recap_number_setting(kwarg_config, :inactivity, "recap.inactivity", IdleRecap::DEFAULT_INACTIVITY_SECONDS, :float),
|
|
65
77
|
min_user_turns: recap_number_setting(kwarg_config, :min_user_turns, "recap.min_user_turns", IdleRecap::DEFAULT_MIN_USER_TURNS, :int),
|
|
66
78
|
timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
|
|
67
|
-
sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
|
|
79
|
+
sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences", session: session)),
|
|
68
80
|
store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call }),
|
|
69
81
|
session_id: -> { session_id.call },
|
|
70
82
|
on_usage: ->(usage) { engine.note_side_usage(usage) }
|
|
@@ -108,9 +120,10 @@ module Samagotchi
|
|
|
108
120
|
end
|
|
109
121
|
private_class_method :recap_sentences
|
|
110
122
|
|
|
111
|
-
# Read a scalar recap setting via the Config registry (
|
|
112
|
-
|
|
113
|
-
|
|
123
|
+
# Read a scalar recap setting via the Config registry (session > CLI >
|
|
124
|
+
# ENV > file > default).
|
|
125
|
+
def self.registry_string(key, session: nil)
|
|
126
|
+
value = Samagotchi::Config.get(key, session: session)
|
|
114
127
|
value = value.to_s.strip
|
|
115
128
|
value.empty? ? nil : value
|
|
116
129
|
rescue StandardError
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "cli/command"
|
|
5
|
+
require_relative "cli/exit"
|
|
6
|
+
require_relative "cli/flags"
|
|
7
|
+
require_relative "paths"
|
|
8
|
+
require_relative "remotes"
|
|
9
|
+
require_relative "session_access"
|
|
10
|
+
require_relative "session_access/http_transport"
|
|
11
|
+
require_relative "session_access/remote"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
# `chi remote`: the other machines whose sessions chi --remote NAME (or
|
|
15
|
+
# CHI_REMOTE=NAME) reaches through their chi web (Remotes,
|
|
16
|
+
# SessionAccess::Remote). add checks the address and the token with GET
|
|
17
|
+
# /api/info before it saves anything; ls never prints a token.
|
|
18
|
+
class RemoteCommand
|
|
19
|
+
include CLI::Command
|
|
20
|
+
|
|
21
|
+
USAGE = <<~TEXT
|
|
22
|
+
Usage: chi remote <add|ls|rm> [options]
|
|
23
|
+
add NAME URL [--token-file FILE | --token-stdin]
|
|
24
|
+
a machine's chi web (http://LAN-IP:PORT, as chi web prints it with web.host: lan;
|
|
25
|
+
port 4567 by default), with its access token (chi web's LAN link carries it, after
|
|
26
|
+
?token=). It is checked first: the chi it runs, or why it can't be reached. Saved
|
|
27
|
+
in #{Paths.tilde(Remotes.path)} (0600); an existing NAME is replaced.
|
|
28
|
+
ls [--format json] the remotes (never their tokens)
|
|
29
|
+
rm NAME forget one
|
|
30
|
+
Then: chi --remote NAME send|answer|note|context|sessions …, or CHI_REMOTE=NAME.
|
|
31
|
+
TEXT
|
|
32
|
+
|
|
33
|
+
SUBCOMMANDS = %w[add ls rm].freeze
|
|
34
|
+
|
|
35
|
+
FLAGS = {
|
|
36
|
+
"add" => CLI::Flags.new(help: HELP_WORDS) do |f|
|
|
37
|
+
f.value "--token-file"
|
|
38
|
+
f.switch "--token-stdin"
|
|
39
|
+
end,
|
|
40
|
+
"ls" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--format" },
|
|
41
|
+
"rm" => CLI::Flags.new(help: HELP_WORDS)
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# @param transport [#call, nil] (entry) → a transport for add's check;
|
|
45
|
+
# HTTPTransport by default
|
|
46
|
+
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, path: Remotes.path, transport: nil)
|
|
47
|
+
@argv = argv.dup
|
|
48
|
+
@stdin = stdin
|
|
49
|
+
@stdout = stdout
|
|
50
|
+
@stderr = stderr
|
|
51
|
+
@path = path
|
|
52
|
+
@transport = transport || ->(entry) { SessionAccess::HTTPTransport.new(entry) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Integer] exit status: 0 done, 1 refused or unreachable, 2 usage
|
|
56
|
+
def run
|
|
57
|
+
@sub = @argv[0]
|
|
58
|
+
if @sub.nil? || HELP_WORDS.include?(@sub)
|
|
59
|
+
@stdout.puts(USAGE)
|
|
60
|
+
return 0
|
|
61
|
+
end
|
|
62
|
+
return usage_error("unknown subcommand #{@sub}") unless SUBCOMMANDS.include?(@sub)
|
|
63
|
+
|
|
64
|
+
parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
|
|
65
|
+
return parsed if parsed.is_a?(Integer)
|
|
66
|
+
|
|
67
|
+
send(:"run_#{@sub}", parsed.options, parsed.args)
|
|
68
|
+
rescue Remotes::Invalid => e
|
|
69
|
+
error_line("#{command_name}: #{e.message}")
|
|
70
|
+
CLI::Exit::FAILED
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi remote #{@sub}" : "chi remote"
|
|
76
|
+
def usage_on_error = nil
|
|
77
|
+
|
|
78
|
+
def run_add(options, args)
|
|
79
|
+
return usage_error("give NAME and URL") unless args.size == 2
|
|
80
|
+
return usage_error("--token-file and --token-stdin: choose one") if options[:token_file] && options[:token_stdin]
|
|
81
|
+
|
|
82
|
+
name, url = args
|
|
83
|
+
begin
|
|
84
|
+
Remotes.check_name!(name)
|
|
85
|
+
url = Remotes.normalize_url(url)
|
|
86
|
+
rescue Remotes::Invalid => e
|
|
87
|
+
return usage_error(e.message)
|
|
88
|
+
end
|
|
89
|
+
token = read_token(options)
|
|
90
|
+
return CLI::Exit::FAILED if token == false
|
|
91
|
+
|
|
92
|
+
entry = Remotes::Entry.new(name: name, url: url, token: token)
|
|
93
|
+
found = check(entry) or return CLI::Exit::FAILED
|
|
94
|
+
Remotes.put(entry, path: @path)
|
|
95
|
+
@stdout.puts("#{name} #{url} chi #{found["version"]}, #{lan_words(found, entry)}")
|
|
96
|
+
0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def run_ls(options, args)
|
|
100
|
+
return usage_error("ls takes no arguments") unless args.empty?
|
|
101
|
+
|
|
102
|
+
format = options[:format]
|
|
103
|
+
return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
|
|
104
|
+
|
|
105
|
+
entries = Remotes.all(path: @path)
|
|
106
|
+
if format == "json"
|
|
107
|
+
@stdout.puts(JSON.generate(entries.map { |e| { name: e.name, url: e.url, token: !e.token.nil? } }))
|
|
108
|
+
elsif entries.empty?
|
|
109
|
+
@stdout.puts("no remotes (chi remote add NAME URL --token-file FILE)")
|
|
110
|
+
else
|
|
111
|
+
width = entries.map { |e| e.name.length }.max
|
|
112
|
+
entries.each { |e| @stdout.puts("#{e.name.ljust(width)} #{e.url}#{" (no token)" unless e.token}") }
|
|
113
|
+
end
|
|
114
|
+
0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def run_rm(_options, args)
|
|
118
|
+
return usage_error("give one NAME") unless args.size == 1
|
|
119
|
+
raise Remotes::NotFound, args.first unless Remotes.remove(args.first, path: @path)
|
|
120
|
+
|
|
121
|
+
@stdout.puts("#{args.first} removed")
|
|
122
|
+
0
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The token from its file or stdin; nil for none (a loopback chi web
|
|
126
|
+
# needs none), false after the error line.
|
|
127
|
+
def read_token(options)
|
|
128
|
+
text = if options[:token_file]
|
|
129
|
+
begin
|
|
130
|
+
File.read(File.expand_path(options[:token_file]))
|
|
131
|
+
rescue SystemCallError => e
|
|
132
|
+
error_line("#{command_name}: can't read #{options[:token_file]}: #{e.message.sub(/ @ .*/, "")}")
|
|
133
|
+
return false
|
|
134
|
+
end
|
|
135
|
+
elsif options[:token_stdin]
|
|
136
|
+
read_stdin.to_s
|
|
137
|
+
end
|
|
138
|
+
return nil if text.nil?
|
|
139
|
+
|
|
140
|
+
token = text.strip
|
|
141
|
+
return token unless token.empty? || token.match?(/\s/)
|
|
142
|
+
|
|
143
|
+
error_line("#{command_name}: #{options[:token_file] || "stdin"} holds no token (one line, chi web's web-token file)")
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# GET /api/info with the token: the answer, or nil after the line that
|
|
148
|
+
# says why it can't be used.
|
|
149
|
+
def check(entry)
|
|
150
|
+
reply = @transport.call(entry).call(:get, "/api/info")
|
|
151
|
+
problem = SessionAccess::Remote.info_problem(entry, reply)
|
|
152
|
+
return fail_line(problem.message) if problem
|
|
153
|
+
|
|
154
|
+
reply.json
|
|
155
|
+
rescue SessionAccess::RemoteFailed => e
|
|
156
|
+
fail_line("#{e.message}: start chi web on #{entry.host} with web.host: lan")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def lan_words(found, entry)
|
|
160
|
+
return "loopback only" unless found["lan"]
|
|
161
|
+
|
|
162
|
+
found["lan"] == entry.host ? "LAN #{found["lan"]}" : "LAN #{found["lan"]} (reached as #{entry.host})"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def fail_line(message)
|
|
166
|
+
error_line("#{command_name}: #{message}")
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|