samagotchi 0.59.0 → 0.61.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/README.md +3 -2
  4. data/bin/chi +23 -2
  5. data/docs/architecture.md +11 -6
  6. data/docs/cli.md +9 -3
  7. data/docs/configuration.md +62 -12
  8. data/docs/hooks.md +82 -5
  9. data/docs/internals/engine.md +50 -13
  10. data/docs/internals/gemma4-contract.md +1 -0
  11. data/docs/internals/prompt-caching.md +6 -3
  12. data/docs/memory.md +1 -1
  13. data/docs/plugins.md +57 -4
  14. data/docs/sessions.md +7 -3
  15. data/docs/sub-agent.md +2 -2
  16. data/docs/testing.md +4 -2
  17. data/lib/samagotchi/answer_display.rb +4 -3
  18. data/lib/samagotchi/answer_refs.rb +210 -0
  19. data/lib/samagotchi/bootstrap_command.rb +2 -1
  20. data/lib/samagotchi/bridge/card_store.rb +6 -1
  21. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  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/check-in/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  26. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  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 +33 -5
  30. data/lib/samagotchi/continue_offer.rb +5 -2
  31. data/lib/samagotchi/engine.rb +101 -23
  32. data/lib/samagotchi/hooks/registry.rb +3 -1
  33. data/lib/samagotchi/interrupted_turn.rb +39 -0
  34. data/lib/samagotchi/iteration_limit.rb +3 -2
  35. data/lib/samagotchi/kernel_loop.rb +40 -12
  36. data/lib/samagotchi/llm/backend.rb +6 -1
  37. data/lib/samagotchi/llm/chat_loop.rb +16 -8
  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 +8 -4
  41. data/lib/samagotchi/plugin/context.rb +16 -3
  42. data/lib/samagotchi/prompt_files.rb +127 -0
  43. data/lib/samagotchi/ref.rb +141 -0
  44. data/lib/samagotchi/reply_wait.rb +6 -2
  45. data/lib/samagotchi/session.rb +53 -5
  46. data/lib/samagotchi/session_manager.rb +64 -6
  47. data/lib/samagotchi/session_metrics.rb +8 -2
  48. data/lib/samagotchi/session_setup.rb +11 -5
  49. data/lib/samagotchi/system_prompt.rb +71 -15
  50. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
  51. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  52. data/lib/samagotchi/terminal_ui.rb +21 -5
  53. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  54. data/lib/samagotchi/turn_flow.rb +22 -3
  55. data/lib/samagotchi/version.rb +1 -1
  56. data/lib/samagotchi/web/app.rb +5 -0
  57. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  58. data/lib/samagotchi/web/public/app.js +15 -18
  59. data/lib/samagotchi/web/public/favicon.svg +1 -0
  60. data/lib/samagotchi/web/public/index.html +6 -3
  61. data/lib/samagotchi/web/public/ref_menu.js +9 -1
  62. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  63. data/lib/samagotchi/web/public/refs.js +129 -15
  64. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  65. data/lib/samagotchi/web/public/turn_view.js +17 -7
  66. data/lib/samagotchi/worker.rb +169 -21
  67. data/lib/samagotchi/worker_signals.rb +81 -0
  68. metadata +11 -1
@@ -11,6 +11,7 @@ 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
@@ -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 = "AGENTS.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
 
@@ -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
- # AGENTS.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
@@ -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
@@ -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
@@ -404,6 +405,16 @@ module Samagotchi
404
405
  "#{"~" if source.to_s == "estimate"}#{number} tok/s"
405
406
  end
406
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
+
407
418
  # "~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)":
408
419
  # the memory indexes the session's prompt holds (the snapshot's
409
420
  # memory_index, symbol or string keys); "" without one. As ctx.js
@@ -600,6 +611,9 @@ module Samagotchi
600
611
  # The model notes the session's prompt carried (Engine#prompt_notes).
601
612
  notes = PromptNote.text(snapshot[:prompt_notes])
602
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
603
617
  llm_context = llm_context_stats_text(snapshot[:llm_context])
604
618
  lines << "llm context: #{llm_context}" if llm_context
605
619
  thinking = setting_stats_text(snapshot[:thinking])
@@ -9,6 +9,7 @@ require_relative "client_id"
9
9
  require_relative "iteration_limit"
10
10
  require_relative "model_profile"
11
11
  require_relative "turn_note"
12
+ require_relative "turn_checkpoint"
12
13
  require_relative "config"
13
14
  require_relative "context_note"
14
15
  require_relative "cancellation_controller"
