samagotchi 0.58.0 → 0.60.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/README.md +3 -2
  4. data/bin/chi +23 -2
  5. data/docs/architecture.md +6 -4
  6. data/docs/cli.md +14 -7
  7. data/docs/configuration.md +96 -31
  8. data/docs/hooks.md +12 -6
  9. data/docs/internals/background-tasks.md +7 -0
  10. data/docs/internals/engine.md +20 -12
  11. data/docs/internals/gemma4-contract.md +1 -0
  12. data/docs/internals/prompt-caching.md +4 -3
  13. data/docs/internals/tool-guardrails.md +2 -1
  14. data/docs/memory.md +1 -1
  15. data/docs/plugins.md +75 -6
  16. data/docs/sessions.md +2 -2
  17. data/docs/sub-agent.md +2 -2
  18. data/docs/testing.md +4 -2
  19. data/lib/samagotchi/bootstrap_command.rb +2 -1
  20. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  21. data/lib/samagotchi/bridge.rb +6 -2
  22. data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
  23. data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
  24. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  25. data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
  26. data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  28. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  29. data/lib/samagotchi/config.rb +44 -10
  30. data/lib/samagotchi/continue_offer.rb +5 -2
  31. data/lib/samagotchi/engine.rb +89 -15
  32. data/lib/samagotchi/host_config.rb +3 -1
  33. data/lib/samagotchi/host_registry.rb +2 -1
  34. data/lib/samagotchi/iteration_limit.rb +3 -2
  35. data/lib/samagotchi/kernel_loop.rb +35 -9
  36. data/lib/samagotchi/llm/backend.rb +6 -1
  37. data/lib/samagotchi/llm/chat_loop.rb +11 -6
  38. data/lib/samagotchi/llm/model_result.rb +11 -2
  39. data/lib/samagotchi/llm/native_backend.rb +3 -2
  40. data/lib/samagotchi/log_subscriber.rb +2 -1
  41. data/lib/samagotchi/model_settings.rb +4 -2
  42. data/lib/samagotchi/plugin/context.rb +16 -3
  43. data/lib/samagotchi/process_group.rb +76 -3
  44. data/lib/samagotchi/prompt_files.rb +127 -0
  45. data/lib/samagotchi/ref.rb +124 -0
  46. data/lib/samagotchi/send_command.rb +1 -1
  47. data/lib/samagotchi/session.rb +15 -3
  48. data/lib/samagotchi/session_commands.rb +15 -3
  49. data/lib/samagotchi/session_metrics.rb +8 -2
  50. data/lib/samagotchi/session_setup.rb +11 -5
  51. data/lib/samagotchi/steer_cut.rb +12 -8
  52. data/lib/samagotchi/steer_cut_after.rb +69 -0
  53. data/lib/samagotchi/system_prompt.rb +74 -18
  54. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  56. data/lib/samagotchi/terminal_ui/formatting.rb +23 -5
  57. data/lib/samagotchi/terminal_ui.rb +18 -6
  58. data/lib/samagotchi/tool_activity.rb +18 -1
  59. data/lib/samagotchi/tool_declarations.rb +1 -1
  60. data/lib/samagotchi/tools/execute.rb +318 -70
  61. data/lib/samagotchi/tools/task_get.rb +2 -0
  62. data/lib/samagotchi/tools/task_runtime.rb +96 -13
  63. data/lib/samagotchi/tools/task_wait.rb +23 -5
  64. data/lib/samagotchi/turn_flow.rb +22 -3
  65. data/lib/samagotchi/utf8_default.rb +1 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/public/favicon.svg +1 -0
  68. data/lib/samagotchi/web/public/index.html +1 -0
  69. data/lib/samagotchi/web/public/read_view.js +3 -1
  70. data/lib/samagotchi/web/public/refs.js +107 -15
  71. data/lib/samagotchi/web/public/task_stop.js +25 -7
  72. data/lib/samagotchi/worker.rb +1 -0
  73. metadata +7 -1
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "memory_paths"
4
+
5
+ module Samagotchi
6
+ # The files whose text goes into the system prompt as they are
7
+ # (SystemPrompt): which AGENTS.md files a cwd loads, and how a file
8
+ # becomes a prompt section.
9
+ module PromptFiles
10
+ AGENTS_FILE = "AGENTS.md"
11
+
12
+ AGENTS_HEADER = "Project specific description:"
13
+
14
+ # A file's text as the prompt carries it: +path+ absolute, +text+ the
15
+ # section with its header.
16
+ Section = Data.define(:path, :text)
17
+
18
+ module_function
19
+
20
+ # The AGENTS.md files a session in +cwd+ loads, as absolute paths,
21
+ # outermost first: inside a git work tree every one from its top (a
22
+ # linked worktree's own checkout) down to the cwd; outside a repository
23
+ # only the cwd's ([] with none).
24
+ # @param cwd [String] where the prompt is built (Dir.pwd, not the
25
+ # session's working directory: /tmp vs /private/tmp would change the
26
+ # prompt bytes)
27
+ # @return [Array<String>]
28
+ def agents_paths(cwd)
29
+ chain_dirs(cwd).map { |dir| File.join(dir, AGENTS_FILE) }.select { |path| File.file?(path) }
30
+ end
31
+
32
+ # The sections of the AGENTS.md chain (#agents_paths) with text: the
33
+ # first keeps the plain header (a one-file session's prompt is as it
34
+ # always was), each further one names its directory relative to the
35
+ # work tree top ("Project specific description for pkg/:").
36
+ # @return [Array<Section>]
37
+ def agents_sections(cwd)
38
+ top = MemoryPaths.work_tree_root(cwd)
39
+ sections = []
40
+ agents_paths(cwd).each do |path|
41
+ dir = File.dirname(path)
42
+ header = sections.empty? ? AGENTS_HEADER : "Project specific description for #{relative_dir(dir, top) || dir}/:"
43
+ text = section(path, header)
44
+ sections << Section.new(path: path, text: text) if text
45
+ end
46
+ sections
47
+ end
48
+
49
+ # The directories whose AGENTS.md a session in +cwd+ reads, outermost first.
50
+ def chain_dirs(cwd)
51
+ cwd = File.expand_path(cwd)
52
+ top = MemoryPaths.work_tree_root(cwd)
53
+ return [cwd] unless top
54
+
55
+ rel = relative_dir(cwd, top)
56
+ return [top] if rel == "."
57
+ # A cwd the walk up didn't reach from (not expected): only its own and the top's.
58
+ return [top, cwd] if rel.nil?
59
+
60
+ parts = rel.split("/")
61
+ [top] + parts.each_index.map { |i| File.join(top, *parts[0..i]) }
62
+ end
63
+
64
+ # +dir+ relative to +top+ ("." for the top itself), nil when it isn't under it.
65
+ def relative_dir(dir, top)
66
+ return "." if dir == top
67
+
68
+ prefix = top.end_with?("/") ? top : "#{top}/"
69
+ dir.start_with?(prefix) ? dir.delete_prefix(prefix) : nil
70
+ end
71
+
72
+ # How a prompt file is named in the prompt and /stats: relative to the
73
+ # work tree +top+ when inside it, else with ~ for the home directory.
74
+ # @return [String]
75
+ def display_path(path, top:)
76
+ rel = top && relative_dir(path, top)
77
+ return rel if rel && rel != "."
78
+
79
+ home = Dir.home
80
+ path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
81
+ rescue ArgumentError
82
+ path
83
+ end
84
+
85
+ # A chi --append-system-prompt value: [absolute path, nil], or
86
+ # [absolute path, the reason it can't be used].
87
+ # @param cwd [String] the shell's cwd the value is relative to
88
+ # @return [Array(String, String)]
89
+ def check_cli_file(value, cwd: Dir.pwd)
90
+ begin
91
+ path = File.expand_path(value.to_s, cwd)
92
+ rescue ArgumentError
93
+ # A quoted "~nosuchuser/x" reaches chi unexpanded.
94
+ return [value.to_s, "no such user"]
95
+ end
96
+ reason = if !File.exist?(path) then "no such file"
97
+ elsif !File.file?(path) then "not a file"
98
+ elsif !File.readable?(path) then "can't be read"
99
+ end
100
+ [path, reason]
101
+ end
102
+
103
+ # Text this long goes into every request: chi warns once, at start.
104
+ LARGE_CHARS = 32_000
105
+
106
+ # The number of chars of +path+ when above LARGE_CHARS, else nil.
107
+ # @return [Integer, nil]
108
+ def large_size(path)
109
+ chars = File.read(path, encoding: "UTF-8").length
110
+ chars > LARGE_CHARS ? chars : nil
111
+ rescue StandardError
112
+ nil
113
+ end
114
+
115
+ # +path+'s text under +header+ ("<header>\n<text>", stripped); nil when
116
+ # the file is empty or can't be read.
117
+ # @return [String, nil]
118
+ def section(path, header)
119
+ content = File.read(path, encoding: "UTF-8").strip
120
+ return nil if content.empty?
121
+
122
+ "#{header}\n#{content}"
123
+ rescue StandardError
124
+ nil
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Samagotchi
6
+ # One thing a click on a ref can do besides the kind's own (the web keeps
7
+ # those per kind, refs.js REF_ACTIONS): open +url+ in a new tab, shown as
8
+ # +label+ in the ref's menu. A plugin names it ("PR diff", "Open in Jira").
9
+ # The URL is http(s) only, checked here and again in the page
10
+ # (refs.js httpUrl): the page opens it as is, so a javascript:, data: or
11
+ # editor URL never gets there.
12
+ RefAction = Data.define(:id, :label, :url) do
13
+ # @raise [ArgumentError] an empty id or label, or a URL that isn't an
14
+ # http(s) one with a host, at most MAX_URL characters
15
+ def initialize(id:, label:, url:)
16
+ id = id.to_s.strip
17
+ label = label.to_s.strip
18
+ url = url.to_s.strip
19
+ raise ArgumentError, "a ref action needs an id" if id.empty?
20
+ raise ArgumentError, "a ref action needs a label" if label.empty?
21
+ raise ArgumentError, "a ref action's url must be http(s) with a host, at most #{RefAction::MAX_URL} characters" unless RefAction.http_url?(url)
22
+
23
+ super
24
+ end
25
+ end
26
+
27
+ class RefAction
28
+ MAX_URL = 2_000
29
+
30
+ # @return [Boolean] +url+ is an http or https URL with a host
31
+ def self.http_url?(url)
32
+ return false if url.length > MAX_URL
33
+
34
+ uri = URI.parse(url)
35
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
36
+ rescue URI::InvalidURIError
37
+ false
38
+ end
39
+
40
+ # @param value [RefAction, Hash] a plugin's {id:, label:, url:} (String
41
+ # or Symbol keys)
42
+ # @raise [ArgumentError] anything else, or an invalid action
43
+ def self.from(value)
44
+ return value if value.is_a?(RefAction)
45
+ raise ArgumentError, "a ref action is a Hash {id:, label:, url:}" unless value.is_a?(Hash)
46
+
47
+ value = value.transform_keys(&:to_sym)
48
+ new(id: value[:id], label: value[:label], url: value[:url])
49
+ end
50
+ end
51
+
52
+ # A ref as data: a kind, a target, an optional label and the plugins'
53
+ # extra actions (RefAction), for a UI to trace it. The kind's own actions
54
+ # are the UI's (a file ref keeps the editor, show in turn and copy), so
55
+ # +actions+ only adds to them.
56
+ # - +kind+ "file": +target+ {path:, line:, end_line:}, the lines only when
57
+ # positive, the end only past the start (FileRef's shape).
58
+ # - Any other kind ("issue", "commit", …): +target+ {id:}. The page shows
59
+ # its +label+ (or the text it matched) and runs its first action.
60
+ # +to_h+ is the shape the page reads (refs.js bindRef). FileRef stays the
61
+ # rows' own ref; this is the one a plugin gives.
62
+ Ref = Data.define(:kind, :target, :label, :actions) do
63
+ # @raise [ArgumentError] a kind that isn't a short lowercase word, a
64
+ # target without its kind's key, a non-list of actions or a bad action
65
+ def initialize(kind:, target:, label: nil, actions: [])
66
+ kind = kind.to_s
67
+ raise ArgumentError, "a ref's kind is a short lowercase word (#{kind.inspect})" unless Ref::KIND.match?(kind)
68
+ raise ArgumentError, "a ref's actions are a list" unless actions.nil? || actions.is_a?(Array)
69
+
70
+ label = label.to_s.strip
71
+ super(kind: kind, target: Ref.target_for(kind, target), label: label.empty? ? nil : label,
72
+ actions: Array(actions).map { |action| RefAction.from(action) }.freeze)
73
+ end
74
+
75
+ # @return [Hash] {kind:, target:, label:, actions: [{id:, label:, url:}]},
76
+ # no label when there is none
77
+ def to_h = { kind: kind, target: target, label: label, actions: actions.map(&:to_h) }.compact
78
+ end
79
+
80
+ class Ref
81
+ KIND = /\A[a-z][a-z0-9_-]{0,31}\z/
82
+ # FileRef::KIND, not required here (it loads the file tools).
83
+ FILE = "file"
84
+
85
+ # A plugin's {kind:, target:, label:, actions:} (String or Symbol keys).
86
+ # @raise [ArgumentError] as +new+
87
+ def self.from(value)
88
+ raise ArgumentError, "a ref is a Hash {kind:, target:, label:, actions:}" unless value.is_a?(Hash)
89
+
90
+ value = value.transform_keys(&:to_sym)
91
+ new(kind: value[:kind], target: value[:target], label: value[:label], actions: value[:actions] || [])
92
+ end
93
+
94
+ # +kind+'s target from +raw+: a file's path and lines, any other kind's id.
95
+ # @raise [ArgumentError] not a Hash, or without the kind's key
96
+ def self.target_for(kind, raw)
97
+ raise ArgumentError, "a ref's target is a Hash" unless raw.is_a?(Hash)
98
+
99
+ raw = raw.transform_keys(&:to_sym)
100
+ kind == FILE ? file_target(raw) : { id: required(raw[:id], :id) }.freeze
101
+ end
102
+
103
+ def self.file_target(raw)
104
+ line = positive(raw[:line])
105
+ end_line = line && positive(raw[:end_line])
106
+ { path: required(raw[:path], :path), line: line, end_line: end_line && end_line > line ? end_line : nil }.compact.freeze
107
+ end
108
+ private_class_method :file_target
109
+
110
+ def self.required(value, key)
111
+ value = value.to_s.strip
112
+ raise ArgumentError, "a ref's target needs #{key}" if value.empty?
113
+
114
+ value
115
+ end
116
+ private_class_method :required
117
+
118
+ def self.positive(value)
119
+ number = Integer(value.to_s.strip, exception: false)
120
+ number&.positive? ? number : nil
121
+ end
122
+ private_class_method :positive
123
+ end
124
+ end
@@ -586,7 +586,7 @@ module Samagotchi
586
586
  outcome = Delivery.cut?(delivery) ? ack["cut"].to_s : ""
