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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -1
- data/README.md +7 -3
- data/docs/architecture.md +4 -4
- data/docs/cli.md +19 -4
- data/docs/configuration.md +1 -0
- data/docs/guardrails.md +44 -4
- data/docs/hooks.md +2 -2
- data/docs/internals/context-telemetry.md +3 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +7 -5
- data/docs/releasing.md +6 -2
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/archive_store.rb +3 -3
- data/lib/samagotchi/atomic_file.rb +34 -0
- data/lib/samagotchi/bootstrap/bundles.rb +37 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
- data/lib/samagotchi/bootstrap/probe.rb +9 -5
- data/lib/samagotchi/bootstrap_command.rb +78 -8
- data/lib/samagotchi/bridge/pending_card.rb +2 -3
- data/lib/samagotchi/bridge.rb +56 -116
- data/lib/samagotchi/bridge_client.rb +7 -11
- data/lib/samagotchi/bundle_command.rb +81 -21
- data/lib/samagotchi/bundle_needs.rb +2 -11
- data/lib/samagotchi/bundles/core/manifest.yml +11 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/small-models.yml +27 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -2
- data/lib/samagotchi/bundles/system/identity.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
- data/lib/samagotchi/client.rb +3 -23
- data/lib/samagotchi/config.rb +9 -11
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +255 -0
- data/lib/samagotchi/desktop/macos.rb +2 -3
- data/lib/samagotchi/engine.rb +19 -7
- data/lib/samagotchi/guardrail_wiring.rb +6 -6
- data/lib/samagotchi/guardrails/approvals.rb +2 -7
- data/lib/samagotchi/guardrails/gate.rb +7 -4
- data/lib/samagotchi/guardrails/model_size.rb +87 -0
- data/lib/samagotchi/guardrails/rules.rb +35 -3
- data/lib/samagotchi/guardrails/targets.rb +22 -4
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
- data/lib/samagotchi/hooks/loader.rb +2 -1
- data/lib/samagotchi/hooks/registry.rb +21 -0
- data/lib/samagotchi/image_store.rb +2 -3
- data/lib/samagotchi/kernel_loop.rb +21 -283
- data/lib/samagotchi/live_versions.rb +9 -13
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/native_backend.rb +8 -8
- data/lib/samagotchi/llm/openai_chat.rb +4 -17
- data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
- data/lib/samagotchi/log_path.rb +2 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
- data/lib/samagotchi/memory_bundle/installer.rb +5 -7
- data/lib/samagotchi/memory_bundle/listing.rb +27 -20
- data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
- data/lib/samagotchi/memory_bundle/profile.rb +145 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
- data/lib/samagotchi/memory_bundle/source.rb +2 -0
- data/lib/samagotchi/memory_bundle/status.rb +1 -2
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/note_command.rb +5 -4
- data/lib/samagotchi/owner_lock.rb +0 -6
- data/lib/samagotchi/paths.rb +32 -0
- data/lib/samagotchi/plugin/api.rb +3 -6
- data/lib/samagotchi/plugin/context.rb +2 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/recap_store.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +2 -1
- data/lib/samagotchi/self_report.rb +2 -3
- data/lib/samagotchi/send_command.rb +3 -2
- data/lib/samagotchi/session.rb +41 -143
- data/lib/samagotchi/session_commands.rb +6 -1
- data/lib/samagotchi/session_inbox.rb +175 -0
- data/lib/samagotchi/session_manager.rb +62 -363
- data/lib/samagotchi/session_metrics.rb +2 -3
- data/lib/samagotchi/session_retention.rb +259 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
- data/lib/samagotchi/terminal_ui.rb +10 -20
- data/lib/samagotchi/tool_runner.rb +12 -3
- data/lib/samagotchi/tools/memory.rb +3 -2
- data/lib/samagotchi/tools/send_note.rb +4 -3
- data/lib/samagotchi/tools/task_runtime.rb +2 -3
- data/lib/samagotchi/turn_note.rb +13 -2
- data/lib/samagotchi/update_command.rb +9 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +44 -76
- data/lib/samagotchi/web/info_probe.rb +33 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +25 -10
- data/lib/samagotchi/web/server.rb +8 -11
- data/lib/samagotchi/web/session_hub.rb +8 -7
- data/lib/samagotchi/web/session_summary.rb +4 -3
- data/lib/samagotchi/web/token.rb +2 -5
- data/lib/samagotchi/worker.rb +34 -20
- data/lib/samagotchi/worker_sidecar.rb +42 -0
- 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
|
-
|
|
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
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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(
|
|
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(
|
|
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]
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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: -> {},
|
|
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,
|
|
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
|
|