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
@@ -11,12 +11,13 @@ require_relative "model_notes"
11
11
  require_relative "bundle_needs"
12
12
  require_relative "memory_bundle/index_size"
13
13
  require_relative "thinking"
14
+ require_relative "prompt_files"
14
15
 
15
16
  module Samagotchi
16
17
  # The system prompt an Engine gives its model: the base prompt (tool
17
18
  # declarations and call syntax for the raw-prompt loop, none for the chat
18
19
  # loop, the shared guidance) and around it the thinking token, rg
19
- # guidance, the identity memory, the model notes, the preloaded memories, AGENT.md, the
20
+ # guidance, the identity memory, the model notes, the preloaded memories, AGENTS.md, the
20
21
  # memory indexes and, last, the model, where the session runs and the
21
22
  # session (#system_prompt_with_index has the order and why).
22
23
  #
@@ -24,8 +25,6 @@ module Samagotchi
24
25
  # tools a plugin replaces, the attached session, the thinking level) is
25
26
  # read at build time through the lookups; the memory lists are fixed.
26
27
  class SystemPrompt
27
- AGENT_DESCRIPTION_FILE = "AGENT.md"
28
-
29
28
  # The model a session runs on, as the prompt names it: the resolved
30
29
  # ref, its host ("name, host:port" or url), the memory overlay key, and
31
30
  # what a llama.cpp server says it serves when that differs (else nil).
@@ -51,8 +50,10 @@ module Samagotchi
51
50
  # model's (none: the section is left out)
52
51
  # @param memories [Array<String>] the --memory list
53
52
  # @param muted_memory_names [Array<String>] normalized (MutedMemories)
53
+ # @param appended_files [Array<String>] chi --append-system-prompt's
54
+ # files (absolute paths), after config.yml's append_system_prompt:
54
55
  def initialize(profile:, tools:, session:, thinking:, model: -> {}, model_notes: -> { [] }, memories: [],
55
- muted_memory_names: [])
56
+ muted_memory_names: [], appended_files: [])
56
57
  @profile_lookup = profile
57
58
  @model_lookup = model
58
59
  @model_notes_lookup = model_notes
@@ -61,6 +62,14 @@ module Samagotchi
61
62
  @thinking_lookup = thinking
62
63
  @muted_memory_names = muted_memory_names
63
64
  @requested_memories = effective_preload_list(preload_memory_list(memories))
65
+ # Read once, as the memories: list is: a config edit is picked up by
66
+ # a new process. The files' text is read at each build.
67
+ @config_appended = begin
68
+ ConfigFile.appended_prompt_files
69
+ rescue StandardError
70
+ []
71
+ end
72
+ @cli_appended = Array(appended_files).map { |path| File.expand_path(path.to_s) }
64
73
  end
65
74
 
66
75
  # @return [Array<String>] the names the session preloads, known before
@@ -81,16 +90,20 @@ module Samagotchi
81
90
  @built ||= {}
82
91
  @index_sizes ||= {}
83
92
  @notes_loaded ||= {}
93
+ @files_loaded ||= {}
84
94
  unless @built.key?(key)
85
95
  @section_notes = nil
96
+ @section_files = []
86
97
  @built[key] = system_prompt_with_index(
87
98
  assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
88
99
  )
89
100
  @index_sizes[key] = @measured_index
90
101
  @notes_loaded[key] = @section_notes || []
102
+ @files_loaded[key] = @section_files.freeze
91
103
  end
92
104
  @memory_index = @index_sizes[key]
93
105
  @prompt_notes = @notes_loaded[key]
106
+ @prompt_files = @files_loaded[key]
94
107
  @built[key]
95
108
  end
96
109
 
@@ -108,6 +121,14 @@ module Samagotchi
108
121
  # @return [Array<ModelNotes::Note>, nil]
109
122
  attr_reader :prompt_notes
110
123
 
