samagotchi 0.7.0 → 0.8.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +7 -3
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +19 -4
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +2 -2
  8. data/docs/internals/context-telemetry.md +3 -1
  9. data/docs/memory.md +48 -1
  10. data/docs/plugins.md +7 -5
  11. data/docs/releasing.md +5 -1
  12. data/docs/sessions.md +2 -2
  13. data/lib/samagotchi/archive_store.rb +3 -3
  14. data/lib/samagotchi/atomic_file.rb +34 -0
  15. data/lib/samagotchi/bootstrap/bundles.rb +37 -0
  16. data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
  17. data/lib/samagotchi/bootstrap/probe.rb +9 -5
  18. data/lib/samagotchi/bootstrap_command.rb +78 -8
  19. data/lib/samagotchi/bridge/pending_card.rb +2 -3
  20. data/lib/samagotchi/bridge.rb +56 -116
  21. data/lib/samagotchi/bridge_client.rb +7 -11
  22. data/lib/samagotchi/bundle_command.rb +81 -21
  23. data/lib/samagotchi/bundle_needs.rb +2 -11
  24. data/lib/samagotchi/bundles/core/manifest.yml +11 -0
  25. data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
  26. data/lib/samagotchi/bundles/system/identity.md +1 -0
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
  29. data/lib/samagotchi/client.rb +3 -23
  30. data/lib/samagotchi/config.rb +6 -11
  31. data/lib/samagotchi/context_note.rb +1 -1
  32. data/lib/samagotchi/context_status.rb +255 -0
  33. data/lib/samagotchi/desktop/macos.rb +2 -3
  34. data/lib/samagotchi/engine.rb +6 -7
  35. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  36. data/lib/samagotchi/guardrails/approvals.rb +2 -7
  37. data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
  38. data/lib/samagotchi/hooks/loader.rb +2 -1
  39. data/lib/samagotchi/hooks/registry.rb +21 -0
  40. data/lib/samagotchi/image_store.rb +2 -3
  41. data/lib/samagotchi/kernel_loop.rb +21 -283
  42. data/lib/samagotchi/live_versions.rb +9 -13
  43. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  44. data/lib/samagotchi/llm/native_backend.rb +8 -8
  45. data/lib/samagotchi/llm/openai_chat.rb +4 -17
  46. data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
  47. data/lib/samagotchi/log_path.rb +2 -3
  48. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
  49. data/lib/samagotchi/memory_bundle/installer.rb +5 -7
  50. data/lib/samagotchi/memory_bundle/listing.rb +27 -20
  51. data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
  52. data/lib/samagotchi/memory_bundle/profile.rb +145 -0
  53. data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
  55. data/lib/samagotchi/memory_bundle/source.rb +2 -0
  56. data/lib/samagotchi/memory_bundle/status.rb +1 -2
  57. data/lib/samagotchi/memory_bundle.rb +1 -0
  58. data/lib/samagotchi/note_command.rb +5 -4
  59. data/lib/samagotchi/owner_lock.rb +0 -6
  60. data/lib/samagotchi/paths.rb +32 -0
  61. data/lib/samagotchi/plugin/api.rb +3 -6
  62. data/lib/samagotchi/plugin/context.rb +2 -3
  63. data/lib/samagotchi/plugin/loader.rb +1 -1
  64. data/lib/samagotchi/question_desk.rb +3 -2
  65. data/lib/samagotchi/recap_store.rb +2 -2
  66. data/lib/samagotchi/reply_wait.rb +2 -1
  67. data/lib/samagotchi/self_report.rb +2 -3
  68. data/lib/samagotchi/send_command.rb +3 -2
  69. data/lib/samagotchi/session.rb +41 -143
  70. data/lib/samagotchi/session_inbox.rb +175 -0
  71. data/lib/samagotchi/session_manager.rb +62 -363
  72. data/lib/samagotchi/session_metrics.rb +2 -3
  73. data/lib/samagotchi/session_retention.rb +259 -0
  74. data/lib/samagotchi/sessions_command.rb +3 -2
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
  76. data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
  77. data/lib/samagotchi/terminal_ui.rb +10 -20
  78. data/lib/samagotchi/tools/memory.rb +3 -2
  79. data/lib/samagotchi/tools/send_note.rb +4 -3
  80. data/lib/samagotchi/tools/task_runtime.rb +2 -3
  81. data/lib/samagotchi/turn_note.rb +13 -2
  82. data/lib/samagotchi/update_command.rb +9 -2
  83. data/lib/samagotchi/version.rb +1 -1
  84. data/lib/samagotchi/web/app.rb +44 -76
  85. data/lib/samagotchi/web/info_probe.rb +33 -0
  86. data/lib/samagotchi/web/public/app.js +1 -1
  87. data/lib/samagotchi/web/public/index.html +1 -0
  88. data/lib/samagotchi/web/public/stage_model.js +22 -3
  89. data/lib/samagotchi/web/public/stage_view.js +25 -10
  90. data/lib/samagotchi/web/server.rb +8 -11
  91. data/lib/samagotchi/web/session_hub.rb +8 -7
  92. data/lib/samagotchi/web/session_summary.rb +4 -3
  93. data/lib/samagotchi/web/token.rb +2 -5
  94. data/lib/samagotchi/worker.rb +34 -20
  95. data/lib/samagotchi/worker_sidecar.rb +42 -0
  96. metadata +13 -1
