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
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "context_usage"
5
+ require_relative "context_window"
6
+
7
+ module Samagotchi
8
+ # One turn's context tracking for KernelLoop#run: how full the window is
9
+ # before each request (the server's last prompt count plus an estimate for
10
+ # what the turn appended since, else a chars-per-token estimate), the
11
+ # bucket that puts it in, and what comes of that:
12
+ #
13
+ # - the status line's value (#display), every request and again from the
14
+ # server's final counts after each generation;
15
+ # - a :context_status stream event (#observe's return), on a bucket change
16
+ # or every context.status_cadence requests;
17
+ # - the model's own short line (#take_guidance), once per rise into a
18
+ # bucket whose guidance asks for a change (the second threshold up).
19
+ #
20
+ # The context.* settings are read once, when the tracker is built (one per
21
+ # run, so a changed setting counts from the next turn).
22
+ class ContextStatus
23
+ STATUS_PREFIX = "CONTEXT_STATUS"
24
+ # The model's own line about its context (a tail system message, kind
25
+ # LINE_KIND).
26
+ LINE_PREFIX = "[CONTEXT: "
27
+ LINE_KIND = "context"
28
+ GUIDANCE_FROM_RANK = 2
29
+
30
+ DEFAULT_CHARS_PER_TOKEN = 4.0
31
+ DEFAULT_THRESHOLDS = [20, 40, 60, 80].freeze
32
+
33
+ def self.enabled?
34
+ Config.get("context.status") != false
35
+ end
36
+
37
+ # Healthy to critical; the top configured bucket gets the last, the
38
+ # ones below it the ones before, and the first bucket (and the one
39
+ # under it) is always healthy.
40
+ GUIDANCE = [
41
+ "context healthy — proceed normally",
42
+ "context moderate — prefer targeted and range reads over full-file dumps",
43
+ "context elevated — be concise, prefer range reads, avoid re-reading large files",
44
+ "context critical — summarize aggressively, avoid large outputs, delegate broad work to subagents"
45
+ ].freeze
46
+
47
+ # @param bucket [String] "under<N>" or "<N>plus"
48
+ # @param thresholds [Array<Integer>] the configured ones, sorted
49
+ def self.guidance(bucket, thresholds = DEFAULT_THRESHOLDS)
50
+ rank = bucket_rank(bucket, thresholds)
51
+ level = rank < GUIDANCE_FROM_RANK ? 0 : GUIDANCE.size - 1 - (thresholds.size - rank)
52
+ GUIDANCE[level.clamp(0, GUIDANCE.size - 1)]
53
+ end
54
+
55
+ # 0 for the bucket under the first threshold, then one per threshold.
56
+ def self.bucket_rank(bucket, thresholds)
57
+ return 0 if bucket.nil? || bucket.to_s.start_with?("under")
58
+
59
+ (thresholds.index(bucket.to_s.delete_suffix("plus").to_i) || -1) + 1
60
+ end
61
+
62
+ # The bucket of the last status line +conversation+ holds: the model's
63
+ # own line, or a legacy session's injected telemetry.
64
+ def self.last_bucket(conversation)
65
+ message = conversation.reverse.find do |entry|
66
+ content = entry[:content].to_s
67
+ entry[:role] == "system" && (content.start_with?(STATUS_PREFIX) || content.start_with?(LINE_PREFIX))
68
+ end
69
+ return nil unless message
70
+
71
+ match = message[:content].match(/\bbucket=([a-z0-9_]+)/)
72
+ match && match[1]
73
+ end
74
+
75
+ # @param conversation [Array<Hash>] the turn's conversation so far (its
76
+ # last status line's bucket is where rises count from)
77
+ def initialize(conversation: [])
78
+ @enabled = self.class.enabled?
79
+ chars_per_token = Config.get("context.chars_per_token").to_f
80
+ @chars_per_token = chars_per_token.positive? ? chars_per_token : DEFAULT_CHARS_PER_TOKEN
81
+ parsed = Config.get("context.status_thresholds").to_s.split(",").map { |value| value.strip.to_i }
82
+ .select { |value| value.between?(1, 99) }.uniq.sort
83
+ @thresholds = parsed.empty? ? DEFAULT_THRESHOLDS : parsed
84
+ @cadence = [Config.get("context.status_cadence").to_i, 0].max
85
+ @last_bucket = self.class.last_bucket(conversation)
86
+ @server_usage = nil
87
+ @counted = nil
88
+ @display = nil
89
+ @guidance = nil
90
+ end
91
+
92
+ # @return [Hash, nil] the status line's value ({est_pct:, bucket:});
93
+ # nil with context.status off or before the first request
94
+ attr_reader :display
95
+
96
+ def enabled? = @enabled
97
+
98
+ # Before a request: estimate +prompt+'s usage against +window+ (this
99
+ # request's ContextWindow::Resolved) and update the status line's
100
+ # value. A rise into a bucket that asks the model for a change leaves
101
+ # a line for #take_guidance.
102
+ # +image_tokens+: the images' estimate (their base64 is not in +prompt+).
103
+ #
104
+ # @return [Hash, nil] the :context_status event's fields (without
105
+ # :type/:iteration) when the emit gate fires, else nil
106
+ def observe(prompt, iteration_index:, window:, image_tokens: 0)
107
+ return nil unless @enabled
108
+
109
+ usage = estimate(prompt, server_usage: @server_usage, window: window, image_tokens: image_tokens, counted: @counted)
110
+ bucket = bucket_for(usage[:estimated_pct])
111
+ # The status line's value, every iteration; the gate below decides
112
+ # only the event and the model's guidance line.
113
+ @display = { est_pct: usage[:estimated_pct], bucket: bucket }
114
+ emit = emit?(bucket: bucket, iteration_index: iteration_index)
115
+ previous_bucket = @last_bucket
116
+ @last_bucket = bucket
117
+ return nil unless emit
118
+
119
+ @guidance = guidance_message(usage: usage, bucket: bucket) if guidance_due?(previous_bucket, bucket)
120
+ { status: status_message(usage: usage, bucket: bucket), usage: usage, bucket: bucket, source: usage[:source] }
121
+ end
122
+
123
+ # @return [Hash, nil] the model's line from the last #observe (once)
124
+ def take_guidance
125
+ line = @guidance
126
+ @guidance = nil
127
+ line
128
+ end
129
+
130
+ # A stream payload's counts, kept for the next estimate.
131
+ # @return [Hash, nil] the payload's normalized counts, when it has any
132
+ def capture(payload)
133
+ normalized = ContextUsage.normalize(payload)
134
+ @server_usage = normalized if normalized
135
+ normalized
136
+ end
137
+
138
+ # After a generation: +usage+ is its own server counts (nil: none), for
139
+ # +prompt_chars+/+image_tokens+ as sent. The next estimate adds only
140
+ # what the turn appended since (answer, tool results), and the status
141
+ # line's value counts the answer. Without counts the pre-generation
142
+ # estimate stays.
143
+ def generation_done(usage, prompt_chars:, image_tokens:, window:)
144
+ return unless usage
145
+
146
+ @counted = { chars: prompt_chars, image_tokens: image_tokens }
147
+ value = display_for(used_tokens: usage[:total_tokens], window_tokens: usage[:context_window_tokens] || window&.tokens)
148
+ @display = value if value
149
+ end
150
+
151
+ # The status line's value ({est_pct:, bucket:}) for +used_tokens+ of
152
+ # +window_tokens+; nil without both, or with context.status off.
153
+ def display_for(used_tokens:, window_tokens:)
154
+ return nil unless @enabled
155
+ return nil unless ContextWindow.positive_integer?(used_tokens) && ContextWindow.positive_integer?(window_tokens)
156
+
157
+ pct = (used_tokens.to_f / window_tokens) * 100.0
158
+ { est_pct: pct, bucket: bucket_for(pct) }
159
+ end
160
+
161
+ # `window` is this request's ContextWindow::Resolved. A window the
162
+ # stream payload reports itself still wins.
163
+ # +counted+: the prompt the server's prompt_tokens counted ({chars:,
164
+ # image_tokens:}); what +prompt+ has on top of it (the answer, tool
165
+ # results since) is added as an estimate, so the value doesn't read low
166
+ # during a long tool loop. A prompt shorter than that one (trimmed) or
167
+ # none known: the server's count alone.
168
+ def estimate(prompt, window:, server_usage: nil, image_tokens: 0, counted: nil)
169
+ window_source = window.source
170
+ window_source = :server if server_usage && server_usage[:context_window_tokens]
171
+
172
+ if server_usage && server_usage[:prompt_tokens]
173
+ window_tokens = server_usage[:context_window_tokens] || window.tokens
174
+ used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt, image_tokens, counted)
175
+ source = "server"
176
+ else
177
+ window_tokens = window.tokens
178
+ used_tokens = (prompt.length / @chars_per_token).ceil + image_tokens
179
+ source = "estimate"
180
+ end
181
+
182
+ {
183
+ window_tokens: window_tokens,
184
+ window_source: window_source,
185
+ estimated_used_tokens: used_tokens,
186
+ estimated_remaining_tokens: [window_tokens - used_tokens, 0].max,
187
+ estimated_pct: (used_tokens.to_f / window_tokens) * 100.0,
188
+ source: source
189
+ }
190
+ end
191
+
192
+ private
193
+
194
+ # The estimate for what +prompt+ added since the +counted+ one.
195
+ def appended_tokens(prompt, image_tokens, counted)
196
+ return 0 unless counted
197
+
198
+ chars = prompt.length - counted[:chars]
199
+ return 0 unless chars.positive?
200
+
201
+ (chars / @chars_per_token).ceil + [image_tokens - counted[:image_tokens].to_i, 0].max
202
+ end
203
+
204
+ def bucket_for(estimated_pct)
205
+ bucket = "under#{@thresholds.first}"
206
+ @thresholds.each do |threshold|
207
+ bucket = "#{threshold}plus" if estimated_pct >= threshold
208
+ end
209
+ bucket
210
+ end
211
+
212
+ def bucket_rank(bucket) = self.class.bucket_rank(bucket, @thresholds)
213
+
214
+ # A rise (never a fall or a cadence tick) into a bucket whose guidance
215
+ # asks for a change. With no previous bucket (a first turn, a resumed
216
+ # session with no line yet), the first bucket counts as a rise from 0.
217
+ def guidance_due?(previous, bucket)
218
+ rank = bucket_rank(bucket)
219
+ rank >= GUIDANCE_FROM_RANK && rank > bucket_rank(previous)
220
+ end
221
+
222
+ def emit?(bucket:, iteration_index:)
223
+ bucket_changed = if @last_bucket.nil?
224
+ bucket != "under#{@thresholds.first}"
225
+ else
226
+ bucket != @last_bucket
227
+ end
228
+ cadence_due = @cadence.positive? && ((iteration_index + 1) % @cadence).zero?
229
+ bucket_changed || cadence_due
230
+ end
231
+
232
+ def guidance_message(usage:, bucket:)
233
+ how = usage[:source].to_s == "server" ? "as the server reports" : "estimated"
234
+ { role: "system", kind: LINE_KIND,
235
+ content: "#{LINE_PREFIX}about #{usage[:estimated_pct].to_f.round}% of the context window is in use " \
236
+ "(#{how}; bucket=#{bucket}). #{self.class.guidance(bucket, @thresholds)}]" }
237
+ end
238
+
239
+ def status_message(usage:, bucket:)
240
+ format(
241
+ "%<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",
242
+ prefix: STATUS_PREFIX,
243
+ window: usage[:window_tokens],
244
+ window_src: usage[:window_source] || "default",
245
+ used: usage[:estimated_used_tokens],
246
+ remaining: usage[:estimated_remaining_tokens],
247
+ pct: usage[:estimated_pct],
248
+ bucket: bucket,
249
+ thresholds: @thresholds.join(","),
250
+ src: usage[:source],
251
+ guidance: self.class.guidance(bucket, @thresholds)
252
+ )
253
+ end
254
+ end
255
+ end
@@ -6,6 +6,7 @@ require "fileutils"
6
6
  require "json"
