samagotchi 0.13.0 → 0.15.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/bin/chi +4 -7
  4. data/docs/configuration.md +50 -7
  5. data/docs/guardrails.md +2 -1
  6. data/docs/plugins.md +28 -13
  7. data/docs/sessions.md +1 -1
  8. data/docs/sub-agent.md +1 -1
  9. data/lib/samagotchi/bridge/card_store.rb +94 -6
  10. data/lib/samagotchi/bridge.rb +3 -1
  11. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
  13. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/config.rb +31 -6
  17. data/lib/samagotchi/context_window.rb +30 -5
  18. data/lib/samagotchi/engine.rb +21 -7
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +5 -2
  20. data/lib/samagotchi/host_registry.rb +31 -6
  21. data/lib/samagotchi/kernel_loop.rb +1 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  23. data/lib/samagotchi/llm/turn_settings.rb +7 -5
  24. data/lib/samagotchi/plugin_session_state.rb +30 -0
  25. data/lib/samagotchi/self_report.rb +24 -4
  26. data/lib/samagotchi/session_manager.rb +8 -0
  27. data/lib/samagotchi/session_metrics.rb +1 -1
  28. data/lib/samagotchi/session_retention.rb +6 -1
  29. data/lib/samagotchi/system_prompt.rb +1 -1
  30. data/lib/samagotchi/terminal_ui/attached_loop.rb +65 -13
  31. data/lib/samagotchi/terminal_ui.rb +27 -3
  32. data/lib/samagotchi/tools/delegate_wait.rb +17 -10
  33. data/lib/samagotchi/tools/memory.rb +3 -3
  34. data/lib/samagotchi/utf8_default.rb +29 -0
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/app.rb +22 -7
  37. data/lib/samagotchi/web/public/activity.js +6 -1
  38. data/lib/samagotchi/web/public/app.js +46 -10
  39. data/lib/samagotchi/web/public/index.html +10 -1
  40. data/lib/samagotchi/web/public/notify.js +16 -3
  41. data/lib/samagotchi/web/public/question_cards.js +3 -1
  42. data/lib/samagotchi/web/public/sessions_list.js +45 -0
  43. data/lib/samagotchi/web/public/strip.js +8 -1
  44. data/lib/samagotchi/web/public/timing.js +11 -2
  45. data/lib/samagotchi/web/public/turn_view.js +10 -10
  46. metadata +3 -1
@@ -271,7 +271,7 @@ module Samagotchi
271
271
  def prepare_request(turn)
272
272
  prompt, images = format_prompt(turn)
273
273
  image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(turn.conversation)
274
- window = ContextWindow.resolve(client: @client, model: turn.model_name)
274
+ window = ContextWindow.resolve(client: @client, model: turn.model_name, setting: @turn_settings.window_setting)
275
275
  emit_context_status_event(turn.on_stream_event, turn.context, prompt, iteration_index: turn.iteration - 1,
276
276
  window: window, image_tokens: image_tokens)
277
277
  if (line = turn.context.take_guidance)
@@ -172,11 +172,13 @@ module Samagotchi
172
172
 
173
173
  # The window for +model+: the kernel's client probes the server it
174
174
  # points at (Engine keeps it on the effective model's host; a local
175
- # llama.cpp answers /props), then the host's model list, config, env
176
- # and the default. A remote provider has no /props to probe.
175
+ # llama.cpp answers /props), then the host's model list, the model's
176
+ # or host's window_tokens, config, env and the default. A remote
177
+ # provider has no /props to probe.
177
178
  def context_window(model)
178
179
  client = @adapter.remote? ? nil : @kernel.client
179
- ContextWindow.resolve(client: client, model: model, adapter: @adapter)
180
+ ContextWindow.resolve(client: client, model: model, adapter: @adapter,
181
+ setting: (@kernel.turn_settings&.window_setting if @kernel.respond_to?(:turn_settings)))
180
182
  rescue StandardError
181
183
  nil
182
184
  end
@@ -191,9 +193,10 @@ module Samagotchi
191
193
  # The shape the engine persists: role and content (parts stay an
