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
@@ -19,7 +19,9 @@ module Samagotchi
19
19
  # (chat messages, timeout:, max_tokens:, cancel_controller:) and returns
20
20
  # the answer text.
21
21
  # +messages_partial+ says whether +messages+ leaves out a running turn;
22
- # +model_name+ (the session's resolved model ref, live after /model) and
22
+ # +prompt_files+ returns the files whose text the built system prompt
23
+ # holds (SystemPrompt#prompt_files, [] before a build); +model_name+
24
+ # (the session's resolved model ref, live after /model) and
23
25
  # +state_dir+ are what ctx.sessions forks with, and +setup+ (the
24
26
  # session's own SessionSetup), which a fork copies; +model_key+ is the
25
27
  # model's memory overlay key (ctx.model, ctx.model_key).
@@ -28,8 +30,8 @@ module Samagotchi
28
30
  # +frontend+ returns the Engine's interface (:repl, :worker,
29
31
  # :non_interactive), which ctx.frontend names for plugins.
30
32
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
31
- :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :steer, :stop_turn, :stop_generation,
32
- :frontend, keyword_init: true)
33
+ :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
34
+ :steer, :stop_turn, :stop_generation, :frontend, keyword_init: true)
33
35
 
34
36
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
35
37
  # sent nothing usable. The message says why, for the user.
@@ -159,6 +161,17 @@ module Samagotchi
159
161
  # a plugin can say what its answer is about.
160
162
  def messages_partial? = !!@host.messages_partial&.call
161
163
 
164
+ # The files whose text the session's system prompt holds now, as
165
+ # absolute paths in prompt order (SystemPrompt#prompt_files): the
166
+ # AGENTS.md chain (the work tree top down to the cwd), then the
167
+ # appended files (append_system_prompt:, --append-system-prompt).
168
+ # [] before the first build and after one that drops them (a model
169
+ # or tools change). A Host without the member (a spec's) reads [].
170
+ # @return [Array<String>] frozen
171
+ def prompt_files
172
+ Array(@host.prompt_files&.call || []).freeze
173
+ end
174
+
162
175
  # Run +block+ as this plugin's handler for +event+: until it returns,
163
176
  # the helpers called on this thread act as the event's own. Nests (a
164
177
  # fire inside a handler gets its own event; the outer one is back