124
+ # The files whose text the prompt #build returned last holds, as
125
+ # absolute paths in prompt order: the AGENTS.md chain, then the
126
+ # appended files (append_system_prompt:, --append-system-prompt). [] before a
127
+ # build and after #reset!. Read when the prompt is built, as the
128
+ # sections are.
129
+ # @return [Array<String>]
130
+ def prompt_files = @prompt_files || []
131
+
111
132
  # Drops the built prompts: the next #build reads the profile, tools,
112
133
  # indexes and memories again (a model or profile switch, changed tools).
113
134
  def reset!
@@ -115,6 +136,8 @@ module Samagotchi
115
136
  @index_sizes = nil
116
137
  @notes_loaded = nil
117
138
  @prompt_notes = nil
139
+ @files_loaded = nil
140
+ @prompt_files = nil
118
141
  @stable_lengths = nil
119
142
  end
120
143
 
@@ -282,7 +305,7 @@ module Samagotchi
282
305
  # Ordered for the servers' prompt caches (they reuse only an exact token
283
306
  # prefix): what every session shares first, what changes least before
284
307
  # what changes more, and the per-session lines last. The global parts
285
- # (identity, preloads) come before the project's AGENT.md, the memory
308
+ # (identity, preloads) come before the project's AGENTS.md, the memory
286
309
  # indexes (a memory write changes them) after both; the model, the
287
310
  # location and the session close the prompt (Gemma's tool declarations
288
311
  # still follow them).
@@ -295,7 +318,7 @@ module Samagotchi
295
318
  "System memories:\n#{indexes[:system]}"
296
319
  ].join("\n\n")
297
320
  stable = [thinking_token + base, rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
298
- project_specific_description, memory_sections]
321
+ project_specific_description, appended_section, memory_sections]
299
322
  stable_text = stable.compact.join("\n")
300
323
  volatile = [current_model, project_location, current_session].compact
301
324
  return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
@@ -464,26 +487,59 @@ module Samagotchi
464
487
 
465
488
  # ── Project / rg helpers ───────────────────────────────────────────────────
466
489
 
467
- # AGENT.md in the cwd, else at the top of its git work tree.
490
+ # The AGENTS.md chain from the top of the git work tree down to the cwd
491
+ # (PromptFiles.agents_sections), one section per file with text.
468
492
  def project_specific_description
469
493
  return nil if skip_agent_description?
470
494
 
471
- path = agent_description_path
472
- return nil unless path
473
-
474
- content = File.read(path, encoding: "UTF-8").strip
475
- return nil if content.empty?
495
+ sections = PromptFiles.agents_sections(Dir.pwd)
496
+ return nil if sections.empty?
476
497
 
477
- "Project specific description:\n#{content}"
498
+ @section_files&.concat(sections.map(&:path))
499
+ sections.map(&:text).join("\n\n")
478
500
  rescue StandardError
479
501
  nil
480
502
  end
481
503
 
482
- def agent_description_path
504
+ # The appended files' text, right after AGENTS.md (the stable, cached
505
+ # part): config.yml's append_system_prompt: entries first (a relative
506
+ # one against the cwd, as AGENTS.md is found; a missing one is skipped),
507
+ # then --append-system-prompt's. A file the AGENTS.md chain holds, a
508
+ # repeat and an empty or vanished file are left out.
509
+ def appended_section
483
510
  cwd = Dir.pwd
484
- [cwd, MemoryPaths.work_tree_root(cwd)].compact.uniq
485
- .map { |dir| File.join(dir, AGENT_DESCRIPTION_FILE) }
486
- .find { |path| File.file?(path) }
511
+ top = MemoryPaths.work_tree_root(cwd)
512
+ seen = Set.new((skip_agent_description? ? [] : PromptFiles.agents_paths(cwd)).map { |path| canonical_path(path) })
513
+ sections = []
514
+ appended_candidates(cwd).each do |path, source|
515
+ next unless seen.add?(canonical_path(path))
516
+
517
+ unless File.file?(path)
518
+ Log.debug(:config, "appended_prompt_file_missing", path: path, source: source)
519
+ next
520
+ end
521
+ text = PromptFiles.section(path, "Appended from #{PromptFiles.display_path(path, top: top)}:")
522
+ next unless text
523
+
524
+ @section_files&.push(path)
525
+ sections << text
526
+ end
527
+ sections.empty? ? nil : sections.join("\n\n")
528
+ rescue StandardError
529
+ nil
530
+ end
531
+
532
+ # [absolute path, "config" | "cli"], in prompt order.
533
+ def appended_candidates(cwd)
534
+ @config_appended.map { |entry| [File.expand_path(entry, cwd), "config"] } +
535
+ @cli_appended.map { |path| [path, "cli"] }
536
+ end
537
+
538
+ # The same file however it's spelled (/tmp vs /private/tmp on macOS).
539
+ def canonical_path(path)
540
+ File.realpath(path)
541
+ rescue SystemCallError
542
+ File.expand_path(path)
487
543
  end