7
7
  require "open3"
8
8
  require "rbconfig"
9
+ require_relative "../atomic_file"
9
10
  require_relative "../version"
10
11
  require_relative "../self_report"
11
12
  require_relative "../installed_gem"
@@ -298,9 +299,7 @@ module Samagotchi
298
299
 
299
300
  def write_launch_file(config = launch_config)
300
301
  FileUtils.mkdir_p(@support_dir)
301
- tmp = "#{launch_path}.tmp"
302
- File.write(tmp, JSON.pretty_generate(config) + "\n")
303
- File.rename(tmp, launch_path)
302
+ AtomicFile.write(launch_path, JSON.pretty_generate(config) + "\n")
304
303
  end
305
304
 
306
305
  def register_app
@@ -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
@@ -128,6 +126,7 @@ module Samagotchi
128
126
  tools: -> { @tools },
129
127
  session: -> { @session },
130
128
  model_key: -> { @model_key },
129
+ model_name: -> { guardrail_model_name },
131
130
  cancelled: -> { !!active_cancel_controller&.cancelled? },
132
131
  ask: ->(fields) { open_question(fields) }
133
132
  )
@@ -430,6 +429,11 @@ module Samagotchi
430
429
  attr_reader :metrics
431
430
  attr_reader :default_model_name, :effective_model_name