@@ -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,141 @@
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, one over its cap
14
+ # (MAX_ID, MAX_LABEL), or a URL that isn't an http(s) one with a host,
15
+ # at most MAX_URL characters
16
+ def initialize(id:, label:, url:)
17
+ id = id.to_s.strip
18
+ label = label.to_s.strip
19
+ url = url.to_s.strip
20
+ raise ArgumentError, "a ref action needs an id" if id.empty?
21
+ raise ArgumentError, "a ref action needs a label" if label.empty?
22
+ raise ArgumentError, "a ref action's id is at most #{RefAction::MAX_ID} characters" if id.length > RefAction::MAX_ID
23
+ raise ArgumentError, "a ref action's label is at most #{RefAction::MAX_LABEL} characters" if label.length > RefAction::MAX_LABEL
24
+ 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)
25
+
26
+ super
27
+ end
28
+ end
29
+
30
+ class RefAction
31
+ MAX_URL = 2_000
32
+ MAX_ID = 64
33
+ MAX_LABEL = 100
34
+
35
+ # @return [Boolean] +url+ is an http or https URL with a host and no
36
+ # userinfo (a "https://github.com@evil.example/" shows one host and
37
+ # opens another, and a token in it would be copied around)
38
+ def self.http_url?(url)
39
+ return false if url.length > MAX_URL
40
+
41
+ uri = URI.parse(url)
42
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && uri.userinfo.nil?
43
+ rescue URI::InvalidURIError
44
+ false
45
+ end
46
+
47
+ # @param value [RefAction, Hash] a plugin's {id:, label:, url:} (String
48
+ # or Symbol keys)
49
+ # @raise [ArgumentError] anything else, or an invalid action
50
+ def self.from(value)
51
+ return value if value.is_a?(RefAction)
52
+ raise ArgumentError, "a ref action is a Hash {id:, label:, url:}" unless value.is_a?(Hash)
53
+
54
+ value = value.transform_keys(&:to_sym)
55
+ new(id: value[:id], label: value[:label], url: value[:url])
56
+ end
57
+ end
58
+
59
+ # A ref as data: a kind, a target, an optional label and the plugins'
60
+ # extra actions (RefAction), for a UI to trace it. The kind's own actions
61
+ # are the UI's (a file ref keeps the editor, show in turn and copy), so
62
+ # +actions+ only adds to them.
63
+ # - +kind+ "file": +target+ {path:, line:, end_line:}, the lines only when
64
+ # positive, the end only past the start (FileRef's shape).
65
+ # - Any other kind ("issue", "commit", …): +target+ {id:}. The page shows
66
+ # its +label+ (or the text it matched) and runs its first action.
67
+ # +to_h+ is the shape the page reads (refs.js bindRef). FileRef stays the
68
+ # rows' own ref; this is the one a plugin gives.
69
+ Ref = Data.define(:kind, :target, :label, :actions) do
70
+ # @raise [ArgumentError] a kind that isn't a short lowercase word, a
71
+ # target without its kind's key, a non-list of actions, a bad action,
72
+ # or a value over its cap (MAX_LABEL, MAX_ACTIONS, MAX_ID, MAX_PATH)
73
+ def initialize(kind:, target:, label: nil, actions: [])
74
+ kind = kind.to_s
75
+ raise ArgumentError, "a ref's kind is a short lowercase word (#{kind.inspect})" unless Ref::KIND.match?(kind)
76
+ raise ArgumentError, "a ref's actions are a list" unless actions.nil? || actions.is_a?(Array)
77
+ raise ArgumentError, "a ref has at most #{Ref::MAX_ACTIONS} actions" if Array(actions).length > Ref::MAX_ACTIONS
78
+
79
+ label = label.to_s.strip
80
+ raise ArgumentError, "a ref's label is at most #{Ref::MAX_LABEL} characters" if label.length > Ref::MAX_LABEL
81
+
82
+ super(kind: kind, target: Ref.target_for(kind, target), label: label.empty? ? nil : label,
83
+ actions: Array(actions).map { |action| RefAction.from(action) }.freeze)
84
+ end
85
+
86
+ # @return [Hash] {kind:, target:, label:, actions: [{id:, label:, url:}]},
87
+ # no label when there is none
88
+ def to_h = { kind: kind, target: target, label: label, actions: actions.map(&:to_h) }.compact
89
+ end
90
+
91
+ class Ref
92
+ KIND = /\A[a-z][a-z0-9_-]{0,31}\z/
93
+ # FileRef::KIND, not required here (it loads the file tools).
94
+ FILE = "file"
95
+ # Caps, so a plugin can't bloat session.json or the page.
96
+ MAX_LABEL = 200
97
+ MAX_ACTIONS = 8
98
+ MAX_ID = 200
99
+ MAX_PATH = 4_096
100
+
101
+ # A plugin's {kind:, target:, label:, actions:} (String or Symbol keys).
102
+ # @raise [ArgumentError] as +new+
103
+ def self.from(value)
104
+ raise ArgumentError, "a ref is a Hash {kind:, target:, label:, actions:}" unless value.is_a?(Hash)
105
+
106
+ value = value.transform_keys(&:to_sym)
107
+ new(kind: value[:kind], target: value[:target], label: value[:label], actions: value[:actions] || [])
108
+ end
109
+
110
+ # +kind+'s target from +raw+: a file's path and lines, any other kind's id.
111
+ # @raise [ArgumentError] not a Hash, or without the kind's key
112
+ def self.target_for(kind, raw)
113
+ raise ArgumentError, "a ref's target is a Hash" unless raw.is_a?(Hash)
114
+
115
+ raw = raw.transform_keys(&:to_sym)
116
+ kind == FILE ? file_target(raw) : { id: required(raw[:id], :id, MAX_ID) }.freeze
117
+ end
118
+
119
+ def self.file_target(raw)
120
+ line = positive(raw[:line])
121
+ end_line = line && positive(raw[:end_line])
122
+ { path: required(raw[:path], :path, MAX_PATH), line: line, end_line: end_line && end_line > line ? end_line : nil }.compact.freeze
123
+ end
124
+ private_class_method :file_target
125
+
126
+ def self.required(value, key, max)
127
+ value = value.to_s.strip
128
+ raise ArgumentError, "a ref's target needs #{key}" if value.empty?
129
+ raise ArgumentError, "a ref's target #{key} is at most #{max} characters" if value.length > max
130
+
131
+ value
132
+ end
133
+ private_class_method :required
134
+
135
+ def self.positive(value)
136
+ number = Integer(value.to_s.strip, exception: false)
137
+ number&.positive? ? number : nil
138
+ end
139
+ private_class_method :positive
140
+ end
141
+ end
@@ -106,10 +106,14 @@ module Samagotchi
106
106
 
