samagotchi 0.101.0 → 0.103.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/bin/chi +31 -196
  4. data/docs/architecture.md +62 -6
  5. data/docs/cli.md +154 -7
  6. data/docs/configuration.md +32 -5
  7. data/docs/guardrails.md +2 -1
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +1 -1
  10. data/docs/internals/llm-context-forget.md +25 -1
  11. data/docs/internals/prompt-caching.md +8 -1
  12. data/docs/internals/remote-sessions.md +303 -0
  13. data/docs/internals/working-set.md +174 -15
  14. data/docs/plugins.md +32 -5
  15. data/docs/sessions.md +38 -14
  16. data/docs/sub-agent.md +8 -0
  17. data/docs/testing.md +7 -0
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
  20. data/lib/samagotchi/bridge.rb +33 -7
  21. data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
  22. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  23. data/lib/samagotchi/bridge_client.rb +22 -38
  24. data/lib/samagotchi/bundle_command/check.rb +5 -1
  25. data/lib/samagotchi/bundle_command/try/command.rb +6 -6
  26. data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
  27. data/lib/samagotchi/bundle_command.rb +4 -4
  28. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  30. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  32. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  33. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  34. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  35. data/lib/samagotchi/chi_invocation.rb +43 -0
  36. data/lib/samagotchi/cli/command.rb +3 -2
  37. data/lib/samagotchi/cli/flags.rb +5 -0
  38. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  39. data/lib/samagotchi/commands/registry.rb +12 -1
  40. data/lib/samagotchi/config.rb +89 -53
  41. data/lib/samagotchi/context_command.rb +80 -73
  42. data/lib/samagotchi/engine.rb +138 -15
  43. data/lib/samagotchi/idle_recap.rb +7 -1
  44. data/lib/samagotchi/idle_scheduler.rb +16 -1
  45. data/lib/samagotchi/kernel_loop.rb +13 -1
  46. data/lib/samagotchi/launch_mode.rb +22 -0
  47. data/lib/samagotchi/llm_context_edit.rb +32 -10
  48. data/lib/samagotchi/llm_context_forget.rb +24 -8
  49. data/lib/samagotchi/llm_context_view.rb +19 -7
  50. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  51. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  52. data/lib/samagotchi/message_parts.rb +8 -3
  53. data/lib/samagotchi/note_command.rb +8 -7
  54. data/lib/samagotchi/parent_report.rb +55 -43
  55. data/lib/samagotchi/plugin/context.rb +2 -2
  56. data/lib/samagotchi/prompt.rb +1 -1
  57. data/lib/samagotchi/recap_command.rb +68 -0
  58. data/lib/samagotchi/recap_setup.rb +27 -14
  59. data/lib/samagotchi/remote_command.rb +170 -0
  60. data/lib/samagotchi/remotes.rb +130 -0
  61. data/lib/samagotchi/run_options.rb +233 -0
  62. data/lib/samagotchi/send_command/busy.rb +58 -0
  63. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  64. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  65. data/lib/samagotchi/send_command.rb +186 -118
  66. data/lib/samagotchi/session.rb +48 -16
  67. data/lib/samagotchi/session_access/contract.rb +129 -0
  68. data/lib/samagotchi/session_access/dispatch.rb +133 -0
  69. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  70. data/lib/samagotchi/session_access/local.rb +349 -0
  71. data/lib/samagotchi/session_access/remote.rb +638 -0
  72. data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
  73. data/lib/samagotchi/session_access.rb +141 -0
  74. data/lib/samagotchi/session_archive_command.rb +9 -5
  75. data/lib/samagotchi/session_commands.rb +53 -6
  76. data/lib/samagotchi/session_config.rb +411 -0
  77. data/lib/samagotchi/session_delete_command.rb +8 -4
  78. data/lib/samagotchi/session_manager.rb +7 -2
  79. data/lib/samagotchi/session_setup.rb +15 -10
  80. data/lib/samagotchi/sessions_command.rb +42 -16
  81. data/lib/samagotchi/terminal_ui/attach_launcher.rb +54 -18
  82. data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +82 -76
  84. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  85. data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
  86. data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
  87. data/lib/samagotchi/terminal_ui.rb +8 -6
  88. data/lib/samagotchi/testing/bundle.rb +3 -2
  89. data/lib/samagotchi/title_command.rb +9 -11
  90. data/lib/samagotchi/version.rb +1 -1
  91. data/lib/samagotchi/web/app.rb +387 -79
  92. data/lib/samagotchi/web/public/app.js +2 -0
  93. data/lib/samagotchi/web/public/commands.js +8 -2
  94. data/lib/samagotchi/web/public/data.js +6 -4
  95. data/lib/samagotchi/web/public/index.html +37 -3
  96. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  97. data/lib/samagotchi/web/public/stage_view.js +3 -2
  98. data/lib/samagotchi/web/public/stream_handlers.js +7 -1
  99. data/lib/samagotchi/web/public/turn_html.js +5 -0
  100. data/lib/samagotchi/web/public/working_set.js +247 -24
  101. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  102. data/lib/samagotchi/web/public/working_set_view.js +198 -25
  103. data/lib/samagotchi/web/session_access_routes.rb +343 -0
  104. data/lib/samagotchi/worker.rb +9 -6
  105. data/lib/samagotchi/worker_sidecar.rb +12 -5
  106. data/lib/samagotchi/working_set.rb +132 -20
  107. data/lib/samagotchi/working_set_command.rb +222 -0
  108. data/lib/samagotchi/working_set_pin.rb +34 -0
  109. data/lib/samagotchi/working_set_steer.rb +414 -0
  110. metadata +23 -2
  111. data/lib/samagotchi/bundle_session_settings.rb +0 -251