488
544
 
489
545
  # Where the session runs and which project memory folder it uses. Only
@@ -566,7 +622,7 @@ module Samagotchi
566
622
  end
567
623
 
568
624
  def skip_agent_description?
569
- Config.get("skip_agent_md") == true
625
+ Config.get("skip_agents_md") == true
570
626
  end
571
627
 
572
628
  def rg_available?
@@ -37,6 +37,8 @@ module Samagotchi
37
37
  # @param memories [Array<String>] --memory: a new session's worker
38
38
  # preloads them (an existing session keeps its own list)
39
39
  # @param muted_memories [Array<String>] --mute: hidden from a new session
40
+ # @param appended_prompt_files [Array<String>] --append-system-prompt's
41
+ # files (absolute): a new session's setup carries them
40
42
  # @param llm_context [Hash{Symbol => String}] --llm-context* as typed
41
43
  # (LLMContextOverride.update): a new session starts with them; a
42
44
  # resumed or attached one's worker gets a /llm-context, after the
@@ -48,9 +50,10 @@ module Samagotchi
48
50
  # :failed when the --model switch didn't go through, or (input from a
49
51
  # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
50
52
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
51
- memories: [], muted_memories: [], llm_context: {}, thinking: nil)
53
+ memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [])
52
54
  client = connect(attach: attach, shared: shared, resume: resume, model: model,
53
- memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking)
55
+ memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
56
+ appended_prompt_files: appended_prompt_files)
54
57
  commands = []
55
58
  commands << "/model #{model}" if model && (attach || resume)
56
59
  commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
@@ -117,9 +120,11 @@ module Samagotchi
117
120
  # @return [BridgeClient] a client for the live Bridge of the session
118
121
  # @raise [Error]
119
122
  def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
120
- memories: [], muted_memories: [], llm_context: {}, thinking: nil, err: $stderr)
123
+ memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], err: $stderr)
121
124
  sd = state_dir || Session.default_state_dir
122
- warn_memory_flags_ignored(attach || resume, memories, muted_memories, err) if attach || resume
125
+ if attach || resume
126
+ warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files)
127
+ end
123
128
  if attach
124
129
  live = connect_existing(attach, sd)
125
130
  return live if live
@@ -134,7 +139,8 @@ module Samagotchi
134
139
  SessionManager.spawn_session(prompt: nil, model_name: model && model_ref(model), state_dir: sd,
135
140
  memories: memories, muted_memories: muted_memories,
136
141
  setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
137
- thinking: Thinking.session_level(thinking)))
142
+ thinking: Thinking.session_level(thinking),
143
+ appended_prompt_files: appended_prompt_files))
138
144
  end
139
145
  client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
140
146
  client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
@@ -150,16 +156,19 @@ module Samagotchi
150
156
  end
151
157
 
152
158
  # An existing session's prompt is built from its own session fields:
153
- # --memory/--mute given with --attach or --resume are ignored, with a
154
- # line saying so (printed before the live region opens).
155
- def warn_memory_flags_ignored(session_id, memories, muted_memories, err)
159
+ # --memory/--mute/--append-system-prompt given with --attach or
160
+ # --resume are ignored, with a line saying so (printed before the live
161
+ # region opens).
162
+ def warn_memory_flags_ignored(session_id, memories, muted_memories, err, appended_prompt_files: [])
156
163
  flags = []
157
164
  flags << "--memory" unless Array(memories).empty?