@@ -71,10 +71,9 @@ module Samagotchi
71
71
  # Used by specs and inspection.
72
72
  def self.system_prompt_for(profile)
73
73
  profile = ModelProfile.normalize(profile) unless profile.is_a?(ModelProfile)
74
- new(mode: :assist, profile: profile, plugins: false).assist_system_prompt
74
+ new(profile: profile, plugins: false).assist_system_prompt
75
75
  end
76
76
 
77
- # @param mode [Symbol] :assist (harness is single-mode; memory-reliant; kwarg kept for compat, ignored)
78
77
  # @param client [Client, nil] defaults to Client.new
79
78
  # @param profile [ModelProfile, Symbol, String, nil]
80
79
  # @param session_id [String, nil] resume an existing session
@@ -89,9 +88,8 @@ module Samagotchi
89
88
  # @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
90
89
  # @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
91
90
  # delegate (a child would outlive it) nor plugin fork
92
- def initialize(mode: :assist, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
91
+ def initialize(client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
93
92
  plugins: true, scratch: false)
94
- @mode = mode.to_sym
95
93
  @scratch = scratch
96
94
  @chat_backend = nil
97
95
  @chat_backend_mutex = Mutex.new
@@ -518,7 +516,7 @@ module Samagotchi
518
516
  # The array is replaced, not mutated, and the append and the event
519
517
  # happen under the event lock, so a joining UI sees both or neither.
520
518
  # The caller saves the session.
521
- # @param note [Hash] SessionManager.read_note's shape
519
+ # @param note [Hash] SessionInbox.read_note's shape
522
520
  # @return [Hash, nil] the message, or nil when the conversation already
523
521
  # holds this note (a note file re-read after a crash)
524
522
  def add_context_note(session, note)
@@ -1278,7 +1276,7 @@ module Samagotchi
1278
1276
  return false unless ctrl && !ctrl.cancelled?
1279
1277
 
1280
1278
  hook_notify("stopped the turn: #{reason}", :warn, hook)
1281
- ctrl.cancel!(:hook)
1279
+ ctrl.cancel!(:hook, { by: hook_source(hook), reason: reason.to_s })
1282
1280
  end
1283
1281
  private :hook_stop_turn
1284
1282
 
@@ -1645,6 +1643,7 @@ module Samagotchi
1645
1643
  conversation << note if conversation
1646
1644
  elsif canceled && conversation
1647
1645
  conversation << TurnNote.cancelled(result.cancellation_reason, seconds: turn_seconds.call,
1646
+ stopped_by: effective_controller.detail,
1648
1647
  shown: TurnNote.interrupted_tail?(conversation),
1649
1648
  running_tasks: Tools::TaskRuntime.running_created_in(conversation))
1650
1649
  replace_session_messages(session, conversation)
@@ -1959,7 +1958,7 @@ module Samagotchi
1959
1958
  def load_hooks_from_bundles
1960
1959
  require_relative "memory_bundle/provenance"
1961
1960
  settings = bundle_settings
1962
- MemoryBundle::Provenance.each_installed_holding_hooks do |bundle_name, data|
1961
+ MemoryBundle::Provenance.each_installed(holding: :hooks) do |bundle_name, data|
1963
1962
  if data[:error]
1964
1963
  Log.warn(:hooks, "bundle_manifest_invalid", echo: "[samagotchi:hooks] bundle '#{bundle_name}': #{data[:error]}; its hooks are not loaded",
1965
1964
  bundle: bundle_name)
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
3
  require "yaml"
5
4
 
6
5
  require_relative "config"
@@ -117,7 +116,7 @@ module Samagotchi
117
116
  def bundle_rules
118
117
  require_relative "memory_bundle/provenance"
119
118
  rules = []
120
- MemoryBundle::Provenance.each_installed_with_guardrails do |bundle_name, data|
119
+ MemoryBundle::Provenance.each_installed(holding: :guardrails) do |bundle_name, data|
121
120
  if data[:error]
122
121
  Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{data[:error]}", bundle: bundle_name)
123
122
  @failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
@@ -130,9 +129,7 @@ module Samagotchi
130
129
  begin
131
130
  raise Guardrails::Rules::ParseError, "the file is missing" unless File.file?(path)
132
131
 
133
- expected = (meta.is_a?(Hash) ? meta[:sha256] : nil).to_s.sub(/\Asha256:/, "")
134
- actual = Digest::SHA256.hexdigest(File.binread(path))
135
- if expected != actual
132
+ unless MemoryBundle::Provenance.sha_matches?(path, meta.is_a?(Hash) ? meta[:sha256] : nil)
136
133
  raise Guardrails::Rules::ParseError, "its sha256 differs from the installed one (edited after install? reinstall the bundle)"
137
134
  end
138
135
 
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "fileutils"
5
5
  require "time"
6
+ require_relative "../atomic_file"
6
7
  require_relative "../log"
7
8
  require_relative "approval"
8
9
 
@@ -163,13 +164,7 @@ module Samagotchi
163
164
  with_lock do
164
165
  list = read(locked: true)
165
166
  yield list
166
- tmp = "#{@path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
167
- begin
168
- File.write(tmp, JSON.pretty_generate(list))
169
- File.rename(tmp, @path)
170
- ensure
171
- FileUtils.rm_f(tmp)
172
- end
167
+ AtomicFile.write(@path, JSON.pretty_generate(list))
173
168
  end
174
169
  end
175
170
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "registry"
4
4
  require "digest"
5
5
  require_relative "../log"
6
+ require_relative "../memory_bundle/provenance"
6
7
 
7
8
  module Samagotchi
8
9
  # Namespace that wraps hook plugin classes per-bundle. Each bundle's hooks are
@@ -79,12 +80,14 @@ module Samagotchi
79
80
 
80
81
  begin
81
82
  plugin = instantiate(bundle_name, basename, file, settings: settings)
83
+ log_failure = Hooks.failure_log_gate(event_sym)
82
84
 
83
85
  registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: priority) do |event|
