samagotchi 0.154.0 → 0.154.1

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -1
  3. data/docs/cli.md +9 -7
  4. data/docs/internals/bridge.md +18 -5
  5. data/docs/internals/engine.md +1 -1
  6. data/docs/internals/prompt-caching.md +3 -1
  7. data/docs/internals/web-frontend.md +2 -2
  8. data/docs/sessions.md +1 -1
  9. data/docs/testing.md +15 -0
  10. data/lib/samagotchi/answer_text.rb +1 -2
  11. data/lib/samagotchi/bridge/card_places.rb +56 -7
  12. data/lib/samagotchi/bridge/card_store.rb +24 -9
  13. data/lib/samagotchi/bridge.rb +9 -0
  14. data/lib/samagotchi/bridge_client.rb +9 -5
  15. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  16. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  18. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  20. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  22. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
  26. data/lib/samagotchi/engine_snapshots.rb +9 -8
  27. data/lib/samagotchi/idle_recap.rb +3 -1
  28. data/lib/samagotchi/kernel_loop.rb +4 -3
  29. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  30. data/lib/samagotchi/session_manager.rb +8 -13
  31. data/lib/samagotchi/session_retention.rb +24 -4
  32. data/lib/samagotchi/steer.rb +12 -1
  33. data/lib/samagotchi/stream_event.rb +22 -0
  34. data/lib/samagotchi/system_prompt.rb +52 -36
  35. data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
  36. data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
  37. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  38. data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
  39. data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
  40. data/lib/samagotchi/tool_runner.rb +2 -1
  41. data/lib/samagotchi/turn_flow.rb +7 -2
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/app.rb +3 -1
  44. data/lib/samagotchi/web/message_display.rb +7 -0
  45. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  46. data/lib/samagotchi/web/public/cards_place.js +5 -3
  47. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  48. data/lib/samagotchi/web/public/format.js +3 -2
  49. data/lib/samagotchi/web/public/turn_html.js +8 -4
  50. data/lib/samagotchi/web/session_access_routes.rb +8 -0
  51. data/lib/samagotchi/web/session_show.rb +4 -0
  52. data/lib/samagotchi/worker.rb +8 -5
  53. data/lib/samagotchi.rb +0 -1
  54. metadata +3 -2
  55. data/lib/samagotchi/pending_input_queue.rb +0 -55
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "degrade"
3
4
  require_relative "session_setting"
4
5
  require_relative "token_usage"
5
6
 
@@ -171,16 +172,16 @@ module Samagotchi
171
172
  # doesn't count. Without a model (EffectiveModel), no probe. A remote
172
173
  # chat host has no /props: nil until a turn. The name asked is never
173
174
  # resolved (HostRegistry#bare_name), so it holds on a broken resolve.
174
- def served_model_for(snapshot, model: nil)
175
- asked = bare_model_name(model ? model.name : effective_model_name)
176
- return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
177
- return [nil, nil] unless model
178
-
179
- model.served_from_props(asked)
180
175
  # Only bare_model_name and the snapshot lookup are left to fail here:
181
176
  # the probe's failure is EffectiveModel#served_from_props's.
182
- rescue StandardError
183
- [nil, nil]
177
+ def served_model_for(snapshot, model: nil)
178
+ Degrade.call(:model, "served_model_unreadable", fallback: [nil, nil]) do
179
+ asked = bare_model_name(model ? model.name : effective_model_name)
180
+ return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
181
+ return [nil, nil] unless model
182
+
183
+ model.served_from_props(asked)
184
+ end
184
185
  end
185
186
 
186
187
  # The next turn's kit prompt text for /stats: its kit and its estimated
@@ -473,7 +473,9 @@ module Samagotchi
473
473
  parsed = safe_parse(@engine.messages_json_for_recap)
474
474
  return :too_short if parsed.nil?
475
475
 
476
- user_turns = parsed.count { |message| Steer.prompt?(message) }
476
+ # The turns as the page counts them (Steer.turn_opening?): not Qwen's
477
+ # recovery line, nor input merged into a running turn.
478
+ user_turns = parsed.count { |message| Steer.turn_opening?(message) }
477
479
  if parsed.empty? || user_turns < @min_user_turns
478
480
  drop_stale(parsed)
479
481
  return :too_short
