samagotchi 0.85.0 → 0.87.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -5
  5. data/docs/bundle-authoring.md +125 -0
  6. data/docs/cli.md +51 -3
  7. data/docs/configuration.md +6 -5
  8. data/docs/guardrails.md +112 -39
  9. data/docs/hooks.md +19 -3
  10. data/docs/internals/anthropic.md +2 -2
  11. data/docs/internals/bundles.md +11 -2
  12. data/docs/internals/context-telemetry.md +19 -1
  13. data/docs/internals/engine.md +1 -1
  14. data/docs/memory.md +1 -1
  15. data/docs/plugins.md +59 -2
  16. data/docs/releasing.md +1 -1
  17. data/docs/sessions.md +19 -7
  18. data/docs/sub-agent.md +39 -25
  19. data/docs/testing.md +7 -2
  20. data/lib/samagotchi/answer_command.rb +21 -25
  21. data/lib/samagotchi/bridge/client_event.rb +50 -0
  22. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  23. data/lib/samagotchi/bridge.rb +22 -11
  24. data/lib/samagotchi/bridge_client.rb +2 -2
  25. data/lib/samagotchi/bundle_command/check.rb +348 -0
  26. data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
  27. data/lib/samagotchi/bundle_command/test_run.rb +89 -0
  28. data/lib/samagotchi/bundle_command.rb +125 -2
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  31. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
  34. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  35. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -3
  36. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
  38. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  39. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  40. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  41. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  42. data/lib/samagotchi/cli/parent_wait.rb +3 -1
  43. data/lib/samagotchi/client_id.rb +1 -1
  44. data/lib/samagotchi/config.rb +195 -115
  45. data/lib/samagotchi/context_status.rb +1 -1
  46. data/lib/samagotchi/continue_offer.rb +2 -1
  47. data/lib/samagotchi/engine.rb +4 -4
  48. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  49. data/lib/samagotchi/guardrails/approval.rb +12 -2
  50. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  51. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  52. data/lib/samagotchi/guardrails/parent_policy.rb +327 -0
  53. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  54. data/lib/samagotchi/hook_routes.rb +5 -3
  55. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  56. data/lib/samagotchi/hooks/registry.rb +13 -6
  57. data/lib/samagotchi/hooks.rb +5 -0
  58. data/lib/samagotchi/kernel_loop.rb +5 -2
  59. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  61. data/lib/samagotchi/memory_bundle.rb +1 -0
  62. data/lib/samagotchi/parent_answers_command.rb +80 -0
  63. data/lib/samagotchi/parent_report.rb +122 -30
  64. data/lib/samagotchi/pending_question.rb +8 -4
  65. data/lib/samagotchi/plugin/api.rb +16 -0
  66. data/lib/samagotchi/plugin/context.rb +11 -5
  67. data/lib/samagotchi/plugin/loader.rb +5 -3
  68. data/lib/samagotchi/question_desk.rb +39 -25
  69. data/lib/samagotchi/queued_command.rb +5 -4
  70. data/lib/samagotchi/relay.rb +5 -1
  71. data/lib/samagotchi/relay_verifier.rb +4 -5
  72. data/lib/samagotchi/reply_wait.rb +14 -5
  73. data/lib/samagotchi/send_command.rb +103 -12
  74. data/lib/samagotchi/session.rb +26 -2
  75. data/lib/samagotchi/session_commands.rb +45 -8
  76. data/lib/samagotchi/session_setup.rb +10 -8
  77. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -12
  79. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  80. data/lib/samagotchi/terminal_ui.rb +20 -11
  81. data/lib/samagotchi/testing/boundary.rb +0 -3
  82. data/lib/samagotchi/testing/bundle.rb +7 -0
  83. data/lib/samagotchi/testing/host.rb +6 -4
  84. data/lib/samagotchi/testing/install.rb +119 -0
  85. data/lib/samagotchi/testing/recorder.rb +13 -4
  86. data/lib/samagotchi/testing.rb +1 -0
  87. data/lib/samagotchi/tools/builtins.rb +2 -1
  88. data/lib/samagotchi/tools/delegate.rb +4 -1
  89. data/lib/samagotchi/tools/delegate_wait.rb +16 -7
  90. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  91. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  92. data/lib/samagotchi/turn_flow.rb +10 -0
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +48 -10
  95. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  96. data/lib/samagotchi/web/public/ctx.js +12 -26
  97. data/lib/samagotchi/web/public/stream_handlers.js +1 -1
  98. data/lib/samagotchi/web/server.rb +4 -0
  99. data/lib/samagotchi/worker.rb +27 -5
  100. data/lib/samagotchi/worker_inbound.rb +4 -4
  101. metadata +12 -2
  102. data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
@@ -22,5 +22,6 @@ require_relative "testing/recording_api"
22
22
  require_relative "testing/waiting"
23
23
  require_relative "testing/fake_executable"