84
86
  begin
85
87
  plugin.call(event)
86
88
  rescue Exception => e
87
- handle_error(bundle_name, basename, on_error, fail_closed, event, e)
89
+ # A veto always applies; only the log line is throttled.
90
+ handle_error(bundle_name, basename, on_error, fail_closed, event, e) if fail_closed || log_failure.call
88
91
  end
89
92
  end
90
93
  loaded += 1
@@ -99,10 +102,10 @@ module Samagotchi
99
102
  # Why the file doesn't match the sha256 recorded at install, or nil.
100
103
  # With none recorded, only a required hook counts as a mismatch.
101
104
  def sha_mismatch(file, recorded, required:)
102
- expected = recorded.to_s.sub(/\Asha256:/, "")
105
+ expected = MemoryBundle::Provenance.recorded_sha(recorded)
103
106
  return (required ? "no sha256 recorded for it (reinstall the bundle)" : nil) if expected.empty?
104
107
 
105
- actual = Digest::SHA256.hexdigest(File.binread(file))
108
+ actual = MemoryBundle::Provenance.file_sha(file)
106
109
  return nil if actual == expected
107
110
 
108
111
  "its sha256 #{actual[0, 12]}… differs from the installed #{expected[0, 12]}… (edited after install? reinstall the bundle)"
@@ -60,6 +60,7 @@ module Samagotchi
60
60
  definitions.each do |defn|
61
61
  begin
62
62
  plugin = load_plugin(hooks_dir, defn[:path], settings: defn[:settings])
63
+ log_failure = Hooks.failure_log_gate(defn[:event_type])
63
64
  # Persistent: config hooks must fire on every turn, not be wiped
64
65
  # by Engine#run_turn's per-turn clear_hooks after turn 1.
65
66
  registry.register_persistent(defn[:event_type].to_sym, label: "#{defn[:path]} (config)") do |event|
@@ -69,7 +70,7 @@ module Samagotchi
69
70
  if defn[:required] && defn[:event_type] == "before_tool_call"
70
71
  deny_for_raise(event, defn[:path], e)
71
72
  else
72
- handle_error(defn[:on_error] || "skip", defn[:path], e)
73
+ handle_error(defn[:on_error] || "skip", defn[:path], e) if log_failure.call
73
74
  end
74
75
  end
75
76
  end
@@ -15,6 +15,27 @@ module Samagotchi
15
15
  takes_settings ? klass.new(settings.is_a?(Hash) ? settings : {}) : klass.new
16
16
  end
17
17
 