192
194
  # Array), plus a model turn's tool_calls and thinking (the host's
193
195
  # reasoning, never sent back), a result's tool_call_id, the image
194
- # refs of a user message or a tool result, and a plugin tool result's
195
- # tool_params and tool_labels (the live row's params line and label,
196
- # never sent back), and an edit/write result's tool_diffs (never sent).
196
+ # refs of a user message or a tool result, a prompt's turn_id (Engine,
197
+ # never sent), and a plugin tool result's tool_params and tool_labels
198
+ # (the live row's params line and label, never sent back), and an
199
+ # edit/write result's tool_diffs (never sent).
197
200
  def plain(conversation)
198
201
  conversation.map do |entry|
199
202
  content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
@@ -201,6 +204,7 @@ module Samagotchi
201
204
  message[:tool_calls] = entry[:tool_calls] if entry[:tool_calls].is_a?(Array) && !entry[:tool_calls].empty?
202
205
  message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
203
206
  message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
207
+ message[:turn_id] = entry[:turn_id] if entry[:turn_id]
204
208
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
205
209
  ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
206
210
  message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
@@ -8,12 +8,14 @@ module Samagotchi
8
8
  # ChatLoop) and ToolRunner: the turn's VisionContext (nil sends no
9
9
  # images, placeholders instead), its request parameters
10
10
  # (SamplingSettings.for; empty sends none), its thinking level
11
- # (Thinking.resolve) and the bare model id the turn runs on (every
12
- # debug dump is tagged with it). The Engine sets them once per turn,
13
- # in Engine#generate, after the model's host is synced.
14
- TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name) do
11
+ # (Thinking.resolve), the bare model id the turn runs on (every debug
12
+ # dump is tagged with it) and the model's or host's configured context
13
+ # window (ContextWindow.setting, nil for none). The Engine sets them
14
+ # once per turn, in Engine#generate, after the model's host is synced.
15
+ TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name, :window_setting) do
15
16
  # No vision, no sampling, the default thinking, no model name.
16
- def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil)
17
+ def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil,
18
+ window_setting: nil)
17
19
  end
18
20
  end
19
21
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "paths"
5
+ require_relative "session"
6
+
7
+ module Samagotchi
8
+ # A plugin's state for one session, by convention under its data dir
9
+ # (ctx.data_dir): sessions/<id>.json or sessions/<id>/. It goes when the
10
+ # session does (SessionManager.delete_session: the CLI, the web, a
11
+ # discarded empty session; SessionRetention's prune), so no bundle keeps
12
+ # state for sessions that are gone (check-in's /checkin settings).
13
+ module PluginSessionState
14
+ module_function
15
+
16
+ # @return [Array<String>] the paths removed
17
+ def remove(session_id, env: ENV)
18
+ id = session_id.to_s
19
+ # Only a plain id: anything else could name a path outside the dir.
20
+ return [] unless Session.valid_id?(id)
21
+
22
+ Dir.glob(File.join(Paths.state_dir(env: env), "plugins", "*", "sessions")).flat_map do |dir|
23
+ [File.join(dir, "#{id}.json"), File.join(dir, id)].select { |path| File.exist?(path) }
24
+ .each { |path| FileUtils.rm_rf(path) }
25
+ end
26
+ rescue SystemCallError
27
+ []
28
+ end
29
+ end
30
+ end
@@ -56,7 +56,7 @@ module Samagotchi
56
56
  ["profile", model ? profile_for(model, env) : "-"],
57
57
  ["thinking", model ? thinking_for(model, env) : "-"],
58
58
  ["served model", model ? served_model_for(model, env) : "-"],
59
- ["context window", context_window(env)],
59
+ ["context window", context_window(env, model)],
60
60
  ["bundles", bundles_summary],
61
61
  ["desktop", desktop_summary(env)],
62
62
  ["chi web", web_summary]
@@ -100,9 +100,29 @@ module Samagotchi
100
100
  def hooks_dir(env) = Hooks::Loader.hooks_dir(env)
101
101
 