@@ -18,8 +18,8 @@ require_relative "cut"
18
18
  require_relative "cut_policy"
19
19
  require_relative "thinking"
20
20
  require_relative "hooks"
21
- require_relative "pending_input_queue"
22
21
  require_relative "steer"
22
+ require_relative "stream_event"
23
23
  require_relative "reasoning_history"
24
24
  require_relative "thought_stream_splitter"
25
25
  require_relative "tools/builtins"
@@ -726,7 +726,8 @@ module Samagotchi
726
726
  turn.pending_tool_calls = false
727
727
  if turn.qwen_partial && turn.qwen_attempts < QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT
728
728
  turn.qwen_attempts += 1
729
- turn.conversation << { role: "user", content: QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT, preserve_literals: true }
729
+ turn.conversation << { role: "user", kind: Steer::RECOVERY_KIND, content: QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT,
730
+ preserve_literals: true }
730
731
  return :next
731
732
  end
732
733
 
@@ -834,7 +835,7 @@ module Samagotchi
834
835
 
835
836
  # A failing stream callback (a UI gone) must not break the turn.
836
837
  def emit_stream_event(callback, event)
837
- Degrade.call(:turn, "stream_event_callback_failed", type: (event[:type] if event.is_a?(Hash))) { callback&.call(event) }
838
+ StreamEvent.emit(callback, event, failure: "stream_event_callback_failed")
838
839
  end
839
840
 
840
841
  # ── Hook dispatch helper ───────────────────────────────────────────────────
@@ -432,7 +432,8 @@ module Samagotchi
432
432
  # Array), plus a model turn's tool_calls and thinking (the host's
433
433
  # reasoning, sent back as #wire_messages says), a result's tool_call_id, the image
434
434
  # refs of a user message or a tool result, a prompt's turn_id and its
435
- # Verify/Revise anchors (Engine, never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
435
+ # Verify/Revise anchors (Engine, never sent), an opening's opening_id
436
+ # (TurnFlow#abort_continue!'s reason line, never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
436
437
  # (the live row's params line and label, never sent back), an
437
438
  # edit/write result's tool_diffs (never sent), an empty-answer
438
439
  # note's marker (TurnNote.empty, never sent), and an answer's display
@@ -448,6 +449,7 @@ module Samagotchi
448
449
  message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
449
450
  message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
450
451
  message[:turn_id] = entry[:turn_id] if entry[:turn_id]
452
+ message[:opening_id] = entry[:opening_id] if entry[:opening_id].is_a?(String)
451
453
  message[:turn_start] = true if entry[:turn_start] == true
452
454
  message[:verify] = entry[:verify] if entry[:verify].is_a?(Hash)
453
455
  message[:revises] = entry[:revises] if entry[:revises].is_a?(Hash)
@@ -33,6 +33,7 @@ require_relative "worker_idle_exit"
33
33
  require_relative "worker_signals"
34
34
  require_relative "chi_invocation"
35
35
  require_relative "log"
36
+ require_relative "degrade"
36
37
  require_relative "log_path"
37
38
  require_relative "paths"
38
39
  require_relative "project_env"
@@ -282,7 +283,7 @@ module Samagotchi
282
283
  child_env[ModelProfile::MODEL_FROM_CLI_ENV] = "1" if child_env.key?(ModelProfile::MODEL_ENV)
283
284
  # And the spawner's debug log, absolute: a relative log.file would
284
285
  # otherwise land in the worker's (the session's) directory.
285
- log_path = begin LogPath.resolve rescue nil end
286
+ log_path = begin LogPath.resolve rescue nil end # degrade-ok: no log to say it in; the worker's is off (below)
286
287
  if log_path
287
288
  child_env["SAMAGOTCHI_LOG_FILE"] = log_path
288
289
  else
@@ -936,9 +937,7 @@ module Samagotchi
936
937
  # session.keep_empty off: sessions left with nothing in them are deleted
937
938
  # (the worker as it exits, the retention sweep).
938
939
  def self.discard_empty?
939
- Samagotchi::Config.get("session.keep_empty") != true
940
- rescue StandardError
941
- false
940
+ Degrade.call(:config, "keep_empty_unreadable", fallback: false) { Samagotchi::Config.get("session.keep_empty") != true }
942
941
  end
943
942
 
944
943
  # Whether a session being left (or found left) is deleted: session.keep_empty
