samagotchi 0.5.0 → 0.6.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +4 -1
  7. data/docs/desktop.md +3 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +2 -2
  22. data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
  23. data/lib/samagotchi/desktop/macos/Panel.swift +31 -3
  24. data/lib/samagotchi/engine.rb +138 -618
  25. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  26. data/lib/samagotchi/hooks/registry.rb +11 -3
  27. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  28. data/lib/samagotchi/hooks.rb +3 -0
  29. data/lib/samagotchi/kernel_loop.rb +65 -6
  30. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  31. data/lib/samagotchi/llm/openai_chat.rb +16 -4
  32. data/lib/samagotchi/log_subscriber.rb +10 -4
  33. data/lib/samagotchi/plugin/api.rb +8 -0
  34. data/lib/samagotchi/plugin/context.rb +12 -3
  35. data/lib/samagotchi/plugin_tasks.rb +229 -0
  36. data/lib/samagotchi/question_desk.rb +280 -0
  37. data/lib/samagotchi/recap_setup.rb +128 -0
  38. data/lib/samagotchi/reply_wait.rb +9 -0
  39. data/lib/samagotchi/send_command.rb +3 -6
  40. data/lib/samagotchi/session.rb +9 -1
  41. data/lib/samagotchi/sessions_command.rb +255 -0
  42. data/lib/samagotchi/steer.rb +19 -2
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  44. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  45. data/lib/samagotchi/tool_declarations.rb +2 -2
  46. data/lib/samagotchi/tools/delegate.rb +6 -2
  47. data/lib/samagotchi/tools/delegate_wait.rb +26 -8
  48. data/lib/samagotchi/turn_note.rb +20 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +28 -10
  51. data/lib/samagotchi/web/public/app.js +94 -52
  52. data/lib/samagotchi/web/public/card.js +8 -1
  53. data/lib/samagotchi/web/public/format.js +1 -1
  54. data/lib/samagotchi/web/public/index.html +2 -5
  55. data/lib/samagotchi/web/public/stage_model.js +44 -0
  56. data/lib/samagotchi/web/public/stage_view.js +26 -17
  57. data/lib/samagotchi/web/public/timing.js +9 -3
  58. data/lib/samagotchi/web/public/turn_events.js +18 -5
  59. data/lib/samagotchi/web/public/turn_view.js +80 -16
  60. data/lib/samagotchi/web/server.rb +4 -3
  61. metadata +9 -2
  62. data/lib/samagotchi/web/public/chat_view.js +0 -376
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "log"
5
+ require_relative "idle_recap"
6
+ require_relative "recap_store"
7
+
8
+ module Samagotchi
9
+ # Builds (or disables) an Engine's idle recap job from the `recap:` kwarg
10
+ # and the Config registry. Init-only: the Engine keeps the job it returns.
11
+ module RecapSetup
12
+ # Build (or disable) the idle recap job. On by default: with no recap
13
+ # host or model configured it asks the session's current model on its
14
+ # host, resolved at each attempt the way a turn does (a /model switch
15
+ # counts). An explicit `recap: {host_ref:, model:}` or `{base_url:,
16
+ # model:}` pins it; an incomplete one warns and leaves recap off.
17
+ #
18
+ # Single precedence path: explicit `recap:` kwarg > Config registry
19
+ # (CLI > ENV > file > default). An explicit disable (`recap: false` as
20
+ # the kwarg or in the config file, `recap: {enabled: false}`, or
21
+ # SAMAGOTCHI_RECAP_ENABLED=false) always wins.
22
+ #
23
+ # @param recap [Hash, false, nil] the Engine's `recap:` kwarg
24
+ # @param engine [Engine] the recap job's engine
25
+ # @param host_registry [HostRegistry]
26
+ # @param session_target [#call] → the session's current model as a recap target
27
+ # @param session_id [#call] → String, nil
28
+ # @param state_dir [#call] → String
29
+ # @return [IdleRecap, nil]
30
+ def self.build(recap, engine:, host_registry:, session_target:, session_id:, state_dir:)
31
+ return nil if recap == false
32
+ # The TUI passes the config file's section; a worker passes nothing, so
33
+ # read it here too (a scalar `recap: false` is only seen this way).
34
+ return nil if recap.nil? && ConfigFile.recap_config == false
35
+ return nil if Samagotchi::Config.get("recap.enabled") == false
36
+
37
+ # Normalize kwarg (TerminalUI passes recap: recap_config hash or nil)
38
+ kwarg_config = recap.is_a?(Hash) ? recap : {}
39
+
40
+ base_url = string_config(kwarg_config, :base_url) || registry_string("recap.base_url")
41
+ host_ref = string_config(kwarg_config, :host_ref) || string_config(kwarg_config, :host) || registry_string("recap.host_ref")
42
+ model = string_config(kwarg_config, :model) || registry_string("recap.model")
43
+ label = model
44
+
45
+ target = nil
46
+ if base_url.nil? && host_ref.nil? && model.nil?
47
+ target = -> { session_target.call }
48
+ else
49
+ # If host_ref given, derive base_url (the host's OpenAI base) and its
50
+ # API key variable from the host_registry entry
51
+ api_key_env = nil
52
+ if host_ref && !host_ref.empty?
53
+ entry = host_registry.find_entry(host_ref)
54
+ if entry
55
+ base_url = entry.openai_base_url
56
+ api_key_env = entry.api_key_env
57
+ # If model is host-qualified, extract bare model for recap client
58
+ _, bare = host_registry.parse_qualified_model(model) if model
59
+ model = bare if bare && !bare.empty?
60
+ else
61
+ Log.warn(:recap, "host_ref_unknown", echo: "Warning: recap host_ref '#{host_ref}' not found in hosts:; recap disabled.", host_ref: host_ref)
62
+ return nil
63
+ end
64
+ end
65
+
66
+ if base_url.to_s.strip.empty? || model.to_s.strip.empty?
67
+ Log.warn(:recap, "recap_unconfigured",
68
+ echo: "Warning: SAMAGOTCHI session recap is enabled but base_url/model are missing; recap disabled. " \
69
+ "Set recap: {host_ref:, model:} or SAMAGOTCHI_RECAP_BASE_URL and SAMAGOTCHI_RECAP_MODEL (or pass recap: {base_url:, model:}), " \
70
+ "or leave them all out to recap with the session's own model.")
71
+ return nil
72
+ end
73
+ fixed = { base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.to_s.strip }
74
+ target = -> { fixed }
75
+ end
76
+
77
+ IdleRecap.new(
78
+ engine: engine,
79
+ target: target,
80
+ inactivity: recap_number_setting(kwarg_config, :inactivity, "recap.inactivity", IdleRecap::DEFAULT_INACTIVITY_SECONDS, :float),
81
+ min_user_turns: recap_number_setting(kwarg_config, :min_user_turns, "recap.min_user_turns", IdleRecap::DEFAULT_MIN_USER_TURNS, :int),
82
+ timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
83
+ sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
84
+ store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call })
85
+ )
86
+ end
87
+
88
+ # recap.sentences as [min, max]; an invalid value warns and falls back to
89
+ # the default range (the recap stays on).
90
+ def self.recap_sentences(value)
91
+ range = IdleRecap::RecapPrompt.sentences_range(value)
92
+ return range if range
93
+
94
+ default = IdleRecap::RecapPrompt::DEFAULT_SENTENCES
95
+ Log.warn(:recap, "sentences_invalid", echo: "Warning: invalid value for recap.sentences: #{value.to_s.inspect} — using #{default.join('-')}",
96
+ value: value.to_s)
97
+ default
98
+ end
99
+ private_class_method :recap_sentences
100
+
101
+ # Read a scalar recap setting via the Config registry (ENV > file > default).
102
+ def self.registry_string(key)
103
+ value = Samagotchi::Config.get(key)
104
+ value = value.to_s.strip
105
+ value.empty? ? nil : value
106
+ rescue StandardError
107
+ nil
108
+ end
109
+ private_class_method :registry_string
110
+
111
+ # Resolve a numeric recap setting: kwarg > Config registry > built-in default.
112
+ def self.recap_number_setting(kwarg_config, kwarg_key, config_key, default, numeric_type)
113
+ value = kwarg_config[kwarg_key] || kwarg_config[kwarg_key.to_s]
114
+ value = Samagotchi::Config.get(config_key) if value.nil? || value.to_s.strip.empty?
115
+ value = default if value.nil? || value.to_s.strip.empty?
116
+ numeric_type == :float ? value.to_f : value.to_i
117
+ rescue StandardError
118
+ numeric_type == :float ? default.to_f : default.to_i
119
+ end
120
+ private_class_method :recap_number_setting
121
+
122
+ def self.string_config(config, key)
123
+ value = config[key]
124
+ value.to_s.strip.empty? ? nil : value.to_s
125
+ end
126
+ private_class_method :string_config
127
+ end
128
+ end
@@ -103,6 +103,15 @@ module Samagotchi
103
103
  end
