samagotchi 0.65.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +50 -28
  6. data/docs/cli.md +14 -2
  7. data/docs/configuration.md +101 -16
  8. data/docs/context.md +39 -2
  9. data/docs/desktop.md +90 -22
  10. data/docs/guardrails.md +8 -5
  11. data/docs/hooks.md +2 -2
  12. data/docs/internals/anthropic.md +156 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bundles.md +2 -0
  15. data/docs/internals/engine.md +75 -30
  16. data/docs/internals/gemma4-contract.md +1 -1
  17. data/docs/internals/llm-context-bench.md +5 -4
  18. data/docs/internals/prompt-caching.md +36 -13
  19. data/docs/memory.md +1 -1
  20. data/docs/plugins.md +289 -25
  21. data/docs/releasing.md +1 -1
  22. data/docs/sessions.md +16 -6
  23. data/docs/sub-agent.md +6 -1
  24. data/docs/testing.md +17 -3
  25. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  26. data/lib/samagotchi/bootstrap_command.rb +13 -3
  27. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  28. data/lib/samagotchi/bridge.rb +6 -3
  29. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/claude-peer/plugin.rb +40 -302
  31. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  33. data/lib/samagotchi/bundles/codex-peer/manifest.yml +22 -0
  34. data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
  35. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
  36. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  37. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
  38. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  39. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  40. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  44. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  45. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  46. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  47. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  48. data/lib/samagotchi/cache_confirm.rb +9 -6
  49. data/lib/samagotchi/commands/registry.rb +25 -8
  50. data/lib/samagotchi/config.rb +23 -8
  51. data/lib/samagotchi/context_absorber.rb +66 -13
  52. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  53. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  54. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  55. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  56. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  57. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  58. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  59. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  60. data/lib/samagotchi/desktop/macos.rb +84 -13
  61. data/lib/samagotchi/desktop_command.rb +14 -3
  62. data/lib/samagotchi/engine.rb +120 -107
  63. data/lib/samagotchi/engine_peers.rb +3 -0
  64. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  65. data/lib/samagotchi/guardrails/approval.rb +1 -1
  66. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  67. data/lib/samagotchi/guardrails/rules.rb +1 -1
  68. data/lib/samagotchi/guardrails/targets.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +151 -0
  70. data/lib/samagotchi/hooks/registry.rb +2 -2
  71. data/lib/samagotchi/host_keys.rb +147 -0
  72. data/lib/samagotchi/kernel_loop.rb +9 -1
  73. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  74. data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
  75. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  76. data/lib/samagotchi/llm/api_key.rb +23 -7
  77. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  78. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  79. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  80. data/lib/samagotchi/llm_context_override.rb +1 -0
  81. data/lib/samagotchi/peer_sources.rb +58 -0
  82. data/lib/samagotchi/plugin/api.rb +138 -156
  83. data/lib/samagotchi/plugin/context.rb +22 -1
  84. data/lib/samagotchi/plugin/loader.rb +5 -2
  85. data/lib/samagotchi/plugin/peer.rb +394 -0
  86. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  87. data/lib/samagotchi/plugin_tasks.rb +2 -2
  88. data/lib/samagotchi/row_refs.rb +1 -1
  89. data/lib/samagotchi/self_report.rb +5 -3
  90. data/lib/samagotchi/send_command.rb +41 -15
  91. data/lib/samagotchi/session.rb +18 -1
  92. data/lib/samagotchi/session_commands.rb +16 -1
  93. data/lib/samagotchi/session_continue.rb +58 -15
  94. data/lib/samagotchi/session_inbox.rb +0 -13
  95. data/lib/samagotchi/session_manager.rb +4 -1
  96. data/lib/samagotchi/session_setup.rb +14 -7
  97. data/lib/samagotchi/system_prompt.rb +16 -1
  98. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  99. data/lib/samagotchi/terminal_ui.rb +18 -2
  100. data/lib/samagotchi/tool_call_parser.rb +1 -1
  101. data/lib/samagotchi/tool_runner.rb +13 -1
  102. data/lib/samagotchi/toolkit.rb +246 -0
  103. data/lib/samagotchi/toolkit_binding.rb +138 -0
  104. data/lib/samagotchi/toolkit_command.rb +117 -0
  105. data/lib/samagotchi/tools/context_read.rb +14 -0
  106. data/lib/samagotchi/tools/execute.rb +12 -12
  107. data/lib/samagotchi/tools/registry.rb +43 -6
  108. data/lib/samagotchi/tools/task_create.rb +6 -6
  109. data/lib/samagotchi/tools/task_get.rb +13 -13
  110. data/lib/samagotchi/tools/task_list.rb +6 -6
  111. data/lib/samagotchi/tools/task_record.rb +52 -0
  112. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  113. data/lib/samagotchi/tools/task_stop.rb +6 -6
  114. data/lib/samagotchi/tools/task_wait.rb +12 -13
  115. data/lib/samagotchi/tools/tool_index.rb +454 -0
  116. data/lib/samagotchi/update_command.rb +9 -6
  117. data/lib/samagotchi/version.rb +1 -1
  118. data/lib/samagotchi/web/app.rb +28 -9
  119. data/lib/samagotchi/web/capabilities.rb +1 -1
  120. data/lib/samagotchi/web/public/activity.js +2 -2
  121. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  122. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  123. data/lib/samagotchi/web/public/app.js +52 -43
  124. data/lib/samagotchi/web/public/chains.js +1 -1
  125. data/lib/samagotchi/web/public/commands.js +6 -4
  126. data/lib/samagotchi/web/public/composer_size.js +2 -2
  127. data/lib/samagotchi/web/public/copy.js +1 -1
  128. data/lib/samagotchi/web/public/ctx.js +1 -1
  129. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  130. data/lib/samagotchi/web/public/families_view.js +1 -1
  131. data/lib/samagotchi/web/public/format.js +11 -7
  132. data/lib/samagotchi/web/public/images.js +25 -2
  133. data/lib/samagotchi/web/public/index.html +9 -0
  134. data/lib/samagotchi/web/public/info_bar.js +5 -3
  135. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  136. data/lib/samagotchi/web/public/notify.js +6 -4
  137. data/lib/samagotchi/web/public/question_card.js +1 -1
  138. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  139. data/lib/samagotchi/web/public/session_state.js +17 -0
  140. data/lib/samagotchi/web/public/session_title.js +2 -3
  141. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  142. data/lib/samagotchi/web/public/stage_model.js +2 -2
  143. data/lib/samagotchi/web/public/stream_handlers.js +13 -0
  144. data/lib/samagotchi/web/public/strip.js +3 -3
  145. data/lib/samagotchi/web/public/task_stop.js +3 -2
  146. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  147. data/lib/samagotchi/web/public/turn_events.js +1 -1
  148. data/lib/samagotchi/web/public/turn_html.js +2 -2
  149. data/lib/samagotchi/web/public/turn_model.js +2 -2
  150. data/lib/samagotchi/web/public/turn_view.js +1 -1
  151. data/lib/samagotchi/web/server.rb +0 -4
  152. data/lib/samagotchi/worker.rb +13 -8
  153. data/lib/samagotchi/worker_inbound.rb +36 -4
  154. data/lib/samagotchi/worker_wakes.rb +67 -7
  155. metadata +23 -1