@@ -977,9 +976,9 @@ module Samagotchi
977
976
  return true if one.to_s == other.to_s
978
977
  return false if one.nil? || other.nil?
979
978
 
980
- ConfigFile.model_ref(one).ref == ConfigFile.model_ref(other).ref
981
- rescue StandardError
982
- false
979
+ Degrade.call(:config, "model_ref_unresolved", fallback: false) do
980
+ ConfigFile.model_ref(one).ref == ConfigFile.model_ref(other).ref
981
+ end
983
982
  end
984
983
 
985
984
  # Only the system prompt (an Engine seeds one, a saved session keeps
@@ -1281,9 +1280,7 @@ module Samagotchi
1281
1280
  end
1282
1281
 
1283
1282
  def self.config_idle_exit_minutes
1284
- Samagotchi::Config.get("session.idle_exit_minutes")
1285
- rescue StandardError
1286
- nil
1283
+ Degrade.call(:config, "idle_exit_minutes_unreadable") { Samagotchi::Config.get("session.idle_exit_minutes") }
1287
1284
  end
1288
1285
 
1289
1286
  private_class_method def self.setup_session_directory(session_dir, session, state_dir:)
@@ -1640,9 +1637,7 @@ module Samagotchi
1640
1637
  end
1641
1638
 
1642
1639
  private_class_method def self.delivery_owner(manager, session_id, state_dir)
1643
- manager.session_owner(session_id, state_dir: state_dir)
1644
- rescue StandardError
1645
- nil
1640
+ Degrade.call(:worker, "owner_unreadable", sid: session_id) { manager.session_owner(session_id, state_dir: state_dir) }
1646
1641
  end
1647
1642
 
1648
1643
  def self.stopped_on_disk?(session_id, state_dir:)
@@ -18,7 +18,7 @@ module Samagotchi
18
18
  # (session.retention_days), count (session.max_count, counting only the
19
19
  # sessions that stay), statuses kept (session.keep_status), sessions left
20
20
  # empty (SessionManager.discardable?, an hour on), leftover scratch
21
- # sessions and skeleton-only directories; a session with a live owner
21
+ # sessions (once past SCRATCH_START_GRACE) and skeleton-only directories; a session with a live owner
22
22
  # always stays.
23
23
  module SessionRetention
24
24
  # The session.retention_days / max_count / keep_status defaults.
@@ -93,6 +93,16 @@ module Samagotchi
93
93
  end
94
94
  end
95
95
 
96
+ # How long after its save an unowned scratch session may still be
97
+ # waiting for the worker just spawned for it to take its owner lock.
98
+ SCRATCH_START_GRACE = SessionManager::WORKER_START_GRACE
99
+
100
+ private_class_method def self.scratch_starting?(path, now)
101
+ now - File.mtime(path) < SCRATCH_START_GRACE
102
+ rescue SystemCallError
103
+ false
104
+ end
105
+
96
106
  # Lazy sweep guard: runs prune at most once per SWEEP_INTERVAL_HOURS
97
107
  # (session.sweep_interval_hours).
98
108
  MARKER = ".last_retention"
@@ -162,7 +172,9 @@ module Samagotchi
162
172
  # count (`chi sessions clean` with no --days: test runs are throwaway).
163
173
  #
164
174
  # A scratch session nobody owns (its terminal and worker were killed) goes whatever its
165
- # age, its status and the count; +test_only+ takes it too.
175
+ # age, its status and the count; +test_only+ takes it too. One saved
176
+ # within SCRATCH_START_GRACE (its file's mtime, against +now+) stays: its
177
+ # worker may not have taken the owner lock yet.
166
178
  #
167
179
  # +max_count+ counts the sessions that stay: one deleted whatever its
168
180
  # place (scratch, left empty, expired, +any_age+) takes no slot.
@@ -173,7 +185,7 @@ module Samagotchi
173
185
  # @return [Hash] { deleted: [ids], kept: [ids], skipped: [ids] }
174
186
  def self.apply(state_dir: Session.default_state_dir, days: DEFAULT_RETENTION_DAYS, max_count: DEFAULT_MAX_COUNT,
175
187
  keep_status: DEFAULT_KEEP_STATUS, dry_run: false, test_only: false, alive_check: nil,
176
- empty_check: nil, any_age: false)
188
+ empty_check: nil, any_age: false, now: Time.now)
177
189
  keep_status = Array(keep_status).map(&:to_s)