432
431
 
432
+ # The effective model's key (ModelOverlay.key_for its bare name): memory
433
+ # overlays, guardrail rules' models:.
434
+ # @return [String, nil]
435
+ attr_reader :model_key
436
+
433
437
  # The prompt profile for the effective model (see #profile_resolution).
434
438
  # @return [ModelProfile]
435
439
  def profile = profile_resolution.profile
@@ -454,6 +458,13 @@ module Samagotchi
454
458
  @host_registry.bare_name(full_ref)
455
459
  end
456
460
 
461
+ # The effective model's bare name for the guardrails' models: rules;
462
+ # nil without a model name.
463
+ def guardrail_model_name
464
+ name = @effective_model_name && bare_model_name(@effective_model_name)
465
+ name.to_s.strip.empty? ? nil : name
466
+ end
467
+
457
468
  # Point the kernel (and a chat backend) at the effective model's host
458
469
  # (after /model, --model, resume).
459
470
  def sync_kernel_client!
@@ -518,7 +529,7 @@ module Samagotchi
518
529
  # The array is replaced, not mutated, and the append and the event
519
530
  # happen under the event lock, so a joining UI sees both or neither.
520
531
  # The caller saves the session.
521
- # @param note [Hash] SessionManager.read_note's shape
532
+ # @param note [Hash] SessionInbox.read_note's shape
522
533
  # @return [Hash, nil] the message, or nil when the conversation already