@@ -39,8 +39,8 @@ module Samagotchi
39
39
 
40
40
  USAGE = <<~TEXT
41
41
  Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
42
- chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [-m TEXT] [--image PATH]...
43
- chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
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) [--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 and
67
- thinking level, 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
@@ -77,6 +79,11 @@ module Samagotchi
77
79
  (--new) its own thinking level (off, low, medium,
78
80
  high; default: none of its own), above a process
79
81
  default such as SAMAGOTCHI_THINKING_LEVEL
82
+ --toolkit NAME, --tool NAME, --no-tool NAME
83
+ (--new) its toolkit, the tools its prompts declare
84
+ (full, lean, child, a config or bundle one; default
85
+ config's toolkit.default), and tools declared on top
86
+ of it or left out (repeatable, a comma list too)
80
87
  --wait wait for the answer and print it (one session); the
81
88
  other lines go to stderr. Exit 3: it waits for an
82
89
  answer from you: the question, its options and the
@@ -126,6 +133,9 @@ module Samagotchi
126
133
  f.value "--llm-context-apply", key: :llm_apply
127
134
  f.value "--llm-context-budget", key: :llm_budget
128
135
  f.value "--thinking"
136
+ f.value "--toolkit"
137
+ f.value "--tool", key: :tools, repeat: true
138
+ f.value "--no-tool", key: :no_tools, repeat: true
129
139
  f.value "--image", key: :images, repeat: true
130
140
  f.switch "--wait"
131
141
  f.switch "--cut"
@@ -262,7 +272,8 @@ module Samagotchi
262
272
  return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
263
273
  return new_options(options) if options[:new]
264
274
 
265
- %i[dir model continues thinking].each { |key| return usage_error("--#{key} needs --new") if options[key] }
275
+ %i[dir model continues thinking toolkit].each { |key| return usage_error("--#{key} needs --new") if options[key] }
276
+ TOOLKIT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") unless Array(options[key]).empty? }
266
277
  LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
267
278
  return usage_error("give session ids") if options[:ids].empty?
268
279
 
@@ -273,15 +284,19 @@ module Samagotchi
273
284
  LLM_CONTEXT_FLAGS = { llm_strategy: "--llm-context", llm_apply: "--llm-context-apply",
274
285
  llm_budget: "--llm-context-budget" }.freeze
275
286
  LLM_CONTEXT_FIELDS = { llm_strategy: :strategy, llm_apply: :apply, llm_budget: :budget_tokens }.freeze
287
+ # --tool / --no-tool's option keys.
288
+ TOOLKIT_FLAGS = { tools: "--tool", no_tools: "--no-tool" }.freeze
276
289
 
277
290
  def new_options(options)
278
291
  return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
279
292
 
280
293
  if options[:continues]
281
- taken = [("--dir" if options[:dir]), ("--model" if options[:model])] +
282
- LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])]
294
+ taken = [("--dir" if options[:dir])] +
295
+ LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
296
+ [("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? }
283
297
  unless taken.compact.empty?
284
- return usage_error("--continues takes the previous session's folder, model, LLM context and thinking level; " \
298
+ return usage_error("--continues takes the previous session's folder, LLM context, thinking level and " \
299
+ "toolkit; " \
285
300
  "leave out #{taken.compact.join(", ")}")
286
301
  end
287
302
  end
@@ -301,6 +316,8 @@ module Samagotchi
301
316
 
302
317
  options[:thinking] = Thinking.session_level(word)
303
318
  end
319
+ toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
320
+ options[:toolkit_value] = toolkit if toolkit
304
321
 
305
322
  if options[:dir]
306
323
  options[:dir] = File.expand_path(options[:dir])
@@ -343,12 +360,14 @@ module Samagotchi
343
360
  begin
344
361
  start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
345
362
  session = if options[:continues]
346
- 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)
347
365
  else
348
366
  SessionManager.spawn_session(**start, working_directory: options[:dir],
349
367
  model_name: options[:model],
350
368
  setup: SessionSetup.new(llm_context: options[:llm_context],
351
- thinking: options[:thinking]),
369
+ thinking: options[:thinking],
370
+ toolkit: options[:toolkit_value]),
352
371
  state_dir: @state_dir)
353
372
  end
354
373
  rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
@@ -372,15 +391,22 @@ module Samagotchi
372
391
  timeout: options[:timeout])