18
+ # A stream hook (:generation_progress) fires ~once a second: a broken
19
+ # one logs its failure once a minute, not on every fire.
20
+ FAILED_STREAM_HOOK_LOG_SECONDS = 60
21
+
22
+ # One hook's failure-log gate: called on each failure, it says whether
23
+ # to log this one (a :generation_progress hook's at most once a
24
+ # minute, any other hook's every time).
25
+ # @param event [Symbol, String] the hook's event
26
+ # @return [Proc] -> Boolean
27
+ def self.failure_log_gate(event)
28
+ quiet_for = event.to_s == "generation_progress" ? FAILED_STREAM_HOOK_LOG_SECONDS : 0
29
+ logged_at = nil
30
+ lambda do
31
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
+ next false if logged_at && now - logged_at < quiet_for
33
+
34
+ logged_at = now
35
+ true
36
+ end
37
+ end
38
+
18
39
  # What a hook can do beyond reading its event: the Engine's
19
40
  # callables, each given the hook's label. +notify+ takes
20
41
  # (text:, level:, hook:) and shows one line to the user; +ask_user+
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require "open3"
6
6
  require "securerandom"
7
7
  require "tmpdir"
8
+ require_relative "atomic_file"
8
9
  require_relative "config"
9
10
 
10
11
  module Samagotchi
@@ -384,9 +385,7 @@ module Samagotchi
384
385
  path = File.join(session_dir.to_s, file)
385
386
  unless File.file?(path)
386
387
  FileUtils.mkdir_p(File.dirname(path))
387
- tmp = "#{path}.#{SecureRandom.hex(4)}.tmp"
388
- File.binwrite(tmp, bytes)
389
- File.rename(tmp, path)
388
+ AtomicFile.write(path, bytes)
390
389
  end
391
390
  { file: file, mime: MIME.fetch(info.format), width: info.width, height: info.height,
392
391
  bytes: bytes.bytesize, name: name, source: source.to_s }
@@ -3,7 +3,7 @@
3
3
  require_relative "model_profile"
4
4
  require_relative "tool_call_parser"
5
5
  require_relative "config"
6
- require_relative "context_usage"
6
+ require_relative "context_status"
7
7
  require_relative "context_window"
8
8
  require_relative "prompt"
9
9
  require_relative "prompt_literal_guard"
@@ -37,20 +37,6 @@ module Samagotchi
37
37
  # - Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
38
38
  class KernelLoop
39
39
  Result = Struct.new(:output, :conversation, :exhausted, :pending_tool_calls, :tool_activity, :canceled, :cancellation_reason, :context_status, keyword_init: true) do
40
- def to_s
41
- output.to_s
42
- end
43
-
44
- alias to_str to_s
45
-
46
- def ==(other)
47
- if other.is_a?(self.class)
48
- super
49
- else
50
- to_s == other
51
- end
52
- end
53
-
54
40
  def exhausted?
55
41
  exhausted
56
42
  end
@@ -66,25 +52,6 @@ module Samagotchi
66
52
  def canceled?
67
53
  canceled
68
54
  end
69
-
70
- # Struct/Enumerable defines include? with collection semantics, but the
71
- # historical KernelLoop#run contract returned a String. Keep include?
72
- # aligned with String#include? for backward compatibility.
73
- def include?(needle)
74
- to_s.include?(needle)
75
- end
76
-
77
- # Keep compatibility with existing callers/specs that treat run() as a
78
- # plain string (e.g., include?, match, start_with?).
79
- def method_missing(name, *args, &block)
80
- return to_s.public_send(name, *args, &block) if to_s.respond_to?(name)
81
-
82
- super
83
- end
84
-
85
- def respond_to_missing?(name, include_private = false)
86
- to_s.respond_to?(name, include_private) || super
87
- end
88
55
  end
89
56
 
90
57
  # The built-in tool classes (Tools::Builtins registers them).
@@ -105,16 +72,6 @@ module Samagotchi
105
72
  def ask_user_question(call) = @kernel.__send__(:handle_ask_user_question, call)
106
73
  end
107
74
 
108
- CONTEXT_STATUS_PREFIX = "CONTEXT_STATUS"
109
- # The model's own line about its context (a tail system message, kind
110
- # CONTEXT_LINE_KIND), left once per rise into a bucket whose guidance
111
- # asks for a change: from the second threshold (40% by default) up.
112
- CONTEXT_LINE_PREFIX = "[CONTEXT: "
113
- CONTEXT_LINE_KIND = "context"
114
- CONTEXT_GUIDANCE_FROM_RANK = 2
115
-
116
- DEFAULT_CONTEXT_CHARS_PER_TOKEN = 4.0
117
- DEFAULT_CONTEXT_THRESHOLDS = [20, 40, 60, 80].freeze
118
75
  DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
119
76
  QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT = 2
120
77
  QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT = "Continue the previous assistant message by finishing the open <tool_call> XML block. Output only the remaining XML needed to complete the tool call."