107
107
  if session.status == Session::STATUS_RUNNING
108
108
  seen_running = true
109
- elsif seen_running || turn_ended_since?(session, baseline)
109
+ elsif (seen_running && !baseline&.key?(:last_turn)) || turn_ended_since?(session, baseline)
110
110
  # It ran and is idle again with no new reply: canceled, failed or
111
111
  # empty. Before it was ever seen running, idle means a
112
- # file-delivered message its worker has not picked up yet.
112
+ # file-delivered message its worker has not picked up yet. With
113
+ # the baseline's last_turn only a turn that ended since counts:
114
+ # a session left running by a worker that died goes idle when the
115
+ # next worker settles that turn (TurnCheckpoint.settle), before
116
+ # the sent message's turn runs.
113
117
  # The worker writes the reply before its idle save, so the read
114
118
  # above has it; one more look costs nothing should that change.
115
119
  return reply_past(id, state_dir: state_dir, cursor: cursor)&.tap { |r| r.session = session } ||
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "time"
7
7
  require_relative "atomic_file"
8
8
  require_relative "context_note"
9
+ require_relative "interrupted_turn"
9
10
  require_relative "last_turn"
10
11
  require_relative "llm_context_override"
11
12
  require_relative "session_setup"
@@ -53,6 +54,15 @@ module Samagotchi
53
54
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
54
55
  # Names as given; the engine normalizes them.
55
56
  attr_accessor :preloaded_memory_names, :muted_memory_names
57
+ # The files chi --append-system-prompt put into the session's system
58
+ # prompt (absolute paths; config.yml's append_system_prompt: entries
59
+ # aren't stored): kept on respawns, copied by a fork and a continue
60
+ # (SessionSetup).
61
+ attr_reader :appended_prompt_files
62
+
63
+ def appended_prompt_files=(paths)
64
+ @appended_prompt_files = Array(paths).map(&:to_s).reject(&:empty?).uniq
65
+ end
56
66
  # The model notes the session's system prompt carried at its last build
57
67
  # (Array<PromptNote>: name, scope, chars, digest; ModelNotes), set by the
58
68
  # Engine at each build (a start, a resume's or a woken worker's first
@@ -98,6 +108,26 @@ module Samagotchi
98
108
  def last_turn=(value)
99
109
  @last_turn = LastTurn.from_file(value)
100
110
  end
111
+
112
+ # The mark of a turn a worker checkpointed and never finished
113
+ # (InterruptedTurn), as loaded; the next worker settles it and sets it
114
+ # to nil. While a turn runs, #turn_checkpoint's marker is saved instead.
115
+ attr_reader :interrupted_turn
116
+
117
+ # @param value [InterruptedTurn, Hash, nil]
118
+ def interrupted_turn=(value)
119
+ @interrupted_turn = InterruptedTurn.from_file(value)
120
+ end
121
+
122
+ # The running turn's checkpoint (TurnCheckpoint): {messages:, marker:}
123
+ # (marker an InterruptedTurn). While +status+ is running, every save
124
+ # writes these messages and the marker instead of #messages and
125
+ # #interrupted_turn, so a save in the middle of the turn (a question
126
+ # asked, the checkpoint's own) keeps the turn so far on disk; #messages
127
+ # itself is never touched. The turn's end sets status idle before it
128
+ # saves, so that save writes the real messages, without a marker. Not
129
+ # saved; the worker clears it after each turn.
130
+ attr_accessor :turn_checkpoint
101
131
  # The session's own llm_context values (LLMContextOverride: strategy,