102
102
  # Offline, so only the fallback: the running server's n_ctx wins at runtime.
103
- def context_window(env)
104
- window = ContextWindow.configured(env: env)
105
- "#{window.tokens} (#{window.source}; the server's n_ctx wins at runtime)"
103
+ # The window config gives the current model (ContextWindow: its
104
+ # models.<key>.window_tokens, its host's, then context.window_tokens or
105
+ # the default) and which of them, as a turn resolves it offline.
106
+ def context_window(env, model = nil)
107
+ tokens, where = model_window(env, model) || ContextWindow.configured(env: env).to_h.values_at(:tokens, :source)
108
+ "#{tokens} (#{where}; the server's n_ctx wins at runtime)"
109
+ end
110
+
111
+ # [tokens, "models: KEY" | "hosts.NAME"], or nil when neither is set.
112
+ def model_window(env, model)
113
+ return nil unless model
114
+
115
+ registry = HostRegistry.new(env: env)
116
+ target = registry.resolve(model)
117
+ names = registry.lookup_names(model, target: target)
118
+ models = ConfigFile.model_settings(env: env)
119
+ window = ContextWindow.setting(target, names: names, models: models)
120
+ return nil unless window
121
+ return [window.tokens, "hosts.#{target.entry.name}"] if window.source == :host_setting
122
+
123
+ [window.tokens, "models: #{ConfigFile.model_setting(names, :window_tokens, models: models).first}"]
124
+ rescue StandardError
125
+ nil
106
126
  end
107
127
 
108
128
  def model_name
@@ -12,6 +12,7 @@ require_relative "config"
12
12
  require_relative "session"
13
13
  require_relative "session_inbox"
14
14
  require_relative "session_metrics"
15
+ require_relative "utf8_default"
15
16
  require_relative "turn_note"
16
17
  require_relative "owner_lock"
17
18
  require_relative "bridge_client"
@@ -22,6 +23,7 @@ require_relative "installed_gem"
22
23
  require_relative "recap_store"
23
24
  require_relative "archive_store"
24
25
  require_relative "image_store"
26
+ require_relative "plugin_session_state"
25
27
  require_relative "terminal_ui"
26
28
 
27
29
  module Samagotchi
@@ -552,6 +554,9 @@ module Samagotchi
552
554
  File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.children(path).empty?)
553
555
  elsif name == "analytics.json"
554
556
  Array(JSON.parse(File.read(path))["turn_records"]).empty?
557
+ # Cards and notices from before any turn (Bridge::CardStore).
558
+ elsif name == "cards.json"
559
+ JSON.parse(File.read(path))["turns"].to_i.zero?
555
560
  else
556
561
  EMPTY_SKELETON_FILES.include?(name)
557
562
  end
@@ -594,6 +599,7 @@ module Samagotchi
594
599
  FileUtils.rm_rf(dir)
595
600
  removed << dir
596
601
  end
602
+ PluginSessionState.remove(id)
597
603
  { id: id, removed: removed, stopped: !owner.nil? }
598
604
  end
599
605
 
@@ -631,6 +637,8 @@ module Samagotchi
631
637
  # @return [Symbol] :idle_exit or :exit_requested
632
638
  def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
633
639
  idle_exit_minutes: nil, poll_interval: nil)
640
+ # A worker inherits chi's locale (LC_ALL=C too): read files as UTF-8.
641
+ Utf8Default.apply!
634
642
  sd = state_dir || Session.default_state_dir
635
643
  # The spawner passed the file and level through ENV; a worker's stderr
636
644
  # is /dev/null, so warnings only reach the file.
@@ -284,7 +284,7 @@ module Samagotchi
284
284
  token_sources: [],
285
285
  retries: 0,
286
286
  model: nil,
287
- id: SecureRandom.uuid,
287
+ id: event[:turn_id] || SecureRandom.uuid,
288
288
  started_at: now.iso8601(3),
289
289
  started_monotonic: monotonic_time,
290
290
  tool_calls_by_id: {}
@@ -8,6 +8,7 @@ require_relative "config"
8
8
  require_relative "log"
