samagotchi 0.7.0 → 0.8.1

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -1
  3. data/README.md +7 -3
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +19 -4
  6. data/docs/configuration.md +1 -0
  7. data/docs/guardrails.md +44 -4
  8. data/docs/hooks.md +2 -2
  9. data/docs/internals/context-telemetry.md +3 -1
  10. data/docs/memory.md +48 -1
  11. data/docs/plugins.md +7 -5
  12. data/docs/releasing.md +6 -2
  13. data/docs/sessions.md +2 -2
  14. data/lib/samagotchi/archive_store.rb +3 -3
  15. data/lib/samagotchi/atomic_file.rb +34 -0
  16. data/lib/samagotchi/bootstrap/bundles.rb +37 -0
  17. data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
  18. data/lib/samagotchi/bootstrap/probe.rb +9 -5
  19. data/lib/samagotchi/bootstrap_command.rb +78 -8
  20. data/lib/samagotchi/bridge/pending_card.rb +2 -3
  21. data/lib/samagotchi/bridge.rb +56 -116
  22. data/lib/samagotchi/bridge_client.rb +7 -11
  23. data/lib/samagotchi/bundle_command.rb +81 -21
  24. data/lib/samagotchi/bundle_needs.rb +2 -11
  25. data/lib/samagotchi/bundles/core/manifest.yml +11 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
  27. data/lib/samagotchi/bundles/guardrails/guardrails/small-models.yml +27 -0
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -2
  29. data/lib/samagotchi/bundles/system/identity.md +1 -0
  30. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  31. data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
  32. data/lib/samagotchi/client.rb +3 -23
  33. data/lib/samagotchi/config.rb +9 -11
  34. data/lib/samagotchi/context_note.rb +1 -1
  35. data/lib/samagotchi/context_status.rb +255 -0
  36. data/lib/samagotchi/desktop/macos.rb +2 -3
  37. data/lib/samagotchi/engine.rb +19 -7
  38. data/lib/samagotchi/guardrail_wiring.rb +6 -6
  39. data/lib/samagotchi/guardrails/approvals.rb +2 -7
  40. data/lib/samagotchi/guardrails/gate.rb +7 -4
  41. data/lib/samagotchi/guardrails/model_size.rb +87 -0
  42. data/lib/samagotchi/guardrails/rules.rb +35 -3
  43. data/lib/samagotchi/guardrails/targets.rb +22 -4
  44. data/lib/samagotchi/guardrails.rb +1 -0
  45. data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
  46. data/lib/samagotchi/hooks/loader.rb +2 -1
  47. data/lib/samagotchi/hooks/registry.rb +21 -0
  48. data/lib/samagotchi/image_store.rb +2 -3
  49. data/lib/samagotchi/kernel_loop.rb +21 -283
  50. data/lib/samagotchi/live_versions.rb +9 -13
  51. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  52. data/lib/samagotchi/llm/native_backend.rb +8 -8
  53. data/lib/samagotchi/llm/openai_chat.rb +4 -17
  54. data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
  55. data/lib/samagotchi/log_path.rb +2 -3
  56. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
  57. data/lib/samagotchi/memory_bundle/installer.rb +5 -7
  58. data/lib/samagotchi/memory_bundle/listing.rb +27 -20
  59. data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
  60. data/lib/samagotchi/memory_bundle/profile.rb +145 -0
  61. data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
  62. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
  63. data/lib/samagotchi/memory_bundle/source.rb +2 -0
  64. data/lib/samagotchi/memory_bundle/status.rb +1 -2
  65. data/lib/samagotchi/memory_bundle.rb +1 -0
  66. data/lib/samagotchi/note_command.rb +5 -4
  67. data/lib/samagotchi/owner_lock.rb +0 -6
  68. data/lib/samagotchi/paths.rb +32 -0
  69. data/lib/samagotchi/plugin/api.rb +3 -6
  70. data/lib/samagotchi/plugin/context.rb +2 -3
  71. data/lib/samagotchi/plugin/loader.rb +1 -1
  72. data/lib/samagotchi/question_desk.rb +3 -2
  73. data/lib/samagotchi/recap_store.rb +2 -2
  74. data/lib/samagotchi/reply_wait.rb +2 -1
  75. data/lib/samagotchi/self_report.rb +2 -3
  76. data/lib/samagotchi/send_command.rb +3 -2
  77. data/lib/samagotchi/session.rb +41 -143
  78. data/lib/samagotchi/session_commands.rb +6 -1
  79. data/lib/samagotchi/session_inbox.rb +175 -0
  80. data/lib/samagotchi/session_manager.rb +62 -363
  81. data/lib/samagotchi/session_metrics.rb +2 -3
  82. data/lib/samagotchi/session_retention.rb +259 -0
  83. data/lib/samagotchi/sessions_command.rb +3 -2
  84. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
  85. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  86. data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
  87. data/lib/samagotchi/terminal_ui.rb +10 -20
  88. data/lib/samagotchi/tool_runner.rb +12 -3
  89. data/lib/samagotchi/tools/memory.rb +3 -2
  90. data/lib/samagotchi/tools/send_note.rb +4 -3
  91. data/lib/samagotchi/tools/task_runtime.rb +2 -3
  92. data/lib/samagotchi/turn_note.rb +13 -2
  93. data/lib/samagotchi/update_command.rb +9 -2
  94. data/lib/samagotchi/version.rb +1 -1
  95. data/lib/samagotchi/web/app.rb +44 -76
  96. data/lib/samagotchi/web/info_probe.rb +33 -0
  97. data/lib/samagotchi/web/public/app.js +1 -1
  98. data/lib/samagotchi/web/public/index.html +1 -0
  99. data/lib/samagotchi/web/public/stage_model.js +22 -3
  100. data/lib/samagotchi/web/public/stage_view.js +25 -10
  101. data/lib/samagotchi/web/server.rb +8 -11
  102. data/lib/samagotchi/web/session_hub.rb +8 -7
  103. data/lib/samagotchi/web/session_summary.rb +4 -3
  104. data/lib/samagotchi/web/token.rb +2 -5
  105. data/lib/samagotchi/worker.rb +34 -20
  106. data/lib/samagotchi/worker_sidecar.rb +42 -0
  107. metadata +15 -1