373
392
  end
374
393
 
375
- # --continues: the model the new link will run (the previous link's),
376
- # 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).
377
398
  # @return [Array, false] [model_name, model_typed], or false after the
378
- # error line (no such session)
399
+ # error line (no such session, a model it can't start on)
379
400
  def continued_model(options)
380
401
  return [options[:model], nil] unless options[:continues]
381
402
 
382
403
  previous = Session.load(SessionChain.resolve(options[:continues], state_dir: @state_dir), state_dir: @state_dir)
383
- [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
384
410
  rescue ArgumentError => e
385
411
  error_line("chi send: #{e.message}")
386
412
  false
@@ -184,6 +184,19 @@ module Samagotchi
184
184
  # couldn't read; nil when there is neither.
185
185
  def reasoning_history_file = @reasoning_history&.to_s || @reasoning_history_unread
186
186
 
187
+ # The session's toolkit (Toolkit: the tools its prompts declare), as
188
+ # given when it was created; nil for a session from before toolkits,
189
+ # which is on Toolkit::FULL (Toolkit.of). Saved and never resolved
190
+ # again from config, so a respawned worker declares the same tools; a
191
+ # continue and a fork copy it (SessionSetup.of), a delegate child gets
192
+ # toolkit.child (SessionManager.spawn_session).
193
+ attr_reader :toolkit
194
+
195
+ # @param value [Toolkit, Hash, String, nil] the session file's form too
196
+ def toolkit=(value)
197
+ @toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
198
+ end
199
+
187
200
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
188
201
  # left), e.g. "loop-guard"; nil when it ended otherwise.
189
202
  def stopped_by
@@ -213,7 +226,7 @@ module Samagotchi
213
226
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
214
227
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
215
228
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
216
- interrupted_turn: nil)
229
+ toolkit: nil, interrupted_turn: nil)
217
230
  @id = id