587
587
  return " (goes in at the running turn's next step)" unless CUT_OUTCOMES.include?(outcome)
588
588
 
589
- " #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym)}"
589
+ " #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym, ack["cut_after"])}"
590
590
  end
591
591
 
592
592
  # More keys for --format json, with --cut only (the line is unchanged
@@ -53,6 +53,15 @@ module Samagotchi
53
53
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
54
54
  # Names as given; the engine normalizes them.
55
55
  attr_accessor :preloaded_memory_names, :muted_memory_names
56
+ # The files chi --append-system-prompt put into the session's system
57
+ # prompt (absolute paths; config.yml's append_system_prompt: entries
58
+ # aren't stored): kept on respawns, copied by a fork and a continue
59
+ # (SessionSetup).
60
+ attr_reader :appended_prompt_files
61
+
62
+ def appended_prompt_files=(paths)
63
+ @appended_prompt_files = Array(paths).map(&:to_s).reject(&:empty?).uniq
64
+ end
56
65
  # The model notes the session's system prompt carried at its last build
57
66
  # (Array<PromptNote>: name, scope, chars, digest; ModelNotes), set by the
58
67
  # Engine at each build (a start, a resume's or a woken worker's first
@@ -182,7 +191,7 @@ module Samagotchi
182
191
  used_memory_names: [], project_root: nil,