178
190
  # Fetch all sessions sorted newest-first for count logic
179
191
  all = Session.list(state_dir: state_dir, sort: "updated_at", order: "desc")
@@ -182,7 +194,6 @@ module Samagotchi
182
194
  all = all.select { |session| session.test_run || session.scratch }
183
195
  end
184
196
 
185
- now = Time.now
186
197
  cutoff = days.to_i.positive? ? now - (days.to_i * 86_400) : nil
187
198
  max = max_count.to_i
188
199
 
@@ -217,6 +228,15 @@ module Samagotchi
217
228
  end
218
229
  end
219
230
 
231
+ # A scratch session saved within SCRATCH_START_GRACE: `chi scratch`
232
+ # saves it, then spawns the worker that takes its owner lock, so no
233
+ # owner yet doesn't mean a leftover. Kept, it takes no count slot.
234
+ if session.scratch && scratch_starting?(path, now)
235
+ kept << session.id
236
+ passed_over += 1
237
+ next
238
+ end
239
+
220
240
  left_empty = begin
221
241
  empty_check&.call(session.id)
222
242
  rescue StandardError
@@ -28,6 +28,12 @@ module Samagotchi
28
28
  module Steer
29
29
  KIND = "steer"
30
30
  INPUT_KIND = "input"
31
+ # KernelLoop's line asking a Qwen model to finish the tool call it left
32
+ # open: a user line inside the running turn, the model's alone. No turn
33
+ # opens with it (#turn_prompt?), and the page draws nothing for it, as it
34
+ # showed nothing live. Its kind adds no header (#header): its bytes go
35
+ # out as before it had one.
36
+ RECOVERY_KIND = "recovery"
31
37
 
32
38
  # A drain item for an input line with its sender (source nil = the user).
33
39
  # A plain String is still a valid item: the user's line. +mark+: fields
@@ -317,6 +323,11 @@ module Samagotchi
317
323
  message.is_a?(Hash) && (message[:role] || message["role"]).to_s == "user" && !steer?(message)
318
324
  end
319
325
 
326
+ # KernelLoop's recovery line (RECOVERY_KIND).
327
+ def recovery?(message)
328
+ message.is_a?(Hash) && (message[:kind] || message["kind"]).to_s == RECOVERY_KIND
329
+ end
330
+
320
331
  # The user's lines merged into a running turn (Merge#messages).
321
332
  def input?(message)
322
333
  message.is_a?(Hash) && (message[:kind] || message["kind"]).to_s == INPUT_KIND
@@ -333,7 +344,7 @@ module Samagotchi
333
344
  start = (message[:turn_start] || message["turn_start"]) == true
334
345
  return start if (message[:role] || message["role"]).to_s == "system"
335
346
 
336
- prompt?(message) && (!input?(message) || start)
347
+ prompt?(message) && !recovery?(message) && (!input?(message) || start)
337
348
  end
338
349
 
339
350
  # A stored `!shell` line: "!(cmd)\n<output>", no turn id. A user message,
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "degrade"
4
+
5
+ module Samagotchi
6
+ # The one rule for handing a loop event to its on_stream_event callback
7
+ # (KernelLoop#emit_stream_event, ToolRunner#emit): no callback is fine, and
8
+ # a failing one (a UI gone) must not break the turn or the tool call. Its
9
+ # failure is a Degrade record under :turn, named by the caller (+failure+,
10
+ # so a log still says which side lost the event), with the event's type.
11
+ module StreamEvent
12
+ module_function
13
+
14
+ # @param callback [#call, nil] the loop's on_stream_event
15
+ # @param event [Hash] the event
16
+ # @param failure [String] the Degrade event slug when +callback+ raises
17
+ # @return the callback's value, nil when it raised or there is none
18
+ def emit(callback, event, failure:)
19
+ Degrade.call(:turn, failure, type: (event[:type] if event.is_a?(Hash))) { callback&.call(event) }
20
+ end
21
+ end
22
+ end
@@ -77,6 +77,10 @@ module Samagotchi
77
77
  # a new process. The files' text is read at each build.
78
78
  @config_appended = Degrade.call(:prompt, "appended_list_unreadable", fallback: []) { ConfigFile.appended_prompt_files }