@@ -204,7 +161,7 @@ module Samagotchi
204
161
  @current_model_name = resolved_model_name
205
162
 
206
163
  conversation = prepare_conversation(messages)
207
- context_state = initial_context_status_state(conversation)
164
+ context = ContextStatus.new(conversation: conversation)
208
165
  exhausted = false
209
166
  pending_tool_calls = false
210
167
  tool_activity = []
@@ -213,7 +170,6 @@ module Samagotchi
213
170
  empty_retries = 0
214
171
  empty_retry_limit = EmptyAnswerRetry.limit
215
172
  @retry_generation = false
216
- context_status = nil
217
173
  stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
218
174
  # Qwen with thinking off: an empty thought after the cue, so the model
219
175
  # answers at once. Kept in the turn's model messages, so each tool-loop
@@ -228,9 +184,9 @@ module Samagotchi
228
184
  prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
229
185
  image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(conversation)
230
186
  context_window = ContextWindow.resolve(client: @client, model: resolved_model_name)
231
- context_status = emit_context_status_event(on_stream_event, prompt, iteration_index: iteration_index, state: context_state, window: context_window,
232
- image_tokens: image_tokens) || context_status
233
- if (line = context_state.delete(:guidance))
187
+ emit_context_status_event(on_stream_event, context, prompt, iteration_index: iteration_index, window: context_window,
188
+ image_tokens: image_tokens)
189
+ if (line = context.take_guidance)
234
190
  # The model's own copy, on the tail (the prompt cache keeps its
235
191
  # prefix), then the prompt again with it.
236
192
  conversation << line
@@ -246,8 +202,8 @@ module Samagotchi
246
202
  profile_source: @profile_source
247
203
  )
248
204
  served_model = nil
249
- # This generation's own server counts (the run-long
250
- # context_state[:server_usage] can hold an earlier one's).
205
+ # This generation's own server counts (the run-long ones in
206
+ # `context` can be an earlier one's).
251
207
  generation_usage = nil
252
208
  # The thinking this generation streamed, for the log (a stuck
253
209
  # thinking generation shows as thinking_chars=N content_length=…).
@@ -266,7 +222,7 @@ module Samagotchi
266
222
  cancel_controller: cancel_controller,
267
223
  model_name: resolved_model_name,
