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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/docs/architecture.md +50 -28
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +71 -29
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +17 -3
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/engine.rb +96 -191
  51. data/lib/samagotchi/engine_peers.rb +3 -0
  52. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  53. data/lib/samagotchi/guardrails/approval.rb +1 -1
  54. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  55. data/lib/samagotchi/guardrails/rules.rb +1 -1
  56. data/lib/samagotchi/guardrails/targets.rb +1 -1
  57. data/lib/samagotchi/hook_routes.rb +151 -0
  58. data/lib/samagotchi/hooks/registry.rb +2 -2
  59. data/lib/samagotchi/host_keys.rb +147 -0
  60. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  61. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  62. data/lib/samagotchi/llm/api_key.rb +23 -7
  63. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  64. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  65. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  66. data/lib/samagotchi/llm_context_override.rb +1 -0
  67. data/lib/samagotchi/peer_sources.rb +58 -0
  68. data/lib/samagotchi/plugin/api.rb +64 -155
  69. data/lib/samagotchi/plugin/loader.rb +4 -2
  70. data/lib/samagotchi/plugin/peer.rb +394 -0
  71. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  72. data/lib/samagotchi/plugin_tasks.rb +2 -2
  73. data/lib/samagotchi/row_refs.rb +1 -1
  74. data/lib/samagotchi/self_report.rb +5 -3
  75. data/lib/samagotchi/send_command.rb +21 -11
  76. data/lib/samagotchi/session_continue.rb +58 -15
  77. data/lib/samagotchi/session_inbox.rb +0 -13
  78. data/lib/samagotchi/tool_call_parser.rb +1 -1
  79. data/lib/samagotchi/toolkit_binding.rb +138 -0
  80. data/lib/samagotchi/tools/context_read.rb +14 -0
  81. data/lib/samagotchi/tools/execute.rb +12 -12
  82. data/lib/samagotchi/tools/registry.rb +1 -1
  83. data/lib/samagotchi/tools/task_create.rb +6 -6
  84. data/lib/samagotchi/tools/task_get.rb +13 -13
  85. data/lib/samagotchi/tools/task_list.rb +6 -6
  86. data/lib/samagotchi/tools/task_record.rb +52 -0
  87. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  88. data/lib/samagotchi/tools/task_stop.rb +6 -6
  89. data/lib/samagotchi/tools/task_wait.rb +12 -13
  90. data/lib/samagotchi/tools/tool_index.rb +454 -0
  91. data/lib/samagotchi/update_command.rb +9 -6
  92. data/lib/samagotchi/version.rb +1 -1
  93. data/lib/samagotchi/web/app.rb +16 -9
  94. data/lib/samagotchi/web/capabilities.rb +1 -1
  95. data/lib/samagotchi/web/public/activity.js +2 -2
  96. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  97. data/lib/samagotchi/web/public/app.js +31 -36
  98. data/lib/samagotchi/web/public/chains.js +1 -1
  99. data/lib/samagotchi/web/public/commands.js +2 -2
  100. data/lib/samagotchi/web/public/composer_size.js +2 -2
  101. data/lib/samagotchi/web/public/copy.js +1 -1
  102. data/lib/samagotchi/web/public/ctx.js +1 -1
  103. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  104. data/lib/samagotchi/web/public/families_view.js +1 -1
  105. data/lib/samagotchi/web/public/format.js +11 -7
  106. data/lib/samagotchi/web/public/images.js +25 -2
  107. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  108. data/lib/samagotchi/web/public/notify.js +6 -4
  109. data/lib/samagotchi/web/public/question_card.js +1 -1
  110. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  111. data/lib/samagotchi/web/public/session_state.js +14 -0
  112. data/lib/samagotchi/web/public/session_title.js +2 -3
  113. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  114. data/lib/samagotchi/web/public/stage_model.js +2 -2
  115. data/lib/samagotchi/web/public/strip.js +3 -3
  116. data/lib/samagotchi/web/public/task_stop.js +3 -2
  117. data/lib/samagotchi/web/public/turn_events.js +1 -1
  118. data/lib/samagotchi/web/public/turn_html.js +2 -2
  119. data/lib/samagotchi/web/public/turn_model.js +2 -2
  120. data/lib/samagotchi/web/public/turn_view.js +1 -1
  121. data/lib/samagotchi/web/server.rb +0 -4
  122. data/lib/samagotchi/worker.rb +11 -8
  123. data/lib/samagotchi/worker_inbound.rb +36 -4
  124. data/lib/samagotchi/worker_wakes.rb +67 -7
  125. 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, LLM context,
67
- thinking level and toolkit, with a note carrying its recap; it
68
- is archived. last:ID names the chain's latest link.
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]), ("--model" if options[:model])] +
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, model, LLM context, thinking level and " \
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, state_dir: @state_dir)
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
- # --continues: the model the new link will run (the previous link's),
392
- # for the images check; [nil, nil] without --continues.
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
- [previous.model_name, previous.model_typed]
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). The previous link is archived
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
- plan = check!(previous, state_dir)
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, state_dir: state_dir, move: plan.move)
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: previous.model_typed || previous.model_name, setup: SessionSetup.of(previous),
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 no longer has.
206
- # @return [ChildMove::Plan]
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
- # Its model's host, as SessionManager.spawn_session checks it: before
215
- # the archive stops the previous link's worker.
216
- ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
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::Api::TOOL_NAME).
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
- text = hand_over(id: id, command: command, cwd: cwd, pid: pid, waiter: wait_thr, started_at: started_at,
207
- timeout_sec: timeout_sec, dir: dir, logs: logs, exit_code_path: exit_code_path)
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; a back-pointer in its dir names
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(id:, command:, cwd:, pid:, waiter:, started_at:, timeout_sec:, dir:, logs:, exit_code_path:)
346
+ def self.hand_over(task, waiter:, timeout_sec:, dir:)
346
347
  return nil unless waiter.alive?
347
348
 
348
- record, error = TaskRuntime.adopt(id: id, command: command, cwd: cwd, pid: pid, started_at: started_at,
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["id"])))
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" => logs[0], "stderr" => logs[1] }.each do |label, path|
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::Api.apply_tools).
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.fetch("id")}",
19
- "status: #{record.fetch("status")}",
20
- "pid: #{record.fetch("pid")}",
21
- "created_at: #{record.fetch("created_at")}",
22
- "output_path: #{record.fetch("output_path")}",
23
- "cwd: #{record.fetch("cwd")}"
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.fetch("id")}",
24
- "status: #{record.fetch("status")}",
25
- "command: #{record.fetch("command")}",
26
- "cwd: #{record.fetch("cwd")}",
27
- "pid: #{record.fetch("pid")}",
28
- "created_at: #{record.fetch("created_at")}",
29
- "started_at: #{record.fetch("started_at")}",
30
- "finished_at: #{record["finished_at"]}",
31
- "exit_code: #{record["exit_code"]}",
32
- "stop_reason: #{record["stop_reason"]}",
33
- "output_path: #{record.fetch("output_path")}",
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["stderr_path"]}" if record["stderr_path"]),
36
- (TaskRuntime::USER_STOP_NOTE if TaskRuntime.stopped_by_user?(record))
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.fetch("id")}",
25
- "status: #{record.fetch("status")}",
26
- "pid: #{record.fetch("pid")}",
27
- "created_at: #{record.fetch("created_at")}",
28
- "command: #{preview(record.fetch("command"))}",
29
- "output_path: #{record.fetch("output_path")}"
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