183
192
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
184
193
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
185
- continues: nil, thinking: nil, reasoning_history: nil)
194
+ continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [])
186
195
  @id = id
187
196
  @metadata_version = metadata_version
188
197
  @mode = mode
@@ -210,6 +219,7 @@ module Samagotchi
210
219
  self.llm_context = llm_context
211
220
  self.thinking = thinking
212
221
  self.reasoning_history = reasoning_history
222
+ self.appended_prompt_files = appended_prompt_files
213
223
  @archived = false
214
224
  end
215
225
 
@@ -289,6 +299,7 @@ module Samagotchi
289
299
  llm_context: setup.llm_context,
290
300
  thinking: setup.thinking,
291
301
  reasoning_history: setup.reasoning_history,
302
+ appended_prompt_files: setup.appended_prompt_files,
292
303
  continues: continues
293
304
  )
294
305
  end
@@ -351,7 +362,8 @@ module Samagotchi
351
362
  "llm_context" => nil,
352
363
  "continues" => nil,
353
364
  "thinking" => nil,
354
- "reasoning_history" => nil
365
+ "reasoning_history" => nil,
366
+ "appended_prompt_files" => []
355
367
  }.freeze
356
368
 
357
369
  # A session from a parsed session file. +messages+ false leaves the
@@ -476,7 +488,7 @@ module Samagotchi
476
488
  when "reasoning_history" then reasoning_history_file