@@ -187,6 +187,9 @@ module Samagotchi
187
187
  mark[:staged] = true unless edit.applied?
188
188
  if edit.kind == :stale
189
189
  mark[:note] = edit.note[/superseded by .*\z/] || edit.note
190
+ elsif edit.kind == LLMContextEdit::DROP
191
+ # The user's: no note, no grouping (a forget's key is its note).
192
+ mark[:by] = edit.by
190
193
  else
191
194
  key = [edit.note, edit.by, edit.staged_at]
192
195
  if first.key?(key)
@@ -201,12 +204,14 @@ module Samagotchi
201
204
  mark
202
205
  end
203
206
 
204
- # +output+'s ✂ mark (+marks+ by +id+); an applied stale stub says
205
- # what it frees: its output, ~chars/4.
207
+ # +output+'s ✂ mark (+marks+ by +id+); an applied stale stub or a
208
+ # user's drop says what it frees: its output, ~chars/4.
209
+ WHOLE_STUBS = %w[stale drop].freeze
210
+
206
211
  def mark_of(id, marks, output)
207
212
  mark = marks[id]
208
213
  return nil unless mark
209
- return mark unless mark[:kind] == "stale" && !mark[:staged] && output
214
+ return mark unless WHOLE_STUBS.include?(mark[:kind]) && !mark[:staged] && output
210
215
 
211
216
  mark.merge(tokens: (output.length / TokenUsage::CHARS_PER_TOKEN).ceil)
212
217
  end
@@ -6,6 +6,7 @@ require_relative "note_delivery"
6
6
  require_relative "session_manager"
7
7
  require_relative "cli/command"
8
8
  require_relative "cli/flags"
9
+ require_relative "session_access/local"
9
10
 
10
11
  module Samagotchi
11
12
  # `chi note`: push text into sessions as a context note (background the
@@ -33,12 +34,15 @@ module Samagotchi
33
34
  end
34
35
 
35
36
  # @param argv [Array<String>] the arguments after "note"
36
- def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil)
37
+ # @param access [SessionAccess::Local, nil] where the sessions are;
38
+ # default this machine's, in +state_dir+
39
+ def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, access: nil)
37
40
  @argv = argv.dup
38
41
  @stdin = stdin
39
42
  @stdout = stdout
40
43
  @stderr = stderr
41
44
  @state_dir = state_dir || Session.default_state_dir
45
+ @access = access || SessionAccess::Local.new(state_dir: @state_dir)
42
46
  end
43
47
 
44
48
  # @return [Integer] exit status: 0 all queued, 1 any refused or failed,
@@ -93,8 +97,7 @@ module Samagotchi
93
97
 
94
98
  # --all: every live session. @return [Integer] exit status
95
99
  def deliver_to_live(text, source)
96
- ids = SessionManager.session_summaries(live: true, include_tests: Session.test_session_env?,
97
- state_dir: @state_dir).map { |s| s[:id] }
100
+ ids = @access.summaries(live: true, include_tests: Session.test_session_env?).map { |s| s[:id] }
98
101
  if ids.empty?
99
102
  error_line("chi note: no live sessions (chi sessions list --live --scope=all)")
100
103
  return 1
@@ -104,9 +107,7 @@ module Samagotchi
104
107
  end
105
108
 
106
109
  def resolve(given)
107
- id = Session.resolve_id(given, state_dir: @state_dir)
108
- Session.load(id, state_dir: @state_dir)
109
- id
110
+ @access.resolve(given)
110
111
  rescue ArgumentError => e
111
112
  message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
112
113
  error_line("chi note: #{message}")
@@ -115,7 +116,7 @@ module Samagotchi
115
116
 
116
117
  # @return [Boolean] whether the note was queued
117
118
  def deliver(id, text, source)
118
- result = NoteDelivery.deliver(id, text: text, source: source, state_dir: @state_dir)
119
+ result = @access.note(id, text: text, source: source)
119
120
  @stdout.puts("#{id[0, 8]} #{outcome(result)}")