104
104
  end
105
105
 
106
+ # The session as it was before a message went in (call's baseline:):
107
+ # its messages, the question pending then, and when its last turn
108
+ # ended, so a turn that ends before the first look still ends the wait.
109
+ # @return [Hash]
110
+ def baseline_of(session, question_id: session.pending_question&.dig(:id))
111
+ last = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
112
+ { messages: session.messages.size, question_id: question_id, last_turn: last }
113
+ end
114
+
106
115
  # An idle session whose turn ended after the baseline was taken: its
107
116
  # last_turn moved on, or its messages grew (a turn's note).
108
117
  def turn_ended_since?(session, baseline)
@@ -307,12 +307,9 @@ module Samagotchi
307
307
  end
308
308
 
309
309
  # The session as it was before the message went in (ReplyWait's
310
- # baseline): its messages, the question pending then, and when its last
311
- # turn ended, so a turn that ends before the first look still ends the
312
- # wait.
313
- def baseline_of(session, question_id: session.pending_question&.dig(:id))
314
- last = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
315
- { messages: session.messages.size, question_id: question_id, last_turn: last }
310
+ # baseline).
311
+ def baseline_of(session, **options)
312
+ ReplyWait.baseline_of(session, **options)
316
313
  end
317
314
 
318
315
  # @return [Integer] the exit status