477
489
  when "prompt_notes" then @prompt_notes.map(&:to_file)
478
490
  when "last_turn" then @last_turn&.to_file
479
- when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
491
+ when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
480
492
  Array(instance_variable_get(:"@#{key}"))
481
493
  else instance_variable_get(:"@#{key}")
482
494
  end
@@ -328,7 +328,9 @@ module Samagotchi
328
328
  command = text.delete_prefix(SHELL_BANG_PREFIX).strip
329
329
  return reply("!: please provide a shell command after '!'", status: :error) if command.empty?
330
330
 
331
- output = Samagotchi::Tools::Execute.call(command, env: Samagotchi::Tools::Builtins.cli_model_unset)
331
+ # Killed at the limit, never handed over: the output goes in a user
332
+ # message, which no one reads a task id from (created_ids_in).
333
+ output = Samagotchi::Tools::Execute.call(command, env: Samagotchi::Tools::Builtins.cli_model_unset, on_timeout: :kill)
332
334
  @engine.append_messages([{ role: "user", content: "!(#{command})\n#{output}" }])
333
335
  # Rolling back past this would silently drop the command output.
334
336
  @turn_flow.note_conversation_changed
@@ -484,9 +486,9 @@ module Samagotchi
484
486
  def model_command(input)
485
487
  suffix = input.delete_prefix(MODEL_COMMAND).strip