120
121
  result.delivered?
121
122
  end
@@ -3,6 +3,7 @@
3
3
  require "delegate"
4
4
  require "json"
5
5
 
6
+ require_relative "chi_invocation"
6
7
  require_relative "cli/exit"
7
8
  require_relative "pending_question"
8
9
  require_relative "session"
@@ -81,7 +82,9 @@ module Samagotchi
81
82
  # @return [Hash] the --format json object
82
83
  # @param strategy [Guardrails::ParentPolicy::Strategy, nil] what a parent
83
84
  # may pick on the waited-for session (#strategy_for); nil: config.yml's
84
- def json(result, session_id:, timeout: nil, extra: {}, strategy: nil)
85
+ # @param via [ChiInvocation] how the printed commands are spelled (this
86
+ # machine's chi, or chi --remote NAME); each function here takes it
87
+ def json(result, session_id:, timeout: nil, extra: {}, strategy: nil, via: ChiInvocation::HERE)
85
88
  report = { status: status(result), session_id: session_id }
86
89
  report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
87
90
  case report[:status]
@@ -89,10 +92,10 @@ module Samagotchi
89
92
  when "question"
90
93
  question = question(result.question, strategy: strategy)
91
94
  report[:question] = question
92
- report[:answer_with] = answer_with(session_id, question)
95
+ report[:answer_with] = answer_with(session_id, question, via: via)
93
96
  else
94
97
  report.merge!(stop_facts(result))
95
- report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy)
98
+ report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy, via: via)
96
99
  end
97
100
  report.merge(extra)
98
101
  end
@@ -110,8 +113,8 @@ module Samagotchi
110
113
  end
111
114
 
112
115
  # @return [String] the JSON object as one line
113
- def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil)
114
- JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
116
+ def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil, via: ChiInvocation::HERE)
117
+ JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy, via: via))
115
118
  end
116
119
 
117
120
  # Print a wait's end the way the commands do: the JSON object on
@@ -120,17 +123,19 @@ module Samagotchi
120
123
  # @param command [String] "chi send", "chi answer"
121
124
  # @param extra [Hash] more keys for the JSON object (#json)
122
125
  # @return [Integer] the exit status
123
- def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil)
126
+ def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil,
127
+ via: ChiInvocation::HERE)
124
128
  if json
125
- stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
129
+ stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy, via: via))
126
130
  elsif result.status == :done
127
131
  stdout.puts(result.text)
128
132
  else
129
133
  stdout.flush
130
134
  text = if result.status == :waiting_for_answer
131
- question_text(result.question, session_id: session_id, strategy: strategy)
135
+ question_text(result.question, session_id: session_id, strategy: strategy, via: via)
132
136
  else
133
- "#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy)}\n"
137
+ "#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy,
138
+ via: via)}\n"
134
139
  end
135
140
  stderr.print("#{command}: #{text}")
136
141
  end
@@ -177,16 +182,22 @@ module Samagotchi
177
182
  # lists an Allow once a parent may give). A step-limit question's
178
183
  # continues the turn, or stops it where a parent may not continue (its
179
184
  # other form is in #question_text).
180
- def answer_with(session_id, question)
181
- return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
182
- return stop_command(session_id, question) if continue?(question) && !may_continue?(question)
183
- return "chi answer #{session_id} --question #{question[:id]} --option Continue" if continue?(question)
185
+ def answer_with(session_id, question, via: ChiInvocation::HERE)
186
+ return answer_command(session_id, question, "Deny --text WHY", via: via) if approval?(question)
187
+ return stop_command(session_id, question, via: via) if continue?(question) && !may_continue?(question)
188
+ return answer_command(session_id, question, "Continue", via: via) if continue?(question)
184
189
 
185
190
  # The one option a parent may pick (cost-cap's Stop under hard).
186
191
  only = Array(question[:allowed_options])
187
- return "chi answer #{session_id} --question #{question[:id]} --option #{option_arg(only.first)}" if only.size == 1
192
+ return answer_command(session_id, question, option_arg(only.first), via: via) if only.size == 1
188
193
 
189
- "chi answer #{session_id} --question #{question[:id]} --option N"
194
+ answer_command(session_id, question, "N", via: via)
195
+ end
196
+
197
+ # chi answer ID --question QID --option +option+ (its words after
198
+ # --option: a label, maybe --text).
199
+ def answer_command(session_id, question, option, via: ChiInvocation::HERE)
200
+ via.command("answer #{session_id} --question #{question[:id]} --option #{option}")
190
201
  end
191
202
 
192
203
  # What a parent does with an approval it may not allow.
@@ -239,17 +250,18 @@ module Samagotchi
239
250
  question.fetch(:allowed_options, question[:options]).include?(Guardrails::ParentPolicy::CONTINUE)
240
251
  end
241
252
 