218
231
  @metadata_version = metadata_version
219
232
  @mode = mode
@@ -242,6 +255,7 @@ module Samagotchi
242
255
  self.thinking = thinking
243
256
  self.reasoning_history = reasoning_history
244
257
  self.appended_prompt_files = appended_prompt_files
258
+ self.toolkit = toolkit
245
259
  self.interrupted_turn = interrupted_turn
246
260
  @turn_checkpoint = nil
247
261
  @archived = false
@@ -336,6 +350,7 @@ module Samagotchi
336
350
  thinking: setup.thinking,
337
351
  reasoning_history: setup.reasoning_history,
338
352
  appended_prompt_files: setup.appended_prompt_files,
353
+ toolkit: setup.toolkit,
339
354
  continues: continues
340
355
  )
341
356
  end
@@ -400,6 +415,7 @@ module Samagotchi
400
415
  "thinking" => nil,
401
416
  "reasoning_history" => nil,
402
417
  "appended_prompt_files" => [],
418
+ "toolkit" => nil,
403
419
  "interrupted_turn" => nil
404
420
  }.freeze
405
421
 
@@ -527,6 +543,7 @@ module Samagotchi
527
543
  when "llm_context" then llm_context_file
528
544
  when "thinking" then thinking_file
529
545
  when "reasoning_history" then reasoning_history_file
546
+ when "toolkit" then @toolkit&.to_file
530
547
  when "prompt_notes" then @prompt_notes.map(&:to_file)
531
548
  when "last_turn" then @last_turn&.to_file
532
549
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
@@ -12,6 +12,7 @@ require_relative "tools/peers"
12
12
  require_relative "commands/registry"
13
13
  require_relative "llm_context_command"
14
14
  require_relative "thinking_command"
15
+ require_relative "toolkit_command"
15
16
  require_relative "reasoning_history_command"
16
17
 
17
18
  module Samagotchi
@@ -32,6 +33,7 @@ module Samagotchi
32
33
  HELP_COMMAND = "/help"
33
34
  LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
34
35
  THINKING_COMMAND = ThinkingCommand::NAME
36
+ TOOLKIT_COMMAND = ToolkitCommand::NAME
35
37
  REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
36
38
  # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
37
39
  # shows this many per host; /models <text> lists every match.
@@ -48,7 +50,7 @@ module Samagotchi
48
50
 
49
51
  # The commands that ask before a change that restarts the prompt cache
50
52
  # (CacheConfirm): #run passes them its confirm flag.
51
- CONFIRMING = %i[thinking reasoning_history].freeze
53
+ CONFIRMING = %i[thinking reasoning_history toolkit].freeze
52
54
 
53
55
  # @!attribute status [Symbol] :ok, or :error when the line was refused
54
56
  # @!attribute output [String, nil] what to tell the user