486
488
  if suffix.empty?
487
- return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if @default_model.nil? || model_name == @engine.model_ref_for(@default_model)
489
+ return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{steer_cut_after_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if @default_model.nil? || model_name == @engine.model_ref_for(@default_model)
488
490
 
489
- return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false]
491
+ return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{steer_cut_after_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false]
490
492
  end
491
493
 
492
494
  # Parse flags: --default and --alias <name> / --alias=<name> (tolerant order)
@@ -606,6 +608,16 @@ module Samagotchi
606
608
  ""
607
609
  end
608
610
 
611
+ # "; steer cut: after 60 s (models: qwen)", or "; steer cut: never
612
+ # (hosts.splash)" for 0 or less, when the model has one configured;
613
+ # "" for the default.
614
+ def steer_cut_after_note
615
+ summary = @engine.steer_cut_summary
616
+ summary ? "; steer cut: #{summary}" : ""
617
+ rescue StandardError
618
+ ""
619
+ end
620
+
609
621
  # "; notes: model_notes_deepseek (system, 612 chars)": the model notes
610
622
  # the session's prompt carries (after a switch, the ones the new
611
623
  # model's prompt loads); "" without any.
@@ -142,9 +142,13 @@ module Samagotchi
142
142
  :cuts,
143
143
  :capped,
144
144
  :model,
145
- # The turn the user started by answering the step-limit question with
146
- # Continue (:turn_started's +continue+); its record carries it.
145
+ # A turn with no prompt of its own (:turn_started's +continue+): a
146
+ # step-limit Continue, a reminder's or a wake's turn; its record
147
+ # carries it.
147
148
  :continue,
149
+ # The turn the user started by answering the step-limit question with
150
+ # Continue (:turn_started's +resumed+): it resumed the run before it.
151
+ :resumed,
148
152
  :id,