9
9
  require_relative "model_profile"
10
10
  require_relative "session_manager"
11
+ require_relative "plugin_session_state"
11
12
 
12
13
  module Samagotchi
13
14
  # Which sessions go: `chi sessions prune` / `clean` (.prune) and the lazy
@@ -79,7 +80,10 @@ module Samagotchi
79
80
  next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && SessionManager.empty_session_dir?(dir)
80
81
  next if SessionManager.session_owner(name, state_dir: state_dir)
81
82
 
82
- FileUtils.rm_rf(dir) unless dry_run
83
+ unless dry_run
84
+ FileUtils.rm_rf(dir)
85
+ PluginSessionState.remove(name)
86
+ end
83
87
  name
84
88
  rescue SystemCallError
85
89
  nil
@@ -246,6 +250,7 @@ module Samagotchi
246
250
  FileUtils.rm_f(path)
247
251
  sidecar = File.join(state_dir, session.id)
248
252
  FileUtils.rm_rf(sidecar) if File.exist?(sidecar)
253
+ PluginSessionState.remove(session.id)
249
254
  deleted << session.id
250
255
  rescue StandardError
251
256
  skipped << session.id
@@ -335,7 +335,7 @@ module Samagotchi
335
335
  path = agent_description_path
336
336
  return nil unless path
337
337
 
338
- content = File.read(path).strip
338
+ content = File.read(path, encoding: "UTF-8").strip
339
339
  return nil if content.empty?
340
340
 
341
341
  "Project specific description:\n#{content}"
@@ -21,6 +21,8 @@ require_relative "../output_formatter"
21
21
  require_relative "../session_commands"
22
22
  require_relative "../session_manager"
23
23
  require_relative "../guardrails/parent_approvals"
24
+ require_relative "../tool_activity"
25
+ require_relative "../web/message_parts"
24
26
 
25
27
  module Samagotchi
26
28
  class TerminalUI
@@ -105,6 +107,7 @@ module Samagotchi
105
107
  @running = false
106
108
  @joined_mid_turn = false
107
109
  @attached = false
110
+ @early_lines = []
108
111
  @question = nil
109
112
  @answered_ids = Set.new
110
113
  @reader = nil
@@ -162,16 +165,17 @@ module Samagotchi
162
165
  when :event
163
166
  ended = safely_handle(payload)
164
167
  return ended if %i[closed failed unanswered].include?(ended)
168
+
169
+ ended = take_early_lines
170
+ return ended if ended
165
171
  return end_of_input if @input_ended && !own_turns_pending?
166
172
  when :line
167
- if payload.nil? && @wait_at_eof && own_turns_pending?
168
- # The pipe's end: this run's turns first. An open question
169
- # has nothing left to answer it.
170
- return unanswered_question(@question_pending) if @question
171
-
172
- next @input_ended = true
173
- end
174
- return end_of_input if submit(payload) == :detach
173
+ # A pipe is read at once: its lines wait for the joining
174
+ # snapshot, which may bring the question they answer.
175
+ next @early_lines << payload if @wait_at_eof && !@attached
176
+
177
+ ended = take_line(payload)
178
+ return ended if ended
175
179
  when :interrupt then return :detached if interrupt(payload) == :detach
176
180
  end
177
181
  end
@@ -187,6 +191,30 @@ module Samagotchi
187
191
  stream&.close
188
192
  end
189
193
 
194
+ # One input line (nil: the input ended).
195
+ # @return [Symbol, nil] how the run ends, or nil to go on
196
+ def take_line(line)
197
+ if line.nil? && @wait_at_eof && own_turns_pending?
198
+ # The pipe's end: this run's turns first. An open question
199
+ # has nothing left to answer it.
200
+ return unanswered_question(@question_pending) if @question
201
+
202
+ @input_ended = true
203
+ return nil
204
+ end
205
+ end_of_input if submit(line) == :detach
206
+ end
207
+
208
+ # The lines read before the snapshot came, once it has.
209
+ # @return [Symbol, nil] how the run ends, or nil to go on
210
+ def take_early_lines
211
+ until !@attached || @early_lines.empty?
212
+ ended = take_line(@early_lines.shift)
213
+ return ended if ended
214
+ end
215
+ nil
216
+ end
217
+
190
218
  # Render one Bridge event (string keys).