24
24
  require_relative "testing/bundle"
25
+ require_relative "testing/install"
25
26
  require_relative "testing/helpers"
26
27
  require_relative "testing/boundary"
@@ -118,7 +118,8 @@ module Samagotchi
118
118
 
119
119
  # What execute and task_create export into the commands they run: a
120
120
  # chi started there answers as a parent agent
121
- # (Guardrails::ParentApprovals.parent_process?), even under a PTY
121
+ # (Guardrails::ParentApprovals.parent_process?, held to
122
+ # Guardrails::ParentPolicy), even under a PTY
122
123
  # wrapper. The session's id, "chi" without one. And the model the
123
124
  # session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
124
125
  # /model), which chi self reports; unset when unknown, so a value
@@ -134,7 +134,10 @@ module Samagotchi
134
134
  "(the most is #{max}, #{MAX_CHILDREN_KEY}): #{ids}. #{full_hint(peers)}"
135
135
  end
136
136
 
137
- setup = toolkit.empty? ? SessionSetup.new : SessionSetup.new(toolkit: Toolkit.new(name: toolkit))
137
+ # The parent's own parent_answers, as it is now (a snapshot): the
138
+ # child's answers are held at least as tight.
139
+ setup = SessionSetup.new(toolkit: toolkit.empty? ? nil : Toolkit.new(name: toolkit),
140
+ parent_answers: parent.parent_answers)
138
141
  child = SessionManager.spawn_session(prompt: task, working_directory: folder,
139
142
  model_name: child_model(model, parent), memories: CHILD_MEMORIES,
140
143
  parent_id: parent.id, delegate: true, setup: setup, state_dir: state_dir)
@@ -213,6 +213,10 @@ module Samagotchi
213
213
  # @return [String]
214
214
  def finish(wait, child_id, timeout:, report: false, reports: "off")
215
215
  status = ParentReport.status(wait)
216
+ # What the parent may pick on the child's question: config.yml
217
+ # tightened by the child session's own parent_answers (the session
218
+ # the wait read; none: config.yml's).
219
+ strategy = Guardrails::ParentPolicy.for_session(wait.session)
216
220
  case wait.status
217
221
  when :done
218
222
  reply_result(child_id, status, wait.text)
@@ -221,11 +225,11 @@ module Samagotchi
221
225
  when :stopped
222
226
  result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
223
227
  when :waiting_for_answer
224
- result(child_id, status, waiting_text(child_id, wait.question, report: report, reports: reports))
228
+ result(child_id, status, waiting_text(child_id, wait.question, report: report, reports: reports, strategy: strategy))
225
229
  when :canceled
226
230
  canceled_result(child_id, reports: reports)
227
231
  when :no_reply
228
- result(child_id, status, "#{no_reply_text(wait, child_id)}; its session shows what happened")
232
+ result(child_id, status, "#{no_reply_text(wait, child_id, strategy: strategy)}; its session shows what happened")
229
233
  when :worker_gone
230
234
  result(child_id, status,
231
235
  "the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
@@ -266,8 +270,8 @@ module Samagotchi
266
270
  end
267
271
 
268
272
  # ParentReport's line for a turn that left no reply, about the child.
269
- def no_reply_text(wait, child_id)
270
- ParentReport.no_reply_line(wait, child_id).sub(/\Athe turn/, "the child's turn")
273
+ def no_reply_text(wait, child_id, strategy: nil)
274
+ ParentReport.no_reply_line(wait, child_id, strategy: strategy).sub(/\Athe turn/, "the child's turn")
271
275
  end
272
276
 
273
277
  def result(child_id, status, text)
@@ -303,12 +307,17 @@ module Samagotchi
303
307
  # A report's last line differs (+report+): "waits again" made models
304
308
  # call delegate_result right after answering Continue.
305
309
  # The tool's last line follows #reports_mode.
306
- def waiting_text(child_id, pending, report: false, reports: "off")
307
- text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
308
- if ParentReport.continue?(pending)
310
+ def waiting_text(child_id, pending, report: false, reports: "off", strategy: nil)
311
+ text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id, strategy: strategy)}"
312
+ if ParentReport.continue?(pending) && ParentReport.may_continue?(pending, strategy: strategy)
309
313
  text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
310
314
  "above with execute) if it is getting somewhere; send it a narrower follow-up with delegate " \
311
315
  "session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
316
+ elsif ParentReport.continue?(pending)
317
+ # guardrails.parent_answers: hard: continuing is the user's.
318
+ text += "The child ran out of steps before it answered. Decide: send it a narrower follow-up with delegate " \
319
+ "session: #{child_id} (that drops the question); or stop it (the chi answer Stop command above, " \
320
+ "with execute) and report back to your user, who may continue it.\n"
312
321
  end
313
322
  return "#{text}#{REPORT_NEXT_REPLY}" if report