79
79
  @cli_appended = Array(appended_files).map { |path| File.expand_path(path.to_s) }
80
+ # { [chat, thinking, layers] => Built }, swapped for a new one by #reset!
81
+ @cache = {}
82
+ # The Built #build returned last; nil before a build and after #reset!.
83
+ @last = nil
80
84
  end
81
85
 
82
86
  # @return [Array<String>] the names the session preloads, known before
@@ -85,37 +89,35 @@ module Samagotchi
85
89
  @requested_memories.map { |raw| split_memory_scope(raw).last }.uniq
86
90
  end
87
91
 
92
+ # A prompt #build made, with what it read while it did: the memory
93
+ # index sizes (#memory_index), the model notes, the files and the
94
+ # plugins' sections it holds, and its stable part's length (chat).
95
+ Built = Data.define(:prompt, :memory_index, :notes, :files, :sections, :stable_length)
96
+
88
97
  # The full prompt: #base wrapped with the index and the rest. Built once
89
98
  # per loop and level (until #reset!) so the prompt prefix, and the
90
99
  # server's KV cache for it, stay stable.
100
+ #
101
+ # #reset! runs on other threads (a plugin's tools_changed! /
102
+ # prompt_changed!, a /stats re-resolving the profile) and, through the
103
+ # profile lookup, inside a build on this one. So a build reads @cache
104
+ # once and computes into locals: a reset meanwhile swaps in a new cache
105
+ # and this build's result goes into the dropped one, so it is returned
106
+ # (the same bytes as without the reset) but not kept, and the next
107
+ # build reads the sources again. No lock: the build reads memory files
108
+ # and calls the lookups, one of which takes ModelBinding's mutex, whose
109
+ # holder (a /stats thread) may itself call #reset!.
91
110
  # @param chat [Boolean] for the chat loop
92
111
  # @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
93
112
  # @param layers [Array<Symbol>] the turn's LLM context layers: the
94
113
  # native prompt declares the tools they add (Tools::Registry#entries)
95
114
  def build(chat: false, thinking: nil, layers: [])
96
115
  key = [chat, thinking, layers]
97
- @built ||= {}
98
- @index_sizes ||= {}
99
- @notes_loaded ||= {}
100
- @files_loaded ||= {}
101
- @sections_loaded ||= {}
102
- unless @built.key?(key)
103
- @section_notes = nil
104
- @section_files = []
105
- @section_plugins = []
106
- @built[key] = system_prompt_with_index(
107
- assist_system_prompt(chat: chat, layers: layers), chat: chat, thinking: thinking, layers: layers
108
- )
109
- @index_sizes[key] = @measured_index
110
- @notes_loaded[key] = @section_notes || []
111
- @files_loaded[key] = @section_files.freeze
112
- @sections_loaded[key] = @section_plugins.freeze
113
- end
114
- @memory_index = @index_sizes[key]
115
- @prompt_notes = @notes_loaded[key]
116
- @prompt_files = @files_loaded[key]
117
- @prompt_sections = @sections_loaded[key]
118
- @built[key]
116
+ cache = @cache
117
+ built = cache[key] || (cache[key] = build_prompt(chat: chat, thinking: thinking, layers: layers))
118
+ @memory_index = built.memory_index
119
+ @last = built
120
+ built.prompt
119
121
  end
120
122
 
121
123
  # The memory indexes the prompt #build returned last holds, per scope
@@ -130,7 +132,7 @@ module Samagotchi
130
132
  # (Array<ModelNotes::Note>, [] with none), nil before a build and after
131
133
  # #reset!. Read when the prompt is built, as the section is.
132
134
  # @return [Array<ModelNotes::Note>, nil]
133
- attr_reader :prompt_notes
135
+ def prompt_notes = @last&.notes
134
136
 
135
137
  # The files whose text the prompt #build returned last holds, as
136
138
  # absolute paths in prompt order: the AGENTS.md chain, then the
@@ -138,26 +140,21 @@ module Samagotchi
138
140
  # build and after #reset!. Read when the prompt is built, as the
139
141
  # sections are.
140
142
  # @return [Array<String>]
141
- def prompt_files = @prompt_files || []
143
+ def prompt_files = @last&.files || []
142
144
 
143
145
  # The plugins' prompt sections the prompt #build returned last holds