@@ -297,6 +297,9 @@ module Samagotchi
297
297
  # A scratch session nobody owns (its REPL was killed) goes whatever its
298
298
  # age, its status and the count; +test_only+ takes it too.
299
299
  #
300
+ # +max_count+ counts the sessions that stay: one deleted whatever its
301
+ # place (scratch, left empty, expired, +any_age+) takes no slot.
302
+ #
300
303
  # Archived sessions are not in .list, so they are neither deleted nor
301
304
  # counted. One unarchived is aged from when it was unarchived, if later.
302
305
  #
@@ -319,6 +322,7 @@ module Samagotchi
319
322
  deleted = []
320
323
  kept = []
321
324
  skipped = []
325
+ passed_over = 0
322
326
 
323
327
  all.each_with_index do |session, idx|
324
328
  path = File.join(state_dir, "#{session.id}#{FILE_EXT}")
@@ -365,7 +369,11 @@ module Samagotchi
365
369
  expired = updated < cutoff
366
370
  end
367
371
 
368
- overflow = max.positive? && idx >= max
372
+ # A session deleted whatever its place (scratch, left empty,
373
+ # expired, any_age) takes no max_count slot: the count is of the
374
+ # sessions ahead that stay.
375
+ overflow = max.positive? && idx - passed_over >= max
376
+ passed_over += 1 if expired || left_empty || any_age || session.scratch
369
377
 
370
378
  # retain forever when both disabled