242
- def stop_command(session_id, question)
243
- "chi answer #{session_id} --question #{question[:id]} --option Stop --text WHY"
253
+ def stop_command(session_id, question, via: ChiInvocation::HERE)
254
+ answer_command(session_id, question, "Stop --text WHY", via: via)
244
255
  end
245
256
 
246
257
  def option_arg(label) = label.match?(/\A[\w.-]+\z/) ? label : label.inspect
247
258
 
248
259
  # The other way: the user answers it in chi (the web's bell and badge
249
260
  # say it waits), and a wait with no message waits past it.
250
- def leave_open(session_id)
251
- "or leave it open: tell your user it waits in chi web (session #{session_id[0, 8]}); " \
252
- "chi send --wait --format json #{session_id} waits until they answer"
261
+ def leave_open(session_id, via: ChiInvocation::HERE)
262
+ web = via.remote? ? via.web : "chi web"
263
+ "or leave it open: tell your user it waits in #{web} (session #{session_id[0, 8]}); " \
264
+ "#{via.command("send --wait --format json #{session_id}")} waits until they answer"
253
265
  end
254
266
 
255
267
  # @param question [PendingQuestion, Hash, nil] a pending question, or a
@@ -267,13 +279,13 @@ module Samagotchi
267
279
  end
268
280
 
269
281
  # What a message does to a step-limit question.
270
- def message_drops(session_id)
271
- "a message instead (chi send #{session_id} -m …) drops it and starts a new turn"
282
+ def message_drops(session_id, via: ChiInvocation::HERE)
283
+ "a message instead (#{via.command("send #{session_id} -m …")}) drops it and starts a new turn"
272
284
  end
273
285
 
274
286
  # What a wait that ended without an answer says, one line.
275
- def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil)
276
- attach = "chi --attach #{session_id}"
287
+ def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil, via: ChiInvocation::HERE)
288
+ shows = via.shows(session_id)
277
289
  case result.status
278
290
  when :waiting_for_answer
279
291
  q = question(result.question, strategy: strategy)
@@ -281,20 +293,20 @@ module Samagotchi
281
293
  "waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q, strategy: strategy)}"
282
294
  elsif continue?(q) && !may_continue?(q)
283
295
  "waiting at the step limit: #{first_line(q[:text])}; a parent may only stop it here " \
284
- "(#{policy(strategy).label}): #{stop_command(session_id, q)}, or tell your user it waits"
296
+ "(#{policy(strategy).label}): #{stop_command(session_id, q, via: via)}, or tell your user it waits"
285
297
  elsif continue?(q)
286
298
  "waiting at the step limit: #{first_line(q[:text])}; " \
287
- "answer Continue or Stop: chi answer #{session_id} --question #{q[:id]} --option Continue"
299
+ "answer Continue or Stop: #{answer_command(session_id, q, "Continue", via: via)}"
288
300
  else
289
- "waiting for an answer: #{first_line(q[:text])}; open it: #{attach} or the web"
301
+ "waiting for an answer: #{first_line(q[:text])}; open it: #{via.open_it(session_id)}"
290
302
  end
291
- when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy)}; #{attach} shows it"
292
- when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
293
- when :worker_gone then "the worker is gone; #{attach} shows what happened"
303
+ when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy, via: via)}; #{shows} shows it"
304
+ when :error then "the worker failed: #{result.text}; #{shows} shows what happened"
305
+ when :worker_gone then "the worker is gone; #{shows} shows what happened"
294
306
  when :stopped then "the session was stopped (chi sessions stop)"
295
307
  else
296
- wait_again = "chi send --wait --turn #{turn_id} #{session_id}" unless turn_id.to_s.empty?
297
- hint = wait_again || attach
308
+ wait_again = via.command("send --wait --turn #{turn_id} #{session_id}") unless turn_id.to_s.empty?
309
+ hint = wait_again || shows
298
310
  timeout ? "still running after #{format("%g", timeout)} s: #{hint}" : "still running: #{hint}"
299
311
  end
300
312
  end
@@ -302,7 +314,7 @@ module Samagotchi
302
314
  # The whole question for stderr: header, text, numbered options, what
303
315
  # else it takes, and the commands that answer it.
304
316
  # @return [String] lines, newline-terminated
305
- def question_text(pending, session_id:, strategy: nil)
317
+ def question_text(pending, session_id:, strategy: nil, via: ChiInvocation::HERE)
306
318
  q = question(pending, strategy: strategy)
307
319
  lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
308
320
  text_lines = q[:text].strip.lines.map(&:rstrip)
@@ -315,31 +327,31 @@ module Samagotchi
315
327
  lines << " allowing it is up to your user: #{DENY_AND_TELL}"
316
328
  else
317
329
  lines << " #{approval_advice(q, strategy: strategy)}"