149
153
  :started_at,
150
154
  :started_monotonic,
@@ -558,6 +562,7 @@ module Samagotchi
558
562
  capped: 0,
559
563
  model: nil,
560
564
  continue: event[:continue] ? true : false,
565
+ resumed: event[:resumed] ? true : false,
561
566
  id: event[:turn_id] || SecureRandom.uuid,
562
567
  started_at: now.iso8601(3),
563
568
  started_monotonic: monotonic_time,
@@ -597,6 +602,7 @@ module Samagotchi
597
602
  # Every record says, so the report can tell a file with no Continue
598
603
  # from one written before the mark.
599
604
  record[:continue] = @turn.continue
605
+ record[:resumed] = @turn.resumed
600
606
  record.merge!(turn_token_fields(@turn))
601
607
  @turn_records << record
602
608
  count_turn(record)
@@ -10,15 +10,20 @@ module Samagotchi
10
10
  # (SessionManager.spawn_session, a continue, a plugin's fork, the web's
11
11
  # create, chi send --new): +llm_context+ is an LLMContextOverride or nil,
12
12
  # +thinking+ a level Symbol (Thinking.session_level) or nil,
13
- # +reasoning_history+ a level Symbol (ReasoningHistory.session_level) or nil.
13
+ # +reasoning_history+ a level Symbol (ReasoningHistory.session_level) or nil,
14
+ # +appended_prompt_files+ the absolute paths chi --append-system-prompt
15
+ # named (their text goes into the system prompt; [] with none).
14
16
  #
15
17
  # It doesn't save itself: the session keeps each setting in its own field
16
18
  # and file key, and resolves it on its own. A delegate child starts
17
19
  # without one; a continue and a fork copy their session's (.of).
18
- SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history) do
19
- def initialize(llm_context: nil, thinking: nil, reasoning_history: nil) = super
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: [])
22
+ super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
23
+ appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze)
24
+ end
20
25
 
21
- def empty? = llm_context.nil? && thinking.nil? && reasoning_history.nil?
26
+ def empty? = llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty?
22
27
 
23
28
  # A session's own setup, for a session that starts from it.
24
29
  # @param session [Session, nil]
@@ -26,7 +31,8 @@ module Samagotchi
26
31
  def self.of(session)
27
32
  return new if session.nil?
28
33
 
29
- new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history)
34
+ new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
35
+ appended_prompt_files: session.appended_prompt_files)
30
36
  end
31
37
  end
32
38
  end
@@ -30,19 +30,23 @@ module Samagotchi
30
30
  end
31
31
 
32
32
  # +clock+: monotonic seconds; +controller+: the running turn's cancel
33
- # controller (nil between turns), read only when a cut is due.
34
- def initialize(clock:, controller:)
33
+ # controller (nil between turns), read only when a cut is due;
34
+ # +cut_after+: seconds a thinking-only generation may stream before a
35
+ # cut, called on every check (the Engine passes the model's own
36
+ # SteerCutAfter; the global steer.cut_after here).
37
+ def initialize(clock:, controller:, cut_after: -> { self.class.cut_after })
35
38
  # What the running generation streams: whether a steer may cut it.
36
39
  @phase = GenerationPhase.new(clock: clock)
37
40
  # A message that may cut it but came too early: it cuts later.
38
41
  @waiting = WaitingSteer.new
42
+ @cut_after = cut_after
39
43
  @controller = controller
40
44
  end
41
45
 
42
46
  # A message for the running turn from +source+ (Steer.source_for_client:
43
47
  # nil for the user, "chi_send", "parent_agent") cuts the streaming
44
- # generation when it has streamed only thinking for steer.cut_after
45
- # seconds (GenerationPhase): the loops' cut path (CutPolicy) then starts
48
+ # generation when it has streamed only thinking for cut_after seconds
49
+ # (GenerationPhase): the loops' cut path (CutPolicy) then starts
46
50
  # the step again with the message, no nudge, no retry spent. Too early,
47
51
  # the message waits (WaitingSteer) and cuts once the thinking passes it
48
52
  # (#recheck, on each thinking chunk), unless a boundary hands it to the
@@ -52,13 +56,13 @@ module Samagotchi
52
56
  # before queueing it. Outside any lock; a cut that lands just after the