@@ -103,6 +105,9 @@ module Samagotchi
103
105
  registry.register(REASONING_HISTORY_COMMAND, "show or set which of its reasoning this session's model gets back (drop|turn|all|default)",
104
106
  id: :reasoning_history, mid_turn: show_mid_turn(REASONING_HISTORY_COMMAND),
105
107
  match: ->(text) { text.match?(%r{\A/reasoning-history(?:\s+.*)?\z}) }) { |text| reasoning_history(text) }
108
+ registry.register(TOOLKIT_COMMAND, "show or change the tools this session declares (NAME, add TOOL, remove TOOL)",
109
+ id: :toolkit, mid_turn: show_mid_turn(TOOLKIT_COMMAND),
110
+ match: ->(text) { text.match?(%r{\A/toolkit(?:\s+.*)?\z}) }) { |text| toolkit(text) }
106
111
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
107
112
  registry.register("/stats", "show the session's stats", local: true)
108
113
  registry.register("/recap", "show the session's recap", local: true)
@@ -467,6 +472,16 @@ module Samagotchi
467
472
  reply(e.message, status: :error)
468
473
  end
469
474
 
475
+ # /toolkit (ToolkitCommand); :toolkit in changed when it set the
476
+ # session's toolkit.
477
+ def toolkit(text, confirm: true)
478
+ output, changed, ask = ToolkitCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
479
+ .run(text.delete_prefix(TOOLKIT_COMMAND), confirm: confirm)
480
+ reply(output, changed: changed ? [:toolkit] : [], confirm: ask)
481
+ rescue ArgumentError => e
482
+ reply(e.message, status: :error)
483
+ end
484
+
470
485
  # A session setting changed (/llm-context, /thinking, /reasoning-history):
471
486
  # saved with its model.
472
487
  def save_setup(session)
@@ -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
@@ -140,7 +140,9 @@ module Samagotchi
140
140
  # @param title [String, nil] what the lists show before the first turn
141
141
  # (the prompt's preview by default, else the seed's first user message)
142
142
  # @param setup [SessionSetup] the session's own settings (chi
143
- # --llm-context; a fork and a continue copy their session's)
143
+ # --llm-context; a fork and a continue copy their session's); without
144
+ # a toolkit the session is stored on config's toolkit.default, a
145
+ # delegate child always on toolkit.child (Toolkit.configured)
144
146
  # @param continues [String, nil] the session this one continues (a
145
147
  # session field; SessionContinue)
146
148
  # @param note [String, nil] a context note the session starts with,
@@ -177,6 +179,7 @@ module Samagotchi
177
179
  setup: setup,
178
180
  continues: continues
179
181
  )
182
+ session.toolkit = Toolkit.configured(delegate: delegate) if delegate || session.toolkit.nil?
180
183
  session.model_warning = model_warning
181
184
  # With no prompt there is no first turn to run (an attaching UI sends
182
185
  # the prompts), so the session starts idle.
@@ -3,6 +3,7 @@
3
3
  require_relative "llm_context_override"
4
4
  require_relative "reasoning_history"
5
5
  require_relative "thinking"
6
+ require_relative "toolkit"
6
7
 
7
8
  module Samagotchi
8
9
  # A session's own settings that come before its model's, carried as one
@@ -12,27 +13,33 @@ module Samagotchi
12
13
  # +thinking+ a level Symbol (Thinking.session_level) or nil,
13
14
  # +reasoning_history+ a level Symbol (ReasoningHistory.session_level) or nil,
14
15
  # +appended_prompt_files+ the absolute paths chi --append-system-prompt
15
- # named (their text goes into the system prompt; [] with none).
16
+ # named (their text goes into the system prompt; [] with none),
17
+ # +toolkit+ a Toolkit or nil (SessionManager.spawn_session gives the
18
+ # session config's toolkit.default then).
16
19
  #
17
20
  # It doesn't save itself: the session keeps each setting in its own field
18
21
  # and file key, and resolves it on its own. A delegate child starts
19
22
  # without one; a continue and a fork copy their session's (.of).
20
- SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files) do
21
- def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [])
23
+ SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit) do
24
+ def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil)
22
25
  super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
23
- appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze)
26
+ appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit)
24
27
  end
25
28
 