268
224
  on_chunk: lambda { |chunk|
269
- generation_usage = capture_server_usage(chunk[:payload], context_state) || generation_usage
225
+ generation_usage = context.capture(chunk[:payload]) || generation_usage
270
226
  # llama.cpp names the loaded model in the stream's last payload.
271
227
  named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
272
228
  served_model = named if named.is_a?(String) && !named.strip.empty?
@@ -333,14 +289,11 @@ module Samagotchi
333
289
  # No retry left: the turn ends as cancelled (hook), with nothing
334
290
  # salvaged and without the spent nudge.
335
291
  drop_last_empty_retry!(conversation)
336
- cancel_controller.cancel!(:hook)
292
+ cancel_controller.cancel!(:hook, cut)
337
293
  emit_stream_event(on_stream_event, type: :generation_cancelled, iteration: iteration_index + 1, reason: :hook)
338
294
  return cancelled_result(conversation, tool_activity: tool_activity, reason: :hook, partial_assistant_text: "")
339
295
  end
340
- # What the server's prompt count covers, so the next estimate adds
341
- # only what the turn appended since (answer, tool results).
342
- context_state[:counted] = { chars: prompt.length, image_tokens: image_tokens } if generation_usage
343
- refresh_context_display(context_state, generation_usage, context_window)
296
+ context.generation_done(generation_usage, prompt_chars: prompt.length, image_tokens: image_tokens, window: context_window)
344
297
  emit_stream_event(
345
298
  on_stream_event,
346
299
  type: :generation_completed,
@@ -465,7 +418,7 @@ module Samagotchi
465
418
  tool_activity: tool_activity,
466
419
  canceled: false,
467
420
  cancellation_reason: nil,
468
- context_status: context_state[:display] || context_status
421
+ context_status: context.display
469
422
  )
470
423
  rescue StandardError => e
471
424
  LLM::FailedTurn.attach(e, conversation && duplicate_conversation(conversation))
@@ -667,227 +620,16 @@ module Samagotchi
667
620
  Log.debug(:model, event, payload: payload, model: @current_model_name, **fields)
668
621
  end
669
622
 
670
- # Estimate context usage for this iteration's prompt and, when the emit
671
- # gate fires, surface it to stream consumers as a :context_status event.
672
- # A rise into a bucket that asks the model for a change also leaves a
673
- # short line for it (state[:guidance], see context_guidance_message):
674
- # not the telemetry, which the model no longer receives (it used to be injected as a
675
- # synthetic system message); the returned {est_pct:, bucket:} hash feeds
676
- # the Result's context_status for UI status lines (nil when not emitted).
677
- def emit_context_status_event(on_stream_event, prompt, iteration_index:, state:, window: nil, image_tokens: 0)
678
- return nil unless context_status_enabled?
679
-
680
- usage = estimate_context_usage(prompt, server_usage: state[:server_usage], window: window, image_tokens: image_tokens,
681
- counted: state[:counted])
682
- bucket = context_status_bucket(usage[:estimated_pct])
683
- # The status line's value, every iteration; the gate below decides
684
- # only the event and the model's guidance line.
685
- state[:display] = { est_pct: usage[:estimated_pct], bucket: bucket }
686
- emit_status = should_emit_context_status?(state: state, bucket: bucket, iteration_index: iteration_index)
687
- previous_bucket = state[:last_bucket]
688
- state[:last_bucket] = bucket
689
- return nil unless emit_status
690
-
691
- state[:guidance] = context_guidance_message(usage: usage, bucket: bucket) if guidance_due?(previous_bucket, bucket)
692
-
693
- status_message = context_status_message(usage: usage, bucket: bucket, source: usage[:source])
694
- emit_stream_event(
695
- on_stream_event,
696
- type: :context_status,
697
- iteration: iteration_index + 1,
698
- status: status_message,
699
- usage: usage,
700
- bucket: bucket,
701
- source: usage[:source]
702
- )
703
- dump_log("context_status", status_message, iteration: iteration_index + 1, bucket: bucket)
704
- { est_pct: usage[:estimated_pct], bucket: bucket }
705
- end
706
-
707
- # @return [Hash, nil] the payload's normalized counts, when it has any
708
- def capture_server_usage(payload, state)
709
- normalized = ContextUsage.normalize(payload)
710
- state[:server_usage] = normalized if normalized
711
- normalized
712
- end
713
-
714
- # After a generation: the status line's value from what the server
715
- # reported for it (prompt + answer), so a turn's value counts its last
716
- # answer. Without counts the pre-generation estimate stays.
717
- def refresh_context_display(state, usage, window)
718
- return unless usage
719
-
720
- display = context_display(used_tokens: usage[:total_tokens],
721
- window_tokens: usage[:context_window_tokens] || window&.tokens)
722
- state[:display] = display if display
723
- end
724
-
725
- def initial_context_status_state(conversation)
726
- { last_bucket: extract_last_context_status_bucket(conversation) }
727
- end
728
-
729
- # The bucket of the last status line the conversation holds: the model's
730
- # own line, or a legacy session's injected telemetry.
731
- def extract_last_context_status_bucket(conversation)
732
- message = conversation.reverse.find do |entry|
733
- content = entry[:content].to_s
734
- entry[:role] == "system" && (content.start_with?(CONTEXT_STATUS_PREFIX) || content.start_with?(CONTEXT_LINE_PREFIX))
735
- end
736
- return nil unless message
737
-
738
- match = message[:content].match(/\bbucket=([a-z0-9_]+)/)
739
- match && match[1]
740
- end
741
-
742
- def context_status_enabled?
743
- Samagotchi::Config.get("context.status") != false
744
- end
745
-
746
- # `window` is this iteration's ContextWindow::Resolved (resolved here when
747
- # not given). A window the stream payload reports itself still wins.
748
- # +image_tokens+: the images' estimate (their base64 is not in +prompt+).
749
- # +counted+: the prompt the server's prompt_tokens counted ({chars:,
750
- # image_tokens:}); what +prompt+ has on top of it (the answer, tool
751
- # results since) is added as an estimate, so the value doesn't read low
752
- # during a long tool loop. A prompt shorter than that one (trimmed) or
753
- # none known: the server's count alone.
754
- def estimate_context_usage(prompt, server_usage: nil, window: nil, image_tokens: 0, counted: nil)
755
- window ||= ContextWindow.resolve(client: @client, model: @current_model_name)
756
- window_source = window.source
757
- if server_usage && server_usage[:context_window_tokens]
758
- window_source = :server
759
- end
760
-
761
- if server_usage && server_usage[:prompt_tokens]
762
- window_tokens = server_usage[:context_window_tokens] || window.tokens
763
- estimated_used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt, image_tokens, counted)
764
- estimated_remaining_tokens = [window_tokens - estimated_used_tokens, 0].max
765
- estimated_pct = (estimated_used_tokens.to_f / window_tokens) * 100.0
766
-
767
- return {
768
- window_tokens: window_tokens,
769
- window_source: window_source,
770
- estimated_used_tokens: estimated_used_tokens,
771
- estimated_remaining_tokens: estimated_remaining_tokens,
772
- estimated_pct: estimated_pct,
773
- source: "server"
774
- }
775
- end
776
-
777
- window_tokens = window.tokens
778
- estimated_used_tokens = (prompt.length / context_chars_per_token).ceil + image_tokens
779
- estimated_remaining_tokens = [window_tokens - estimated_used_tokens, 0].max
780
- estimated_pct = (estimated_used_tokens.to_f / window_tokens) * 100.0
781
-
782
- {
783
- window_tokens: window_tokens,
784
- window_source: window_source,
785
- estimated_used_tokens: estimated_used_tokens,
786
- estimated_remaining_tokens: estimated_remaining_tokens,
787
- estimated_pct: estimated_pct,
788
- source: "estimate"
789
- }
790
- end
791
-
792
- # The estimate for what +prompt+ added since the +counted+ one.
793
- def appended_tokens(prompt, image_tokens, counted)
794
- return 0 unless counted
795
-
796
- chars = prompt.length - counted[:chars]
797
- return 0 unless chars.positive?
798
-
799
- (chars / context_chars_per_token).ceil + [image_tokens - counted[:image_tokens].to_i, 0].max
800
- end
801
-
802
- def context_chars_per_token
803
- value = Samagotchi::Config.get("context.chars_per_token").to_f
804
- value.positive? ? value : DEFAULT_CONTEXT_CHARS_PER_TOKEN
805
- end
806
-
807
- def context_status_thresholds
808
- raw = Samagotchi::Config.get("context.status_thresholds").to_s
809
- parsed = raw.split(",").map { |value| value.strip.to_i }.select { |value| value.between?(1, 99) }.uniq.sort
810
- parsed.empty? ? DEFAULT_CONTEXT_THRESHOLDS : parsed
811
- end
812
-
813
- def context_status_cadence
814
- [Samagotchi::Config.get("context.status_cadence").to_i, 0].max
815
- end
816
-
817
- # 0 for the bucket under the first threshold, then one per threshold.
818
- def bucket_rank(bucket)
819
- return 0 if bucket.nil? || bucket.to_s.start_with?("under")
820
-
821
- (context_status_thresholds.index(bucket.to_s.delete_suffix("plus").to_i) || -1) + 1
822
- end
823
-
824
- # A rise (never a fall or a cadence tick) into a bucket whose guidance
825
- # asks for a change. With no previous bucket (a first turn, a resumed
826
- # session with no line yet), the first bucket counts as a rise from 0.
827
- def guidance_due?(previous, bucket)
828
- rank = bucket_rank(bucket)
829
- rank >= CONTEXT_GUIDANCE_FROM_RANK && rank > bucket_rank(previous)
830
- end
831
-
832
- def context_guidance_message(usage:, bucket:)
833
- how = usage[:source].to_s == "server" ? "as the server reports" : "estimated"
834
- { role: "system", kind: CONTEXT_LINE_KIND,
835
- content: "#{CONTEXT_LINE_PREFIX}about #{usage[:estimated_pct].to_f.round}% of the context window is in use " \
836
- "(#{how}; bucket=#{bucket}). #{context_status_guidance(bucket)}]" }
837
- end
838
-
839
- def context_status_bucket(estimated_pct)
840
- thresholds = context_status_thresholds
841
- bucket = "under#{thresholds.first}"
842
- thresholds.each do |threshold|
843
- bucket = "#{threshold}plus" if estimated_pct >= threshold
844
- end
845
- bucket
846
- end
847
-
848
- def should_emit_context_status?(state:, bucket:, iteration_index:)
849
- last_bucket = state[:last_bucket]
850
- below_threshold_bucket = "under#{context_status_thresholds.first}"
851
- bucket_changed = if last_bucket.nil?
852
- bucket != below_threshold_bucket
853
- else
854
- bucket != last_bucket
855
- end
856
-
857
- cadence = context_status_cadence
858
- cadence_due = cadence.positive? && ((iteration_index + 1) % cadence).zero?
859
- bucket_changed || cadence_due
860
- end
623
+ # Estimate context usage for this iteration's prompt (ContextStatus#observe)
624
+ # and, when the emit gate fires, surface it to stream consumers as a
625
+ # :context_status event. The telemetry is not for the model (it used to
626
+ # be injected as a synthetic system message).
627
+ def emit_context_status_event(on_stream_event, context, prompt, iteration_index:, window:, image_tokens: 0)
628
+ event = context.observe(prompt, iteration_index: iteration_index, window: window, image_tokens: image_tokens)
629
+ return unless event
861
630
 