371
379
  if max.zero? && cutoff.nil? && !left_empty && !any_age && !session.scratch
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "session"
5
+ require_relative "session_manager"
6
+ require_relative "project_scope"
7
+ require_relative "session_metrics"
8
+ require_relative "recap_store"
9
+ require_relative "session_delete_command"
10
+ require_relative "session_archive_command"
11
+
12
+ module Samagotchi
13
+ # `chi sessions`: list, stop, archive/unarchive, delete, prune and clean
14
+ # sessions from the shell (bin/chi dispatches here before OptionParser;
15
+ # the subcommands have their own flags). Not SessionCommands, the REPL's
16
+ # slash commands for sessions (session_commands.rb).
17
+ class SessionsCommand
18
+ USAGE = <<~TEXT
19
+ Usage: chi sessions <list|stop|archive|unarchive|delete|prune|clean> [options]
20
+ list [--sort updated_at|created_at] [--order desc|asc] [--limit N]
21
+ [--live] [--cwd PATH] [--format text|json|tsv] [--archived]
22
+ --live: sessions a worker runs now (the ones chi note reaches), 10 unless --limit
23
+ --cwd PATH: sessions in PATH or below; json/tsv (id<TAB>description) are for scripts
24
+ [--scope=all]: every project's sessions; by default only this git project's (all outside a repo)
25
+ --archived: archived sessions too (marked [archived]; json: archived: true)
26
+ stop ID... # stop each session's worker (IDs or unique prefixes); chi --resume ID then starts a fresh one
27
+ archive ID... # hide sessions (and their delegates) from every list and keep them for good; unarchive ID... brings them back
28
+ delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
29
+ prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
30
+ clean [--dry-run] [--days N] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
31
+ Defaults: days=14 keep=500 keep_status=none (config: session.retention_days, session.max_count, session.keep_status)
32
+ TEXT
33
+
34
+ # The value flags: "--flag V" or "--flag=V", converted with the method
35
+ # given (nil: the string as it is). Every argument is scanned, the values
36
+ # too (--cwd --live takes "--live" as the folder and turns --live on),
37
+ # a flag with nothing after it is ignored, and so is an unknown one.
38
+ VALUE_FLAGS = {
39
+ "--days" => [:days, :to_i], "--keep" => [:keep, :to_i], "--keep-status" => [:keep_status, nil],
40
+ "--sort" => [:sort, nil], "--order" => [:order, nil], "--limit" => [:limit, :to_i],
41
+ "--cwd" => [:cwd, nil], "--format" => [:format, nil], "--scope" => [:scope, nil]
42
+ }.freeze
43
+
44
+ # @param argv [Array<String>] the arguments after "sessions"
45
+ def initialize(argv, stdout: $stdout, stderr: $stderr)
46
+ @argv = argv.dup
47
+ @stdout = stdout
48
+ @stderr = stderr
49
+ end
50
+
51
+ # @return [Integer] exit status
52
+ def run
53
+ sub = @argv[0]
54
+ if sub.nil? || %w[-h --help help].include?(sub)
55
+ @stdout.puts USAGE
56
+ return 0
57
+ end
58
+ @opts = parse_flags
59
+ case sub
60
+ when "delete"
61
+ Samagotchi::SessionDeleteCommand.new(@argv[1..], stdout: @stdout, stderr: @stderr).run
62
+ when "archive", "unarchive"
63
+ Samagotchi::SessionArchiveCommand.new(sub, @argv[1..], stdout: @stdout, stderr: @stderr).run
64
+ when "list" then list
65
+ when "stop" then stop
66
+ when "prune", "clean" then prune(sub)
67
+ else
68
+ @stderr.puts "Unknown sessions subcommand: #{sub}. Use: list, stop, archive, unarchive, delete, prune, clean"
69
+ 1
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def parse_flags
76
+ opts = {
77
+ dry_run: @argv.include?("--dry-run"),
78
+ test_only: @argv.include?("--test-only") || @argv.include?("--test"),
79
+ live: @argv.include?("--live"),
80
+ include_archived: @argv.include?("--archived")
81
+ }
82
+ @argv.each_with_index do |arg, i|
83
+ nxt = @argv[i + 1]
84
+ VALUE_FLAGS.each do |flag, (key, convert)|
85
+ value = if arg == flag && nxt then nxt
86
+ elsif arg.start_with?("#{flag}=") then arg.split("=", 2).last
87
+ end
88
+ next unless value
89
+
90
+ opts[key] = convert ? value.public_send(convert) : value
91
+ break
92
+ end
93
+ end
94
+ opts
95
+ end
96
+
97
+ def list
98
+ format, scope, cwd = @opts.values_at(:format, :scope, :cwd)
99
+ unless format.nil? || %w[text json tsv].include?(format)
100
+ @stderr.puts "Unknown format #{format.inspect}: use --format text|json|tsv"
101
+ return 1
102
+ end
103
+ unless scope.nil? || %w[project all].include?(scope)
104
+ @stderr.puts "Unknown scope #{scope.inspect}: use --scope=project|all"
105
+ return 1
106
+ end
107
+ # The current git project's sessions (Samagotchi::ProjectScope), like
108
+ # chi web's; --scope=all, a folder in no repo or an explicit --cwd: every
109
+ # project's.
110
+ project = scope == "all" || cwd ? nil : Samagotchi::ProjectScope.root_for(Dir.pwd)
111
+ scope_note = ->(count) { "#{count} session(s) in #{File.basename(project)} (--scope=all: every project)" }
112
+
113
+ # The picker (chi note from a script): filters apply before the limit,
114
+ # and test runs stay out, unless this is one (SAMAGOTCHI_ENV=test, CI):
115
+ # then its own sessions are what it looks for.
116
+ return list_summaries(project, scope_note) if @opts[:live] || cwd || (format && format != "text")
117
+
118
+ list_plain(project, scope_note)
119
+ end
120
+
121
+ def list_summaries(project, scope_note)
122
+ live, cwd, limit, include_archived, format = @opts.values_at(:live, :cwd, :limit, :include_archived, :format)
123
+ summaries = Samagotchi::SessionManager.session_summaries(
124
+ live: live, cwd: cwd, limit: limit || (live ? 10 : nil), include_tests: Samagotchi::Session.test_session_env?,
125
+ project_root: project,
126
+ include_archived: include_archived
127
+ )
128
+ case format
129
+ when "json"
130
+ # owner: "worker", "tui" (a chi REPL: it takes no notes or messages)
131
+ # or nil; recap: the saved recap's first sentence, or nil; project:
132
+ # its git project's root, or nil
133
+ # parent_id: the session that delegated it (the delegate tool), or nil
134
+ # archived: hidden from the lists (only with --archived can it be true)
135
+ # scratch: a `chi scratch` session (deleted when its REPL ends, a
136
+ # leftover one at the next sweep)
137
+ # ctx_pct: how full the context was after the last turn, or nil
138
+ keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct]
139
+ @stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
140
+ when "tsv"
141
+ summaries.each { |summary| @stdout.puts "#{summary[:id]}\t#{summary[:desc]}" }
142
+ else
143
+ summaries.each do |summary|
144
+ state = summary[:busy] ? "running" : (summary[:live] ? "live" : summary[:status].to_s)
145
+ # The saved recap's first sentence says more than the last prompt
146
+ # (text only: tsv and json keep desc for pickers).
147
+ desc = summary[:desc]
148
+ if summary[:recap]
149
+ desc = [File.basename(summary[:cwd].to_s), summary[:recap]].reject(&:empty?).join(" · ")
150
+ desc = "#{desc[0, 59]}…" if desc.length > 60
151
+ end
152
+ # A delegated session points at its parent.
153
+ child = summary[:parent_short_id] ? " ↳ #{summary[:parent_short_id]}" : ""
154
+ flag = summary[:scratch] ? " [scratch]" : (summary[:test_run] ? " [test]" : "")
155
+ flag += " [archived]" if summary[:archived]
156
+ ctx = Samagotchi::SessionMetrics.context_label(summary[:ctx_pct])
157
+ @stdout.puts "#{summary[:id]} #{state.ljust(8)} #{ctx.ljust(8)} #{summary[:updated_at]} #{desc}#{flag}#{child}"
158
+ end
159
+ if project
160
+ @stdout.puts "#{summaries.empty? ? "" : "\n"}#{scope_note.call(summaries.size)}"
161
+ else
162
+ @stdout.puts summaries.empty? ? "No sessions." : "\n#{summaries.size} session(s)"
163
+ end
164
+ end
165
+ 0
166
+ end
167
+
168
+ def list_plain(project, scope_note)
169
+ sort, order, limit, include_archived = @opts.values_at(:sort, :order, :limit, :include_archived)
170
+ sort ||= "updated_at"
171
+ order ||= "desc"
172
+ sessions = Samagotchi::SessionManager.list_sessions(sort: sort, order: order, limit: limit, project_root: project,
173
+ include_archived: include_archived)
174
+ # The saved recap's first sentence, else the last prompt, cut as before.
175
+ state_dir = Samagotchi::Session.default_state_dir
176
+ list_text = lambda do |s|
177
+ recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
178
+ (recap || Samagotchi::SessionManager.one_line(s.last_prompt))[0, 60]
179
+ end
180
+ # A delegated session points at its parent: ↳ <parent's short id>.
181
+ row = lambda do |s|
182
+ flag = s.scratch ? " [scratch]" : (s.test_run ? " [test]" : "")
183
+ flag += " [archived]" if s.archived
184
+ child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
185
+ # How full the context was after the last turn: "ctx 12%", blank when unknown.
186
+ ctx = Samagotchi::SessionMetrics.context_label(
187
+ Samagotchi::SessionMetrics.saved_context_pct(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
188
+ )
189
+ "#{s.id} #{s.status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
190
+ end
191
+ if project
192
+ sessions.each { |s| @stdout.puts row.call(s) }
193
+ @stdout.puts "#{sessions.empty? ? "" : "\n"}#{scope_note.call(sessions.size)}"
194
+ elsif sessions.empty?
195
+ @stdout.puts "No sessions."
196
+ else
197
+ sessions.each { |s| @stdout.puts row.call(s) }
198
+ @stdout.puts "\n#{sessions.size} session(s) (sort=#{sort} order=#{order})"
199
+ end
200
+ 0
201
+ end
202
+
203
+ def stop
204
+ ids = @argv[1..]
205
+ if ids.empty? || ids.any? { |arg| arg.start_with?("-") }
206
+ @stderr.puts "Usage: chi sessions stop ID..."
207
+ return 1
208
+ end
209
+ # Each id in turn, like chi sessions delete: stdout is flushed before an
210
+ # error line, so the output keeps the order of the ids given.
211
+ ok = ids.uniq.map do |given|
212
+ begin
213
+ id = Samagotchi::Session.resolve_id(given)
214
+ released = Samagotchi::SessionManager.stop_session(id, wait: 10)
215
+ rescue Samagotchi::SessionManager::OwnedByTUI
216
+ @stdout.flush
217
+ @stderr.puts "session #{id} is open in a chi REPL; close it there first"
218
+ next false
219
+ rescue ArgumentError => e
220
+ @stdout.flush
221
+ @stderr.puts e.message
222
+ next false
223
+ end
224
+ if released
225
+ @stdout.puts "Stopped session #{id}. chi --resume #{id} starts a fresh worker."
226
+ else
227
+ @stdout.flush
228
+ @stderr.puts "Stopped session #{id}, but its worker is still shutting down; try chi --resume #{id} in a moment."
229
+ end
230
+ true
231
+ end
232
+ ok.all? ? 0 : 1
233
+ end
234
+
235
+ def prune(sub)
236
+ days, keep, keep_status, dry_run, test_only = @opts.values_at(:days, :keep, :keep_status, :dry_run, :test_only)
237
+ test_only = true if sub == "clean" && !@argv.include?("--all")
238
+ # Test sessions are throwaway: clean takes them whatever their age,
239
+ # unless --days asks for the older ones only. Live workers and
240
+ # keep_status still protect a session.
241
+ any_age = sub == "clean" && test_only && days.nil?
242
+ result = Samagotchi::SessionManager.prune_sessions(days: days, max_count: keep, keep_status: keep_status, dry_run: dry_run,
243
+ test_only: test_only, any_age: any_age)
244
+ mode = dry_run ? "Would delete" : "Deleted"
245
+ @stdout.puts "#{mode} #{result[:deleted].size} sessions (kept #{result[:kept].size}, skipped #{result[:skipped].size})"
246
+ if result[:deleted].any?
247
+ result[:deleted].each { |id| @stdout.puts " #{id}" }
248
+ end
249
+ if dry_run && result[:deleted].any?
250
+ @stdout.puts "\nRun without --dry-run to delete."
251
+ end
252
+ 0
253
+ end
254
+ end
255
+ end
@@ -7,17 +7,23 @@ module Samagotchi
7
7
  # lines merge into one user message as before; each steer follows it as its
8
8
  # own user message marked kind: "steer" and source: (the ContextNote keys,
9
9
  # which every message copy keeps). The model reads it as user text.
10
+ #
11
+ # The user's own lines merged into a running turn are marked kind:
12
+ # "input": they are part of that turn, not a turn of their own, so a
13
+ # reloaded history keeps the turn in one piece.
10
14
  module Steer
11
15
  KIND = "steer"
16
+ INPUT_KIND = "input"
12
17
 
13
18
  # What one drain brought: the merged user text (nil when none) and how
14
19
  # many lines made it, and the steers in order.
15
20
  Merge = Struct.new(:content, :count, :steers, keyword_init: true) do
16
21
  def empty? = content.nil? && steers.empty?
17
22
 
18
- # The messages it appends: the user's first, then each steer.
23
+ # The messages it appends: the user's first (marked merged input),
24
+ # then each steer.
19
25
  def messages
20
- list = content ? [{ role: "user", content: content }] : []
26
+ list = content ? [{ role: "user", kind: INPUT_KIND, content: content }] : []
21
27
  list + steers.map { |steer| Steer.message(**steer) }
22
28
  end
23
29
 
@@ -46,6 +52,17 @@ module Samagotchi
46
52
  message.is_a?(Hash) && (message[:role] || message["role"]).to_s == "user" && !steer?(message)
47
53
  end
48
54
 
55
+ # The user's lines merged into a running turn (Merge#messages).
56
+ def input?(message)
57
+ message.is_a?(Hash) && (message[:kind] || message["kind"]).to_s == INPUT_KIND
58
+ end
59
+
60
+ # A prompt that started a turn: not a steer, not input merged into a
61
+ # running turn. What the turns a page shows count.
62
+ def turn_prompt?(message)
63
+ prompt?(message) && !input?(message)
64
+ end
65
+
49
66
  # Call a loop's drain: +at_answer+ goes only to a drain that takes it
50
67
  # (the Engine's); a caller's own drain (a queue's #drain) is called bare.
51
68
  # A failing drain drains nothing (the loop keeps going).
@@ -945,15 +945,19 @@ module Samagotchi
945
945
  # The snapshot's cards and between-turns notices (Bridge CardStore):
946
946
  # on join the ones since the last turn (and the running turn's), as
947
947
  # the join header shows the last exchange; on a resync only cards not
948
- # shown here yet. A turn's own notices are left out: the running
949
- # turn's come with its parts, and the join shows no older turn's steps.
948
+ # shown here yet. A turn's own rows (notices, retries) are left out:
949
+ # the running turn's come with its parts, and the join shows no older
950
+ # turn's steps.
950
951
  def render_snapshot_cards(entries, joining:)
951
952
  entries.each do |entry|
952
- if entry[:type].to_s == "hook_notice"
953
+ case entry[:type].to_s
954
+ when "hook_notice"
953
955
  next if entry[:in_turn]
954
956
 
955
957
  @screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
956
- elsif joining ? entry[:turns_since].to_i.zero? : !@renderer.card_shown?(entry[:id])
958
+ when "card"
959
+ next unless joining ? entry[:turns_since].to_i.zero? : !@renderer.card_shown?(entry[:id])
960
+
957
961
  @renderer.render_card(entry, updated: entry[:updated] == true)
958
962
  end
959
963
  end
@@ -986,13 +990,22 @@ module Samagotchi
986
990
  when "input" then @screen.commit("input> #{part[:text]}")
987
991
  when "steer" then @screen.commit(format_steer_line(source: part[:source], text: part[:text]))
988
992
  when "reminder" then @screen.commit(reminder_line(part[:reminders]))
989
- when "hook_notice" then @screen.commit(EventRenderer.hook_notice_line(part))
993
+ when "notice" then replay_notice(part[:event])
990
994
  end
991
995
  end
992
996
  @view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
993
997
  ask(turn[:pending_question]) if turn[:pending_question]
994
998
  end
995
999
 
1000
+ # One of the running turn's rows (Bridge::TurnNotice), drawn as it was
1001
+ # live: a hook's notice and a retry's line. Questions are left out:
1002
+ # the pending one is asked after the replay, answered ones showed no
1003
+ # line.
1004
+ def replay_notice(event)
1005
+ event = EventRenderer.symbolize(event || {})
1006
+ @renderer.call(event) if %i[hook_notice empty_answer_retry].include?(event[:type])
1007
+ end
1008
+
996
1009
  def show_enqueued(event)
997
1010
  return if own?(event[:client_id])
998
1011
 
@@ -37,9 +37,11 @@ module Samagotchi
37
37
  paint("#{source.to_s.empty? ? "plugin" : source}> nudged: #{first}", 90)
38
38
  end
39
39
 
40
- # "↻ empty answer, asking again (1/1)": the loop retries an empty answer.
40
+ # "↻ empty answer, asking again (1/1)": the loop retries an empty answer;
41
+ # "↻ cut by loop-guard, asking again (1/1)" after a plugin cut it.
41
42
  def format_empty_retry_line(event)
42
- paint("↻ empty answer, asking again (#{event[:attempt]}/#{event[:of]})", 90)
43
+ what = event[:stopped_by] ? "cut by #{event[:stopped_by]}" : "empty answer"
44
+ paint("↻ #{what}, asking again (#{event[:attempt]}/#{event[:of]})", 90)
43
45
  end
44
46
 
45
47
  # " → image 1280×800" after a tool line whose tool read an image.
@@ -348,7 +348,7 @@ module Samagotchi
348
348
  },