523
534
  # holds this note (a note file re-read after a crash)
524
535
  def add_context_note(session, note)
@@ -1278,7 +1289,7 @@ module Samagotchi
1278
1289
  return false unless ctrl && !ctrl.cancelled?
1279
1290
 
1280
1291
  hook_notify("stopped the turn: #{reason}", :warn, hook)
1281
- ctrl.cancel!(:hook)
1292
+ ctrl.cancel!(:hook, { by: hook_source(hook), reason: reason.to_s })
1282
1293
  end
1283
1294
  private :hook_stop_turn
1284
1295
 
@@ -1645,6 +1656,7 @@ module Samagotchi
1645
1656
  conversation << note if conversation
1646
1657
  elsif canceled && conversation
1647
1658
  conversation << TurnNote.cancelled(result.cancellation_reason, seconds: turn_seconds.call,
1659
+ stopped_by: effective_controller.detail,
1648
1660
  shown: TurnNote.interrupted_tail?(conversation),
1649
1661
  running_tasks: Tools::TaskRuntime.running_created_in(conversation))
1650
1662
  replace_session_messages(session, conversation)
@@ -1959,7 +1971,7 @@ module Samagotchi
1959
1971
  def load_hooks_from_bundles
1960
1972
  require_relative "memory_bundle/provenance"
1961
1973
  settings = bundle_settings
1962
- MemoryBundle::Provenance.each_installed_holding_hooks do |bundle_name, data|
1974
+ MemoryBundle::Provenance.each_installed(holding: :hooks) do |bundle_name, data|
1963
1975
  if data[:error]