@@ -92,8 +93,12 @@ module Samagotchi
92
93
  # (LLMContextOverride.update): set on the new or resumed session
93
94
  # @param thinking [String, nil] --thinking as typed: the new or resumed
94
95
  # session's own level (default unsets it)
96
+ # @param appended_prompt_files [Array<String>] --append-system-prompt's
97
+ # files (absolute): a new session saves them; a resumed one's own come
98
+ # first, and the merge goes only to the Engine (as --memory's does)
95
99
  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)
100
+ spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
101
+ appended_prompt_files: [])
97
102
  @scratch = scratch
98
103
  @thinking_word = thinking
99
104
  @prompt = prompt
@@ -142,9 +147,11 @@ module Samagotchi
142
147
  # session started attached with mutes keeps them here.
143
148
  @requested_memories = Array(memories)
144
149
  @muted_memory_names = Array(muted_memories)
150
+ @appended_prompt_files = Array(appended_prompt_files)
145
151
  if @resume_session
146
152
  @requested_memories = (@resume_session.preloaded_memory_names + @requested_memories).uniq
147
153
  @muted_memory_names = (@resume_session.muted_memory_names + @muted_memory_names).uniq
154
+ @appended_prompt_files = (@resume_session.appended_prompt_files + @appended_prompt_files).uniq
148
155
  end
149
156
  # Every terminal write goes through the surface. The REPL swaps in a
150
157
  # live region when the terminal can show one (#assist_loop), unless it
@@ -178,6 +185,7 @@ module Samagotchi
178
185
  model_typed: no_default ? @resumed_model_typed : nil,
179
186
  memories: @requested_memories,
180
187
  muted_memories: @muted_memory_names,
188
+ appended_prompt_files: @appended_prompt_files,
181
189
  recap: scratch ? nil : recap_config,
182
190
  scratch: scratch,
183
191
  reminders: {
@@ -258,7 +266,8 @@ module Samagotchi
258
266
  working_directory: Dir.pwd,
259
267
  preloaded_memory_names: @requested_memories,
260
268
  muted_memory_names: @muted_memory_names,
261
- scratch: @scratch
269
+ scratch: @scratch,
270
+ setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files)
262
271
  ))
263
272
  # --llm-context*: the session's own values, over a resumed one's.
264
273
  # Saved at once on a resumed one: leaving before a turn keeps them.
@@ -269,6 +278,9 @@ module Samagotchi
269
278
  session.save if @resume_session
270
279
  end
271
280
  claim_session!(session.id) unless @owner_lock
281
+ # A turn a worker died in (TurnCheckpoint): ended as canceled, with its
282
+ # note, before this REPL's first turn appends after it.
283
+ session.save if @resume_session && TurnCheckpoint.settle(session)
272
284
  keep_cards(session)
273
285
  # Saved at once, so a process killed before its first turn leaves a
274
286
  # file the sweep knows to delete.
@@ -628,7 +640,7 @@ module Samagotchi
628
640
  # Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
629
641
  # Engine#run_turn, rendering via @renderer.
630
642
  # @param images [Array<Hash>] the prompt's `@path` images ({path:})
631
- def run_engine_turn(session, prompt, continue: false, max_iterations: IterationLimit.for, images: [])
643
+ def run_engine_turn(session, prompt, continue: false, max_iterations: IterationLimit.for, images: [], resumed: false, resume_from: nil)
632
644
  cancellation_controller = CancellationController.new
633
645
  @active_cancel_controller = cancellation_controller
634
646
  @renderer.begin_turn
@@ -646,7 +658,9 @@ module Samagotchi
646
658
  cancel_controller: cancellation_controller,
647
659
  pending_input: method(:drain_steering),
648
660
  continue: continue,
649
- images: images
661
+ images: images,
662
+ resumed: resumed,
663
+ resume_from: resume_from
650
664
  )
651
665
  end
652
666
  rescue Interrupt
@@ -774,9 +788,11 @@ module Samagotchi
774
788
  end
775
789
  return show_command_result(answer) unless answer.resume
776
790
 
791
+ # The run the offer's turn started: the Continue keeps its reasoning.
792
+ resume_from = @turn_flow.resume_from
777
793
  @turn_flow.before_continue_turn
778
794
  begin
779
- result = run_engine_turn(session, nil, continue: true)
795
+ result = run_engine_turn(session, nil, continue: true, resumed: true, resume_from: resume_from)
780
796
  rescue LLM::ProviderError
781
797
  # The renderer showed the failure (:turn_failed).