862
- def context_status_message(usage:, bucket:, source:)
863
- format(
864
- "%<prefix>s window_tokens=%<window>d window_src=%<window_src>s est_used_tokens=%<used>d est_remaining_tokens=%<remaining>d est_pct=%<pct>.1f bucket=%<bucket>s thresholds=%<thresholds>s src=%<src>s guidance=%<guidance>s",
865
- prefix: CONTEXT_STATUS_PREFIX,
866
- window: usage[:window_tokens],
867
- window_src: usage[:window_source] || "default",
868
- used: usage[:estimated_used_tokens],
869
- remaining: usage[:estimated_remaining_tokens],
870
- pct: usage[:estimated_pct],
871
- bucket: bucket,
872
- thresholds: context_status_thresholds.join(","),
873
- src: source,
874
- guidance: context_status_guidance(bucket)
875
- )
876
- end
877
-
878
- def context_status_guidance(bucket)
879
- case bucket
880
- when "under20", "20plus"
881
- "context healthy — proceed normally"
882
- when "40plus"
883
- "context moderate — prefer targeted and range reads over full-file dumps"
884
- when "60plus"
885
- "context elevated — be concise, prefer range reads, avoid re-reading large files"
886
- when "80plus"
887
- "context critical — summarize aggressively, avoid large outputs, delegate broad work to subagents"
888
- else
889
- "context healthy — proceed normally"
890
- end
631
+ emit_stream_event(on_stream_event, type: :context_status, iteration: iteration_index + 1, **event)
632
+ dump_log("context_status", event[:status], iteration: iteration_index + 1, bucket: event[:bucket])
891
633
  end