1964
1976
  Log.warn(:hooks, "bundle_manifest_invalid", echo: "[samagotchi:hooks] bundle '#{bundle_name}': #{data[:error]}; its hooks are not loaded",
1965
1977
  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"
@@ -30,14 +29,16 @@ module Samagotchi
30
29
  # @param tools [#call] → Tools::Registry
31
30
  # @param session [#call] → Session, nil
32
31
  # @param model_key [#call] → String, the effective model's overlay key
32
+ # @param model_name [#call] → String, nil: the effective model's bare name
33
33
  # @param cancelled [#call] → Boolean, whether the running turn is cancelled
34
34
  # @param ask [#call] (fields) → the answer; the Engine's question flow
35
- def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:)
35
+ def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {})
36
36
  @scratch = scratch
37
37
  @hooks_lookup = hooks
38
38
  @tools_lookup = tools
39
39
  @session_lookup = session
40
40
  @model_key_lookup = model_key
41
+ @model_name_lookup = model_name
41
42
  @cancelled_lookup = cancelled
42
43
  @ask = ask
43
44
  @failures = Guardrails::LoadFailures.new
@@ -79,6 +80,7 @@ module Samagotchi
79
80
  -> { @hooks_lookup.call },
80
81
  context_lookup: -> { context },
81
82
  model_key_lookup: -> { @model_key_lookup.call },
83
+ model_name_lookup: -> { @model_name_lookup.call },
82
84
  approver: ->(verdict) { request_approval(verdict) },
83
85
  approvals_lookup: -> { @approvals },
84
86
  checks_lookup: -> { checks },
@@ -117,7 +119,7 @@ module Samagotchi
117
119
  def bundle_rules
118
120
  require_relative "memory_bundle/provenance"
119
121
  rules = []
120
- MemoryBundle::Provenance.each_installed_with_guardrails do |bundle_name, data|
122
+ MemoryBundle::Provenance.each_installed(holding: :guardrails) do |bundle_name, data|
121
123
  if data[:error]
122
124
  Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{data[:error]}", bundle: bundle_name)
123
125
  @failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
@@ -130,9 +132,7 @@ module Samagotchi
130
132
  begin
131
133
  raise Guardrails::Rules::ParseError, "the file is missing" unless File.file?(path)
132
134
 
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
135
+ unless MemoryBundle::Provenance.sha_matches?(path, meta.is_a?(Hash) ? meta[:sha256] : nil)
136
136
  raise Guardrails::Rules::ParseError, "its sha256 differs from the installed one (edited after install? reinstall the bundle)"
137
137
  end
138
138
 
@@ -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
@@ -21,7 +21,8 @@ module Samagotchi
21
21
  # the kernel is built.
22
22
  # @param context_lookup [#call] returns the Context for this call
23
23
  # (the Engine's: session, interface, origin, per-turn git cache)
24
- # @param model_key_lookup [#call] the model key (memory overlays)
24
+ # @param model_key_lookup [#call] the model key (memory overlays, rules' models:)
25
+ # @param model_name_lookup [#call] the bare model name (rules' models:)
25
26
  # @param approver [#call, nil] settles an ask (Engine#request_approval);
26
27
  # without one an ask is denied
27
28
  # @param approvals_lookup [#call] returns the Approvals store (or nil)
@@ -31,12 +32,13 @@ module Samagotchi
31
32
  # hook's stop_turn): the rest of a batch is denied without a vote
32
33
  # @param tools_lookup [#call] returns the Tools::Registry (or nil): a
33
34
  # plugin tool's targets: say what its call acts on