782
798
  @surface.commit(turn_end_hint("continue prompt preserved"))
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "interrupted_turn"
5
+ require_relative "log"
6
+ require_relative "session"
7
+ require_relative "steer"
8
+ require_relative "turn_note"
9
+
10
+ module Samagotchi
11
+ # A worker's running turn, kept on disk at the loop's iteration
12
+ # boundaries, so a worker killed mid-turn (SIGKILL, a crash of the
13
+ # process) loses at most INTERVAL seconds of it instead of all of it.
14
+ #
15
+ # At each boundary (Worker#pending_input_drain, on the turn's thread) the
16
+ # turn's live conversation (Engine#live_conversation) is copied, with the
17
+ # lines that boundary merges (their input files are deleted as they are
18
+ # claimed), into the session's overlay (Session#turn_checkpoint), marked
19
+ # with an InterruptedTurn. Every save while the turn runs writes the
20
+ # overlay instead of the session's messages: this one's, written at the
21
+ # first boundary (the prompt reaches the disk) and then at most every
22
+ # INTERVAL (or when the boundary merged input), and any other save in the
23
+ # turn (a question asked). The session's in-memory messages never change
24
+ # (a Bridge snapshot shows the turn in progress or in the messages, never
25
+ # both). The turn's end saves the real messages without a marker.
26
+ #
27
+ # The copy is what a Stop at that boundary would keep, minus its note, so
28
+ # the history before the turn is never rewritten (prompt caching); later
29
+ # steps of the same turn may still edit their own entries in place.
30
+ class TurnCheckpoint
31
+ INTERVAL = 10.0
32
+
33
+ # A session loaded with an InterruptedTurn mark (its worker died in the
34
+ # turn): the turn ends now, as canceled. Its note goes at the tail
35
+ # (append-only: the model reads on its next turn that the previous one
36
+ # was cut, after how long), the status goes idle and the mark goes. The
37
+ # dead turn is not run again. The caller saves.
38
+ #
39
+ # last_turn is left as it was: a new last_turn saved idle here, before
40
+ # the turn a `chi send --wait` started this worker for, would end that
41
+ # wait at once as the sent turn's (ReplyWait compares last_turn), with
42
+ # the dead turn's outcome.
43
+ # @return [InterruptedTurn, nil] the mark settled, nil when there was none
44
+ def self.settle(session)
45
+ mark = session.interrupted_turn
46
+ return nil unless mark
47
+
48
+ messages = Array(session.messages)
49
+ # How long it ran up to its last checkpoint (it died later); none
50
+ # when that was its first boundary ("after 0s" would mislead).
51
+ seconds = mark.seconds
52
+ seconds = nil if seconds && seconds < 1
53
+ note = TurnNote.cancelled(:worker_stopped, seconds: seconds, shown: TurnNote.interrupted_tail?(messages))
54
+ session.messages = messages + [note]
55
+ session.status = Session::STATUS_IDLE if session.status == Session::STATUS_RUNNING
56
+ session.interrupted_turn = nil
57
+ mark
58
+ end
59
+
60
+ # @param session [Session] the worker's session (saved as a whole)
61
+ # @param clock [#call] monotonic seconds (specs)
62
+ def initialize(session:, state_dir:, interval: INTERVAL, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
63
+ @session = session
64
+ @state_dir = state_dir
65
+ @interval = interval
66
+ @clock = clock
67
+ @boundaries = 0
68
+ @written_at = nil
69
+ @started_at = nil
70
+ end
71
+
72
+ # A turn begins (before Engine#run_turn and the worker's running save):
73
+ # the overlay starts as the history plus +prompt+ (text only: no
74
+ # images, no turn id), marked at iteration 0, so a worker killed before
75
+ # the loop's first boundary leaves the prompt and a mark the next one
76
+ # settles. A continue turn (no prompt) is the history, marked.
77
+ # @param prompt [String, nil]
78
+ def begin_turn(prompt: nil)
79
+ @boundaries = 0
80
+ @written_at = nil
81
+ @started_at = Time.now.iso8601(3)
82
+ messages = Array(@session.messages).dup
83
+ messages << { role: "user", content: prompt } unless prompt.to_s.empty?
84
+ @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0) }
85
+ end
86
+
87
+ # The turn ended (or never began): the session saves its own messages.
88
+ def end_turn
89
+ @session.turn_checkpoint = nil
90
+ end
91
+
92
+ # An iteration boundary of the running turn.
93
+ # @param conversation [Array<Hash>, nil] the loop's live conversation;
94
+ # nil (no turn): nothing
95
+ # @param merging [Array<Steer::Line>] the lines this boundary merges
96
+ # (appended after the conversation, as Steer.inject! will)
97
+ # @param turn_id [String, nil]
98
+ # @return [Boolean] whether the session file was written
99
+ def boundary(conversation, merging: [], turn_id: nil)
100
+ return false unless conversation
101
+
102
+ @boundaries += 1
103
+ merged = merging.empty? ? [] : Steer.merge(merging).messages
104
+ # One level deep: the loop may edit an entry in place later (a
105
+ # forget stub) while a save on another thread serializes this one.
106
+ messages = conversation.map { |message| message.is_a?(Hash) ? message.dup : message } + merged
107
+ marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries)
108
+ @session.turn_checkpoint = { messages: messages, marker: marker }
109
+ return false unless due? || !merged.empty?
110
+
111
+ write
112
+ end
113
+
114
+ private
115
+
116
+ def due? = @written_at.nil? || @clock.call - @written_at >= @interval
117
+
118
+ def write
119
+ @session.save(state_dir: @state_dir)
120
+ @written_at = @clock.call
121
+ true
122
+ rescue StandardError => e
123
+ # The turn goes on; the next boundary tries again.
124
+ Log.warn(:worker, "checkpoint_failed", error: e.class.name, msg: e.message)
125
+ false
126
+ end
127
+ end
128
+ end
@@ -42,8 +42,9 @@ module Samagotchi
42
42
  [:invalid, nil]