318
- allows.each { |label| lines << " allow: chi answer #{session_id} --question #{q[:id]} --option #{option_arg(label)}" }
330
+ allows.each { |label| lines << " allow: #{answer_command(session_id, q, option_arg(label), via: via)}" }
319
331
  end
320
- lines << " deny: #{answer_with(session_id, q)}"
321
- lines << " #{leave_open(session_id)}"
332
+ lines << " deny: #{answer_with(session_id, q, via: via)}"
333
+ lines << " #{leave_open(session_id, via: via)}"
322
334
  elsif continue?(q)
323
335
  lines << if may_continue?(q)
324
- " continue: #{answer_with(session_id, q)}"
336
+ " continue: #{answer_with(session_id, q, via: via)}"
325
337
  else
326
338
  " continuing it is up to your user (#{policy(strategy).label}): stop it, or tell your user it waits"
327
339
  end
328
- lines << " stop: #{stop_command(session_id, q)} (the text is optional; the model reads it)"
329
- lines << " #{message_drops(session_id)}"
340
+ lines << " stop: #{stop_command(session_id, q, via: via)} (the text is optional; the model reads it)"
341
+ lines << " #{message_drops(session_id, via: via)}"
330
342
  else
331
343
  lines << " more than one allowed: repeat --option" if q[:multi_select]
332
344
  lines << " free text allowed: --text" if q[:allow_freeform]
333
345
  if q[:allowed_options]
334
346
  lines << " a parent may pick #{q[:allowed_options].map { |o| option_arg(o) }.join(", ")}; the others are up to your user"
335
347
  end
336
- lines << " answer: #{answer_with(session_id, q)}"
337
- lines << " or open it: chi --attach #{session_id} or the web"
348
+ lines << " answer: #{answer_with(session_id, q, via: via)}"
349
+ lines << " or open it: #{via.open_it(session_id)}"
338
350
  end
339
351
  lines.map { |line| "#{line}\n" }.join
340
352
  end
341
353
 
342
- def no_reply_line(result, session_id = nil, strategy: nil)
354
+ def no_reply_line(result, session_id = nil, strategy: nil, via: ChiInvocation::HERE)
343
355
  return command_line(result) if result.origin == TurnCheckpoint::COMMAND_ORIGIN
344
356
 
345
357
  case result.outcome
@@ -354,7 +366,7 @@ module Samagotchi
354
366
  ran_out = "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered"
355
367
  strategy = policy(strategy)
356
368
  if strategy.allow?(Guardrails::ParentPolicy::SPEND)
357
- "#{ran_out}; chi send #{session_id} -m '/continue yes' continues it, a message drops it"
369
+ "#{ran_out}; #{via.command("send #{session_id} -m '/continue yes'")} continues it, a message drops it"
358
370
  else
359
371
  # No /continue yes hint: a parent may not continue it here.
360
372
  "#{ran_out}; continuing it is up to your user (#{strategy.label}), a message drops it"
@@ -104,7 +104,7 @@ module Samagotchi
104
104
 
105
105
  # config.yml `bundles: <bundle>:` with the session's own values for
106
106
  # the bundle laid over it (chi --set BUNDLE.KEY=VALUE, through the
107
- # Host's setup: SessionSetup#bundle_settings), string keys, frozen.
107
+ # Host's setup: SessionSetup#settings's bundles), string keys, frozen.
108
108
  # Plugin init and chi.ctx run before the session is bound: with no
109
109
  # session id yet it is the config alone, and it is built again once
110
110
  # per session id (the session's values never change after it starts).
@@ -473,7 +473,7 @@ module Samagotchi
473
473
  # The session's own values for this bundle ({} without any; nil when
474
474
  # the Host couldn't tell them, so #settings can retry).
475
475
  def session_values(id)
476
- values = @host.setup&.call&.bundle_settings
476
+ values = @host.setup&.call&.settings&.bundles
477
477
  values.is_a?(Hash) ? values.fetch(@bundle, {}) : {}
478
478
  rescue StandardError => e
479
479
  unless (@session_settings_unread ||= {}).key?(id)
@@ -27,7 +27,7 @@ module Samagotchi
27
27
  # - Generation cue: <|im_start|>assistant\n
28
28
  module Prompt
29
29
  # Tail system messages whose text isn't the harness's own.
30
- ESCAPED_KINDS = %w[note turn_note context].freeze
30
+ ESCAPED_KINDS = %w[note turn_note context working_set].freeze
31
31
 
32
32
  # @param messages [Array<Hash>] each element has :role and :content.