892
634
 
893
635
  public
@@ -904,11 +646,7 @@ module Samagotchi
904
646
  # of +window_tokens+; nil without both, or with context.status off. The
905
647
  # chat loop builds its value with it too.
906
648
  def context_display(used_tokens:, window_tokens:)
907
- return nil unless context_status_enabled?
908
- return nil unless ContextWindow.positive_integer?(used_tokens) && ContextWindow.positive_integer?(window_tokens)
909
-
910
- pct = (used_tokens.to_f / window_tokens) * 100.0
911
- { est_pct: pct, bucket: context_status_bucket(pct) }
649
+ ContextStatus.new.display_for(used_tokens: used_tokens, window_tokens: window_tokens)
912
650
  end
913
651
 
914
652
  # Per-profile parse strategy. Rebuilt when the active profile changes
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "net/http"
5
3
  require "socket"
6
4
  require_relative "session"
7
5
  require_relative "version"
6
+ require_relative "worker_sidecar"
7
+ require_relative "web/info_probe"
8
8
 
9
9
  module Samagotchi
10
10
  # Which chi versions the running processes run, for `chi update`: session
@@ -22,13 +22,12 @@ module Samagotchi
22
22
 
23
23
  # @return [Array<Worker>] the workers whose Bridge answers, by session id
24
24
  def workers(state_dir: Session.default_state_dir)
25
- Dir[File.join(state_dir, "*", "bridge.json")].sort.filter_map do |sidecar|
26
- data = JSON.parse(File.read(sidecar))
27
- next unless data.is_a?(Hash) && listening?(data["port"].to_i)
25
+ Dir[File.join(state_dir, "*", WorkerSidecar::FILE)].sort.filter_map do |path|
26
+ session_dir = File.dirname(path)
27
+ sidecar = WorkerSidecar.read(session_dir)
28
+ next unless sidecar && listening?(sidecar.port)
28
29
 
29
- Worker.new(session_id: File.basename(File.dirname(sidecar)), version: data["version"])
30
- rescue JSON::ParserError, SystemCallError
31
- nil
30
+ Worker.new(session_id: File.basename(session_dir), version: sidecar.version)
32
31
  end
33
32
  end
34
33
 
@@ -46,11 +45,8 @@ module Samagotchi
46
45
  # The /api/info of a chi web on host:port, or nil when nothing (or not
47
46
  # chi web) answers.
48
47
  def web_info(host, port, timeout: WEB_TIMEOUT)
49
- response = Net::HTTP.start(host, port, open_timeout: timeout, read_timeout: timeout) { |http| http.get("/api/info") }
50
- info = response.code.to_i == 200 ? JSON.parse(response.body.to_s) : nil
51
- info.is_a?(Hash) && info["app"] == "chi-web" ? info : nil
52
- rescue StandardError
53
- nil
48
+ info = Web::InfoProbe.call(host, port, timeout: timeout)
49
+ info.is_a?(Hash) ? info : nil
54
50
  end
55
51
 
56
52
  def listening?(port, host: "127.0.0.1")
@@ -423,7 +423,7 @@ module Samagotchi
423
423
  end
424
424
  index = @conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
425
425
  @conversation.delete_at(index) if index
426
- @cancel_controller.cancel!(:hook)
426
+ @cancel_controller.cancel!(:hook, response.cut)
427
427
  canceled(iteration, :hook)
428
428
  end
429
429