144
146
  # (Plugin::PromptSections::Built, prompt order), [] before a build and
145
147
  # after #reset!.
146
148
  # @return [Array<Plugin::PromptSections::Built>]
147
- def prompt_sections = @prompt_sections || []
149
+ def prompt_sections = @last&.sections || []
148
150
 
149
151
  # Drops the built prompts: the next #build reads the profile, tools,
150
152
  # indexes and memories again (a model or profile switch, changed tools).
151
153
  def reset!
152
- @built = nil
153
- @index_sizes = nil
154
- @notes_loaded = nil
155
- @prompt_notes = nil
156
- @files_loaded = nil
157
- @prompt_files = nil
158
- @sections_loaded = nil
159
- @prompt_sections = nil
160
- @stable_lengths = nil
154
+ # Single assignments: a build in flight keeps the cache it read
155
+ # (#build), so it neither fails nor is kept.
156
+ @cache = {}
157
+ @last = nil
161
158
  end
162
159
 
163
160
  # The length of +prompt+'s stable part when it is a chat prompt #build
@@ -165,7 +162,12 @@ module Samagotchi
165
162
  # a remote prompt cache can keep for every session (PromptCache). nil
166
163
  # for any other text, or a prompt without those lines.
167
164
  # @return [Integer, nil]
168
- def stable_length(prompt) = @stable_lengths&.[](prompt)
165
+ def stable_length(prompt)
166
+ last = @last
167
+ return last.stable_length if last&.prompt == prompt
168
+
169
+ @cache.each_value.find { |built| built.prompt == prompt }&.stable_length
170
+ end
169
171
 
170
172
  # The base prompt (specs, plugins' declarations), with Gemma's tool
171
173
  # declarations at its end as #build has them.
@@ -182,6 +184,20 @@ module Samagotchi
182
184
 
183
185
  private
184
186
 
187
+ # One #build's work: the prompt and what it read, in a Built. The
188
+ # @section_* and @measured_* scratch is this build's (the turn thread's)
189
+ # alone: #reset! never touches it.
190
+ def build_prompt(chat:, thinking:, layers:)
191
+ @section_notes = nil
192
+ @section_files = []
193
+ @section_plugins = []
194
+ @measured_stable_length = nil
195
+ prompt = system_prompt_with_index(assist_system_prompt(chat: chat, layers: layers),
196
+ chat: chat, thinking: thinking, layers: layers)
197
+ Built.new(prompt: prompt, memory_index: @measured_index, notes: @section_notes || [],
198
+ files: @section_files.freeze, sections: @section_plugins.freeze, stable_length: @measured_stable_length)
199
+ end
200
+
185
201
  def profile = @profile_lookup.call
186
202
 
187
203
  def turn_thinking = @thinking_lookup.call
@@ -331,7 +347,7 @@ module Samagotchi
331
347
  return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
332
348
 
333
349
  prompt = "#{stable_text}\n#{volatile.join("\n")}"
334
- (@stable_lengths ||= {})[prompt] = stable_text.length + 1 if chat
350
+ @measured_stable_length = stable_text.length + 1 if chat
335
351
  with_tail_declarations(prompt, chat: chat, layers: layers)
336
352
  end
337
353
 
@@ -60,15 +60,6 @@ module Samagotchi
60
60
  @screen.commit(text)
61
61
  end
62
62
 
63
- # Draw on +surface+ from now on (no caller since the REPL went): what the slot shows
64
- # moves there.
65
- def surface=(surface)
66
- @lock.synchronize do
67
- @screen = surface
68
- redraw_status if status_text
69
- end
70
- end
71
-
72
63
  # A new turn: its tally starts over too.
73
64
  def reset_turn_feedback
74
65
  @lock.synchronize do
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require "reline"
6
6
 
7
7
  require_relative "../config"
8
+ require_relative "../degrade"
8
9
  require_relative "../memory_paths"
9
10
  require_relative "../prompt_history"
10
11
  require_relative "../tools/memory"
@@ -90,20 +91,18 @@ module Samagotchi
90
91
  fragment = slash_fragment.to_s.strip
91
92
  return [] unless fragment.start_with?("/")
92
93
 
93
- begin
94
+ # Only at the line's start: "/x" mid-line is a path, not a command.
95
+ Degrade.call(:attached, "completion_line_unreadable") do
94
96
  buf = Reline.line_buffer.to_s