34
- def initialize(hooks_lookup, context_lookup: -> { Context.new }, model_key_lookup: -> {}, approver: nil,
35
- approvals_lookup: -> {}, checks_lookup: -> { [] }, cancelled_lookup: -> { false },
35
+ def initialize(hooks_lookup, context_lookup: -> { Context.new }, model_key_lookup: -> {}, model_name_lookup: -> {},
36
+ approver: nil, approvals_lookup: -> {}, checks_lookup: -> { [] }, cancelled_lookup: -> { false },
36
37
  tools_lookup: -> {})
37
38
  @hooks_lookup = hooks_lookup
38
39
  @context_lookup = context_lookup
39
40
  @model_key_lookup = model_key_lookup
41
+ @model_name_lookup = model_name_lookup
40
42
  @approver = approver
41
43
  @approvals_lookup = approvals_lookup
42
44
  @checks_lookup = checks_lookup
@@ -113,7 +115,8 @@ module Samagotchi
113
115
  end
114
116
 
115
117
  def targets_for(call, context)
116
- Targets.for(call, context, model_key: @model_key_lookup.call, registry: @tools_lookup.call)
118
+ Targets.for(call, context, model_key: @model_key_lookup.call, model_name: @model_name_lookup.call,
119
+ registry: @tools_lookup.call)
117
120
  end
118
121
 
119
122
  # Registry#fire_each rescues a raising hook itself.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+
5
+ module Samagotchi
6
+ module Guardrails
7
+ # Whether the effective model is a small one, for rules with
8
+ # `models: small` (guardrails.small_models). Nothing reports a model's
9
+ # size (llama.cpp's /props has no parameter count), so `auto` reads it
10
+ # from the bare model name: Qwen3.6-27B → 27, gemma-4-E4B-it → 4,
11
+ # Ornith-1.5-35B-A3B → 3 (an MoE's active size counts), Mixtral-8x7B
12
+ # → 56. A name without a size, and no model at all, is not small (fail
13
+ # open: the rules for small models are extra asks, not the defaults).
14
+ module ModelSize
15
+ SETTING = "guardrails.small_models"
16
+ # The largest auto-small size, in billions of parameters.
17
+ MAX_SMALL = 32
18
+ GLOB_FLAGS = File::FNM_CASEFOLD | File::FNM_EXTGLOB
19
+
20
+ # A size starts at the name's start or after a separator, and ends at
21
+ # a separator or the name's end (so 4bit and v4.1 aren't sizes).
22
+ NUM = '(\d+(?:\.\d+)?)'
23
+ BEFORE = '(?:\A|[-_/:\s])'
24
+ AFTER = '(?=[-_:\s]|\z)'
25
+ ACTIVE = /#{BEFORE}a#{NUM}b#{AFTER}/i
26
+ EXPERTS = /#{BEFORE}(\d+)x#{NUM}b#{AFTER}/i
27
+ PLAIN = /#{BEFORE}e?#{NUM}b#{AFTER}/i
28
+
29
+ module_function
30
+
31
+ # The size the bare model name gives, in billions; nil without one.
32
+ # @return [Numeric, nil]
33
+ def billions(name)
34
+ name = name.to_s
35
+ if (m = name.match(ACTIVE))
36
+ number(m[1])
37
+ elsif (m = name.match(EXPERTS))
38
+ m[1].to_i * number(m[2])
39
+ elsif (m = name.match(PLAIN))
40
+ number(m[1])
41
+ end
42
+ end
43
+
44
+ def number(text)
45
+ text.include?(".") ? text.to_f : text.to_i
46
+ end
47
+
48
+ # @param name [String, nil] the bare model name (no host prefix)
49
+ # @param key [String, nil] its model key (ModelOverlay.key_for)
50
+ # @param setting [String, nil] guardrails.small_models: "auto", globs
51
+ # on the name or the key, "|"-separated ("auto" may be one of them),
52
+ # or "" (never)
53
+ def small?(name, key, setting = self.setting)
54
+ return false if name.nil? || name.to_s.strip.empty?
55
+
56
+ entries = (setting.nil? ? "auto" : setting.to_s).split("|").map(&:strip).reject(&:empty?)
57
+ entries.any? do |entry|
58
+ if entry.casecmp?("auto")
59
+ (size = billions(name)) ? size <= MAX_SMALL : false
60
+ else
61
+ [name, key].compact.any? { |candidate| File.fnmatch(entry, candidate.to_s, GLOB_FLAGS) }
62
+ end
63
+ end
64
+ end
65
+
66
+ # How /guardrails says it: "small (auto, 27B)", "not small (auto, no
67
+ # size in the name)", "small (small_models: qwen*)".
68
+ def describe(name, key, setting = self.setting)
69
+ return "not small (no model name)" if name.nil? || name.to_s.strip.empty?
70
+
71
+ verdict = small?(name, key, setting) ? "small" : "not small"
72
+ return "#{verdict} (small_models: #{setting.empty? ? "[]" : setting})" unless setting.nil? || setting.strip.casecmp?("auto")
73
+
74
+ size = billions(name)
75
+ "#{verdict} (auto, #{size ? "#{size}B" : "no size in the name"})"
76
+ end
77
+
78
+ # The setting, live (config.yml is re-read on change); auto when it
79
+ # can't be read.
80
+ def setting
81
+ Config.get(SETTING)
82
+ rescue StandardError
83
+ "auto"
84
+ end
85
+ end
86
+ end
87
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "verdict"
4
4
  require_relative "targets"
5
+ require_relative "model_size"
5
6
 
6
7
  module Samagotchi
7
8
  module Guardrails
@@ -13,6 +14,10 @@ module Samagotchi
13
14
  # path: "outside_repo", or a glob on the resolved paths (absolute
14
15
  # or "**/…" globs match the absolute path; others the path
15
16
  # relative to the repo root, the cwd outside one)
17
+ # models: "small" (Guardrails::ModelSize, guardrails.small_models),
18
+ # or a glob on the bare model name or the model key, or a
19
+ # list of them: the rule only votes for a model that matches
20
+ # (no model matches none; missing = every model)
16
21
  # verdict ask|deny, reason, scopes (for an ask; default all).
17
22
  # `guardrails.disable` switches single rules off (#parse_disable).
18
23
  # A rule that doesn't parse raises ParseError: the Engine then denies
@@ -21,13 +26,14 @@ module Samagotchi
21
26
  class Rules
22
27
  class ParseError < StandardError; end
23
28
 
24
- KEYS = %w[id tool command path verdict reason scopes].freeze
29
+ KEYS = %w[id tool command path models verdict reason scopes].freeze
25
30
  VERDICTS = %w[ask deny].freeze
26
31
  GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
27
32
 
28
- Rule = Struct.new(:id, :tools, :command, :path, :verdict, :reason, :scopes, :source, keyword_init: true) do
33
+ Rule = Struct.new(:id, :tools, :command, :path, :models, :verdict, :reason, :scopes, :source, keyword_init: true) do
29
34
  def matches?(targets)
30
35
  return false unless targets
36
+ return false unless for_model?(targets.model_name, targets.model_key, -> { targets.small_model? })
31
37
  return false if tools && !tool_matches?(targets.tool)
32
38
  return false if command && !(targets.command && command.match?(targets.command))
33
39
  return false if path && !path_matches?(targets)
@@ -39,6 +45,21 @@ module Samagotchi
39
45
  tools.any? { |tool| Rules.glob?(tool) ? File.fnmatch(tool, name.to_s, File::FNM_EXTGLOB) : tool == name }
40
46
  end
41
47
 
48
+ # Whether the rule votes for this model (its models:; without one,
49
+ # for every model). +small+ says whether the model is a small one.
50
+ def for_model?(name, key, small = -> { ModelSize.small?(name, key) })
51
+ return true unless models
52
+ return false if name.nil? || name.empty?
53
+
54
+ models.any? do |entry|
55
+ if entry == "small"
56
+ small.call
57
+ else
58
+ [name, key].compact.any? { |candidate| File.fnmatch(entry, candidate, ModelSize::GLOB_FLAGS) }
59
+ end
60
+ end
61
+ end
62
+
42
63
  def path_matches?(targets)
43
64
  return targets.outside_repo? if path == "outside_repo"
44
65
 
@@ -83,7 +104,7 @@ module Samagotchi
83
104
  end
84
105
 
85
106
  Rule.new(id: id, tools: tools_of(raw["tool"], label), command: regex_of(raw["command"], label),
86
- path: path_of(raw["path"], label), verdict: verdict.to_sym,
107
+ path: path_of(raw["path"], label), models: models_of(raw["models"], label), verdict: verdict.to_sym,
87
108
  reason: (raw["reason"] || "rule #{id}").to_s, scopes: scopes_of(raw["scopes"], label), source: source)
88
109
  end
89
110
 
@@ -114,6 +135,17 @@ module Samagotchi
114
135
  value
115
136
  end
116
137
 
138
+ def self.models_of(value, label)
139
+ return nil if value.nil?
140
+
141
+ names = Array(value)
142
+ unless !names.empty? && names.all? { |n| n.is_a?(String) && !n.strip.empty? }
143
+ raise ParseError, "#{label}: models must be small, a glob or a list of them"
144
+ end
145
+
146
+ names.map(&:strip)
147
+ end
148
+
117
149
  def self.scopes_of(value, label)
118
150
  return nil if value.nil?
119
151