26
- def empty? = llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty?
29
+ def empty?
30
+ llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil?
31
+ end
27
32
 
28
- # A session's own setup, for a session that starts from it.
33
+ # A session's own setup, for a session that starts from it. Its
34
+ # toolkit always: one from before toolkits is full (Toolkit.of), not
35
+ # whatever config's default is now.
29
36
  # @param session [Session, nil]
30
37
  # @return [SessionSetup]
31
38
  def self.of(session)
32
39
  return new if session.nil?
33
40
 
34
41
  new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
35
- appended_prompt_files: session.appended_prompt_files)
42
+ appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit))
36
43
  end
37
44
  end
38
45
  end
@@ -317,7 +317,7 @@ module Samagotchi
317
317
  "Project memories:\n#{indexes[:project]}",
318
318
  "System memories:\n#{indexes[:system]}"
319
319
  ].join("\n\n")
320
- stable = [thinking_token + base, rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
320
+ stable = [thinking_token + base, not_loaded_line(layers), rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
321
321
  project_specific_description, appended_section, memory_sections]
322
322
  stable_text = stable.compact.join("\n")
323
323
  volatile = [current_model, project_location, current_session].compact
@@ -328,6 +328,21 @@ module Samagotchi
328
328
  with_tail_declarations(prompt, chat: chat, layers: layers)
329
329
  end
330
330
 
331
+ # The tools the session's toolkit leaves out, named once right after the
332
+ # base prompt: sessions on another toolkit already differ at the tools,
333
+ # and nothing later (a memory write) moves it. nil when none are (full):
334
+ # the prompt is what it was before toolkits.
335
+ def not_loaded_line(layers)
336
+ registry = @tools_lookup.call
337
+ return nil unless registry.respond_to?(:left_out_names)
338
+
339
+ names = registry.left_out_names(layers: layers)
340
+ return nil if names.empty?
341
+
342
+ "Not loaded in this session (toolkit #{registry.toolkit&.name}): #{names.join(", ")}. " \
343
+ "Only the user can load one (/toolkit add <name>)."
344
+ end
345
+
331
346
  def with_tail_declarations(prompt, chat:, layers:)
332
347
  chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(layers)
333
348
  end
@@ -46,17 +46,23 @@ module Samagotchi
46
46
  # @param thinking [String, nil] --thinking as typed: a new session
47
47
  # starts with it as its own level; a resumed or attached one's
48
48
  # worker gets a /thinking, after those
49
+ # @param toolkit [Toolkit, nil] --toolkit/--tool/--no-tool: a new
50
+ # session's toolkit (config's toolkit.default without); an existing
51
+ # session keeps its own
52
+ # @param toolkit_flags [Array<String>] which of those were given, for
53
+ # the line an existing session's run prints
49
54
  # @param state_dir [String, nil] where the sessions are (the default
50
55
  # state dir by default): the worker's, for the attached loop too
51
56
  # @return [Symbol] :detached, :closed when the worker went away,
52
57
  # :failed when the --model switch didn't go through, or (input from a
53
58
  # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
54
59
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
55
- memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], state_dir: nil)
60
+ memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
61
+ toolkit_flags: [], state_dir: nil)
56
62
  state_dir ||= Session.default_state_dir
57
63
  client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
58
64
  memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
59
- appended_prompt_files: appended_prompt_files)
65
+ appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags)
60
66
  commands = []
61
67
  commands << "/model #{model}" if model && (attach || resume)
62
68
  commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
@@ -123,10 +129,12 @@ module Samagotchi
123
129
  # @return [BridgeClient] a client for the live Bridge of the session
124
130
  # @raise [Error]
125
131
  def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
126
- memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], err: $stderr)
132
+ memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
133
+ toolkit_flags: [], err: $stderr)
127
134
  sd = state_dir || Session.default_state_dir
128
135
  if attach || resume
129
- warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files)
136
+ warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files,
137
+ toolkit_flags: toolkit_flags)
130
138
  end
131
139
  if attach
132
140
  live = connect_existing(attach, sd)
@@ -143,7 +151,8 @@ module Samagotchi
143
151
  memories: memories, muted_memories: muted_memories,