95
- if !buf.empty? && !buf.lstrip.start_with?("/")
96
- return []
97
- end
98
- rescue StandardError
99
- nil
97
+ return [] if !buf.empty? && !buf.lstrip.start_with?("/")
100
98
  end
101
99
 
102
100
  lowered = fragment.downcase
103
101
  return slash_commands.dup if lowered == "/"
104
102
 
105
103
  slash_commands.select { |cmd| cmd.start_with?(lowered) }
106
- rescue StandardError
104
+ rescue StandardError => e
105
+ Degrade.note(:attached, "slash_completion_failed", e)
107
106
  []
108
107
  end
109
108
 
@@ -134,7 +133,8 @@ module Samagotchi
134
133
  relative_path = "#{relative_path}/" if File.directory?(absolute_path)
135
134
  "#{AT_PATH_COMPLETION_PREFIX}#{relative_path}"
136
135
  end
137
- rescue StandardError
136
+ rescue StandardError => e
137
+ Degrade.note(:attached, "path_completion_failed", e)
138
138
  []
139
139
  end
140
140
 
@@ -190,7 +190,8 @@ module Samagotchi
190
190
  yield scope, name
191
191
  end
192
192
  end
193
- rescue StandardError
193
+ rescue StandardError => e
194
+ Degrade.note(:attached, "memory_completion_failed", e)
194
195
  []
195
196
  end
196
197
 
@@ -207,8 +208,8 @@ module Samagotchi
207
208
  @history_signature = PromptHistory.signature
208
209
  @history_seen = PromptHistory.entries
209
210
  @history_seen.last(PromptHistory::LIMIT).each { |entry| Reline::HISTORY << entry }
210
- rescue StandardError
211
- nil
211
+ rescue StandardError => e
212
+ Degrade.note(:attached, "history_load_failed", e)
212
213
  end
213
214
 
214
215
  # A scratch session keeps nothing, its typed lines neither.
@@ -223,8 +224,8 @@ module Samagotchi
223
224
  history_lock.synchronize { forget_own_line(own) } if own
224
225
  raise
225
226
  end
226
- rescue StandardError
227
- nil
227
+ rescue StandardError => e
228
+ Degrade.note(:attached, "history_append_failed", e)
228
229
  end
229
230
 
230
231
  # Before a main-prompt read, and on the first ↑ of a walk at an open
@@ -255,8 +256,8 @@ module Samagotchi
255
256
  @history_signature = signature
256
257
  @history_seen = entries
257
258
  history_lock.synchronize { add_others_lines(fresh) }
258
- rescue StandardError
259
- nil
259
+ rescue StandardError => e
260
+ Degrade.note(:attached, "history_pickup_failed", e)
260
261
  end
261
262
  end
262
263
 
@@ -77,8 +77,11 @@ module Samagotchi
77
77
  Log.session_id = @link.client.session_id
78
78
  Log.info(:attached, "joined", session: @link.client.session_id, messages: exchange.size,
79
79
  notes: messages.count { |m| ContextNote.note?(m) })
80
- last_user = exchange.rindex { |m| Steer.prompt?(m) }
81
- render_join_notes(messages)
80
+ # The last turn's own prompt (Steer.turn_prompt?, as the page's
81
+ # turns): not Qwen's recovery line, nor input merged into the turn.
82
+ last_user = exchange.rindex { |m| Steer.turn_prompt?(m) }
83
+ from = last_user && messages.index { |m| m.equal?(exchange[last_user]) }
84
+ render_join_notes(messages, from)
82
85
  return unless last_user
83
86
 
84
87
  @screen.commit(join_prompt_line(exchange[last_user]))
@@ -86,7 +89,6 @@ module Samagotchi
86
89
  unless ImageRef.placeholder_only?(exchange[last_user][:content])
87
90
  Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
88
91
  end
89
- from = messages.index { |m| m.equal?(exchange[last_user]) }
90
92
  render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
91
93
  record = @link.saved_turn_record(exchange[last_user][:turn_id] || exchange[last_user]["turn_id"])
92
94
  failed = join_failed_record(record)
@@ -114,19 +116,21 @@ module Samagotchi
114
116
  .find { |text| !text.empty? }