@@ -3,6 +3,7 @@
3
3
  require_relative "../tools/tool_path"
4
4
  require_relative "../tools/memory"
5
5
  require_relative "../log"
6
+ require_relative "model_size"
6
7
 
7
8
  module Samagotchi
8
9
  module Guardrails
@@ -16,6 +17,10 @@ module Samagotchi
16
17
 
17
18
  attr_reader :tool, :command, :paths, :cwd, :repo_root
18
19
 
20
+ # @return [String, nil] the effective model: its bare name (no host
21
+ # prefix) and its key (ModelOverlay.key_for), for a rule's models:
22
+ attr_reader :model_name, :model_key
23
+
19
24
  # @return [Hash, nil] a plugin tool's arguments (call[:args]), for an
20
25
  # approval of a call whose targets name no command or path; nil for
21
26
  # chi's own tools
@@ -23,10 +28,12 @@ module Samagotchi
23
28
 
24
29
  # @param call [Hash] the parsed tool call
25
30
  # @param context [Context]
26
- # @param model_key [String, nil] for a memory_write model overlay
31
+ # @param model_key [String, nil] for a memory_write model overlay, and
32
+ # a rule's models:
33
+ # @param model_name [String, nil] the bare model name, for a rule's models:
27
34
  # @param registry [Tools::Registry, nil] the session's tools: a plugin
28
35
  # tool's entry says what it acts on (its targets:)
29
- def self.for(call, context, model_key: nil, registry: nil)
36
+ def self.for(call, context, model_key: nil, model_name: nil, registry: nil)
30
37
  tool = call[:name].to_s
31
38
  base = context.cwd
32
39
  command = nil
@@ -53,7 +60,8 @@ module Samagotchi
53
60
  entry = registry && registry[tool]
54
61
  args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
55
62
  end
56
- new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd), args: args)
63
+ new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd), args: args,
64
+ model_name: model_name, model_key: model_key)
57
65
  end
58
66
 
59
67
  # What a plugin tool's targets: callable says the call acts on:
@@ -93,8 +101,10 @@ module Samagotchi
93
101
  nil
94
102
  end
95
103
 
96
- def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil)
104
+ def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil, model_name: nil, model_key: nil)
97
105
  @args = args
106
+ @model_name = model_name
107
+ @model_key = model_key
98
108
  @tool = tool
99
109
  @command = command
100
110
  @paths = paths
@@ -104,6 +114,14 @@ module Samagotchi
104
114
 
105
115
  def shell? = SHELL_TOOLS.include?(@tool)
106
116
 
117
+ # Whether the effective model is a small one (guardrails.small_models,
118
+ # read when first asked: once per call's targets).
119
+ def small_model?
120
+ return @small_model if defined?(@small_model)
121
+
122
+ @small_model = ModelSize.small?(@model_name, @model_key)
123
+ end
124
+
107
125
  # Whether any path is outside the repo root (the cwd outside a repo).
108
126
  def outside_repo?
109
127
  root = @repo_root || @cwd
@@ -3,6 +3,7 @@
3
3
  require_relative "guardrails/verdict"
4
4
  require_relative "guardrails/context"
5
5
  require_relative "guardrails/targets"
6
+ require_relative "guardrails/model_size"
6
7
  require_relative "guardrails/approval"
7
8
  require_relative "guardrails/approvals"
8
9
  require_relative "guardrails/protected_paths"
@@ -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
 
@@ -32,17 +32,17 @@ module Samagotchi
32
32
  pending_input: pending_input
33
33
  )
34
34
  ModelResult.new(
35
- text: kernel_result.respond_to?(:output) ? kernel_result.output.to_s : kernel_result.to_s,
35
+ text: kernel_result.output.to_s,
36
36
  tool_calls: nil,
37
37
  provider: :native,
38
38
  usage: usage.usage(prompt_text: Array(messages).sum("") { |message| message[:content].to_s }),
39
- conversation: kernel_result.respond_to?(:conversation) ? kernel_result.conversation : nil,
40
- canceled: kernel_result.respond_to?(:canceled?) && kernel_result.canceled?,
41
- cancellation_reason: kernel_result.respond_to?(:cancellation_reason) ? kernel_result.cancellation_reason : nil,
42
- exhausted: kernel_result.respond_to?(:exhausted) && kernel_result.exhausted,
43
- tool_activity: kernel_result.respond_to?(:tool_activity) ? kernel_result.tool_activity : [],
44
- context_status: kernel_result.respond_to?(:context_status) ? kernel_result.context_status : nil,
45
- pending_tool_calls: kernel_result.respond_to?(:pending_tool_calls) && kernel_result.pending_tool_calls
39
+ conversation: kernel_result.conversation,
40
+ canceled: kernel_result.canceled?,
41
+ cancellation_reason: kernel_result.cancellation_reason,
42
+ exhausted: kernel_result.exhausted,
43
+ tool_activity: kernel_result.tool_activity,
44
+ context_status: kernel_result.context_status,
45
+ pending_tool_calls: kernel_result.pending_tool_calls
46
46
  )
47
47
  end
48
48
  end