144
152
  setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
145
153
  thinking: Thinking.session_level(thinking),
146
- appended_prompt_files: appended_prompt_files))
154
+ appended_prompt_files: appended_prompt_files,
155
+ toolkit: toolkit))
147
156
  end
148
157
  client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
149
158
  client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
@@ -159,14 +168,15 @@ module Samagotchi
159
168
  end
160
169
 
161
170
  # An existing session's prompt is built from its own session fields:
162
- # --memory/--mute/--append-system-prompt given with --attach or
163
- # --resume are ignored, with a line saying so (printed before the live
164
- # region opens).
165
- def warn_memory_flags_ignored(session_id, memories, muted_memories, err, appended_prompt_files: [])
171
+ # --memory/--mute/--append-system-prompt and the toolkit flags given
172
+ # with --attach or --resume are ignored, with a line saying so (printed
173
+ # before the live region opens).
174
+ def warn_memory_flags_ignored(session_id, memories, muted_memories, err, appended_prompt_files: [], toolkit_flags: [])
166
175
  flags = []
167
176
  flags << "--memory" unless Array(memories).empty?
168
177
  flags << "--mute" unless Array(muted_memories).empty?
169
178
  flags << "--append-system-prompt" unless Array(appended_prompt_files).empty?
179
+ flags.concat(toolkit_flags)
170
180
  return if flags.empty?
171
181
 
172
182
  verb = flags.size == 1 ? "applies" : "apply"
@@ -97,10 +97,15 @@ module Samagotchi
97
97
  # @param appended_prompt_files [Array<String>] --append-system-prompt's
98
98
  # files (absolute): a new session saves them; a resumed one's own come
99
99
  # first, and the merge goes only to the Engine (as --memory's does)
100
+ # @param toolkit [Toolkit, nil] --toolkit/--tool/--no-tool: a new
101
+ # session's toolkit (config's toolkit.default without); a resumed one
102
+ # keeps its own, with a line naming +toolkit_flags+ (the flags given)
100
103
  def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
101
104
  spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
102
- appended_prompt_files: [])
105
+ appended_prompt_files: [], toolkit: nil, toolkit_flags: [])
103
106
  @scratch = scratch
107
+ @toolkit = toolkit
108
+ @toolkit_flags = toolkit_flags
104
109
  @thinking_word = thinking
105
110
  @prompt = prompt
106
111
  @llm_context_words = llm_context
@@ -269,7 +274,7 @@ module Samagotchi
269
274
  preloaded_memory_names: @requested_memories,
270
275
  muted_memory_names: @muted_memory_names,
271
276
  scratch: @scratch,
272
- setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files)
277
+ setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured)
273
278
  ))
274
279
  # --llm-context*: the session's own values, over a resumed one's.
275
280
  # Saved at once on a resumed one: leaving before a turn keeps them.
@@ -279,6 +284,7 @@ module Samagotchi
279
284
  session.thinking = Thinking.session_level(@thinking_word) if @thinking_word
280
285
  session.save if @resume_session
281
286
  end
287
+ warn_toolkit_flags_ignored(session) if @resume_session
282
288
  claim_session!(session.id) unless @owner_lock
283
289
  # A turn a worker died in (TurnCheckpoint): ended as canceled, with its
284
290
  # note, before this REPL's first turn appends after it.
@@ -714,6 +720,16 @@ module Samagotchi
714
720
  end
715
721
  end
716
722
 
723
+ # The toolkit flags are a new session's: a resumed one keeps its own
724
+ # toolkit (as attached mode says it, AttachLauncher).
725
+ def warn_toolkit_flags_ignored(session)
726
+ return if @toolkit_flags.empty?
727
+
728
+ names = @toolkit_flags.size > 1 ? "#{@toolkit_flags[0..-2].join(", ")} and #{@toolkit_flags.last}" : @toolkit_flags.first
729
+ warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id}'s prompt is already built)"
730
+ end
731
+ private :warn_toolkit_flags_ignored
732
+
717
733
  private
718
734
 
719
735
  # Interactive REPL loop. Session seed + messages are built by #run and
@@ -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