158
165
  flags << "--mute" unless Array(muted_memories).empty?
166
+ flags << "--append-system-prompt" unless Array(appended_prompt_files).empty?
159
167
  return if flags.empty?
160
168
 
161
169
  verb = flags.size == 1 ? "applies" : "apply"
162
- err.puts "(#{flags.join(" and ")} #{verb} to a new session; #{session_id}'s prompt is already built)"
170
+ names = flags.size > 2 ? "#{flags[0..-2].join(", ")} and #{flags.last}" : flags.join(" and ")
171
+ err.puts "(#{names} #{verb} to a new session; #{session_id}'s prompt is already built)"
163
172
  end
164
173
 
165
174
  # @return [BridgeClient, nil] the running worker's, or nil when none runs
@@ -712,7 +712,7 @@ module Samagotchi
712
712
 
713
713
  return NEXT_STEP_NOTE unless delivery == Delivery::CUT
714
714
 
715
- steer_cut_note(ack["cut"].to_s.to_sym)
715
+ steer_cut_note(ack["cut"].to_s.to_sym, ack["cut_after"])
716
716
  end
717
717
 
718
718
  def send_prompt(text, delivery: nil)
@@ -8,6 +8,7 @@ require_relative "../steer"
8
8
  require_relative "../steer_cut"
9
9
  require_relative "../memory_bundle/index_size"
10
10
  require_relative "../prompt_note"
11
+ require_relative "../prompt_files"
11
12
 
12
13
  module Samagotchi
13
14
  class TerminalUI
@@ -99,8 +100,9 @@ module Samagotchi
99
100
  # What a line submitted at the open prompt says became of it: a plain
100
101
  # line goes in at the running turn's next step boundary (nothing cuts
101
102
  # unless the message asked for it), /cut's cutting now or once the
102
- # thinking passes steer.cut_after, and /queue's waiting for the end.
103
- # Both TUIs and chi send --cut print the same words
103
+ # thinking passes the model's cut wait (the run's cut_after; the global
104
+ # steer.cut_after when the worker didn't say), and /queue's waiting for
105
+ # the end. Both TUIs and chi send --cut print the same words
104
106
  # (SteerCut#cut_for_steer's answer).
105
107
  NEXT_STEP_NOTE = "(goes in at the next step)"
106
108
  CUT_NOW_NOTE = "(cut in now)"
@@ -116,17 +118,20 @@ module Samagotchi
116
118
  STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
117
119
 
118
120
  # @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
121
+ # @param after [Integer, nil] the run's cut wait (the model's own
122
+ # SteerCutAfter): the seconds a :waits waits for; the global
123
+ # steer.cut_after when not given.
119
124
  # @return [String] the dim line to show for a /cut
120
- def self.steer_cut_note(outcome)
125
+ def self.steer_cut_note(outcome, after = nil)
121
126
  case outcome
122
127
  when :now then CUT_NOW_NOTE
123
- when :waits then format(CUT_WAITS_NOTE, SteerCut.cut_after)
128
+ when :waits then format(CUT_WAITS_NOTE, after || SteerCut.cut_after)
124
129
  else CUT_OFF_NOTE
125
130
  end
126
131
  end
127
132
 
128
133
  # The same words, for a UI that includes the module.
129
- def steer_cut_note(outcome) = Formatting.steer_cut_note(outcome)
134
+ def steer_cut_note(outcome, after = nil) = Formatting.steer_cut_note(outcome, after)
130
135
 
131
136
  # The choice a mid-turn line carries, as [:cut, "TEXT"] or
132
137
  # [:queue, "TEXT"]; nil for any other line. The text is "" when the
@@ -400,6 +405,16 @@ module Samagotchi
400
405
  "#{"~" if source.to_s == "estimate"}#{number} tok/s"
401
406
  end
402
407
 
408
+ # The prompt files (absolute paths) named as the prompt names them:
409
+ # relative to this checkout's top, else with ~; nil with none.
410
+ def prompt_files_text(paths)
411
+ list = Array(paths).map(&:to_s).reject(&:empty?)
412
+ return nil if list.empty?
413
+
414
+ top = MemoryPaths.work_tree_root(Dir.pwd)
415
+ list.map { |path| PromptFiles.display_path(path, top: top) }.join(", ")
416
+ end
417
+
403
418
  # "~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)":