191
219
  # @return [Symbol, nil] :closed when the stream ended, :failed when the
192
220
  # launch's first command didn't go through
@@ -911,9 +939,7 @@ module Samagotchi
911
939
 
912
940
  @screen.commit(prompt_line(nil, exchange[last_user][:content]))
913
941
  Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
914
- exchange[(last_user + 1)..].select { |m| Steer.steer?(m) }.each do |m|
915
- @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
916
- end
942
+ render_join_steps(messages, messages.index { |m| m.equal?(exchange[last_user]) })
917
943
  # The saved answer is raw: the latest keeps its thinking, and one may
918
944
  # be only a tool call.
919
945
  answer = exchange[(last_user + 1)..].reverse_each
@@ -923,6 +949,31 @@ module Samagotchi
923
949
  render_join_notes(messages, after_exchange: true)
924
950
  end
925
951
 
952
+ # What the last exchange did, as the live turn drew it: each saved
953
+ # call's tool row (action, params, status from its result; no
954
+ # duration, which the session doesn't keep) and a plugin's steer
955
+ # where it came. The step texts stay out, as live (the ticker's).
956
+ def render_join_steps(messages, from)
957
+ messages.each_with_index.drop(from + 1).each do |m, i|
958
+ if Steer.steer?(m)
959
+ @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
960
+ elsif %w[model assistant].include?(m[:role].to_s)
961
+ responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
962
+ Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each { |part| @screen.commit(join_tool_line(part)) }
963
+ end
964
+ end
965
+ end
966
+
967
+ # A saved call's row; "no result" for a call the turn never answered
968
+ # (it ended first).
969
+ def join_tool_line(part)
970
+ output = part[:output]
971
+ status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
972
+ activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
973
+ params: part[:params], status: status }
974
+ "#{format_tool_activity_line(activity)}#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
975
+ end
976
+
926
977
  # The context notes that came since the last prompt (all of them in a
927
978
  # session with none), after the exchange the header shows.
928
979
  def render_join_notes(messages, after_exchange: false)
@@ -948,7 +999,8 @@ module Samagotchi
948
999
  # The snapshot's cards and between-turns notices (Bridge CardStore):
949
1000
  # on join the ones since the last turn (and the running turn's), as
950
1001
  # the join header shows the last exchange; on a resync only cards not
951
- # shown here yet. A turn's own rows (notices, retries) are left out:
1002
+ # shown here yet, and not the ones an earlier worker saved (a resync
1003
+ # onto a new worker: earlier turns, not news). A turn's own rows (notices, retries) are left out:
952
1004
  # the running turn's come with its parts, and the join shows no older
953
1005
  # turn's steps.
954
1006
  def render_snapshot_cards(entries, joining:)
@@ -959,7 +1011,7 @@ module Samagotchi
959
1011
 
960
1012
  @screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
961
1013
  when "card"
962
- next unless joining ? entry[:turns_since].to_i.zero? : !@renderer.card_shown?(entry[:id])
1014
+ next unless joining ? entry[:turns_since].to_i.zero? : !entry[:earlier] && !@renderer.card_shown?(entry[:id])
963
1015
 
964
1016
  @renderer.render_card(entry, updated: entry[:updated] == true)
965
1017
  end
@@ -14,6 +14,7 @@ require_relative "llm/errors"
14
14
  require_relative "host_registry"
15
15
  require_relative "session"
16
16
  require_relative "owner_lock"
17
+ require_relative "bridge/card_store"
17
18
  require_relative "engine"
18
19
  require_relative "guardrails/parent_approvals"
19
20
  require_relative "tools/memory"
@@ -223,6 +224,7 @@ module Samagotchi
223
224
  scratch: @scratch
224
225
  ))
225
226
  claim_session!(session.id) unless @owner_lock