43
43
  end
44
44
 
45
- # @return [Hash, nil] the pending continue offer: {context:, no_interrupt:}
46
- # (context summarizes the interrupted turn for a "no, <reason>")
45
+ # @return [Hash, nil] the pending continue offer: {context:, no_interrupt:,
46
+ # run_start:, model:} (context summarizes the interrupted turn for a
47
+ # "no, <reason>"; run_start and model are #resume_from's)
47
48
  attr_reader :offer
48
49
 
49
50
  # How many times the conversation went back to a checkpoint (a failed
@@ -94,7 +95,11 @@ module Samagotchi
94
95
  end
95
96
 
96
97
  if result.resumable?
97
- @offer = { context: interrupted_turn_context(result), no_interrupt: no_interrupt }
98
+ # A Continue that ran out again goes on from the run the chain
99
+ # started; any other turn's offer (a prompt's, a wake's) from its own.
100
+ run_start = (continue && resume_from) || result.run_start
101
+ @offer = { context: interrupted_turn_context(result), no_interrupt: no_interrupt, run_start: run_start,
102
+ model: @engine.effective_model_name }
98
103
  :continue_offered
99
104
  else
100
105
  @offer = nil
@@ -143,6 +148,20 @@ module Samagotchi
143
148
  :restored
144
149
  end
145
150
 
151
+ # Where a Continue of the pending offer resumes: the run its turn (or
152
+ # the first turn of a chain of Continues) started (ModelResult#run_start),
153
+ # so the Continue keeps that run's reasoning (Engine#run_turn
154
+ # resume_from:). nil with no offer, and after a model change while the
155
+ # offer stands: another model's reasoning isn't replayed, the Continue
156
+ # starts cold. A cancelled or failed Continue keeps the offer, and so
157
+ # this; every way the offer goes takes it along.
158
+ # @return [Integer, nil]
159
+ def resume_from
160
+ return nil unless @offer && @offer[:model] == @engine.effective_model_name
161
+
162
+ @offer[:run_start]
163
+ end
164
+
146
165
  # Answer the offer with no (Stop): the interrupted turn stays, as
147
166
  # after a Ctrl-C or a new prompt (!rollback still erases it), and the
148
167
  # model reads that it wasn't continued: a note, or the user's reason
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.59.0"
4
+ VERSION = "0.61.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -13,6 +13,7 @@ require_relative "../analytics_file"
13
13
  require_relative "../client_id"
14
14
  require_relative "../delivery"
15
15
  require_relative "../answer_tail"
16
+ require_relative "../answer_refs"
16
17
  require_relative "../bridge_client"
17
18
  require_relative "../bridge/bounded_queue"
18
19
  require_relative "../bridge/card_store"
@@ -1907,6 +1908,10 @@ module Samagotchi
1907
1908
  message[:display] = shown unless shown.strip.empty?
1908
1909
  rendered = message[:display] || stripped
1909
1910
  message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
1911
+ # The refs after_turn hooks attached (AnswerRefs), checked again:
1912
+ # a stored file is never trusted. Never raises.
1913
+ refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"])
1914
+ message[:display_refs] = refs unless refs.empty?
1910
1915
  end
1911
1916
  message[:parts] = did if did
1912
1917
  filtered << message