53
57
  # generation ended is harmless (the loop re-asks).
54
58
  # @return [Symbol] :now (a generation was cut now), :waits (the message
55
- # waits for the thinking to pass steer.cut_after), or :off (cutting
59
+ # waits for the thinking to pass cut_after), or :off (cutting
56
60
  # off for this source: a plugin, no turn, cut_after 0, a generation
57
61
  # that isn't thinking-only, or a drain already took the message)
58
62
  def cut_for_steer(source, epoch: nil)
59
63
  return :off unless Steer.cuts?(source)
60
64
 
61
- after = self.class.cut_after
65
+ after = @cut_after.call.to_i
62
66
  return :off unless after.positive?
63
67
 
64
68
  ctrl = @controller.call
@@ -107,11 +111,11 @@ module Samagotchi
107
111
  private
108
112
 
109
113
  # A thinking chunk streamed: a waiting message cuts once the thinking
110
- # passes steer.cut_after.
114
+ # passes cut_after.
111
115
  def recheck
112
116
  return unless @waiting.waiting?
113
117
 
114
- after = self.class.cut_after
118
+ after = @cut_after.call.to_i
115
119
  return unless after.positive? && @phase.cuttable?(after)
116
120
 
117
121
  source = @waiting.take
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+
5
+ module Samagotchi
6
+ # How long a message sent as a cut waits for the generation it came during:
7
+ # the seconds a generation must have streamed only thinking before a cut may
8
+ # land (SteerCut), one per model, host or run, like Thinking for the level.
9
+ #
10
+ # Order (#resolve): the env (SAMAGOTCHI_STEER_CUT_AFTER), then
11
+ # models.<key>.steer_cut_after, hosts.<name>.steer_cut_after,
12
+ # steer.cut_after in config.yml, then 20. 0 = never cut (a slow local
13
+ # thinker is left alone); it is a value, not an unset.
14
+ module SteerCutAfter
15
+ KEY = "steer_cut_after"
16
+ ENV_KEY = "SAMAGOTCHI_STEER_CUT_AFTER"
17
+ DEFAULT = 20
18
+
19
+ module_function
20
+
21
+ # A `steer_cut_after:` setting (hosts: entry or models: entry): whole
22
+ # seconds ≥ 0 (0 = never), nil when unset; anything else warns once,
23
+ # naming +where+, and counts as unset.
24
+ # @return [Integer, nil]
25
+ def seconds(value, where)
26
+ return nil if value.nil?
27
+
28
+ text = value.to_s.strip
29
+ return text.to_i if text.match?(/\A\d+\z/)
30
+
31
+ ConfigFile.warn_once "Warning: #{where}: steer_cut_after must be whole seconds (0 = never); ignored"
32
+ nil
33
+ end
34
+
35
+ # The seconds for +target+ and where they came from
36
+ # ("SAMAGOTCHI_STEER_CUT_AFTER", "models: qwen", "hosts.splash",
37
+ # "steer.cut_after"; nil for the default).
38
+ # @param target [HostRegistry::ModelTarget]
39
+ # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
40
+ # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
41
+ # @return [Array(Integer, String|nil)]
42
+ def resolve(target, names:, models: nil)
43
+ value, origin = Config.get_with_origin("steer.cut_after")
44
+ return [value.to_i, ENV_KEY] if origin == :env
45
+
46
+ models ||= begin
47
+ ConfigFile.model_settings
48
+ rescue StandardError
49
+ {}
50
+ end
51
+ setting = ConfigFile.model_setting(names, :steer_cut_after, models: models)
52
+ return [setting.value, "models: #{setting.key}"] if setting
53
+
54
+ host = target.entry.steer_cut_after
55
+ return [host, "hosts.#{target.entry.name}"] unless host.nil?
56
+
57
+ return [value.to_i, "steer.cut_after"] if origin == :file
58
+
59
+ [DEFAULT, nil]
60
+ end
61
+
62
+ # "after 60 s (models: qwen)"; 0 or less is "never (hosts.splash)".
63
+ # No source: just the seconds.
64
+ def label(seconds, source)
65
+ text = seconds.to_i <= 0 ? "never" : "after #{seconds} s"
66
+ source ? "#{text} (#{source})" : text
67
+ end
68
+ end
69
+ end