404
419
  # the memory indexes the session's prompt holds (the snapshot's
405
420
  # memory_index, symbol or string keys); "" without one. As ctx.js
@@ -596,6 +611,9 @@ module Samagotchi
596
611
  # The model notes the session's prompt carried (Engine#prompt_notes).
597
612
  notes = PromptNote.text(snapshot[:prompt_notes])
598
613
  lines << "model notes: #{notes}" unless notes.empty?
614
+ # The files whose text is in the prompt (SystemPrompt#prompt_files).
615
+ files = prompt_files_text(snapshot[:prompt_files])
616
+ lines << "prompt files: #{files}" if files
599
617
  llm_context = llm_context_stats_text(snapshot[:llm_context])
600
618
  lines << "llm context: #{llm_context}" if llm_context
601
619
  thinking = setting_stats_text(snapshot[:thinking])
@@ -92,8 +92,12 @@ module Samagotchi
92
92
  # (LLMContextOverride.update): set on the new or resumed session
93
93
  # @param thinking [String, nil] --thinking as typed: the new or resumed
94
94
  # session's own level (default unsets it)
95
+ # @param appended_prompt_files [Array<String>] --append-system-prompt's
96
+ # files (absolute): a new session saves them; a resumed one's own come
97
+ # first, and the merge goes only to the Engine (as --memory's does)
95
98
  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,
96
- spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil)
99
+ spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
100
+ appended_prompt_files: [])
97
101
  @scratch = scratch
98
102
  @thinking_word = thinking
99
103
  @prompt = prompt
@@ -142,9 +146,11 @@ module Samagotchi
142
146
  # session started attached with mutes keeps them here.
143
147
  @requested_memories = Array(memories)
144
148
  @muted_memory_names = Array(muted_memories)
149
+ @appended_prompt_files = Array(appended_prompt_files)
145
150
  if @resume_session
146
151
  @requested_memories = (@resume_session.preloaded_memory_names + @requested_memories).uniq
147
152
  @muted_memory_names = (@resume_session.muted_memory_names + @muted_memory_names).uniq
153
+ @appended_prompt_files = (@resume_session.appended_prompt_files + @appended_prompt_files).uniq
148
154
  end
149
155
  # Every terminal write goes through the surface. The REPL swaps in a
150
156
  # live region when the terminal can show one (#assist_loop), unless it
@@ -178,6 +184,7 @@ module Samagotchi
178
184
  model_typed: no_default ? @resumed_model_typed : nil,
179
185
  memories: @requested_memories,
180
186
  muted_memories: @muted_memory_names,
187
+ appended_prompt_files: @appended_prompt_files,
181
188
  recap: scratch ? nil : recap_config,
182
189
  scratch: scratch,
183
190
  reminders: {
@@ -258,7 +265,8 @@ module Samagotchi
258
265
  working_directory: Dir.pwd,
259
266
  preloaded_memory_names: @requested_memories,
260
267
  muted_memory_names: @muted_memory_names,
261
- scratch: @scratch
268
+ scratch: @scratch,
269
+ setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files)
262
270
  ))
263
271
  # --llm-context*: the session's own values, over a resumed one's.
264
272
  # Saved at once on a resumed one: leaving before a turn keeps them.
@@ -628,7 +636,7 @@ module Samagotchi
628
636
  # Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
629
637
  # Engine#run_turn, rendering via @renderer.
630
638
  # @param images [Array<Hash>] the prompt's `@path` images ({path:})
631
- def run_engine_turn(session, prompt, continue: false, max_iterations: IterationLimit.for, images: [])
639
+ def run_engine_turn(session, prompt, continue: false, max_iterations: IterationLimit.for, images: [], resumed: false, resume_from: nil)
632
640
  cancellation_controller = CancellationController.new
633
641
  @active_cancel_controller = cancellation_controller
634
642
  @renderer.begin_turn