102
132
  # apply rule, budget), before the model's; nil when it has none.
103
133
  # Saved, so a --resume and a respawned worker keep them; a plugin's
@@ -182,7 +212,8 @@ module Samagotchi
182
212
  used_memory_names: [], project_root: nil,
183
213
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
184
214
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
185
- continues: nil, thinking: nil, reasoning_history: nil)
215
+ continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
216
+ interrupted_turn: nil)
186
217
  @id = id
187
218
  @metadata_version = metadata_version
188
219
  @mode = mode
@@ -210,6 +241,9 @@ module Samagotchi
210
241
  self.llm_context = llm_context
211
242
  self.thinking = thinking
212
243
  self.reasoning_history = reasoning_history
244
+ self.appended_prompt_files = appended_prompt_files
245
+ self.interrupted_turn = interrupted_turn
246
+ @turn_checkpoint = nil
213
247
  @archived = false
214
248
  end
215
249
 
@@ -289,6 +323,7 @@ module Samagotchi
289
323
  llm_context: setup.llm_context,
290
324
  thinking: setup.thinking,
291
325
  reasoning_history: setup.reasoning_history,
326
+ appended_prompt_files: setup.appended_prompt_files,
292
327
  continues: continues
293
328
  )
294
329
  end
@@ -351,7 +386,9 @@ module Samagotchi
351
386
  "llm_context" => nil,
352
387
  "continues" => nil,
353
388
  "thinking" => nil,
354
- "reasoning_history" => nil
389
+ "reasoning_history" => nil,
390
+ "appended_prompt_files" => [],
391
+ "interrupted_turn" => nil
355
392
  }.freeze
356
393
 
357
394
  # A session from a parsed session file. +messages+ false leaves the
@@ -463,11 +500,15 @@ module Samagotchi
463
500
  self
464
501
  end
465
502
 
466
- # The session as #save writes it (FIELDS, in their order).
503
+ # The session as #save writes it (FIELDS, in their order); a running
504
+ # turn's checkpoint (#turn_checkpoint) in place of its messages.
467
505
  def to_h
506
+ checkpoint = checkpointed
468
507
  FIELDS.keys.to_h do |key|
469
508
  value = case key