33
33
  # Recognised roles: "system", "user", "model", "tool_response".
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+
5
+ module Samagotchi
6
+ # /recap on|off|default, the setting form of /recap (the bare /recap,
7
+ # which shows the recap, is the UIs' own): this session's own
8
+ # recap.enabled (Engine#recap_override=), the same value
9
+ # `chi --set recap.enabled=…` saves, applied at once (the idle recap job
10
+ # is rebuilt or dropped). `default` removes it, and config.yml (or env)
11
+ # decides again. The session's value beats config.yml's: `/recap on`
12
+ # turns it on under `recap: false` too. It doesn't save: its host does
13
+ # on the reply's changed: [:recap] (Worker#run_command).
14
+ class RecapCommand
15
+ NAME = "/recap"
16
+ CHOICES = { "on" => true, "off" => false, "default" => nil }.freeze
17
+ USAGE = "usage: #{NAME} [on|off|default]".freeze
18
+
19
+ # @param engine [Engine] #session, #recap_override=, #recap
20
+ def initialize(engine:)
21
+ @engine = engine
22
+ end
23
+
24
+ # @param args [String] what follows /recap
25
+ # @return [Array(String, Boolean, Symbol)] the reply, whether this
26
+ # session's recap setting changed, and :ok or :error
27
+ def run(args)
28
+ word = args.to_s.strip.downcase
29
+ return [USAGE, false, :error] unless CHOICES.key?(word)
30
+ return ["no session yet", false, :error] unless @engine.session
31
+
32
+ value = CHOICES.fetch(word)
33
+ changed = @engine.recap_override != value
34
+ @engine.recap_override = value if changed
35
+ [status_line(value), changed, :ok]
36
+ end
37
+
38
+ # "recap: off (this session; config.yml: on)", "recap: on (config.yml)".
39
+ # @param own [Boolean, nil] the session's own value, nil for none
40
+ def status_line(own)
41
+ from_config = self.class.config_status
42
+ line = if own.nil?
43
+ "recap: #{word(from_config[:on])} (#{from_config[:source]})"
44
+ else
45
+ "recap: #{word(own)} (this session; #{from_config[:source]}: #{word(from_config[:on])})"
46
+ end
47
+ # On by its own word, yet no job: a pinned recap host or model that
48
+ # doesn't resolve (warned in the log), or an embedder's recap: false.
49
+ on = own.nil? ? from_config[:on] : own
50
+ on && @engine.recap.nil? ? "#{line}; no recap is written: its host or model doesn't resolve (see the log)" : line
51
+ end
52
+
53
+ # Whether the recap is on without the session's own value: off by a
54
+ # scalar `recap: false`, `recap: {enabled: false}` or
55
+ # SAMAGOTCHI_RECAP_ENABLED=false (ConfigFile.recap_config sees all
56
+ # three), else on (the default).
57
+ # @return [Hash] {on: Boolean, source: "config.yml" or "env"}
58
+ def self.config_status
59
+ on = ConfigFile.recap_config != false
60
+ _value, origin = Config.get_with_origin("recap.enabled")
61
+ { on: on, source: origin == :env ? "env" : "config.yml" }
62
+ end
63
+
64
+ private
65
+
66
+ def word(enabled) = enabled ? "on" : "off"
67
+ end
68
+ end
@@ -7,8 +7,10 @@ require_relative "idle_target"
7
7
  require_relative "recap_store"
8
8
 
9
9
  module Samagotchi
10
- # Builds (or disables) an Engine's idle recap job from the `recap:` kwarg
11
- # and the Config registry. Init-only: the Engine keeps the job it returns.
10
+ # Builds (or disables) an Engine's idle recap job from the `recap:` kwarg,
11
+ # the session's own settings and the Config registry. The Engine builds
12
+ # one at start, and again when it binds a session whose own recap values
13
+ # differ (Engine#session=).
12
14
  module RecapSetup
13
15
  # Build (or disable) the idle recap job. On by default: with no recap
14
16
  # host or model configured it asks the session's current model on its
@@ -18,9 +20,10 @@ module Samagotchi
18
20
  # --model goes; an incomplete one warns and leaves recap off.
19
21
  #
20
22
  # Single precedence path: explicit `recap:` kwarg > Config registry
21
- # (CLI > ENV > file > default). An explicit disable (`recap: false` as
22
- # the kwarg or in the config file, `recap: {enabled: false}`, or
23
- # SAMAGOTCHI_RECAP_ENABLED=false) always wins.
23
+ # (session > CLI > ENV > file > default). An explicit disable
24
+ # (`recap: false` as the kwarg or in the config file, `recap: {enabled:
25
+ # false}`, or SAMAGOTCHI_RECAP_ENABLED=false) wins, but over a session's
26
+ # own recap.enabled (chi --set recap.enabled=true) only as the kwarg.
24
27
  #
25
28
  # @param recap [Hash, false, nil] the Engine's `recap:` kwarg
26
29
  # @param engine [Engine] the recap job's engine
@@ -28,13 +31,22 @@ module Samagotchi
28
31
  # @param session_target [#call] → the session's current model as a recap target
29
32
  # @param session_id [#call] → String, nil
30
33
  # @param state_dir [#call] → String