@@ -646,7 +654,9 @@ module Samagotchi
646
654
  cancel_controller: cancellation_controller,
647
655
  pending_input: method(:drain_steering),
648
656
  continue: continue,
649
- images: images
657
+ images: images,
658
+ resumed: resumed,
659
+ resume_from: resume_from
650
660
  )
651
661
  end
652
662
  rescue Interrupt
@@ -774,9 +784,11 @@ module Samagotchi
774
784
  end
775
785
  return show_command_result(answer) unless answer.resume
776
786
 
787
+ # The run the offer's turn started: the Continue keeps its reasoning.
788
+ resume_from = @turn_flow.resume_from
777
789
  @turn_flow.before_continue_turn
778
790
  begin
779
- result = run_engine_turn(session, nil, continue: true)
791
+ result = run_engine_turn(session, nil, continue: true, resumed: true, resume_from: resume_from)
780
792
  rescue LLM::ProviderError
781
793
  # The renderer showed the failure (:turn_failed).
782
794
  @surface.commit(turn_end_hint("continue prompt preserved"))
@@ -1199,7 +1211,7 @@ module Samagotchi
1199
1211
  @pending_input_queue.push(text)
1200
1212
  # With no session running the cut is :off; the text still waits for
1201
1213
  # the running turn's next step.
1202
- @surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch)))
1214
+ @surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch), @engine.steer_cut_after))
1203
1215
  true
1204
1216
  end
1205
1217
 
@@ -46,7 +46,11 @@ module Samagotchi
46
46
  status: tool_activity_status(result, tool_name, command: call.is_a?(Hash) ? call[:content] : nil)
47
47
  }
48
48
  # A grep that found nothing: ok, and the web row says "no match".
49
- event[:no_match] = true if event[:status] == "ok" && tool_name == Tools::Execute::NAME && execute_failed?(result.to_s)
49
+ # Not a hand-over: an "exit: 1" in its output tail is the command's.
50
+ if event[:status] == "ok" && tool_name == Tools::Execute::NAME && !handed_over?(tool_name, result) &&
51
+ execute_failed?(result.to_s)
52
+ event[:no_match] = true
53
+ end
50
54
  title = tool_title(tool_name, call, cwd: cwd)
51
55
  event[:title] = title if title
52
56
  # The name the model used when an alias ran a built-in (called_as: "bash"
@@ -214,6 +218,8 @@ module Samagotchi
214
218
  return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
215
219
  return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
216
220
  return "error" if text.start_with?("Error:")
221
+ # Still running, as a task: its output tail may hold any "exit: N".
222
+ return "ok" if handed_over?(tool_name, text)
217
223
  if tool_name == Tools::Execute::NAME && execute_failed?(text)
218
224
  return no_match?(text, command) ? "ok" : "error"
219
225
  end
@@ -222,6 +228,17 @@ module Samagotchi
222
228
  "ok"
223
229
  end
224
230
 
231
+ # An execute still running at its limit, handed over as a task
232
+ # (Tools::Execute::HANDED_OVER).
233
+ def handed_over?(tool_name, text)
234
+ return false unless tool_name == Tools::Execute::NAME && text.to_s.start_with?(Tools::Execute::HANDED_OVER)
235
+
236
+ # The first line only, scrubbed: a regexp raises on bytes that aren't UTF-8.
237
+ text.to_s.each_line.first.to_s.scrub.match?(HANDED_OVER_START)
238
+ end
239
+
240
+ HANDED_OVER_START = /\A#{Regexp.escape(Tools::Execute::HANDED_OVER)} \d+s limit:/
241
+
225
242
  def execute_failed?(text)
226
243
  code = execute_exit_code(text)
227
244
  !code.nil? && code != "0"
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  TOOL_SCHEMAS = [
21
21
  {
22
22
  name: "execute",
23
- description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata. Don't background with &; use task_create.",
23
+ description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata. A command still running at the time limit keeps running as a background task (by default): the result gives its task_id, and task_wait waits for it. Don't background with &; use task_create.",
24
24
  parameters: {
25
25
  type: "object",
26
26
  properties: {