samagotchi 0.66.0 → 0.67.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 +60 -1
- data/docs/architecture.md +50 -28
- data/docs/cli.md +3 -1
- data/docs/configuration.md +50 -9
- data/docs/context.md +35 -1
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +71 -29
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +26 -11
- data/docs/plugins.md +126 -23
- data/docs/sessions.md +6 -6
- data/docs/sub-agent.md +4 -1
- data/docs/testing.md +17 -3
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +15 -7
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/engine.rb +96 -191
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +64 -155
- data/lib/samagotchi/plugin/loader.rb +4 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +21 -11
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +1 -1
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +16 -9
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +1 -1
- data/lib/samagotchi/web/public/app.js +31 -36
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/commands.js +2 -2
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +14 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_model.js +2 -2
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +11 -8
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +14 -1
|
@@ -40,7 +40,7 @@ module Samagotchi
|
|
|
40
40
|
USAGE = <<~TEXT
|
|
41
41
|
Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
|
|
42
42
|
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [-m TEXT] [--image PATH]...
|
|
43
|
-
chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
|
|
43
|
+
chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
|
|
44
44
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
45
45
|
chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
|
|
46
46
|
chi send --wait [--timeout S] [--format json] ID
|
|
@@ -63,9 +63,11 @@ module Samagotchi
|
|
|
63
63
|
the current one
|
|
64
64
|
--continues ID
|
|
65
65
|
(--new) start the next link of that session's chain:
|
|
66
|
-
in its folder, on its model
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
in its folder, on its model (or --model's; one whose
|
|
67
|
+
host is gone falls back to the default, with a
|
|
68
|
+
warning), LLM context, thinking level and toolkit,
|
|
69
|
+
with a note carrying its recap; it is archived.
|
|
70
|
+
last:ID names the chain's latest link.
|
|
69
71
|
Without -m the new session waits idle. Refused for a
|
|
70
72
|
session continued already (naming the next link) or
|
|
71
73
|
with delegates still open
|
|
@@ -289,11 +291,11 @@ module Samagotchi
|
|
|
289
291
|
return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
|
|
290
292
|
|
|
291
293
|
if options[:continues]
|
|
292
|
-
taken = [("--dir" if options[:dir])
|
|
294
|
+
taken = [("--dir" if options[:dir])] +
|
|
293
295
|
LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
|
|
294
296
|
[("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? }
|
|
295
297
|
unless taken.compact.empty?
|
|
296
|
-
return usage_error("--continues takes the previous session's folder,
|
|
298
|
+
return usage_error("--continues takes the previous session's folder, LLM context, thinking level and " \
|
|
297
299
|
"toolkit; " \
|
|
298
300
|
"leave out #{taken.compact.join(", ")}")
|
|
299
301
|
end
|
|
@@ -358,7 +360,8 @@ module Samagotchi
|
|
|
358
360
|
begin
|
|
359
361
|
start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
|
|
360
362
|
session = if options[:continues]
|
|
361
|
-
SessionManager.continue_session(options[:continues], **start,
|
|
363
|
+
SessionManager.continue_session(options[:continues], **start, model: options[:model],
|
|
364
|
+
state_dir: @state_dir)
|
|
362
365
|
else
|
|
363
366
|
SessionManager.spawn_session(**start, working_directory: options[:dir],
|
|
364
367
|
model_name: options[:model],
|
|
@@ -388,15 +391,22 @@ module Samagotchi
|
|
|
388
391
|
timeout: options[:timeout])
|
|
389
392
|
end
|
|
390
393
|
|
|
391
|
-
#
|
|
392
|
-
#
|
|
394
|
+
# The model the new session will run, for the images check: --model
|
|
395
|
+
# (nil: the default), or with --continues the one the new link will run
|
|
396
|
+
# (SessionContinue.model_for: --model's, else the previous link's, else
|
|
397
|
+
# the default it falls back to).
|
|
393
398
|
# @return [Array, false] [model_name, model_typed], or false after the
|
|
394
|
-
# error line (no such session)
|
|
399
|
+
# error line (no such session, a model it can't start on)
|
|
395
400
|
def continued_model(options)
|
|
396
401
|
return [options[:model], nil] unless options[:continues]
|
|
397
402
|
|
|
398
403
|
previous = Session.load(SessionChain.resolve(options[:continues], state_dir: @state_dir), state_dir: @state_dir)
|
|
399
|
-
|
|
404
|
+
choice = SessionContinue.model_for(previous, options[:model])
|
|
405
|
+
inherited = options[:model].to_s.strip.empty? && choice.warning.nil?
|
|
406
|
+
inherited ? [previous.model_name, previous.model_typed] : [choice.model, nil]
|
|
407
|
+
rescue ModelProfile::MissingModel => e
|
|
408
|
+
error_line("chi send: could not start a session: #{e.message}")
|
|
409
|
+
false
|
|
400
410
|
rescue ArgumentError => e
|
|
401
411
|
error_line("chi send: #{e.message}")
|
|
402
412
|
false
|
|
@@ -56,11 +56,21 @@ module Samagotchi
|
|
|
56
56
|
# moved the chain's end while it waited for the lock.
|
|
57
57
|
TRIES = 3
|
|
58
58
|
|
|
59
|
+
# The model a new link runs on (#model_for): the name to spawn it with,
|
|
60
|
+
# and why it isn't the previous link's (nil when it is, or was given).
|
|
61
|
+
ModelChoice = Data.define(:model, :warning)
|
|
62
|
+
# What #check! decided before anything changes: the delegates to move
|
|
63
|
+
# (ChildMove::Plan) and the new link's model (ModelChoice).
|
|
64
|
+
Checked = Data.define(:plan, :choice)
|
|
65
|
+
|
|
59
66
|
# Start the next link of a chain: a new session that continues
|
|
60
67
|
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
61
68
|
# it was typed as, so an alias holds) and with its own setup (SessionSetup),
|
|
62
69
|
# starting with a context note from chi: the link and the previous
|
|
63
|
-
# link's recap (SessionChain.note_text).
|
|
70
|
+
# link's recap (SessionChain.note_text). +model+, when given, is the
|
|
71
|
+
# new link's model instead; an inherited one whose host can't run it any
|
|
72
|
+
# more (gone, disabled, an alias naming another host) falls back to the
|
|
73
|
+
# default with a warning (#model_for). The previous link is archived
|
|
64
74
|
# first, with its finished delegates (archive_session's rules: a turn
|
|
65
75
|
# running, a prompt queued or the step-limit question refuse; an idle
|
|
66
76
|
# worker is stopped), so a refusal starts nothing. Its open delegates
|
|
@@ -81,7 +91,7 @@ module Samagotchi
|
|
|
81
91
|
# @raise [Refused] continued already, delegates open that can't
|
|
82
92
|
# move, or its folder is gone
|
|
83
93
|
# @raise [ArchiveRefused, OwnedByTUI] as archive_session
|
|
84
|
-
def self.run(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
|
|
94
|
+
def self.run(id_or_ref, prompt: nil, title: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
|
|
85
95
|
sd = state_dir || Session.default_state_dir
|
|
86
96
|
TRIES.times do |try|
|
|
87
97
|
id = SessionChain.resolve(id_or_ref, state_dir: sd)
|
|
@@ -89,7 +99,7 @@ module Samagotchi
|
|
|
89
99
|
follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < TRIES - 1
|
|
90
100
|
next :moved if follow && SessionChain.next_of(id, state_dir: sd)
|
|
91
101
|
|
|
92
|
-
run_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
|
|
102
|
+
run_locked(id, prompt: prompt, title: title, model: model, state_dir: sd, recap_wait: recap_wait,
|
|
93
103
|
archive_wait: archive_wait)
|
|
94
104
|
end
|
|
95
105
|
return result unless result == :moved
|
|
@@ -105,9 +115,10 @@ module Samagotchi
|
|
|
105
115
|
end
|
|
106
116
|
end
|
|
107
117
|
|
|
108
|
-
private_class_method def self.run_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
|
|
118
|
+
private_class_method def self.run_locked(id, prompt:, title:, model:, state_dir:, recap_wait:, archive_wait:)
|
|
109
119
|
previous = Session.load(id, state_dir: state_dir)
|
|
110
|
-
|
|
120
|
+
checked = check!(previous, state_dir, model: model)
|
|
121
|
+
plan = checked.plan
|
|
111
122
|
# archive_session's refusals that can be known now, before the wait.
|
|
112
123
|
SessionManager.archivable!(id, state_dir, except: plan.move)
|
|
113
124
|
recap_before_archive(id, state_dir, wait: recap_wait)
|
|
@@ -120,7 +131,8 @@ module Samagotchi
|
|
|
120
131
|
archive = SessionManager.archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)
|
|
121
132
|
archived = archive[:archived] - already
|
|
122
133
|
begin
|
|
123
|
-
spawn_continuation(previous, prompt: prompt, title: title,
|
|
134
|
+
spawn_continuation(previous, prompt: prompt, title: title, choice: checked.choice, state_dir: state_dir,
|
|
135
|
+
move: plan.move)
|
|
124
136
|
rescue StandardError
|
|
125
137
|
# Nothing started: the delegates go back, what this archived goes
|
|
126
138
|
# back to the lists, and a new link saved before its worker failed
|
|
@@ -181,7 +193,7 @@ module Samagotchi
|
|
|
181
193
|
|
|
182
194
|
# The new link, holding off any worker but its own (ChildMove.starting?)
|
|
183
195
|
# while +move+ goes to it.
|
|
184
|
-
private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:, move:)
|
|
196
|
+
private_class_method def self.spawn_continuation(previous, prompt:, title:, choice:, state_dir:, move:)
|
|
185
197
|
recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
|
|
186
198
|
start = prompt.to_s.strip.empty? ? nil : prompt
|
|
187
199
|
title = previous.first_preview if title.to_s.strip.empty? && start.nil?
|
|
@@ -191,33 +203,64 @@ module Samagotchi
|
|
|
191
203
|
end
|
|
192
204
|
link = SessionManager.spawn_session(
|
|
193
205
|
prompt: start, title: title, working_directory: previous.working_directory,
|
|
194
|
-
model_name:
|
|
206
|
+
model_name: choice.model, setup: SessionSetup.of(previous),
|
|
195
207
|
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
196
208
|
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
|
|
197
209
|
)
|
|
198
210
|
link.moved_children = move
|
|
211
|
+
# The fallback's warning first: spawn_session set its own.
|
|
212
|
+
warnings = [choice.warning, link.model_warning].compact
|
|
213
|
+
link.model_warning = warnings.join("; ") unless warnings.empty?
|
|
199
214
|
link
|
|
200
215
|
end
|
|
201
216
|
|
|
217
|
+
# The model a new link of +previous+'s chain runs on: +given+ when
|
|
218
|
+
# there is one (its host checked: ModelProfile::UnknownHost as for a new
|
|
219
|
+
# session), else the previous link's, as typed (so an alias holds). An
|
|
220
|
+
# inherited one whose host can't run it any more (gone from config.yml,
|
|
221
|
+
# disabled, an alias naming another host: UnknownHost) falls back to the
|
|
222
|
+
# default, with a warning naming why. `chi send --new --continues`
|
|
223
|
+
# judges images by it before #run decides the same.
|
|
224
|
+
# @param previous [Session]
|
|
225
|
+
# @param given [String, nil] blank: inherit
|
|
226
|
+
# @return [ModelChoice]
|
|
227
|
+
# @raise [ModelProfile::MissingModel] a +given+ model it can't run, or
|
|
228
|
+
# a fallback with no default configured (or the default's host can't run it)
|
|
229
|
+
def self.model_for(previous, given)
|
|
230
|
+
unless given.to_s.strip.empty?
|
|
231
|
+
return ModelChoice.new(model: ModelProfile.check_host!(ModelProfile.required_model_name(given.strip)),
|
|
232
|
+
warning: nil)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
inherited = ModelProfile.required_model_name(previous.model_typed || previous.model_name)
|
|
236
|
+
begin
|
|
237
|
+
ModelChoice.new(model: ModelProfile.check_host!(inherited), warning: nil)
|
|
238
|
+
rescue ModelProfile::UnknownHost => e
|
|
239
|
+
default = ModelProfile.check_host!(ModelProfile.required_model_name(nil))
|
|
240
|
+
ModelChoice.new(model: default, warning: "#{inherited} can't run (#{e.message}); started on #{default}")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
202
244
|
# The refusals that come before anything changes: continued already (no
|
|
203
245
|
# forks in a chain), an open delegate that can't move to the new link
|
|
204
246
|
# (ChildMove.plan), the folder gone (the worker would run elsewhere), a
|
|
205
|
-
# model whose host config.yml
|
|
206
|
-
#
|
|
247
|
+
# given model whose host config.yml doesn't have, or an inherited one
|
|
248
|
+
# that can't run with no default to fall back to (#model_for).
|
|
249
|
+
# @return [Checked]
|
|
207
250
|
# @raise [Refused, ModelProfile::MissingModel]
|
|
208
|
-
private_class_method def self.check!(previous, state_dir)
|
|
251
|
+
private_class_method def self.check!(previous, state_dir, model: nil)
|
|
209
252
|
if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
|
|
210
253
|
raise Refused.new(previous.id, :continued, ids: [following])
|
|
211
254
|
end
|
|
212
255
|
|
|
213
256
|
plan = move_plan!(previous.id, state_dir)
|
|
214
|
-
#
|
|
215
|
-
# the archive stops the previous link's worker.
|
|
216
|
-
|
|
257
|
+
# The new link's model, its host checked as SessionManager.spawn_session
|
|
258
|
+
# checks it: before the archive stops the previous link's worker.
|
|
259
|
+
choice = model_for(previous, model)
|
|
217
260
|
dir = previous.working_directory.to_s
|
|
218
261
|
raise Refused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
|
|
219
262
|
|
|
220
|
-
plan
|
|
263
|
+
Checked.new(plan: plan, choice: choice)
|
|
221
264
|
end
|
|
222
265
|
|
|
223
266
|
# A child still open below a session (#open_children); why: running,
|
|
@@ -236,15 +236,6 @@ module Samagotchi
|
|
|
236
236
|
UNREADABLE_INPUT
|
|
237
237
|
end
|
|
238
238
|
|
|
239
|
-
# Whether an unclaimed input file carries images (a mid-turn drain
|
|
240
|
-
# leaves it for its own turn).
|
|
241
|
-
def self.input_has_images?(input_file)
|
|
242
|
-
data = JSON.parse(File.read(input_file))
|
|
243
|
-
data.is_a?(Hash) && Array(data["images"]).any?
|
|
244
|
-
rescue JSON::ParserError, SystemCallError
|
|
245
|
-
false
|
|
246
|
-
end
|
|
247
|
-
|
|
248
239
|
# Whether a mid-turn drain leaves this unclaimed input file for a turn
|
|
249
240
|
# of its own instead of merging it at a step boundary: a queued message
|
|
250
241
|
# (`delivery: queue`) or an image message (steering merges text only).
|
|
@@ -258,10 +249,6 @@ module Samagotchi
|
|
|
258
249
|
false
|
|
259
250
|
end
|
|
260
251
|
|
|
261
|
-
# Whether an input file carrying +enqueued_id+ still waits unclaimed in
|
|
262
|
-
# input/ (no worker has taken it for a turn or a step yet).
|
|
263
|
-
def self.input_waiting?(session_dir, enqueued_id) = !waiting_input(session_dir, enqueued_id).nil?
|
|
264
|
-
|
|
265
252
|
# How the unclaimed input file carrying +enqueued_id+ waits: :turn_end
|
|
266
253
|
# (a queue or an image message: it runs as a turn of its own, never
|
|
267
254
|
# merged, #waits_for_turn_end?), :merges (a running turn's drain may
|
|
@@ -36,7 +36,7 @@ module Samagotchi
|
|
|
36
36
|
# <|channel>thought......<channel|>
|
|
37
37
|
class Gemma
|
|
38
38
|
# Digits after the first character: plugin tools may have them
|
|
39
|
-
# (Plugin::
|
|
39
|
+
# (Plugin::ToolSpec::TOOL_NAME).
|
|
40
40
|
TOOL_CALL_BODY_RE = /\Acall:([a-z_][a-z0-9_]{0,49})\{/
|
|
41
41
|
THOUGHT_CHANNEL_OPEN = ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN
|
|
42
42
|
THOUGHT_CHANNEL_CLOSE = ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "log"
|
|
4
|
+
require_relative "toolkit"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# An Engine's session toolkit: the kits it resolves against, what the
|
|
8
|
+
# session's next turn declares (#explained, the UIs' summary), a plugin's
|
|
9
|
+
# ctx.use_toolkit (#use) and the turn-start apply into the tool Registry
|
|
10
|
+
# (#apply!), with its load warnings said once per process.
|
|
11
|
+
#
|
|
12
|
+
# What it needs from the Engine comes through lookups, called at use.
|
|
13
|
+
class ToolkitBinding
|
|
14
|
+
# @param tools [#call] → Tools::Registry
|
|
15
|
+
# @param session [#call] → Session, nil
|
|
16
|
+
# @param state_dir [#call] → the sessions' state dir (#use saves there)
|
|
17
|
+
# @param can_stage [#call] → whether a plugin may change the toolkit now
|
|
18
|
+
# (no turn runs, no model reply yet)
|
|
19
|
+
# @param reset_prompt [#call] → build the system prompts again
|
|
20
|
+
# @param emit [#call] (sink, event) → emits to a turn's sink + observers
|
|
21
|
+
# @param notify [#call] (text, level, source, extra:) → a notice
|
|
22
|
+
def initialize(tools:, session:, state_dir:, can_stage:, reset_prompt:, emit:, notify:)
|
|
23
|
+
@tools = tools
|
|
24
|
+
@session = session
|
|
25
|
+
@state_dir = state_dir
|
|
26
|
+
@can_stage = can_stage
|
|
27
|
+
@reset_prompt = reset_prompt
|
|
28
|
+
@emit = emit
|
|
29
|
+
@notify = notify
|
|
30
|
+
# The toolkits plugins declare (chi.toolkit), beside the built-in and
|
|
31
|
+
# config ones (#kits); Plugin::Registries appends to it.
|
|
32
|
+
@plugin_kits = []
|
|
33
|
+
@warned = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Array<Toolkit::Kit>] the plugins' kits (Plugin::Registries' toolkits:)
|
|
37
|
+
attr_reader :plugin_kits
|
|
38
|
+
|
|
39
|
+
# The kits a session's toolkit resolves against: the built-ins, config's
|
|
40
|
+
# toolkit.kits, the plugins' (Toolkit.kits). Config's are read once per
|
|
41
|
+
# process, like the plugins': an edit to toolkit.kits never changes a
|
|
42
|
+
# live session's tools mid-session (a new worker reads it).
|
|
43
|
+
# @param errors [Array<String>] gets a line per config kit left out
|
|
44
|
+
# @return [Hash{String => Toolkit::Kit}]
|
|
45
|
+
def kits(errors = [])
|
|
46
|
+
@config_kits = Toolkit.config_kits unless defined?(@config_kits)
|
|
47
|
+
Toolkit.kits(config: @config_kits, plugins: @plugin_kits, errors: errors)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The session's toolkit resolved (Toolkit::Resolved): what its next
|
|
51
|
+
# turn declares. A plain full toolkit is nil, the Registry's "every tool".
|
|
52
|
+
def resolved(session, errors = [])
|
|
53
|
+
resolved = Toolkit.of(session&.toolkit).resolve(kits(errors))
|
|
54
|
+
resolved.everything? ? nil : resolved
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# +toolkit+ (the session's by default) as its next turn would declare
|
|
58
|
+
# it (/toolkit, the UIs' chip).
|
|
59
|
+
# @return [Toolkit::Explained]
|
|
60
|
+
def explained(toolkit = nil)
|
|
61
|
+
toolkit ||= Toolkit.of(@session.call&.toolkit)
|
|
62
|
+
resolved = toolkit.resolve(kits)
|
|
63
|
+
filter = resolved.everything? ? nil : resolved
|
|
64
|
+
tools = @tools.call
|
|
65
|
+
Toolkit::Explained.new(toolkit: toolkit, resolved: resolved, declared: tools.entries(toolkit: filter),
|
|
66
|
+
left_out: tools.left_out_names(toolkit: filter), pending: filter != tools.toolkit)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The session's toolkit for the UIs (the snapshot, command_ran): nil on
|
|
70
|
+
# full, or with no session.
|
|
71
|
+
def summary
|
|
72
|
+
@session.call && explained.summary
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The session's toolkit name, nil with no session (ctx.toolkit).
|
|
76
|
+
def name
|
|
77
|
+
session = @session.call
|
|
78
|
+
session && Toolkit.of(session.toolkit).name
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A plugin's ctx.use_toolkit: put the session on toolkit +name+, saved,
|
|
82
|
+
# when it has no model reply yet and no turn runs (D3: no confirm from
|
|
83
|
+
# plugins), and tell the user as /toolkit would; it applies at the next
|
|
84
|
+
# turn's start.
|
|
85
|
+
# @return [Boolean] whether it was staged
|
|
86
|
+
# @raise [ArgumentError] an unknown toolkit
|
|
87
|
+
def use(name)
|
|
88
|
+
raise ArgumentError, "no toolkit named #{name}" unless kits.key?(name)
|
|
89
|
+
|
|
90
|
+
session = @session.call
|
|
91
|
+
return false if session.nil? || !@can_stage.call
|
|
92
|
+
|
|
93
|
+
toolkit = Toolkit.new(name: name)
|
|
94
|
+
return true if session.toolkit == toolkit
|
|
95
|
+
|
|
96
|
+
session.toolkit = toolkit
|
|
97
|
+
session.save(state_dir: @state_dir.call)
|
|
98
|
+
@notify.call("toolkit: #{name} from the next turn's start", :info, "toolkit", extra: { toolkit: summary })
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Resolve the session's toolkit into the Registry, on the turn thread at
|
|
103
|
+
# a turn's start (before its prompt is built), and build the prompts
|
|
104
|
+
# again when what it declares changed. A name that doesn't resolve (a
|
|
105
|
+
# typo, an uninstalled bundle) declares everything for now and says so
|
|
106
|
+
# once; the saved toolkit stays as it is.
|
|
107
|
+
# @return [Boolean] whether the declared tools changed
|
|
108
|
+
def apply!(session, on_event = nil)
|
|
109
|
+
errors = []
|
|
110
|
+
resolved = Toolkit.of(session&.toolkit).resolve(kits(errors))
|
|
111
|
+
warn_unresolved(session, resolved, errors, on_event)
|
|
112
|
+
resolved = nil if resolved.everything?
|
|
113
|
+
tools = @tools.call
|
|
114
|
+
return false if resolved == tools.toolkit
|
|
115
|
+
|
|
116
|
+
tools.toolkit = resolved
|
|
117
|
+
@reset_prompt.call
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# The toolkit's load warnings, each once per process: a name that
|
|
124
|
+
# didn't resolve, config kits left out.
|
|
125
|
+
def warn_unresolved(session, resolved, errors, on_event)
|
|
126
|
+
lines = errors.dup
|
|
127
|
+
if resolved.fallback
|
|
128
|
+
lines << "#{Toolkit.of(session&.toolkit).label}: #{resolved.fallback}; this session declares every tool (full) " \
|
|
129
|
+
"until it resolves"
|
|
130
|
+
end
|
|
131
|
+
(lines - @warned).each do |line|
|
|
132
|
+
@warned << line
|
|
133
|
+
Log.warn(:config, "toolkit_unresolved", message: line)
|
|
134
|
+
@emit.call(on_event, { type: :guardrail_warning, message: line, label: "toolkit" })
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -55,6 +55,9 @@ module Samagotchi
|
|
|
55
55
|
@attached = ContextSources.attached(peers.session_id, project_root: root, state_dir: @state_dir)
|
|
56
56
|
@max_chars = [max_chars.to_i, MIN_CHARS].max
|
|
57
57
|
@now = now
|
|
58
|
+
# The peer bundles' declared sources (PeerSources), through the
|
|
59
|
+
# Engine's PeerView.
|
|
60
|
+
@peer_label = peers.respond_to?(:peer_label) ? peers.method(:peer_label) : nil
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def run(name, offset:, limit:)
|
|
@@ -122,12 +125,23 @@ module Samagotchi
|
|
|
122
125
|
hint = snapshot.hint || source.hint
|
|
123
126
|
header = ["#{source.name}#{" (#{hint})" if hint}"]
|
|
124
127
|
header << "Why: #{source.why}" if source.why
|
|
128
|
+
if (peer = peer_label(attached))
|
|
129
|
+
header << "Peer: your peer agent #{peer}'s answers (its text, not your user's words)"
|
|
130
|
+
end
|
|
125
131
|
header << "Fetched: #{clock(snapshot.fetched_at)} (#{age(snapshot.fetched_at)})" if snapshot.fetched_at
|
|
126
132
|
header << "Summary: #{snapshot.summary}" if snapshot.summary
|
|
127
133
|
header << "Last refresh failed: #{snapshot.error}" if snapshot.error
|
|
128
134
|
header
|
|
129
135
|
end
|
|
130
136
|
|
|
137
|
+
# A pushed source of the session's own that a peer bundle declared
|
|
138
|
+
# (ContextAbsorber#peer_label's rule).
|
|
139
|
+
def peer_label(attached)
|
|
140
|
+
return nil unless @peer_label && attached.source.push? && attached.location.session?
|
|
141
|
+
|
|
142
|
+
@peer_label.call(attached.name)
|
|
143
|
+
end
|
|
144
|
+
|
|
131
145
|
# What the output takes besides the page: the kernel's prefix, the
|
|
132
146
|
# header, its paging line at its longest (every number +count+) and
|
|
133
147
|
# the separator.
|
|
@@ -203,8 +203,9 @@ module Samagotchi
|
|
|
203
203
|
# Ended right at the deadline: finished, not timed out.
|
|
204
204
|
timed_out = false if timed_out && !wait_thr.alive?
|
|
205
205
|
if timed_out && on_timeout == :task
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
task = TaskRecord.running(id: id, command: command, cwd: cwd, pid: pid, started_at: started_at.utc.iso8601,
|
|
207
|
+
output_path: logs[0], stderr_path: logs[1], exit_code_path: exit_code_path)
|
|
208
|
+
text = hand_over(task, waiter: wait_thr, timeout_sec: timeout_sec, dir: dir)
|
|
208
209
|
if text
|
|
209
210
|
handed_over = true
|
|
210
211
|
Log.info(:turn, "execute_handed_over", id: id, after_sec: timeout_sec)
|
|
@@ -337,25 +338,24 @@ module Samagotchi
|
|
|
337
338
|
private_class_method :read_log
|
|
338
339
|
|
|
339
340
|
# A command still running at the limit becomes a task (TaskRuntime.
|
|
340
|
-
# adopt) with the logs where they are
|
|
341
|
-
# the record for the sweep. nil when it can't be adopted (the caller
|
|
341
|
+
# adopt +task+, a TaskRecord.running) with the logs where they are
|
|
342
|
+
# (+dir+); a back-pointer in that dir names the record for the sweep. nil when it can't be adopted (the caller
|
|
342
343
|
# then kills it as in :kill mode) or when it ended meanwhile (the
|
|
343
344
|
# caller then reads it as finished).
|
|
344
345
|
# @return [String, nil] the HANDED_OVER result
|
|
345
|
-
def self.hand_over(
|
|
346
|
+
def self.hand_over(task, waiter:, timeout_sec:, dir:)
|
|
346
347
|
return nil unless waiter.alive?
|
|
347
348
|
|
|
348
|
-
record, error = TaskRuntime.adopt(
|
|
349
|
-
output_path: logs[0], stderr_path: logs[1], exit_code_path: exit_code_path)
|
|
349
|
+
record, error = TaskRuntime.adopt(task)
|
|
350
350
|
return nil if error
|
|
351
351
|
|
|
352
|
-
File.write(File.join(dir, BACK_POINTER), File.expand_path(TaskRuntime.metadata_path(record
|
|
353
|
-
write_missing_exit_code(waiter, exit_code_path)
|
|
352
|
+
File.write(File.join(dir, BACK_POINTER), File.expand_path(TaskRuntime.metadata_path(record.id)))
|
|
353
|
+
write_missing_exit_code(waiter, record.exit_code_path)
|
|
354
354
|
# Ended between the deadline and the adopt: the record says how.
|
|
355
|
-
TaskRuntime.get_record(id) unless waiter.alive?
|
|
356
|
-
lines = [handed_over_line(timeout_sec), "task_id: #{id}",
|
|
355
|
+
TaskRuntime.get_record(record.id) unless waiter.alive?
|
|
356
|
+
lines = [handed_over_line(timeout_sec), "task_id: #{record.id}",
|
|
357
357
|
"Wait for it with task_wait (it may take minutes); task_stop stops it."]
|
|
358
|
-
{ "stdout" =>
|
|
358
|
+
{ "stdout" => record.output_path, "stderr" => record.stderr_path }.each do |label, path|
|
|
359
359
|
tail = TaskRuntime.output_tail_lines(path, HAND_OVER_TAIL_LINES)
|
|
360
360
|
lines << "#{label} (so far, tail):\n#{tail.chomp}" unless tail.empty?
|
|
361
361
|
end
|
|
@@ -85,7 +85,7 @@ module Samagotchi
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
# Every tool +source+ registered, declared or not (a plugin's tool set
|
|
88
|
-
# changing: Plugin::
|
|
88
|
+
# changing: Plugin::ToolSpec.apply).
|
|
89
89
|
# @return [Array<Entry>]
|
|
90
90
|
def by_source(source) = @entries.values.select { |entry| entry.source == source }
|
|
91
91
|
|
|
@@ -15,12 +15,12 @@ module Samagotchi
|
|
|
15
15
|
return error if error
|
|
16
16
|
|
|
17
17
|
[
|
|
18
|
-
"task_id: #{record.
|
|
19
|
-
"status: #{record.
|
|
20
|
-
"pid: #{record.
|
|
21
|
-
"created_at: #{record.
|
|
22
|
-
"output_path: #{record.
|
|
23
|
-
"cwd: #{record.
|
|
18
|
+
"task_id: #{record.id}",
|
|
19
|
+
"status: #{record.status}",
|
|
20
|
+
"pid: #{record.pid}",
|
|
21
|
+
"created_at: #{record.created_at}",
|
|
22
|
+
"output_path: #{record.output_path}",
|
|
23
|
+
"cwd: #{record.cwd}"
|
|
24
24
|
].join("\n")
|
|
25
25
|
rescue StandardError => e
|
|
26
26
|
"Error: #{e.message}"
|
|
@@ -20,20 +20,20 @@ module Samagotchi
|
|
|
20
20
|
|
|
21
21
|
def self.format_record(record)
|
|
22
22
|
[
|
|
23
|
-
"task_id: #{record.
|
|
24
|
-
"status: #{record.
|
|
25
|
-
"command: #{record.
|
|
26
|
-
"cwd: #{record.
|
|
27
|
-
"pid: #{record.
|
|
28
|
-
"created_at: #{record.
|
|
29
|
-
"started_at: #{record.
|
|
30
|
-
"finished_at: #{record
|
|
31
|
-
"exit_code: #{record
|
|
32
|
-
"stop_reason: #{record
|
|
33
|
-
"output_path: #{record.
|
|
23
|
+
"task_id: #{record.id}",
|
|
24
|
+
"status: #{record.status}",
|
|
25
|
+
"command: #{record.command}",
|
|
26
|
+
"cwd: #{record.cwd}",
|
|
27
|
+
"pid: #{record.pid}",
|
|
28
|
+
"created_at: #{record.created_at}",
|
|
29
|
+
"started_at: #{record.started_at}",
|
|
30
|
+
"finished_at: #{record.finished_at}",
|
|
31
|
+
"exit_code: #{record.exit_code}",
|
|
32
|
+
"stop_reason: #{record.stop_reason}",
|
|
33
|
+
"output_path: #{record.output_path}",
|
|
34
34
|
# A handed-over execute's stderr has a log of its own.
|
|
35
|
-
("stderr_path: #{record
|
|
36
|
-
(TaskRuntime::USER_STOP_NOTE if
|
|
35
|
+
("stderr_path: #{record.stderr_path}" if record.stderr_path),
|
|
36
|
+
(TaskRuntime::USER_STOP_NOTE if record.stopped_by_user?)
|
|
37
37
|
].compact.join("\n")
|
|
38
38
|
end
|
|
39
39
|
private_class_method :format_record
|
|
@@ -21,12 +21,12 @@ module Samagotchi
|
|
|
21
21
|
|
|
22
22
|
def self.format_entry(record)
|
|
23
23
|
[
|
|
24
|
-
"task_id: #{record.
|
|
25
|
-
"status: #{record.
|
|
26
|
-
"pid: #{record.
|
|
27
|
-
"created_at: #{record.
|
|
28
|
-
"command: #{preview(record.
|
|
29
|
-
"output_path: #{record.
|
|
24
|
+
"task_id: #{record.id}",
|
|
25
|
+
"status: #{record.status}",
|
|
26
|
+
"pid: #{record.pid}",
|
|
27
|
+
"created_at: #{record.created_at}",
|
|
28
|
+
"command: #{preview(record.command)}",
|
|
29
|
+
"output_path: #{record.output_path}"
|
|
30
30
|
].join("\n")
|
|
31
31
|
end
|
|
32
32
|
private_class_method :format_entry
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Tools
|
|
5
|
+
# A background task's record, as TaskRuntime keeps it in
|
|
6
|
+
# tmp/tasks/<id>/task.json. Read with .from_file, written with #to_file;
|
|
7
|
+
# an update is a #with. The values are as on disk: times are iso8601
|
|
8
|
+
# strings, +pid+ whatever the file holds (a hand-edited one may not be
|
|
9
|
+
# a number; TaskRuntime never signals that).
|
|
10
|
+
#
|
|
11
|
+
# The file keeps its key order (the members' order) and leaves out the
|
|
12
|
+
# optional keys while unset: +stderr_path+ and +handed_over_by+ (only a
|
|
13
|
+
# handed-over execute has them) and +stop_requested_by+ (only once a
|
|
14
|
+
# stop started). bundles/slow-commands reads these files on its own.
|
|
15
|
+
TaskRecord = Data.define(
|
|
16
|
+
:metadata_version, :id, :command, :cwd, :workspace_root, :status, :pid, :pid_started,
|
|
17
|
+
:created_at, :started_at, :finished_at, :exit_code, :stop_reason,
|
|
18
|
+
:output_path, :stderr_path, :exit_code_path, :handed_over_by, :stop_requested_by
|
|
19
|
+
) do
|
|
20
|
+
self::METADATA_VERSION = 1
|
|
21
|
+
self::OPTIONAL = %i[stderr_path handed_over_by stop_requested_by].freeze
|
|
22
|
+
|
|
23
|
+
# A record for a task that just started (task_create), or that an
|
|
24
|
+
# execute hands over (TaskRuntime.adopt fills in +pid_started+ then).
|
|
25
|
+
# @param started_at [String] iso8601; also its +created_at+
|
|
26
|
+
def self.running(id:, command:, cwd:, pid:, started_at:, output_path:, exit_code_path:, pid_started: nil,
|
|
27
|
+
stderr_path: nil, handed_over_by: nil)
|
|
28
|
+
new(metadata_version: self::METADATA_VERSION, id: id, command: command, cwd: cwd, workspace_root: Dir.pwd,
|
|
29
|
+
status: "running", pid: pid, pid_started: pid_started, created_at: started_at, started_at: started_at,
|
|
30
|
+
finished_at: nil, exit_code: nil, stop_reason: nil, output_path: output_path, stderr_path: stderr_path,
|
|
31
|
+
exit_code_path: exit_code_path, handed_over_by: handed_over_by, stop_requested_by: nil)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param data [Hash] the parsed task.json; a key it lacks reads nil
|
|
35
|
+
# @return [TaskRecord, nil] nil when +data+ isn't an object
|
|
36
|
+
def self.from_file(data)
|
|
37
|
+
return nil unless data.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
new(**members.to_h { |key| [key, data[key.to_s]] })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Hash] the task.json object, string keys in member order
|
|
43
|
+
def to_file
|
|
44
|
+
to_h.reject { |key, value| value.nil? && self.class::OPTIONAL.include?(key) }.transform_keys(&:to_s)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def running? = status == "running"
|
|
48
|
+
|
|
49
|
+
def stopped_by_user? = stop_reason == "stopped_by_user"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|