470
- when "messages" then @messages.map { |msg| scrub_utf8(stringify_message_keys(msg)) }
509
+ when "messages"
510
+ (checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
511
+ when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
471
512
  when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
472
513
  when "test_run" then !!@test_run
473
514
  when "delegate" then @delegate
@@ -476,7 +517,7 @@ module Samagotchi
476
517
  when "reasoning_history" then reasoning_history_file
477
518
  when "prompt_notes" then @prompt_notes.map(&:to_file)
478
519
  when "last_turn" then @last_turn&.to_file
479
- when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
520
+ when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
480
521
  Array(instance_variable_get(:"@#{key}"))
481
522
  else instance_variable_get(:"@#{key}")
482
523
  end
@@ -484,6 +525,13 @@ module Samagotchi
484
525
  end
485
526
  end
486
527
 
528
+ # The running turn's checkpoint, while the turn runs (#turn_checkpoint).
529
+ def checkpointed
530
+ checkpoint = @turn_checkpoint
531
+ checkpoint if checkpoint && @status == STATUS_RUNNING
532
+ end
533
+ private :checkpointed
534
+
487
535
  # Mark a session as errored.
488
536
  def self.mark_error(session_id, reason:, state_dir: default_state_dir)
489
537
  session = load(session_id, state_dir: state_dir)
@@ -22,6 +22,7 @@ require_relative "turn_note"
22
22
  require_relative "owner_lock"
23
23
  require_relative "bridge_client"
24
24
  require_relative "worker_sidecar"
25
+ require_relative "worker_signals"
25
26
  require_relative "log"
26
27
  require_relative "log_path"
27
28
  require_relative "model_profile"
@@ -210,7 +211,8 @@ module Samagotchi
210
211
  # wakes the worker picks which config.yml it reads (docs/guardrails.md).
211
212
  private_class_method def self.spawn_options(session)
212
213
  # Own process group: workers outlive `chi web`, and a Ctrl-C in its
213
- # terminal must not reach them.
214
+ # terminal must not reach them (the worker then leaves the process
215
+ # tree too: WORKER_DETACH_ENV).
214
216
  opts = { out: File::NULL, err: File::NULL, pgroup: true }
215
217
  # The worker's tools (and `!cmd`) run in the session's directory, not
216
218
  # in the cwd of whoever woke it (`chi web`, another terminal).
@@ -253,6 +255,10 @@ module Samagotchi
253
255
  SESSION_MARKER_ENV.each { |key| child_env[key] = nil }
254
256
  # A smoke's fake "newest installed" is for this process's notices only.
255
257
  child_env[InstalledVersions::ENV_KEY] = nil
258
+ # A worker process: run_session_loop traps signals, and leaves this
259
+ # process's tree.
260
+ child_env[WORKER_PROCESS_ENV] = "1"
261
+ child_env[WORKER_DETACH_ENV] = "1"
256
262
  # A gem-mode worker activates the newest installed chi itself: under
257
263
  # `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
258
264
  # hold it to the bundle's version, and its badge would never clear.
@@ -688,8 +694,8 @@ module Samagotchi
688
694
  # so a resume right after spawns a fresh worker instead of finding the
689
695
  # dying one.
690
696
  # A turn still running is canceled first (STOP_CANCEL_WAIT), as the
691
- # web's Cancel does: its Engine saves the prompt, which the TERM alone
692
- # would lose with the worker.
697
+ # web's Cancel does: its worker saves it. The TERM stops (and saves)
698
+ # what still runs (WorkerSignals), with a short grace after the marker.
693
699
  # @return [Boolean, nil] with +wait+: whether the owner was gone in time
694
700
  # @raise [OwnedByTUI] when the interactive TUI owns the session
695
701
  def self.stop_session(session_id, state_dir: nil, wait: nil)
@@ -930,7 +936,8 @@ module Samagotchi
930
936
  # @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
931
937
  # @param poll_interval [Numeric, nil] seconds between the loop's fallback
932
938
  # ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
933
- # A stopped session's worker exits 0 and a crashed one 1, there.
939
+ # A stopped session's worker exits 0 (a signalled one too) and a
940
+ # crashed one 1, there.
934
941
  # @return [Symbol] :idle_exit, :exit_requested or :restart
935
942
  def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
936
943
  idle_exit_minutes: nil, poll_interval: nil)
@@ -942,6 +949,12 @@ module Samagotchi
942
949
  Log.configure(stderr: false)
943
950
  Log.session_id = session_id
944
951
  session_dir = Session.session_dir(session_id, state_dir: sd)
952
+ # Read (and taken out of ENV, so nothing the worker runs inherits it)
953
+ # before anything else: set by spawn_options for a spawned worker.
954
+ spawned = ENV.delete(WORKER_PROCESS_ENV) == "1"
955
+ # Out of the spawner's process tree before the lock (it records this
956
+ # process's pid) and before any thread starts.
957
+ detach_from_spawner(session_dir) if ENV.delete(WORKER_DETACH_ENV) == "1"
945
958
  # Kept in a class ivar so the lock's File lives as long as the worker.
946
959
  @owner_lock = OwnerLock.acquire(session_dir, kind: "worker", wait: owner_wait)
947
960
  exit(0) unless @owner_lock
@@ -949,8 +962,11 @@ module Samagotchi
949
962
  fallback = ENV.delete(BOOT_FALLBACK_ENV)
950
963
  Log.info(:worker, "start", cwd: Dir.pwd, version: VERSION)
951
964
  Log.warn(:worker, "boot_fallback", version: VERSION, error: fallback) if fallback
965
+ # A spawned worker stops its turn on TERM/HUP/INT and saves it; a
966
+ # loop run in-process (specs) traps nothing.
967
+ signals = spawned ? WorkerSignals.new.install! : WorkerSignals::NONE
952
968
  worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
953
- idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval)
969
+ idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval, signals: signals)
954
970
  result = begin