115
117
  @screen.commit(last_lines(answer)) if answer
116
118
  end
117
- render_join_notes(messages, after_exchange: true)
119
+ render_join_notes(messages, from, after_exchange: true)
118
120
  end
119
121
 
120
122
  # What the last exchange did, as the live turn drew it: each saved
121
123
  # call's tool row (action, params, status from its result, and its
122
124
  # duration from +durations+, the turn's saved tool records) and a
123
- # plugin's steer where it came. The step texts stay out, as live (the
124
- # ticker's).
125
+ # plugin's steer and the user's input merged into the turn where they
126
+ # came. The step texts stay out, as live (the ticker's).
125
127
  def render_join_steps(messages, from, durations: [])
126
128
  records = durations.dup
127
129
  messages.each_with_index.drop(from + 1).each do |m, i|
128
130
  if Steer.steer?(m)
129
131
  @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
132
+ elsif Steer.input?(m) && !Steer.turn_prompt?(m)
133
+ @screen.commit("input> #{m[:content]}")
130
134
  elsif %w[model assistant].include?(m[:role].to_s)
131
135
  responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
132
136
  Array(MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
@@ -209,13 +213,13 @@ module Samagotchi
209
213
  format_tool_guardrail_suffix({ decided_by: "system one", p: saved["p"] || saved[:p] })
210
214
  end
211
215
 
212
- # The context notes that came since the last prompt (all of them in a
213
- # session with none), after the exchange the header shows.
214
- def render_join_notes(messages, after_exchange: false)
215
- last_user = messages.rindex { |m| Steer.prompt?(m) }
216
- return if after_exchange != !last_user.nil?
216
+ # The context notes that came since the last prompt (+from+, its index
217
+ # in +messages+; all of them in a session with none), after the
218
+ # exchange the header shows.
219
+ def render_join_notes(messages, from, after_exchange: false)
220
+ return if after_exchange != !from.nil?
217
221
 
218
- messages[(last_user ? last_user + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
222
+ messages[(from ? from + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
219
223
  @screen.commit(context_note_line(ContextNote.label_of(m), ContextNote.text_of(m)))
220
224
  end
221
225
  end
@@ -44,11 +44,6 @@ module Samagotchi
44
44
  # @return [#call, nil]
45
45
  attr_accessor :interrupt_handler
46
46
 
47
- # Called with no arguments for each key a read takes (typing is
48
- # activity for an idle clock; nobody sets it now).
49
- # @return [#call, nil]
50
- attr_accessor :key_handler
51
-
52
47
  # @return [Boolean] a Reline read is open (it owns stdin)
53
48
  def reading? = @reading == true
54
49
 
@@ -150,11 +145,6 @@ module Samagotchi
150
145
  @rendered_screen.base_y = 0 if RelineSeam.screen
151
146
  end
152
147
 
153
- def update(key)
154
- RelineSeam.key_handler&.call
155
- super
156
- end
157
-
158
148
  def screen_height
159
149
  screen = RelineSeam.screen
160
150
  screen ? [super, screen.editor_budget].min : super
@@ -54,13 +54,6 @@ module Samagotchi
54
54
  @shown = nil
55
55
  end
56
56
 
57
- # The surface it draws on (no caller since the REPL went): the row
58
- # is drawn there at the next change.
59
- def surface=(surface)
60
- @surface = surface
61
- @shown = nil
62
- end
63
-
64
57
  def [](field) = @values[field]
65
58
 
66
59
  # Change some fields; the row is redrawn when its text changed.
@@ -7,6 +7,7 @@ require_relative "file_ref"
7
7
  require_relative "guardrails"
8
8
  require_relative "vision_context"
9
9
  require_relative "degrade"
10
+ require_relative "stream_event"
10
11
  require_relative "log"
11
12
  require_relative "edit_preview"
12
13
  require_relative "tool_output_cut"
@@ -562,7 +563,7 @@ module Samagotchi
562
563
 
563
564
  # A failing stream callback (a UI gone) must not break the call either.
564
565
  def emit(callback, event)
565
- Degrade.call(:turn, "tool_event_callback_failed", type: (event[:type] if event.is_a?(Hash))) { callback&.call(event) }
566
+ StreamEvent.emit(callback, event, failure: "tool_event_callback_failed")
566
567
  end
567
568
  end
568
569
  end