227
+ keep_cards(session)
226
228
  # Saved at once, so a process killed before its first turn leaves a
227
229
  # file the sweep knows to delete.
228
230
  session.save if @scratch
@@ -345,6 +347,15 @@ module Samagotchi
345
347
  @surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
346
348
  end
347
349
 
350
+ # The session's cards.json, kept as a worker's Bridge keeps it (it owns
351
+ # the session as a worker would): this REPL's kept turns count on from
352
+ # the saved count, so a card an earlier worker showed stays that many
353
+ # turns back, and its own cards and notices are saved for the next.
354
+ def keep_cards(session)
355
+ path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
356
+ @cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
357
+ end
358
+
348
359
  # Take the session's OwnerLock for this process's lifetime: the TUI runs
349
360
  # its own Engine, so no worker may run the session meanwhile.
350
361
  # @raise [SessionBusy] when a worker or another TUI owns it
@@ -1112,6 +1123,7 @@ module Samagotchi
1112
1123
  # was cancelled).
1113
1124
  def answer_question_widget(prompt)
1114
1125
  loop do
1126
+ @choice_echoed = false
1115
1127
  raw = begin
1116
1128
  yield
1117
1129
  rescue Interrupt
@@ -1133,7 +1145,7 @@ module Samagotchi
1133
1145
  @surface.commit(answer.note) if answer.note
1134
1146
  unless answer.ok?
1135
1147
  # The read left no echo: the line shows above its error.
1136
- @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}")
1148
+ echo_choice(raw)
1137
1149
  @surface.commit(answer.error)
1138
1150
  next
1139
1151
  end
@@ -1146,7 +1158,7 @@ module Samagotchi
1146
1158
  rescue QuestionDesk::Refused => e
1147
1159
  # A parent's allow guardrails.parent_approvals doesn't let through:
1148
1160
  # the question stays open for a deny (or the end of the input).
1149
- @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}")
1161
+ echo_choice(raw)
1150
1162
  @surface.commit(Guardrails::ParentApprovals.message(e.reason, typed: true))
1151
1163
  next
1152
1164
  rescue ArgumentError => e
@@ -1173,7 +1185,19 @@ module Samagotchi
1173
1185
  # A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
1174
1186
  def read_choice_line(question_prompt)
1175
1187
  @surface.set_slot(:editor, [question_prompt])
1176
- $stdin.gets
1188
+ line = $stdin.gets
1189
+ # A pipe doesn't echo: the line completes the ? prompt here, once,
1190
+ # and the widget doesn't echo it again (#echo_choice).
1191
+ unless $stdin.tty?
1192
+ @surface.commit(line.to_s.chomp)
1193
+ @choice_echoed = true
1194
+ end
1195
+ line
1196
+ end
1197
+
1198
+ # The answer line above its error, unless its read echoed it already.
1199
+ def echo_choice(raw)
1200
+ @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}") unless @choice_echoed
1177
1201
  end
1178
1202
 
1179
1203
  # The next line submitted at the open prompt (?), nil for Ctrl-D
@@ -9,7 +9,8 @@ require_relative "peers"
9
9
  module Samagotchi
10
10
  module Tools
11
11
  # Waiting for a delegated session's next reply, shared by delegate and
12
- # delegate_result: ReplyWait in the tools' words, with a cursor per
12
+ # delegate_result: ReplyWait in the tools' words, with the status words
13
+ # `chi send --wait` and `chi answer` use (ParentReport.status), with a cursor per
13
14
  # child (the newest reply this parent was already given) and the child
14
15
  # as it was before the message went in (ReplyWait's baseline), so a turn
15
16
  # that ends before the wait's first look (a fast failure) still ends it.
@@ -56,22 +57,23 @@ module Samagotchi
56
57
  owner_grace: owner_grace)
57
58
  # The turn sent to is handed over: a later wait looks for a later one.
58
59
  baselines.delete(key) if %i[done no_reply].include?(wait.status)
60
+ status = ParentReport.status(wait)
59
61
  case wait.status
60
62
  when :done