955
971
  worker.run
956
972
  rescue StandardError, ScriptError => e
@@ -959,11 +975,19 @@ module Samagotchi
959
975
  raise
960
976
  ensure
961
977
  @owner_lock.release
978
+ # A signal from here on ends the process as Ruby's default does.
979
+ signals.restore!
962
980
  end
963
981
  Log.info(:worker, "stop", reason: result)
964
982
  # A stopped session is left as it is: no resume for input that came
965
- # in, no discard.
983
+ # in, no discard. One whose worker a signal stopped isn't discarded
984
+ # either; input that came in (queued behind the turn it stopped)
985
+ # wakes a new worker, as after an idle exit.
966
986
  exit(0) if result == :stopped
987
+ if result == :signal
988
+ resume_session(session_id, state_dir: sd) unless SessionInbox.find_new_input_files(session_dir).empty?
989
+ exit(0)
990
+ end
967
991
  exit(1) if result == :crashed
968
992
  # Only after the release: a writer that still saw this worker as the
969
993
  # owner may have queued input since the last check. Either it finds no
@@ -981,6 +1005,31 @@ module Samagotchi
981
1005
  result
982
1006
  end
983
1007
 
1008
+ # A spawned worker leaves its spawner's process tree: a killer that
1009
+ # walks the tree by parent pid (a terminal closing, a tool's teardown
1010
+ # of a `chi send --wait` it ran in the background) no longer reaches it.
1011
+ # This process (the spawner's child, which its Process.detach waits on)
1012
+ # forks the worker, which starts a session of its own (setsid: no
1013
+ # controlling terminal, no SIGHUP from one) and returns to run the loop;
1014
+ # this one waits until the worker holds the session's OwnerLock, or has
1015
+ # exited, or WORKER_START_GRACE passed, and exits: the spawner's waiter
1016
+ # stays alive exactly while the worker starts (#still_starting?). The
1017
+ # worker is reparented to init (launchd), which reaps it.
1018
+ private_class_method def self.detach_from_spawner(session_dir)
1019
+ worker = Process.fork
1020
+ unless worker
1021
+ Process.setsid
1022
+ return
1023
+ end
1024
+
1025
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_START_GRACE
1026
+ until Process.waitpid(worker, Process::WNOHANG) || OwnerLock.owner(session_dir)&.pid == worker ||
1027
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
1028
+ sleep(OwnerLock::RETRY_INTERVAL)
1029
+ end
1030
+ Process.exit!(0)
1031
+ end
1032
+
984
1033
  # Delete a session its worker left empty. Checked again now the lock is
985
1034
  # free: a note or input may have come in since the worker looked, and a
986
1035
  # worker woken meanwhile owns it (delete_session refuses). A `chi send`
@@ -1024,6 +1073,13 @@ module Samagotchi
1024
1073
  # format are read by a NEWER chi than the one writing them. Change them
1025
1074
  # only additively (spec/worker_contract_spec.rb).
1026
1075
  BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
1076
+ # "1" in a worker spawn_options started: it traps signals (WorkerSignals).
1077
+ # run_session_loop deletes it from ENV as it reads it.
1078
+ WORKER_PROCESS_ENV = "SAMAGOTCHI_WORKER_PROCESS"
1079
+ # "1" in a worker spawn_options started: run_session_loop leaves the
1080
+ # spawner's process tree first (detach_from_spawner); deleted from ENV
1081
+ # as it is read, so an rspec run by the worker's execute never forks.
1082
+ WORKER_DETACH_ENV = "SAMAGOTCHI_WORKER_DETACH"
1027
1083
 
1028
1084
  def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec)
1029
1085
  boot = "require 'samagotchi/session_manager'; " \
@@ -1046,6 +1102,8 @@ module Samagotchi
1046
1102
  command = worker_command(session.id, state_dir: state_dir)
1047
1103
  pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
1048
1104
  # Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
1105
+ # +pid+ is the worker's starter, which exits once the worker it forked
1106
+ # holds the lock (detach_from_spawner): the lock's pid is the worker's.
1049
1107
  note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
1050
1108
  Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
1051
1109
  pid
@@ -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