314
323
  return "#{text}delegate_result #{child_id} waits again once it is answered." if reports == "off"
@@ -100,15 +100,15 @@ module Samagotchi
100
100
  # between (failed, no exit code) is corrected; a stopped task (its
101
101
  # stop flag on disk) is left as task_stop records it.
102
102
  # The thread is named EXIT_STATUS_THREAD (a spec joins them before it
103
- # removes the tasks' folder).
103
+ # removes the tasks' folder) here, before it returns: named from
104
+ # inside, a thread not yet scheduled was missed by that join.
104
105
  # @return [Thread]
105
106
  def record_exit_status(waiter, path, task_id:)
106
107
  # Absolute now: safe against a later chdir.
107
108
  record_path = File.expand_path(metadata_path(task_id))
108
109
  path = File.expand_path(path)
109
- Thread.new do
110
+ thread = Thread.new do
110
111
  Thread.current.report_on_exception = false
111
- Thread.current.name = EXIT_STATUS_THREAD
112
112
  code = status_code(waiter.value)
113
113
  next if code.nil? || record_at(record_path)&.stop_requested_by
114
114
 
@@ -117,6 +117,8 @@ module Samagotchi
117
117
  rescue StandardError => e
118
118
  Log.debug(:turn, "task_exit_code_failed", id: task_id, error: e.class.name, msg: e.message)
119
119
  end
120
+ thread.name = EXIT_STATUS_THREAD
121
+ thread
120
122
  end
121
123
 
122
124
  # A Process::Status as a shell reports one: its exit status, or
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "json"
3
5
  require "time"
6
+ require_relative "atomic_file"
4
7
  require_relative "interrupted_turn"
8
+ require_relative "last_turn"
5
9
  require_relative "log"
6
10
  require_relative "session"
11
+ require_relative "session_inbox"
7
12
  require_relative "steer"
8
13
  require_relative "turn_note"
9
14
 
@@ -39,9 +44,12 @@ module Samagotchi
39
44
  # last_turn is left as it was: a new last_turn saved idle here, before
40
45
  # the turn a `chi send --wait` started this worker for, would end that
41
46
  # wait at once as the sent turn's (ReplyWait compares last_turn), with
42
- # the dead turn's outcome.
47
+ # the dead turn's outcome. A mark with an enqueued_id records the dead
48
+ # turn's end under that id instead (+session_dir+, #settled_turn): only
49
+ # a wait for that very turn (ReplyWait's turn:) reads it.
50
+ # @param session_dir [String, nil] where to record it; nil: nowhere
43
51
  # @return [InterruptedTurn, nil] the mark settled, nil when there was none
44
- def self.settle(session)
52
+ def self.settle(session, session_dir: nil)
45
53
  mark = session.interrupted_turn
46
54
  return nil unless mark
47
55
 
@@ -54,9 +62,63 @@ module Samagotchi
54
62
  session.messages = messages + [note]
55
63
  session.status = Session::STATUS_IDLE if session.status == Session::STATUS_RUNNING
56
64
  session.interrupted_turn = nil
65
+ record_settled(session_dir, mark, seconds) if session_dir
57
66
  mark
58
67
  end
59
68
 
69
+ # Where a per-input terminal outcome is kept: input/settled/<enqueued_id>,
70
+ # outside the *.json glob like SessionInbox's removed tombstones.
71
+ SETTLED_DIR = "settled"
72
+ WORKER_STOPPED = "worker_stopped"
73
+
74
+ # The terminal outcome recorded for +enqueued_id+.
75
+ # @return [LastTurn, nil] nil when no settle recorded one
76
+ def self.settled_turn(session_dir, enqueued_id)
77
+ id = enqueued_id.to_s
78
+ return nil unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
79
+
80
+ raw = File.read(File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR, id))
81
+ LastTurn.from_file(JSON.parse(raw)) || settled_record(id)
82
+ rescue Errno::ENOENT, Errno::ENOTDIR
83
+ nil
84
+ rescue JSON::ParserError, SystemCallError
85
+ # The file says the turn was settled even when it can't be read.
86
+ settled_record(id)
87
+ end
88
+
89
+ def self.settled_record(id, ended_at: nil, seconds: nil)
90
+ LastTurn.new(outcome: "canceled", cancel_reason: WORKER_STOPPED, ended_at: ended_at, seconds: seconds, enqueued_id: id)
91
+ end
92
+ private_class_method :settled_record
93
+
94
+ # A file-delivered command ends its sender's wait without running a
95
+ # turn. Keep a terminal outcome under the same per-input settled path.
96
+ def self.record_command_outcome(session_dir, enqueued_id)
97
+ id = enqueued_id.to_s
98
+ return if id.empty? || !SessionInbox::ENQUEUED_ID_NAME.match?(id)
99
+
100
+ dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
101
+ FileUtils.mkdir_p(dir)
102
+ record = LastTurn.new(outcome: "completed", ended_at: Time.now.iso8601(3), enqueued_id: id)
103
+ AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
104
+ rescue SystemCallError, IOError => e
105
+ Log.warn(:worker, "command_outcome_write_failed", error: e.class.name, msg: e.message, enqueued_id: id)
106
+ end
107
+
108
+ def self.record_settled(session_dir, mark, seconds)
109
+ id = mark.enqueued_id.to_s
110
+ return unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
111
+
112
+ dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
113
+ FileUtils.mkdir_p(dir)
114
+ record = settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds)
115
+ AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
116
+ rescue SystemCallError => e
117
+ # The settle goes on; a wait for that turn runs to its timeout.
118
+ Log.warn(:worker, "settled_turn_write_failed", error: e.class.name, msg: e.message)
119
+ end
120
+ private_class_method :record_settled
121
+
60
122
  # @param session [Session] the worker's session (saved as a whole)