61
63
  seen[key] = wait.file
62
- reply_result(child_id, wait.text)
64
+ reply_result(child_id, status, wait.text)
63
65
  when :error
64
- result(child_id, "error", "the child's worker failed: #{wait.text}; its session shows what happened")
66
+ result(child_id, status, "the child's worker failed: #{wait.text}; its session shows what happened")
65
67
  when :stopped
66
- result(child_id, "stopped", "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
68
+ result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
67
69
  when :waiting_for_answer
68
- result(child_id, "waiting_for_answer", waiting_text(child_id, wait.question))
70
+ result(child_id, status, waiting_text(child_id, wait.question))
69
71
  when :canceled
70
- result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
72
+ result(child_id, status, "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
71
73
  when :no_reply
72
- result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
74
+ result(child_id, status, "#{no_reply_text(wait)}; its session shows what happened")
73
75
  when :worker_gone
74
- result(child_id, "worker_gone",
76
+ result(child_id, status,
75
77
  "the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
76
78
  else
77
79
  timeout_result(child_id, timeout)
@@ -95,8 +97,13 @@ module Samagotchi
95
97
  baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child, question_id: nil)
96
98
  end
97
99
 
98
- def reply_result(child_id, text)
99
- "session: #{child_id}\nstatus: done\n---\n#{cut(text)}"
100
+ def reply_result(child_id, status, text)
101
+ "session: #{child_id}\nstatus: #{status}\n---\n#{cut(text)}"
102
+ end
103
+
104
+ # ParentReport's line for a turn that left no reply, about the child.
105
+ def no_reply_text(wait)
106
+ ParentReport.no_reply_line(wait).sub(/\Athe turn/, "the child's turn")
100
107
  end
101
108
 
102
109
  def result(child_id, status, text)
@@ -59,13 +59,13 @@ module Samagotchi
59
59
  resolved_scopes.each do |resolved_scope|
60
60
  path = memory_path(name, resolved_scope)
61
61
  if File.exist?(path)
62
- body = File.read(path)
62
+ body = File.read(path, encoding: "UTF-8")
63
63
  # Append model-specific overlay if key is provided and overlay exists.
64
64
  [model_key, fallback_model_key].compact.each do |key|
65
65
  overlay_path = ModelOverlay.overlay_path_for(name, key, resolved_scope)
66
66
  next unless overlay_path && File.exist?(overlay_path)
67
67
 
68
- body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path)
68
+ body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path, encoding: "UTF-8")
69
69
  break
70
70
  end
71
71
  results << body
@@ -114,7 +114,7 @@ module Samagotchi
114
114
  def self.scoped_index(scope)
115
115
  dir = memories_dir(scope)
116
116
  index_path = File.join(dir, "#{MEMORY_INDEX}.md")
117
- return File.read(index_path) if File.exist?(index_path)
117
+ return File.read(index_path, encoding: "UTF-8") if File.exist?(index_path)
118
118
 
119
119
  files = Dir.glob(File.join(dir, "*.md")).sort
120
120
  return "No memories stored yet." if files.empty?
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # chi's files (memories, AGENT.md, sessions, config.yml) are UTF-8,
5
+ # whatever the locale. Under LC_ALL=C (or with no locale at all: an app
6
+ # started from Finder or launchd, `env -i`) Ruby reads files as US-ASCII,
7
+ # and a UTF-8 memory index then breaks the system prompt
8
+ # (Encoding::CompatibilityError). bin/chi and every worker call #apply!
9
+ # first.
10
+ module Utf8Default
11
+ LOCALE_KEYS = %w[LC_ALL LC_CTYPE LANG].freeze
12
+
13
+ # Files read as UTF-8 when the locale says US-ASCII. With no locale set
14
+ # at all, LANG too, so the workers and tools chi spawns get one; a
15
+ # locale the user set (LC_ALL=C) is left to them.
16
+ def self.apply!(env = ENV)
17
+ return unless Encoding.default_external == Encoding::US_ASCII
18
+
19
+ env["LANG"] = "en_US.UTF-8" if LOCALE_KEYS.none? { |key| env[key] }
20
+ verbose = $VERBOSE
21
+ begin
22
+ $VERBOSE = nil # Encoding.default_external= warns
23
+ Encoding.default_external = Encoding::UTF_8
24
+ ensure
25
+ $VERBOSE = verbose
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.13.0"
4
+ VERSION = "0.15.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -11,6 +11,7 @@ require "rack/request"
11
11
 
12
12
  require_relative "../bridge_client"
13
13
  require_relative "../bridge/bounded_queue"
14
+ require_relative "../bridge/card_store"
14
15
  require_relative "../session"
15
16
  require_relative "../session_manager"
16
17
  require_relative "../session_commands"
@@ -533,7 +534,7 @@ module Samagotchi
533
534
  # ?cards=1: the page's re-read when a card arrives, for its rendered
534
535
  # body (the stream carries the card's plain text only).
535
536
  if req.params["cards"] == "1"
536
- return json_response(200, { cards: cards_for_display(turn_snapshot) })
537
+ return json_response(200, { cards: cards_for_display(turn_snapshot, id) })
537
538
  end
538
539
  snapshot = live && live["session_state_snapshot"]
539
540
  last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
@@ -584,7 +585,7 @@ module Samagotchi
584
585
  plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
585
586
  # Plugins' slow setup still running (chi.init): the page's init row.
586
587
  init_tasks: turn_snapshot ? Array(turn_snapshot["init_tasks"]) : [],
587
- cards: cards_for_display(turn_snapshot),
588
+ cards: cards_for_display(turn_snapshot, id),
588
589
  # The composer's / autocomplete; the built-ins until a worker
589
590
  # names its plugins' too.
590
591
  commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
@@ -601,11 +602,14 @@ module Samagotchi
601
602
  end
602
603
 
603
604
  # The worker's last cards and between-turns notices (Bridge
604
- # snapshot[:cards]), a card's body as body_html: rendered markdown
605
- # when the renderer is on, else the escaped text in a <pre>.
606
- # @return [Array<Hash>] [] without a live worker
607
- def cards_for_display(turn_snapshot)
608
- Array(turn_snapshot && turn_snapshot["cards"]).filter_map do |card|
605
+ # snapshot[:cards]), else (no live worker) the ones the last worker
606
+ # saved in the session's folder (CardStore.saved), a card's body as
607
+ # body_html: rendered markdown when the renderer is on, else the
608
+ # escaped text in a <pre>.
609
+ # @return [Array<Hash>]
610
+ def cards_for_display(turn_snapshot, id)
611
+ cards = turn_snapshot ? turn_snapshot["cards"] : saved_cards(id)
612
+ Array(cards).filter_map do |card|
609
613
  next unless card.is_a?(Hash)
610
614
  next card unless card["type"].to_s == "card"
611
615
 
@@ -613,6 +617,13 @@ module Samagotchi
613
617
  end
614
618
  end
615
619
 
620
+ # As JSON has them (string keys and values), like a snapshot's.
621
+ def saved_cards(id)
622
+ JSON.parse(JSON.generate(Samagotchi::Bridge::CardStore.saved(Session.session_dir(id, state_dir: default_state_dir))))
623
+ rescue StandardError
624
+ []
625
+ end
626
+
616
627
  def card_body_html(body)
617
628
  return "" if body.strip.empty?
618
629
 
@@ -1258,6 +1269,10 @@ module Samagotchi
1258
1269
  # Merged into the running turn (Steer::INPUT_KIND): part of it,
1259
1270
  # read by step +step+.
1260
1271
  message.merge!(merged: true, step: steer_step(list, index)) if norm_role == "user" && Samagotchi::Steer.input?(m)
1272
+ # The turn it started (Engine): the page pairs it with that turn's
1273
+ # record by this, not by its place.
1274
+ turn_id = m[:turn_id] || m["turn_id"]
1275
+ message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
1261
1276
  images = m[:images] || m["images"]
1262
1277
  message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
1263
1278
  if norm_role == "assistant"