349
349
  timeout: {
350
350
  type: "integer",
351
- description: "Maximum seconds to wait (default: 600); a timed-out child keeps running"
351
+ description: "Maximum seconds to wait (default: 600; 0 checks once without waiting); a timed-out child keeps running"
352
352
  }
353
353
  },
354
354
  required: ["task"]
@@ -366,7 +366,7 @@ module Samagotchi
366
366
  },
367
367
  timeout: {
368
368
  type: "integer",
369
- description: "Maximum seconds to wait (default: 600)"
369
+ description: "Maximum seconds to wait (default: 600; 0 checks once without waiting)"
370
370
  }
371
371
  }
372
372
  }
@@ -83,6 +83,7 @@ module Samagotchi
83
83
  child = SessionManager.spawn_session(prompt: task, working_directory: parent.working_directory,
84
84
  model_name: child_model(model, parent), memories: CHILD_MEMORIES,
85
85
  parent_id: parent.id, state_dir: state_dir)
86
+ DelegateWait.mark_started(parent.id, child)
86
87
  child.id
87
88
  end
88
89
  private_class_method :start_child
@@ -100,7 +101,7 @@ module Samagotchi
100
101
  end
101
102
 
102
103
  # Only a reply after this delivery counts as the follow-up's.
103
- DelegateWait.mark_seen(parent.id, id, state_dir: state_dir)
104
+ DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
104
105
  delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{CLIENT_PREFIX}:#{parent.id[0, 8]}",
105
106
  state_dir: state_dir)
106
107
  case delivered[:status]
@@ -158,9 +159,12 @@ module Samagotchi
158
159
  !%w[false 0 no off].include?(value.to_s.strip.downcase)
159
160
  end
160
161
 
162
+ # Seconds to wait: 0 (or less) looks once and returns at once; a
163
+ # missing or unreadable value is the default.
164
+ # @return [Integer]
161
165
  def self.parse_timeout(value)
162
166
  parsed = Integer(value.to_s.strip, exception: false)
163
- parsed&.positive? ? parsed : DelegateWait::TIMEOUT_DEFAULT
167
+ parsed ? [parsed, 0].max : DelegateWait::TIMEOUT_DEFAULT
164
168
  end
165
169
  end
166
170
  end