61
123
  # @param clock [#call] monotonic seconds (specs)
62
124
  def initialize(session:, state_dir:, interval: INTERVAL, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
@@ -23,6 +23,16 @@ module Samagotchi
23
23
 
24
24
  # Read an answer to the continue offer.
25
25
  # @return [Array(Symbol, String|nil)] [:resume | :abort | :abort_with_reason | :invalid, reason]
26
+ # Whether +line+ is a /continue that continues the turn (/continue,
27
+ # /continue yes): what a parent agent's must be checked for.
28
+ def self.continue_yes?(line)
29
+ text = line.to_s.strip
30
+ return false unless text.match?(/\A#{Regexp.escape(CONTINUE_COMMAND)}(\s|\z)/)
31
+
32
+ answer = text.delete_prefix(CONTINUE_COMMAND).strip
33
+ continue_decision(answer.empty? ? CONTINUE_COMMAND : answer).first == :resume
34
+ end
35
+
26
36
  def self.continue_decision(input)
27
37
  normalized = input.to_s.strip
28
38
  return [:resume, nil] if normalized.empty?
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.85.0"
4
+ VERSION = "0.87.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -926,14 +926,10 @@ module Samagotchi
926
926
  state = nil unless state.is_a?(Hash)
927
927
  if state
928
928
  status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
929
- # No session file read here: the folder the answer's file refs must
930
- # stay under is the worker's (its tail's working_directory), else the
931
- # hub's session list entry's (a worker from before the field, or read
932
- # through its snapshot); nil then without a hub or before it saw the
933
- # session: refs aren't checked.
934
- cwd = tail["working_directory"]
935
- cwd = @hub&.working_directory(id) unless cwd.is_a?(String) && !cwd.empty?
936
- messages = last_assistant_for_display([tail["answer"]].compact, cwd: cwd)
929
+ answer = tail["answer"]
930
+ cwd = tail_working_directory(id, tail, answer)
931
+ answer = without_file_refs(answer) unless cwd
932
+ messages = last_assistant_for_display([answer].compact, cwd: cwd)
937
933
  else
938
934
  session = @session_class.load(id, state_dir: default_state_dir)
939
935
  status = displayed_status(session)
@@ -956,6 +952,35 @@ module Samagotchi
956
952
  error_response(404, "not_found", e.message)
957
953
  end
958
954
 
955
+ # The folder the live tail's answer's file refs must stay under: the
956
+ # worker's (its tail's working_directory), else the hub's session list
957
+ # entry's (a worker from before the field, or read through its
958
+ # snapshot), else, only when the answer has refs to check, the session
959
+ # file's, as the full read takes it. nil when none is known: the
960
+ # caller drops the file refs rather than show them unchecked.
961
+ # @return [String, nil]
962
+ def tail_working_directory(id, tail, answer)
963
+ known = ->(dir) { dir.is_a?(String) && !dir.empty? }
964
+ cwd = tail["working_directory"]
965
+ cwd = @hub&.working_directory(id) unless known.call(cwd)
966
+ return cwd if known.call(cwd)
967
+ return nil unless answer.is_a?(Hash) && (answer[AnswerRefs::KEY.to_s] || answer[AnswerRefs::KEY])
968
+
969
+ cwd = @session_class.load(id, state_dir: default_state_dir).working_directory
970
+ known.call(cwd) ? cwd : nil
971
+ rescue ArgumentError, KeyError, SystemCallError
972
+ nil
973
+ end
974
+
975
+ # +answer+ without its file refs (the rest kept), for a read that
976
+ # knows no folder to check them against.
977
+ def without_file_refs(answer)
978
+ return answer unless answer.is_a?(Hash)
979
+
980
+ key = [AnswerRefs::KEY.to_s, AnswerRefs::KEY].find { |name| answer[name].is_a?(Array) } or return answer
981
+ answer.merge(key => answer[key].reject { |ref| ref.is_a?(Hash) && (ref["kind"] || ref[:kind]).to_s == Ref::FILE })
982
+ end
983
+
959
984
  # ?timing=1: the whole timing (every record, analytics.json merged with
960
985
  # the live ones) and turn_count, when the page's merge comes up short.
961
986
  # The worker's GET state, never its messages.
@@ -1572,7 +1597,7 @@ module Samagotchi
1572
1597
  return [200, headers.merge("rack.hijack" => hijack), []]
1573
1598
  end
1574
1599
 
1575
- query = req.query_string.to_s.empty? ? "" : "?#{req.query_string}"
1600
+ query = stream_query(req)
1576
1601
  [200, headers, ProxyStreamBody.new(host: DEFAULT_HOST, port: bridge_port, session_id: id, query: query, headers: req.env,
1577
1602
  server_running: @server_running)]
1578
1603
  end
@@ -1616,13 +1641,26 @@ module Samagotchi
1616
1641
  [200, headers, body]
1617
1642
  end
1618
1643
 
1644
+ # The stream's query as the worker's Bridge gets it ("?..." or ""):
1645
+ # every parameter but `raw`. `?raw=1` is the worker port's debugging view
1646
+ # (a chunk's provider payload); a browser never gets it through here.
1647
+ # Keys are compared decoded, as the Bridge reads them.
1648
+ def stream_query(req)
1649
+ pairs = req.query_string.to_s.split("&").reject do |pair|
1650
+ URI.decode_www_form_component(pair.split("=", 2).first.to_s) == "raw"
1651
+ rescue ArgumentError
1652
+ false
1653
+ end
1654
+ pairs.empty? ? "" : "?#{pairs.join("&")}"
1655
+ end
1656
+
1619
1657
  # Rack hijack lambda (env["rack.hijack?"] truthy): pipes the bridge's SSE
1620
1658
  # frames directly to the client socket until it disconnects. The handler
1621
1659
  # (WEBrick via rackup) has already sent the status line + headers, so
1622
1660
  # only body bytes go to the socket here.
1623
1661
  def sse_hijack(id, req, port)
1624
1662
  lambda do |io|
1625
- query = req.query_string.to_s.empty? ? "" : "?#{req.query_string}"
1663
+ query = stream_query(req)
1626
1664
  body = ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: query, headers: req.env,
1627
1665
  server_running: @server_running)
1628
1666
  body.each { |chunk| io.write(chunk) }
@@ -1,26 +1,15 @@
1
1
  // Pure routing for one :generation_chunk event into the two render lanes.
2
2
  //
3
- // Every :generation_chunk the server sends carries additive `text` (visible
4
- // prose, thinking + tool_call blocks removed) and `thinking` (thinking-only)
5
- // fields beside the raw `content`: the native loop splits its stream per
6
- // generation for every profile (Gemma's thought channel included), the chat
7
- // loop gets reasoning apart from the answer. We use those fields and ignore raw
8
- // `content`; a chunk without them (an older server) falls back to raw
9
- // `content` as text.
10
- //
11
- // Presence is checked with `typeof === "string"` (not truthiness) so an empty
12
- // `text` (e.g. a chunk that is entirely a tool_call body) still selects the new
13
- // wire instead of accidentally falling back to raw `content`.
3
+ // Every :generation_chunk the server sends carries `text` (visible prose,
4
+ // thinking + tool_call blocks removed) and `thinking` (thinking-only): the
5
+ // native loop splits its stream per generation for every profile (Gemma's
6
+ // thought channel included), the chat loop gets reasoning apart from the
7
+ // answer. The raw stream text and the provider's payload stay in the worker
8
+ // (Bridge::ClientEvent); a missing or non-string field is an empty lane.
14
9
  export function routeChunk(data) {
15
10
  data = data || {};
16
- if (typeof data.text === "string" || typeof data.thinking === "string") {
17
- return {
18
- text: typeof data.text === "string" ? data.text : "",
19
- thinking: typeof data.thinking === "string" ? data.thinking : "",
20
- };
21
- }
22
11
  return {
23
- text: typeof data.content === "string" ? data.content : "",
24
- thinking: "",
12
+ text: typeof data.text === "string" ? data.text : "",
13
+ thinking: typeof data.thinking === "string" ? data.thinking : "",
25
14
  };
26
15
  }
@@ -1,7 +1,10 @@
1
- // Context-window usage (%) from a stream event. `windowTokens` is the window
2
- // the kernel resolved for this generation (:generation_started carries it as
3
- // context_window_tokens); llama.cpp's stream payloads report token counts
4
- // but no n_ctx, so without it only a precomputed pct or an n_ctx works.
1
+ // Context-window usage (%) from a stream event: a :context_status's
2
+ // estimated_pct, or a :generation_chunk's `counts` (chi's own counts, which
3
+ // the worker's Bridge works out from the provider's payload; the payload
4
+ // itself never reaches a client). `windowTokens` is the window the kernel
5
+ // resolved for this generation (:generation_started carries it as
6
+ // context_window_tokens); a chunk's counts carry a window only when the server
7
+ // reported one (llama.cpp's /completion doesn't), and that one wins.
5
8
  // `budgetTokens` is the session's LLM context budget (llm_context's
6
9
  // budget_tokens): a percentage this file computes counts against it when it
7
10
  // is smaller than the window, as the kernel's :context_status does
@@ -20,32 +23,15 @@ function countedTokens(windowTokens, budgetTokens = null) {
20
23
  return budget && budget > 0 ? Math.min(budget, window) : window;
21
24
  }
22
25
 
23
- // Tokens in context after this chunk: prompt + generated so far.
24
- function usedTokens(p) {
25
- if (!p || typeof p !== "object") return null;
26
- if (num(p.n_past)) return p.n_past;
27
- if (num(p.usage?.total_tokens)) return p.usage.total_tokens;
28
- const prompt = num(p.usage?.prompt_tokens) ?? num(p.tokens_evaluated) ?? num(p.timings?.prompt_n);
29
- const completion = num(p.usage?.completion_tokens) ?? num(p.tokens_predicted) ?? num(p.timings?.predicted_n);
30
- if (prompt === null && completion === null) return null;
31
- return (prompt ?? 0) + (completion ?? 0);
32
- }
33
-
34
26
  export function extractCtxPct(data, windowTokens = null, budgetTokens = null) {
35
27
  if (!data || typeof data !== "object") return null;
36
- if (num(data.ctx_pct) !== null) return data.ctx_pct;
37
- if (num(data.est_pct) !== null) return data.est_pct;
38
- if (num(data.ctxPct) !== null) return data.ctxPct;
39
28
  // The kernel's :context_status event: { usage: { estimated_pct, ... }, bucket }.
40
29
  if (num(data.usage?.estimated_pct) !== null) return data.usage.estimated_pct;
41
- if (data.context_window_tokens && data.total_tokens) {
42
- const against = countedTokens(data.context_window_tokens, budgetTokens);
43
- if (against) return (data.total_tokens / against) * 100;
44
- }
45
- const p = data.payload || data;
46
- if (num(p.ctx_pct) !== null) return p.ctx_pct;
47
- const against = countedTokens(num(p.n_ctx) || num(windowTokens), budgetTokens);
48
- const used = usedTokens(p);
30
+ // A chunk's counts: tokens in context after it (prompt + generated so far).
31
+ const counts = data.counts;
32
+ if (!counts || typeof counts !== "object") return null;
33
+ const used = num(counts.total_tokens);
34
+ const against = countedTokens(num(counts.context_window_tokens) || num(windowTokens), budgetTokens);
49
35
  if (!against || used === null) return null;
50
36
  return (used / against) * 100;
51
37
  }
@@ -61,7 +61,7 @@ export function createStreamHandlers(deps) {
61
61
  return {
62
62
  generation_chunk: (data) => {
63
63
  // Route the chunk's bytes to the text bubble and the thinking block (see
64
- // chunk_router.js). Raw `content` is only a fallback for an older server.
64
+ // chunk_router.js).
65
65
  const { text, thinking } = routeChunk(data);
66
66
  liveTurn.timing.setNote("");
67
67
  turnDom.chunk({ text, thinking, iteration: data.iteration ?? null, now: data.replayed ? null : Date.now() });
@@ -120,6 +120,9 @@ module Samagotchi
120
120
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
121
121
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
122
122
  editor: Config::BY_KEY["web.editor"].default, token_path: Token.path)
123
+ config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
124
+ Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
125
+ end
123
126
  port = resolve_port(port)
124
127
  host = resolve_host(host)
125
128
  url ||= "http://#{url_host(host)}:#{port}/"
@@ -166,6 +169,7 @@ module Samagotchi
166
169
  warn "Error: can't listen on #{lan.ip}:#{port} (#{e.message}); did the address change? Run chi web again"
167
170
  false
168
171
  ensure
172
+ config_restart_unsubscribe&.call
169
173
  hub&.stop
170
174
  Samagotchi::Log.info(:web, "stop") if app
171
175
  end
@@ -229,6 +229,9 @@ module Samagotchi
229
229
  # The running turn, on disk at its iteration boundaries.
230
230
  @checkpoint = TurnCheckpoint.new(session: @session, state_dir: @state_dir)
231
231
  @engine = build_engine
232
+ @config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :worker) do |message|
233
+ @engine.announce(type: :hook_notice, hook: "config", text: message, level: :warn, between_turns: true)
234
+ end
232
235
  settle_interrupted_turn
233
236
  # Before the Bridge serves anything: a UI joining a resumed worker's
234
237
  # stream gets the session's history and status in its snapshot, not
@@ -278,8 +281,9 @@ module Samagotchi
278
281
  context_absorber: @context_absorber, wakes: @wakes,
279
282
  awaiting_continue: -> { @turn_flow.awaiting_continue? },
280
283
  stopped: -> { stopped_on_disk? },
281
- queue_command: lambda { |line, client_id, after_file|
282
- @bridge.queue_command(line, client_id: client_id, after_file: after_file)
284
+ queue_command: lambda { |line, client_id, after_file, enqueued_id|
285
+ @bridge.queue_command(line, client_id: client_id, after_file: after_file,
286
+ enqueued_id: enqueued_id)
283
287
  })
284
288
  # /model's default is the config's, as in the REPL; the Engine started
285
289
  # on the session's model. nil when there is no default.model.
@@ -414,6 +418,7 @@ module Samagotchi
414
418
  # The context poller first: a command it runs goes (its process
415
419
  # group) before the Engine does.
416
420
  def teardown
421
+ @config_restart_unsubscribe&.call
417
422
  @context_poller&.stop
418
423
  drop_queued_commands
419
424
  @engine&.shutdown
@@ -453,9 +458,10 @@ module Samagotchi
453
458
 
454
459
  # A turn an earlier worker died in (TurnCheckpoint's mark in the file):
455
460
  # ended now as canceled, with its note, and saved, before the Bridge
456
- # serves the session (TurnCheckpoint.settle).
461
+ # serves the session (TurnCheckpoint.settle). Its queued id's wait
462
+ # (chi send --queue --wait) reads the end it records in the session dir.
457
463
  def settle_interrupted_turn
458
- mark = TurnCheckpoint.settle(@session)
464
+ mark = TurnCheckpoint.settle(@session, session_dir: @session_dir)
459
465
  return unless mark
460
466
 
461
467
  Log.info(:worker, "interrupted_turn_settled", turn_id: mark.id, iteration: mark.iteration, at: mark.at)
@@ -787,7 +793,11 @@ module Samagotchi
787
793
  # the cards and notices it showed, held: they follow its command_ran,
788
794
  # as its output
789
795
  def run_command_line(command)
790
- @engine.holding_announcements { command_result(command) { @commands.run(command.line, confirm: confirm?(command)) } }
796
+ @engine.holding_announcements do
797
+ command_result(command) do
798
+ @commands.run(command.line, confirm: confirm?(command), as_parent: as_parent?(command), user_ui: user_ui?(command))
799
+ end
800
+ end
791
801
  end
792
802
 
793
803
  # Whether +command+ asks before a costly /thinking or /reasoning-history
@@ -795,6 +805,17 @@ module Samagotchi
795
805
  # question; chi send, a delegate, a plugin and a card's action apply it.
796
806
  def confirm?(command) = ClientId.interactive?(command.client_id) && !command.card
797
807
 
808
+ # Whether +command+ is a parent agent's (chi answer's marker, which chi
809
+ # send and a parent's attach give a /continue yes too): held to
810
+ # guardrails.parent_answers (SessionCommands#continue_answer).
811
+ def as_parent?(command) = command.client_id.to_s == ClientId::CLI_ANSWER
812
+
813
+ # Whether +command+ is a user's at a UI (a web tab, a chi TUI, a card's
814
+ # click), not a parent agent's: only such a line loosens
815
+ # /parent-answers. chi send, a delegate's follow-up, a plugin and an
816
+ # input with no client never do (ParentAnswersCommand).
817
+ def user_ui?(command) = ClientId.interactive?(command.client_id) && !as_parent?(command)
818
+
798
819
  # The block's Result for +command+; one that raised, or found no
799
820
  # command for the line, is an error Result saying so.
800
821
  # @return [SessionCommands::Result]
@@ -866,6 +887,7 @@ module Samagotchi
866
887
  # @param confirm [CacheConfirm::Confirm, nil] the change asks first: the
867
888
  # line that applies it (the TUI's prefill, the web's Apply button)
868
889
  def announce_command(command, status:, output:, changed:, anytime: false, confirm: nil)
890
+ TurnCheckpoint.record_command_outcome(@session_dir, command.enqueued_id)
869
891
  text = output.to_s
870
892
  event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
871
893
  changed: changed.map(&:to_s), model_name: @engine.effective_model_name }
@@ -70,9 +70,9 @@ module Samagotchi
70
70
  # @param wakes [WorkerWakes] the wake budget a context wake asks
71
71
  # @param awaiting_continue [#call] whether a continue offer waits
72
72
  # @param stopped [#call] whether the session was stopped on disk
73
- # @param queue_command [#call] (line, client_id, after_file) queues a
74
- # session command for the loop's next pass; after_file is the input
75
- # file it came in, or nil
73
+ # @param queue_command [#call] (line, client_id, after_file, enqueued_id)
74
+ # queues a session command for the loop's next pass; after_file is the
75
+ # input file it came in, or nil
76
76
  def initialize(session:, state_dir:, session_dir:, engine:, context_absorber:, wakes:, awaiting_continue:,
77
77
  stopped:, queue_command:)
78
78
  @session = session
@@ -228,7 +228,7 @@ module Samagotchi
228
228
  def queue_as_command(text, origin, after_file = nil)
229
229
  return false unless @engine.command_registry.command?(text.to_s)
230
230
 
231
- @queue_command.call(text, origin&.dig(:client_id), after_file)
231
+ @queue_command.call(text, origin&.dig(:client_id), after_file, origin&.dig(:enqueued_id))
232
232
  true
233
233
  end
234
234
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.85.0
4
+ version: 0.87.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -124,6 +124,7 @@ files:
124
124
  - bin/chi
125
125
  - docs/architecture.md
126
126
  - docs/broadcast.md
127
+ - docs/bundle-authoring.md
127
128
  - docs/cli.md
128
129
  - docs/configuration.md
129
130
  - docs/context.md
@@ -162,6 +163,7 @@ files:
162
163
  - lib/samagotchi/bridge.rb
163
164
  - lib/samagotchi/bridge/bounded_queue.rb
164
165
  - lib/samagotchi/bridge/card_store.rb
166
+ - lib/samagotchi/bridge/client_event.rb
165
167
  - lib/samagotchi/bridge/event_id.rb
166
168
  - lib/samagotchi/bridge/pending_card.rb
167
169
  - lib/samagotchi/bridge/ring_buffer.rb
@@ -179,6 +181,9 @@ files:
179
181
  - lib/samagotchi/broadcast/triage_model.rb
180
182
  - lib/samagotchi/broadcast_command.rb
181
183
  - lib/samagotchi/bundle_command.rb
184
+ - lib/samagotchi/bundle_command/check.rb
185
+ - lib/samagotchi/bundle_command/scaffold.rb
186
+ - lib/samagotchi/bundle_command/test_run.rb
182
187
  - lib/samagotchi/bundle_needs.rb
183
188
  - lib/samagotchi/bundle_session_settings.rb
184
189
  - lib/samagotchi/bundles/agents-md/manifest.yml
@@ -218,6 +223,8 @@ files:
218
223
  - lib/samagotchi/bundles/loop-guard/plugin.rb
219
224
  - lib/samagotchi/bundles/mcp/manifest.yml
220
225
  - lib/samagotchi/bundles/mcp/plugin.rb
226
+ - lib/samagotchi/bundles/ruby/manifest.yml
227
+ - lib/samagotchi/bundles/ruby/plugin.rb
221
228
  - lib/samagotchi/bundles/skills/manifest.yml
222
229
  - lib/samagotchi/bundles/skills/plugin.rb
223
230
  - lib/samagotchi/bundles/slow-commands/manifest.yml
@@ -301,7 +308,7 @@ files:
301
308
  - lib/samagotchi/guardrails/model_size.rb
302
309
  - lib/samagotchi/guardrails/outside.rb
303
310
  - lib/samagotchi/guardrails/parent_approvals.rb
304
- - lib/samagotchi/guardrails/parent_continue.rb
311
+ - lib/samagotchi/guardrails/parent_policy.rb
305
312
  - lib/samagotchi/guardrails/protected_paths.rb
306
313
  - lib/samagotchi/guardrails/read_only_shell.rb
307
314
  - lib/samagotchi/guardrails/rm_targets.rb
@@ -391,6 +398,7 @@ files:
391
398
  - lib/samagotchi/memory_bundle/plugin_ref.rb
392
399
  - lib/samagotchi/memory_bundle/profile.rb
393
400
  - lib/samagotchi/memory_bundle/provenance.rb
401
+ - lib/samagotchi/memory_bundle/shas.rb
394
402
  - lib/samagotchi/memory_bundle/shipped_update.rb
395
403
  - lib/samagotchi/memory_bundle/source.rb
396
404
  - lib/samagotchi/memory_bundle/status.rb
@@ -414,6 +422,7 @@ files:
414
422
  - lib/samagotchi/note_delivery.rb
415
423
  - lib/samagotchi/output_formatter.rb
416
424
  - lib/samagotchi/owner_lock.rb
425
+ - lib/samagotchi/parent_answers_command.rb
417
426
  - lib/samagotchi/parent_report.rb
418
427
  - lib/samagotchi/paths.rb
419
428
  - lib/samagotchi/peer_sources.rb
@@ -519,6 +528,7 @@ files:
519
528
  - lib/samagotchi/testing/fake_http.rb
520
529
  - lib/samagotchi/testing/helpers.rb
521
530
  - lib/samagotchi/testing/host.rb
531
+ - lib/samagotchi/testing/install.rb
522
532
  - lib/samagotchi/testing/minitest.rb
523
533
  - lib/samagotchi/testing/recorder.rb
524
534
  - lib/samagotchi/testing/recording_api.rb