34
+ # @param session_config [#call] → the session's own core settings
35
+ # (SessionConfig::Settings#core), read once here
31
36
  # @return [IdleRecap, nil]
32
- def self.build(recap, engine:, host_registry:, session_target:, session_id:, state_dir:)
37
+ def self.build(recap, engine:, host_registry:, session_target:, session_id:, state_dir:, session_config: -> { {} })
33
38
  return nil if recap == false
34
- # The REPL and a worker pass nothing: the config is read here alone
35
- # (a scalar `recap: false` is only seen this way).
36
- return nil if recap.nil? && ConfigFile.recap_config == false
37
- return nil if Samagotchi::Config.get("recap.enabled") == false
39
+
40
+ session = session_config.call || {}
41
+ # The session's own recap.enabled replaces both checks below.
42
+ if session.key?("recap.enabled")
43
+ return nil unless session["recap.enabled"]
44
+ else
45
+ # The REPL and a worker pass nothing: the config is read here alone
46
+ # (a scalar `recap: false` is only seen this way).
47
+ return nil if recap.nil? && ConfigFile.recap_config == false
48
+ return nil if Samagotchi::Config.get("recap.enabled") == false
49
+ end
38
50
 
39
51
  # An explicit kwarg (a spec, an embedder) over the registry.
40
52
  kwarg_config = recap.is_a?(Hash) ? recap : {}
@@ -64,7 +76,7 @@ module Samagotchi
64
76
  inactivity: recap_number_setting(kwarg_config, :inactivity, "recap.inactivity", IdleRecap::DEFAULT_INACTIVITY_SECONDS, :float),
65
77
  min_user_turns: recap_number_setting(kwarg_config, :min_user_turns, "recap.min_user_turns", IdleRecap::DEFAULT_MIN_USER_TURNS, :int),
66
78
  timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
67
- sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
79
+ sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences", session: session)),
68
80
  store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call }),
69
81
  session_id: -> { session_id.call },
70
82
  on_usage: ->(usage) { engine.note_side_usage(usage) }
@@ -108,9 +120,10 @@ module Samagotchi
108
120
  end
109
121
  private_class_method :recap_sentences
110
122
 
111
- # Read a scalar recap setting via the Config registry (ENV > file > default).
112
- def self.registry_string(key)
113
- value = Samagotchi::Config.get(key)
123
+ # Read a scalar recap setting via the Config registry (session > CLI >
124
+ # ENV > file > default).
125
+ def self.registry_string(key, session: nil)
126
+ value = Samagotchi::Config.get(key, session: session)
114
127
  value = value.to_s.strip
115
128
  value.empty? ? nil : value
116
129
  rescue StandardError
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "cli/command"
5
+ require_relative "cli/exit"
6
+ require_relative "cli/flags"
7
+ require_relative "paths"
8
+ require_relative "remotes"
9
+ require_relative "session_access"
10
+ require_relative "session_access/http_transport"
11
+ require_relative "session_access/remote"
12
+
13
+ module Samagotchi
14
+ # `chi remote`: the other machines whose sessions chi --remote NAME (or
15
+ # CHI_REMOTE=NAME) reaches through their chi web (Remotes,
16
+ # SessionAccess::Remote). add checks the address and the token with GET
17
+ # /api/info before it saves anything; ls never prints a token.
18
+ class RemoteCommand
19
+ include CLI::Command
20
+
21
+ USAGE = <<~TEXT
22
+ Usage: chi remote <add|ls|rm> [options]
23
+ add NAME URL [--token-file FILE | --token-stdin]
24
+ a machine's chi web (http://LAN-IP:PORT, as chi web prints it with web.host: lan;
25
+ port 4567 by default), with its access token (chi web's LAN link carries it, after
26
+ ?token=). It is checked first: the chi it runs, or why it can't be reached. Saved
27
+ in #{Paths.tilde(Remotes.path)} (0600); an existing NAME is replaced.
28
+ ls [--format json] the remotes (never their tokens)
29
+ rm NAME forget one
30
+ Then: chi --remote NAME send|answer|note|context|sessions …, or CHI_REMOTE=NAME.
31
+ TEXT
32
+
33
+ SUBCOMMANDS = %w[add ls rm].freeze
34
+
35
+ FLAGS = {
36
+ "add" => CLI::Flags.new(help: HELP_WORDS) do |f|
37
+ f.value "--token-file"
38
+ f.switch "--token-stdin"
39
+ end,
40
+ "ls" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--format" },
41
+ "rm" => CLI::Flags.new(help: HELP_WORDS)
42
+ }.freeze
43
+
44
+ # @param transport [#call, nil] (entry) → a transport for add's check;
45
+ # HTTPTransport by default
46
+ def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, path: Remotes.path, transport: nil)
47
+ @argv = argv.dup
48
+ @stdin = stdin
49
+ @stdout = stdout
50
+ @stderr = stderr
51
+ @path = path
52
+ @transport = transport || ->(entry) { SessionAccess::HTTPTransport.new(entry) }
53
+ end
54
+
55
+ # @return [Integer] exit status: 0 done, 1 refused or unreachable, 2 usage
56
+ def run
57
+ @sub = @argv[0]
58
+ if @sub.nil? || HELP_WORDS.include?(@sub)
59
+ @stdout.puts(USAGE)
60
+ return 0
61
+ end
62
+ return usage_error("unknown subcommand #{@sub}") unless SUBCOMMANDS.include?(@sub)
63
+
64
+ parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
65
+ return parsed if parsed.is_a?(Integer)
66
+
67
+ send(:"run_#{@sub}", parsed.options, parsed.args)
68
+ rescue Remotes::Invalid => e
69
+ error_line("#{command_name}: #{e.message}")
70
+ CLI::Exit::FAILED
71
+ end
72
+
73
+ private
74
+
75
+ def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi remote #{@sub}" : "chi remote"
76
+ def usage_on_error = nil
77
+
78
+ def run_add(options, args)
79
+ return usage_error("give NAME and URL") unless args.size == 2
80
+ return usage_error("--token-file and --token-stdin: choose one") if options[:token_file] && options[:token_stdin]
81
+
82
+ name, url = args
83
+ begin
84
+ Remotes.check_name!(name)
85
+ url = Remotes.normalize_url(url)
86
+ rescue Remotes::Invalid => e
87
+ return usage_error(e.message)
88
+ end
89
+ token = read_token(options)
90
+ return CLI::Exit::FAILED if token == false
91
+
92
+ entry = Remotes::Entry.new(name: name, url: url, token: token)
93
+ found = check(entry) or return CLI::Exit::FAILED
94
+ Remotes.put(entry, path: @path)
95
+ @stdout.puts("#{name} #{url} chi #{found["version"]}, #{lan_words(found, entry)}")
96
+ 0
97
+ end
98
+
99
+ def run_ls(options, args)
100
+ return usage_error("ls takes no arguments") unless args.empty?
101
+
102
+ format = options[:format]
103
+ return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
104
+
105
+ entries = Remotes.all(path: @path)
106
+ if format == "json"
107
+ @stdout.puts(JSON.generate(entries.map { |e| { name: e.name, url: e.url, token: !e.token.nil? } }))
108
+ elsif entries.empty?
109
+ @stdout.puts("no remotes (chi remote add NAME URL --token-file FILE)")
110
+ else
111
+ width = entries.map { |e| e.name.length }.max
112
+ entries.each { |e| @stdout.puts("#{e.name.ljust(width)} #{e.url}#{" (no token)" unless e.token}") }
113
+ end
114
+ 0
115
+ end
116
+
117
+ def run_rm(_options, args)
118
+ return usage_error("give one NAME") unless args.size == 1
119
+ raise Remotes::NotFound, args.first unless Remotes.remove(args.first, path: @path)
120
+
121
+ @stdout.puts("#{args.first} removed")
122
+ 0
123
+ end
124
+
125
+ # The token from its file or stdin; nil for none (a loopback chi web
126
+ # needs none), false after the error line.
127
+ def read_token(options)
128
+ text = if options[:token_file]
129
+ begin
130
+ File.read(File.expand_path(options[:token_file]))
131
+ rescue SystemCallError => e
132
+ error_line("#{command_name}: can't read #{options[:token_file]}: #{e.message.sub(/ @ .*/, "")}")
133
+ return false
134
+ end
135
+ elsif options[:token_stdin]
136
+ read_stdin.to_s
137
+ end
138
+ return nil if text.nil?
139
+
140
+ token = text.strip
141
+ return token unless token.empty? || token.match?(/\s/)
142
+
143
+ error_line("#{command_name}: #{options[:token_file] || "stdin"} holds no token (one line, chi web's web-token file)")
144
+ false
145
+ end
146
+
147
+ # GET /api/info with the token: the answer, or nil after the line that
148
+ # says why it can't be used.
149
+ def check(entry)
150
+ reply = @transport.call(entry).call(:get, "/api/info")
151
+ problem = SessionAccess::Remote.info_problem(entry, reply)
152
+ return fail_line(problem.message) if problem
153
+
154
+ reply.json
155
+ rescue SessionAccess::RemoteFailed => e
156
+ fail_line("#{e.message}: start chi web on #{entry.host} with web.host: lan")
157
+ end
158
+
159
+ def lan_words(found, entry)
160
+ return "loopback only" unless found["lan"]
161
+
162
+ found["lan"] == entry.host ? "LAN #{found["lan"]}" : "LAN #{found["lan"]} (reached as #{entry.host})"
163
+ end
164
+
165
+ def fail_line(message)
166
+ error_line("#{command_name}: #{message}")
167
+ nil
168
+ end
169
+ end
170
+ end