samagotchi 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -1
- data/docs/architecture.md +16 -9
- data/docs/cli.md +4 -3
- data/docs/configuration.md +22 -13
- data/docs/hooks.md +1 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +478 -0
- data/docs/internals/prompt-caching.md +120 -0
- data/docs/plugins.md +18 -3
- data/docs/testing.md +12 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +59 -11
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cancellation_controller.rb +8 -0
- data/lib/samagotchi/client.rb +53 -3
- data/lib/samagotchi/command_steps.rb +233 -0
- data/lib/samagotchi/config.rb +14 -4
- data/lib/samagotchi/empty_answer_retry.rb +2 -2
- data/lib/samagotchi/engine.rb +73 -11
- data/lib/samagotchi/guardrails/shell_lex.rb +49 -5
- data/lib/samagotchi/idle_client.rb +14 -2
- data/lib/samagotchi/kernel_loop.rb +60 -6
- data/lib/samagotchi/llm/chat_loop.rb +18 -4
- data/lib/samagotchi/llm/errors.rb +26 -1
- data/lib/samagotchi/llm/http.rb +5 -4
- data/lib/samagotchi/llm/openai_chat.rb +5 -4
- data/lib/samagotchi/llm/prompt_cache.rb +34 -9
- data/lib/samagotchi/llm/usage.rb +18 -3
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/prompt_warmup.rb +95 -0
- data/lib/samagotchi/session_metrics.rb +38 -4
- data/lib/samagotchi/system_prompt.rb +30 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -2
- data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +14 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -9
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/thinking_tails.rb +104 -0
- data/lib/samagotchi/token_usage.rb +18 -6
- data/lib/samagotchi/tool_activity.rb +37 -3
- data/lib/samagotchi/tool_declarations.rb +14 -0
- data/lib/samagotchi/tool_view.rb +40 -4
- data/lib/samagotchi/tools/builtins.rb +4 -1
- data/lib/samagotchi/tools/execute.rb +2 -1
- data/lib/samagotchi/tools/registry.rb +11 -4
- data/lib/samagotchi/tools/shell.rb +32 -0
- data/lib/samagotchi/tools/task_runtime.rb +3 -3
- data/lib/samagotchi/turn_note.rb +26 -2
- data/lib/samagotchi/turn_state.rb +6 -0
- data/lib/samagotchi/turn_tally.rb +17 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/command_view.js +74 -3
- data/lib/samagotchi/web/public/format.js +11 -1
- data/lib/samagotchi/web/public/index.html +45 -13
- data/lib/samagotchi/web/public/stage_model.js +5 -1
- data/lib/samagotchi/web/public/stage_view.js +1 -1
- data/lib/samagotchi/web/public/turn_view.js +3 -3
- data/lib/samagotchi/worker.rb +3 -0
- metadata +7 -1
data/docs/testing.md
CHANGED
|
@@ -15,6 +15,18 @@ bug) or a broken spec: fix it, or disable that line with the reason (`# rubocop:
|
|
|
15
15
|
No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
|
|
16
16
|
clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
|
|
17
17
|
|
|
18
|
+
## Worktrees
|
|
19
|
+
|
|
20
|
+
Each fix or feature gets its own worktree next to the main checkout:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
script/worktree NAME [-b BRANCH] # ../samagotchi-NAME off main, branch NAME unless -b; prints the path
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
It runs `git worktree add ../samagotchi-NAME -b BRANCH main`, then `npm ci --prefer-offline` in it, so
|
|
27
|
+
`npm test` and `npm run e2e` work there right away (Ruby gems and Playwright's browsers are shared: nothing else
|
|
28
|
+
to install). It refuses when the folder or the branch already exists.
|
|
29
|
+
|
|
18
30
|
## Integration specs
|
|
19
31
|
|
|
20
32
|
Examples tagged `:integration` (`spec/integration/`, the "with LLM access" part of `spec/hooks/integration_spec.rb`)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: loop-guard
|
|
3
|
-
version: 0.3.
|
|
3
|
+
version: 0.3.4
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Breaks loops: the same tool call with the same result again and again in one turn is denied with advice (after a few denies the turn stops with a card), and thinking that repeats itself while it streams is cut and the model asked again"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:a267a325aa134e3d9c57f41d9446c4385a6a1f6bee6267ef127880f8bc4d121f
|
|
10
10
|
requires_chi: ">= 0.6.0"
|
|
@@ -56,12 +56,20 @@ require "digest"
|
|
|
56
56
|
# too ("Hmm.", "Fine."), but spread out, or in runs of different ones
|
|
57
57
|
# (code lines); a sentence of min_words or more ends the run, one with no
|
|
58
58
|
# letters or digits neither counts nor ends it. Lines inside a ``` code
|
|
59
|
-
# block are not short sentences
|
|
59
|
+
# block are not short sentences;
|
|
60
|
+
# - a low-diversity window: among the last window_sentences sentences of any
|
|
61
|
+
# length, at most window_distinct different ones (exact, normalized). It
|
|
62
|
+
# sees a cycle too long for the short run (7-8 short sentences), or one
|
|
63
|
+
# where a longer sentence keeps ending the run ("Go. OK. I'll write the
|
|
64
|
+
# spec file now. Go."). Real thinking holds 40+ different sentences in
|
|
65
|
+
# any 48 (the lowest seen: 42); a loop holds under 10. Lines inside a ```
|
|
66
|
+
# code block don't count.
|
|
60
67
|
# Nothing triggers before min_chars of thinking. O(new chars) per feed.
|
|
61
68
|
class ThinkingWatch
|
|
62
69
|
DEFAULTS = { "watch" => true, "action" => "retry", "min_chars" => 2000, "repeats" => 3, "max_period" => 6,
|
|
63
70
|
"similarity" => 0.5, "min_span_sentences" => 6, "min_span_chars" => 600, "max_same" => 8,
|
|
64
|
-
"min_words" => 5, "short_run" => 24, "short_distinct" => 6, "
|
|
71
|
+
"min_words" => 5, "short_run" => 24, "short_distinct" => 6, "window_sentences" => 48,
|
|
72
|
+
"window_distinct" => 12, "forget_after" => 10 }.freeze
|
|
65
73
|
ACTIONS = %w[retry stop notify].freeze
|
|
66
74
|
MAX_CARRY = 400
|
|
67
75
|
# One sentence again and again needs this much more alike than a cycle.
|
|
@@ -69,7 +77,18 @@ class ThinkingWatch
|
|
|
69
77
|
MAX_DISTINCT = 5000
|
|
70
78
|
SPLIT = /(?<=[.!?])\s+|\n+/
|
|
71
79
|
|
|
72
|
-
|
|
80
|
+
# +span+: the sentences a loop that isn't a cycle (the short run, the
|
|
81
|
+
# window) was seen in, +period+ the different ones among them; nil for a
|
|
82
|
+
# cycle of +period+ sentences seen +times+ times.
|
|
83
|
+
Loop = Struct.new(:period, :times, :sentences, :chars, :span, keyword_init: true) do
|
|
84
|
+
# "one sentence ×8", "3 sentences ×3", "12 different sentences in 48".
|
|
85
|
+
def describe
|
|
86
|
+
return "one sentence ×#{times}" if period == 1
|
|
87
|
+
return "#{period} sentences ×#{times}" unless span
|
|
88
|
+
|
|
89
|
+
"#{period} different sentences in #{span}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
73
92
|
Sentence = Struct.new(:text, :words, :bigrams, :key, :length, keyword_init: true)
|
|
74
93
|
|
|
75
94
|
attr_reader :thinking_chars
|
|
@@ -81,7 +100,7 @@ class ThinkingWatch
|
|
|
81
100
|
out["watch"] = !%w[false no off 0].include?(raw["watch"].to_s.downcase) if raw.key?("watch")
|
|
82
101
|
out["action"] = raw["action"].to_s if ACTIONS.include?(raw["action"].to_s)
|
|
83
102
|
%w[min_chars repeats max_period min_span_sentences min_span_chars max_same min_words short_run
|
|
84
|
-
short_distinct].each do |key|
|
|
103
|
+
short_distinct window_sentences window_distinct].each do |key|
|
|
85
104
|
number = Integer(raw[key].to_s, exception: false)
|
|
86
105
|
out[key] = number if number&.positive?
|
|
87
106
|
end
|
|
@@ -104,6 +123,8 @@ class ThinkingWatch
|
|
|
104
123
|
@min_words = settings["min_words"]
|
|
105
124
|
@short_run = settings["short_run"]
|
|
106
125
|
@short_distinct = settings["short_distinct"]
|
|
126
|
+
@window_sentences = settings["window_sentences"]
|
|
127
|
+
@window_distinct = settings["window_distinct"]
|
|
107
128
|
@same_similarity = [@similarity + SAME_MARGIN, 0.95].min
|
|
108
129
|
@carry = +""
|
|
109
130
|
@window = [] # the last max_period + 1 sentences
|
|
@@ -112,6 +133,8 @@ class ThinkingWatch
|
|
|
112
133
|
@counts = Hash.new(0)
|
|
113
134
|
@short_count = 0 # short sentences in a row
|
|
114
135
|
@short_window = [] # the last short_run of them, [key, text]
|
|
136
|
+
@recent = [] # the last window_sentences sentences, [key, text]
|
|
137
|
+
@recent_counts = Hash.new(0) # key => times in @recent
|
|
115
138
|
@in_code = false
|
|
116
139
|
@thinking_chars = 0
|
|
117
140
|
end
|
|
@@ -153,7 +176,8 @@ class ThinkingWatch
|
|
|
153
176
|
end
|
|
154
177
|
words = text.downcase.gsub(/[^[:alnum:]]+/, " ").split
|
|
155
178
|
short = short_loop(text.strip, words) unless @in_code
|
|
156
|
-
|
|
179
|
+
window = window_loop(text.strip, words) unless @in_code
|
|
180
|
+
return (@thinking_chars >= @min_chars ? short || window : nil) if words.length < @min_words
|
|
157
181
|
|
|
158
182
|
key = words.join(" ")
|
|
159
183
|
sentence = Sentence.new(text: text.strip, words: words.map(&:hash).uniq,
|
|
@@ -164,7 +188,28 @@ class ThinkingWatch
|
|
|
164
188
|
@window.shift if @window.length > @max_period + 1
|
|
165
189
|
return nil if @thinking_chars < @min_chars
|
|
166
190
|
|
|
167
|
-
cycle || (same >= @max_same ? Loop.new(period: 1, times: same, sentences: [sentence.text], chars: nil) : nil)
|
|
191
|
+
cycle || (same >= @max_same ? Loop.new(period: 1, times: same, sentences: [sentence.text], chars: nil) : nil) || window
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The last window_sentences sentences (any length) hold at most
|
|
195
|
+
# window_distinct different ones: a loop. Its sentences are the most
|
|
196
|
+
# frequent first.
|
|
197
|
+
def window_loop(text, words)
|
|
198
|
+
return nil if words.empty?
|
|
199
|
+
|
|
200
|
+
key = words.join(" ")
|
|
201
|
+
@recent << [key, text]
|
|
202
|
+
@recent_counts[key] += 1
|
|
203
|
+
if @recent.length > @window_sentences
|
|
204
|
+
old, = @recent.shift
|
|
205
|
+
@recent_counts.delete(old) if (@recent_counts[old] -= 1).zero?
|
|
206
|
+
end
|
|
207
|
+
return nil if @recent.length < @window_sentences || @recent_counts.size > @window_distinct
|
|
208
|
+
|
|
209
|
+
texts = @recent.to_h # key => its last text
|
|
210
|
+
top = @recent_counts.sort_by { |_key, n| -n }.first(3).map { |key, _n| texts[key] }
|
|
211
|
+
Loop.new(period: @recent_counts.size, times: @window_sentences / @recent_counts.size, sentences: top, chars: nil,
|
|
212
|
+
span: @window_sentences)
|
|
168
213
|
end
|
|
169
214
|
|
|
170
215
|
def short_loop(text, words)
|
|
@@ -183,7 +228,8 @@ class ThinkingWatch
|
|
|
183
228
|
distinct = @short_window.uniq(&:first)
|
|
184
229
|
return nil if distinct.length > @short_distinct
|
|
185
230
|
|
|
186
|
-
Loop.new(period: distinct.length, times: @short_count / distinct.length, sentences: distinct.first(3).map(&:last), chars: nil
|
|
231
|
+
Loop.new(period: distinct.length, times: @short_count / distinct.length, sentences: distinct.first(3).map(&:last), chars: nil,
|
|
232
|
+
span: @short_window.length)
|
|
187
233
|
end
|
|
188
234
|
|
|
189
235
|
def count(key)
|
|
@@ -274,16 +320,18 @@ class Plugin
|
|
|
274
320
|
return unless found
|
|
275
321
|
|
|
276
322
|
@watch_done = true
|
|
277
|
-
what = "#{found.
|
|
278
|
-
|
|
323
|
+
what = "#{found.describe}, #{(@watch.thinking_chars / 1000.0).round}k chars, #{(event[:elapsed_ms].to_i / 1000.0).round} s"
|
|
324
|
+
# The notices quote the loop's sentences on one line (a short cycle
|
|
325
|
+
# whole), as the stop card does.
|
|
326
|
+
quoted = "\"#{cut(found.sentences.first(3).join(" ").gsub(/\s+/, " "))}\", #{what}"
|
|
279
327
|
case @thinking["action"]
|
|
280
328
|
when "notify"
|
|
281
|
-
ctx.notify("thinking repeats itself (#{
|
|
329
|
+
ctx.notify("thinking repeats itself (#{quoted})", level: :warn)
|
|
282
330
|
when "retry"
|
|
283
331
|
@thinking_loops += 1
|
|
284
332
|
return stop_thinking(ctx, found, what) if @thinking_loops > 1
|
|
285
333
|
|
|
286
|
-
ctx.notify("thinking repeats itself (#{
|
|
334
|
+
ctx.notify("thinking repeats itself (#{quoted}): cut", level: :warn) if ctx.stop_generation("its thinking kept repeating itself")
|
|
287
335
|
else
|
|
288
336
|
stop_thinking(ctx, found, what)
|
|
289
337
|
end
|
|
@@ -54,6 +54,14 @@ module Samagotchi
|
|
|
54
54
|
@mutex.synchronize { @detail }
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Who stopped it when a hook did: the detail's +by+ (the bundle, or the
|
|
58
|
+
# hook's label); nil for any other cancel.
|
|
59
|
+
def stopped_by
|
|
60
|
+
reason, detail = @mutex.synchronize { [@reason, @detail] }
|
|
61
|
+
by = detail[:by].to_s if reason == :hook && detail.is_a?(Hash)
|
|
62
|
+
by unless by.nil? || by.empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
57
65
|
# Yields a child controller for one generation: it is cancelled when
|
|
58
66
|
# this one is, and #cancel_generation! cancels it alone. Gone after the
|
|
59
67
|
# block.
|
data/lib/samagotchi/client.rb
CHANGED
|
@@ -148,6 +148,9 @@ module Samagotchi
|
|
|
148
148
|
value.empty? ? nil : value
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
+
# llama.cpp's own API: slots (`id_slot`), `cache_prompt`, /props.
|
|
152
|
+
def native? = @name == :llama_cpp
|
|
153
|
+
|
|
151
154
|
private
|
|
152
155
|
|
|
153
156
|
def openai_compatible?
|
|
@@ -243,11 +246,15 @@ module Samagotchi
|
|
|
243
246
|
# ImagePlan::NATIVE_PLACEHOLDER in the prompt (llama.cpp only)
|
|
244
247
|
# @param sampling [Hash] request fields to add (SamplingSettings):
|
|
245
248
|
# temperature, penalties, …; they can't replace the fields above
|
|
249
|
+
# @param slot [Integer, nil] llama.cpp only: the slot (`id_slot`)
|
|
250
|
+
# this request runs on. PromptWarmup's pin, and only while a warm-up
|
|
251
|
+
# still runs there: a request pinned to a slot the server has since
|
|
252
|
+
# cleared skips its prompt cache and prefills everything again
|
|
246
253
|
# @return [String] the generated text
|
|
247
254
|
def complete(prompt, stop: [], n_predict: nil, model: nil, on_chunk: nil, cancel_controller: nil, on_retry: nil,
|
|
248
|
-
images: [], sampling: {})
|
|
255
|
+
images: [], sampling: {}, slot: nil)
|
|
249
256
|
images = Array(images)
|
|
250
|
-
request = { stop: stop, n_predict: n_predict, model: model, sampling: sampling }
|
|
257
|
+
request = { stop: stop, n_predict: n_predict, model: model, sampling: sampling, slot: slot }
|
|
251
258
|
return stream_completion(LLM::Utf8Scrub.call(prompt), request, on_chunk, cancel_controller, on_retry) if images.empty?
|
|
252
259
|
|
|
253
260
|
# The media marker is random per server process: a restart between the
|
|
@@ -269,6 +276,41 @@ module Samagotchi
|
|
|
269
276
|
|
|
270
277
|
OPENAI_API_HINT = "does this host speak the OpenAI API? set `api: openai` on it"
|
|
271
278
|
|
|
279
|
+
# What a warm-up (#warm_up) did: the slot it ran on and the server's
|
|
280
|
+
# counts (tokens reused from the cache, tokens prefilled, prefill ms).
|
|
281
|
+
Warmup = Data.define(:slot, :cache_n, :prompt_n, :prompt_ms)
|
|
282
|
+
|
|
283
|
+
# Prefill +prompt+ and keep it in the server's cache, so the next
|
|
284
|
+
# request that starts with it only prefills what follows (PromptWarmup:
|
|
285
|
+
# the turn-end warm-up). llama.cpp's native /completion only; nil on
|
|
286
|
+
# any other transport. One non-streamed request generating one token
|
|
287
|
+
# (n_predict 0 does the same on current builds, 1 on every one): no
|
|
288
|
+
# retries, no first-token limit, no stream events. Errors are raised.
|
|
289
|
+
# @param slot [Integer, nil] the slot to run on (`id_slot`): the one the
|
|
290
|
+
# last request used, which still holds its state
|
|
291
|
+
# @param images [Array<String>] as #complete's
|
|
292
|
+
# @return [Warmup, nil]
|
|
293
|
+
def warm_up(prompt, model: nil, slot: nil, images: [])
|
|
294
|
+
return nil unless @transport.native?
|
|
295
|
+
|
|
296
|
+
images = Array(images)
|
|
297
|
+
text = LLM::Utf8Scrub.call(images.empty? ? prompt : prompt.gsub(ImagePlan::NATIVE_PLACEHOLDER, media_marker!(model)))
|
|
298
|
+
payload = { prompt: images.empty? ? text : { prompt_string: text, multimodal_data: images },
|
|
299
|
+
n_predict: 1, cache_prompt: true, stream: false }
|
|
300
|
+
payload[:id_slot] = slot if slot.is_a?(Integer)
|
|
301
|
+
model_name = @transport.model_for_payload(model)
|
|
302
|
+
payload[:model] = model_name if model_name
|
|
303
|
+
uri = completion_uri
|
|
304
|
+
request = Net::HTTP::Post.new(uri)
|
|
305
|
+
request["Content-Type"] = "application/json"
|
|
306
|
+
request.body = payload.to_json
|
|
307
|
+
response = @http.fetch(uri, request, retries: false, log_fields: { model: model, purpose: "warmup" })
|
|
308
|
+
body = JSON.parse(response.body.to_s)
|
|
309
|
+
timings = body["timings"].is_a?(Hash) ? body["timings"] : {}
|
|
310
|
+
Warmup.new(slot: body["id_slot"], cache_n: timings["cache_n"], prompt_n: timings["prompt_n"],
|
|
311
|
+
prompt_ms: timings["prompt_ms"]&.round)
|
|
312
|
+
end
|
|
313
|
+
|
|
272
314
|
private def stream_completion(prompt, fields, on_chunk, cancel_controller, on_retry)
|
|
273
315
|
uri = completion_uri
|
|
274
316
|
request = Net::HTTP::Post.new(uri)
|
|
@@ -456,11 +498,19 @@ module Samagotchi
|
|
|
456
498
|
URI("#{@scheme}://#{@host}:#{@port}#{@transport.completion_path}")
|
|
457
499
|
end
|
|
458
500
|
|
|
459
|
-
def completion_payload(prompt, stop:, n_predict:, model:, sampling: {})
|
|
501
|
+
def completion_payload(prompt, stop:, n_predict:, model:, sampling: {}, slot: nil)
|
|
460
502
|
payload = { prompt: prompt, stop: stop, stream: true }
|
|
503
|
+
# A non-positive cap is dropped, which leaves the length to the server
|
|
504
|
+
# (unbounded): 0 here never means "only process the prompt". The
|
|
505
|
+
# turn-end warm-up (#warm_up) sends its own payload for that reason.
|
|
461
506
|
payload[@transport.token_limit_key] = n_predict if n_predict && n_predict.to_i.positive?
|
|
462
507
|
model_name = @transport.model_for_payload(model)
|
|
463
508
|
payload[:model] = model_name if model_name
|
|
509
|
+
if @transport.native?
|
|
510
|
+
# llama.cpp's default today, sent anyway: chi's next turn relies on it.
|
|
511
|
+
payload[:cache_prompt] = true
|
|
512
|
+
payload[:id_slot] = slot if slot.is_a?(Integer)
|
|
513
|
+
end
|
|
464
514
|
sendable_sampling(sampling).merge(payload)
|
|
465
515
|
end
|
|
466
516
|
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "guardrails/shell_lex"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# An execute command as the steps a person reads, for the web's tool row
|
|
7
|
+
# (display only: the guardrails read the command themselves). Built from
|
|
8
|
+
# ShellLex's tokens and their spans, so each step's text is a slice of the
|
|
9
|
+
# command as the model wrote it. Rules, in order:
|
|
10
|
+
#
|
|
11
|
+
# - a heredoc's body leaves its step's text and becomes its +heredoc+
|
|
12
|
+
# ({tag:, lines:}), also one inside a "$(cat <<'EOF' … EOF)" argument;
|
|
13
|
+
# - steps are split at && || ; | |& & and newlines; a ( … ) subshell stays
|
|
14
|
+
# one step; +op+ is the operator before a step (nil for the first);
|
|
15
|
+
# - a leading "cd X &&" / "cd X;" becomes +cd+ (only the first);
|
|
16
|
+
# - a pipeline's last "| head …" / "| tail …" becomes the +limit+ of the
|
|
17
|
+
# step before it ("head 20", "tail", "head -c 60");
|
|
18
|
+
# - a lone echo of a marker-wrapped string ("=== x ===", "--- x ---",
|
|
19
|
+
# "## x") becomes the +label+ of the next step; a bare marker echo
|
|
20
|
+
# ("---", "====", "") only spaces the output and goes;
|
|
21
|
+
# - anything the parser isn't sure of (a quote or heredoc left open,
|
|
22
|
+
# for/while/until/if/case/function bodies, { … } groups, unbalanced
|
|
23
|
+
# parentheses) gives nil: the UI then shows the title and the raw
|
|
24
|
+
# command.
|
|
25
|
+
module CommandSteps
|
|
26
|
+
Step = Data.define(:text, :op, :label, :limit, :heredoc) do
|
|
27
|
+
def initialize(text:, op: nil, label: nil, limit: nil, heredoc: nil) = super
|
|
28
|
+
|
|
29
|
+
def to_h = super.compact
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Parsed = Data.define(:cd, :steps) do
|
|
33
|
+
def to_h = { cd: cd, steps: steps.map(&:to_h) }.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Lex = Guardrails::ShellLex
|
|
37
|
+
|
|
38
|
+
SPLIT = ["&&", "||", ";", "|", "|&", "&", "\n"].freeze
|
|
39
|
+
KEYWORDS = %w[for while until if then elif else fi case esac select do done function { }].freeze
|
|
40
|
+
LIMITERS = %w[head tail].freeze
|
|
41
|
+
MARKER = /\A\s*(?:={2,}|-{2,}|#+)\s*(.*?)\s*(?:={2,}|-{2,})?\s*\z/m
|
|
42
|
+
ECHO_FLAGS = %w[-e -n -en -ne].freeze
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# @return [Parsed, nil] nil when the command isn't plain enough to show
|
|
47
|
+
# as steps
|
|
48
|
+
def parse(command)
|
|
49
|
+
text = command.to_s
|
|
50
|
+
lexer = Lex::Lexer.new(text)
|
|
51
|
+
tokens = lexer.tokens.zip(lexer.spans).map { |token, span| [*token, span] }
|
|
52
|
+
return nil if lexer.unterminated? || tokens.empty?
|
|
53
|
+
|
|
54
|
+
bodies = lexer.heredoc_spans.to_h { |doc| [doc[:span], doc[:tag]] }
|
|
55
|
+
raw = split(tokens)
|
|
56
|
+
return nil if raw.nil? || raw.empty?
|
|
57
|
+
|
|
58
|
+
steps = raw.map { |op, words| step(text, op, words, bodies) }
|
|
59
|
+
return nil if steps.any?(&:nil?)
|
|
60
|
+
|
|
61
|
+
cd, steps = leading_cd(steps, raw)
|
|
62
|
+
steps = labels(limits(steps))
|
|
63
|
+
steps.empty? ? nil : Parsed.new(cd: cd, steps: steps)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# [[op, word tokens]] split at SPLIT outside parentheses; nil for a
|
|
67
|
+
# control keyword at a command's start, ;; or unbalanced parentheses.
|
|
68
|
+
def split(tokens)
|
|
69
|
+
steps = []
|
|
70
|
+
op = nil
|
|
71
|
+
words = []
|
|
72
|
+
depth = 0
|
|
73
|
+
tokens.each do |token|
|
|
74
|
+
kind, value = token
|
|
75
|
+
if kind == :op && value == "("
|
|
76
|
+
depth += 1
|
|
77
|
+
words << token
|
|
78
|
+
elsif kind == :op && value == ")"
|
|
79
|
+
depth -= 1
|
|
80
|
+
return nil if depth.negative?
|
|
81
|
+
|
|
82
|
+
words << token
|
|
83
|
+
elsif kind == :op && depth.zero?
|
|
84
|
+
return nil unless SPLIT.include?(value)
|
|
85
|
+
|
|
86
|
+
if words.empty?
|
|
87
|
+
op = value unless value == "\n" # "a;\nb" keeps the ;
|
|
88
|
+
else
|
|
89
|
+
steps << [steps.empty? ? nil : op, words]
|
|
90
|
+
op = value
|
|
91
|
+
end
|
|
92
|
+
words = []
|
|
93
|
+
else
|
|
94
|
+
return nil if keyword?(words, token)
|
|
95
|
+
|
|
96
|
+
words << token
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
return nil unless depth.zero?
|
|
100
|
+
|
|
101
|
+
steps << [steps.empty? ? nil : op, words] unless words.empty?
|
|
102
|
+
steps
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Whether +token+ is a control keyword where a command starts (the
|
|
106
|
+
# step's first word, or the first after a "(" inside it).
|
|
107
|
+
def keyword?(words, token)
|
|
108
|
+
return false unless token[0] == :word
|
|
109
|
+
|
|
110
|
+
at_start = words.empty? || words.last[0] == :op
|
|
111
|
+
at_start && KEYWORDS.include?(token[1])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A Step from its tokens: the text from the first to the last token,
|
|
115
|
+
# heredoc bodies cut out.
|
|
116
|
+
def step(text, op, tokens, bodies)
|
|
117
|
+
body = tokens.find { |t| bodies.key?(t.last) }
|
|
118
|
+
shown = tokens.reject { |t| bodies.key?(t.last) }
|
|
119
|
+
from = shown.map { |t| t.last.begin }.min
|
|
120
|
+
to = shown.map { |t| t.last.end }.max
|
|
121
|
+
return nil unless from
|
|
122
|
+
|
|
123
|
+
slice = text[from...to]
|
|
124
|
+
heredoc = body && { tag: bodies[body.last], lines: body_lines(text[body.last]) }
|
|
125
|
+
slice, heredoc = inner_heredoc(slice) unless heredoc
|
|
126
|
+
Step.new(text: slice.strip, op: op, heredoc: heredoc)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A heredoc inside a $(…) in +slice+ (git commit -m "$(cat <<'EOF' …)"):
|
|
130
|
+
# the slice with the body cut out, and the heredoc; or the slice as is.
|
|
131
|
+
def inner_heredoc(slice)
|
|
132
|
+
return [slice, nil] unless slice.include?("<<")
|
|
133
|
+
|
|
134
|
+
at = 0
|
|
135
|
+
while (open = slice.index("$(", at))
|
|
136
|
+
lexer = Lex::Lexer.new(slice[(open + 2)..])
|
|
137
|
+
lexer.tokens
|
|
138
|
+
doc = lexer.heredoc_spans.first
|
|
139
|
+
if doc
|
|
140
|
+
from = open + 2 + doc[:span].begin - 1 # the newline before the body
|
|
141
|
+
to = open + 2 + doc[:span].end
|
|
142
|
+
to += 1 if slice[to] == "\n"
|
|
143
|
+
return [slice[0...from] + slice[to..], { tag: doc[:tag], lines: body_lines(slice[(from + 1)...to]) }]
|
|
144
|
+
end
|
|
145
|
+
at = open + 2
|
|
146
|
+
end
|
|
147
|
+
[slice, nil]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The body's line count, its terminator line not counted.
|
|
151
|
+
def body_lines(body_and_tag) = [body_and_tag.to_s.chomp.lines.size - 1, 0].max
|
|
152
|
+
|
|
153
|
+
# A first "cd X" step followed by && or ; becomes the cd.
|
|
154
|
+
def leading_cd(steps, raw)
|
|
155
|
+
words = raw.first[1]
|
|
156
|
+
next_op = steps[1]&.op
|
|
157
|
+
plain = words.size == 2 && words.all? { |t| t[0] == :word } && !words[1][1].include?(Lex::SUBST)
|
|
158
|
+
return [nil, steps] unless plain && words[0][1] == "cd" && ["&&", ";"].include?(next_op)
|
|
159
|
+
|
|
160
|
+
[words[1][1], [steps[1].with(op: nil), *steps.drop(2)]]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A pipeline's last head/tail becomes the limit of the step before it.
|
|
164
|
+
def limits(steps)
|
|
165
|
+
steps.each_with_index.with_object([]) do |(step, i), out|
|
|
166
|
+
limit = step.op == "|" && !out.empty? && out.last.limit.nil? && limit_of(step.text)
|
|
167
|
+
if limit && !["|", "|&"].include?(steps[i + 1]&.op)
|
|
168
|
+
out[-1] = out.last.with(limit: limit)
|
|
169
|
+
else
|
|
170
|
+
out << step
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# "head 20" for "head -n 20" / "head -20" / "head -n20"; "head -c 60";
|
|
176
|
+
# "tail +5"; "head" alone. nil for anything else (tail -f, a file).
|
|
177
|
+
def limit_of(text)
|
|
178
|
+
words = Lex.lex(text)
|
|
179
|
+
return nil unless words.all? { |t| t[0] == :word }
|
|
180
|
+
|
|
181
|
+
name, *args = words.map { |t| t[1] }
|
|
182
|
+
return nil unless LIMITERS.include?(name)
|
|
183
|
+
|
|
184
|
+
count = case args
|
|
185
|
+
in [] then nil
|
|
186
|
+
in [/\A-\d+\z/ => arg] then arg[1..]
|
|
187
|
+
in [/\A-n\+?\d+\z/ => arg] then arg[2..]
|
|
188
|
+
in ["-n", /\A\+?\d+\z/ => arg] then arg
|
|
189
|
+
in ["-c", /\A\d+\z/ => arg] then "-c #{arg}"
|
|
190
|
+
else return nil
|
|
191
|
+
end
|
|
192
|
+
[name, count].compact.join(" ")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# A marker echo labels the next step; a bare one goes.
|
|
196
|
+
# A dropped echo's op goes to the step after it (the link from the
|
|
197
|
+
# step before).
|
|
198
|
+
def labels(steps)
|
|
199
|
+
out = []
|
|
200
|
+
label = nil
|
|
201
|
+
op = nil
|
|
202
|
+
steps.each_with_index do |step, i|
|
|
203
|
+
marker = marker_label(step.text)
|
|
204
|
+
if marker.nil? || (!marker.empty? && i == steps.size - 1)
|
|
205
|
+
step = step.with(label: label, op: op || step.op) if op
|
|
206
|
+
out << (out.empty? ? step.with(op: nil) : step)
|
|
207
|
+
label = op = nil
|
|
208
|
+
else
|
|
209
|
+
label = marker unless marker.empty?
|
|
210
|
+
op ||= step.op || "\n"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
out
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The label of a lone marker echo ("" for a bare marker or an empty
|
|
217
|
+
# echo), nil for any other step.
|
|
218
|
+
def marker_label(text)
|
|
219
|
+
words = Lex.lex(text)
|
|
220
|
+
return nil unless words.all? { |t| t[0] == :word && !t[1].include?(Lex::SUBST) }
|
|
221
|
+
|
|
222
|
+
name, *args = words.map { |t| t[1] }
|
|
223
|
+
args.shift while ECHO_FLAGS.include?(args.first)
|
|
224
|
+
return nil unless name == "echo" && args.size <= 1
|
|
225
|
+
return "" if args.empty? || args[0].strip.empty?
|
|
226
|
+
|
|
227
|
+
match = MARKER.match(args[0])
|
|
228
|
+
return nil unless match && args[0].strip.match?(/\A(?:={2,}|-{2,}|#)/)
|
|
229
|
+
|
|
230
|
+
match[1].strip
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -138,6 +138,11 @@ module Samagotchi
|
|
|
138
138
|
Entry.new(key: "default.max_tokens", yaml_path: %w[default max_tokens], type: :integer, default: nil, expose: %i[env config cli]),
|
|
139
139
|
Entry.new(key: "max_tool_output_chars", yaml_path: %w[max_tool_output_chars], type: :integer, default: 10_000, expose: %i[env config cli]),
|
|
140
140
|
|
|
141
|
+
# The turn-end warm-up (PromptWarmup): auto prefills the next turn's
|
|
142
|
+
# prompt on a local llama.cpp host while the user reads; off never
|
|
143
|
+
# does. A :string, since YAML reads an unquoted off as false.
|
|
144
|
+
Entry.new(key: "cache.warmup", yaml_path: %w[cache warmup], type: :string, default: "auto", expose: %i[env config]),
|
|
145
|
+
|
|
141
146
|
Entry.new(key: "retry.max", yaml_path: %w[retry max], type: :integer, default: 5, expose: %i[env config cli]),
|
|
142
147
|
Entry.new(key: "retry.base_delay", yaml_path: %w[retry base_delay], type: :float, default: 0.5, expose: %i[env config cli]),
|
|
143
148
|
Entry.new(key: "retry.max_delay", yaml_path: %w[retry max_delay], type: :float, default: 8.0, expose: %i[env config cli]),
|
|
@@ -174,6 +179,8 @@ module Samagotchi
|
|
|
174
179
|
Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
|
|
175
180
|
# Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
|
|
176
181
|
Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 120, expose: %i[env config cli]),
|
|
182
|
+
# Whether execute declares its optional description parameter (a few model-written words, a tool row's title).
|
|
183
|
+
Entry.new(key: "execute.description", yaml_path: %w[execute description], type: :bool, default: true, expose: %i[env config]),
|
|
177
184
|
|
|
178
185
|
Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
|
|
179
186
|
Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
|
|
@@ -659,15 +666,18 @@ module Samagotchi
|
|
|
659
666
|
end
|
|
660
667
|
|
|
661
668
|
# Request keys chi sets itself; a `sampling:` map can't override them.
|
|
662
|
-
# (Length caps would need their own semantics.)
|
|
669
|
+
# (Length caps would need their own semantics.) `id_slot` too: pinning
|
|
670
|
+
# every request to one llama.cpp slot makes a request after an idle
|
|
671
|
+
# gap skip the server's prompt cache (measured: 0.5 s → 12 s).
|
|
663
672
|
SAMPLING_RESERVED_KEYS = %w[model messages prompt stream stream_options tools tool_choice stop n_predict
|
|
664
|
-
max_tokens n parallel_tool_calls response_format cache_prompt].freeze
|
|
673
|
+
max_tokens n parallel_tool_calls response_format cache_prompt id_slot].freeze
|
|
665
674
|
|
|
666
675
|
# A `sampling:` setting (hosts entry or models: entry): request parameters
|
|
667
676
|
# passed through to the provider as written (keys symbolized, nested maps
|
|
668
677
|
# too, so e.g. chat_template_kwargs works). A null value means "don't
|
|
669
|
-
# send it" (
|
|
670
|
-
#
|
|
678
|
+
# send it" (a null temperature also keeps the empty-answer retry from
|
|
679
|
+
# setting one). Not a map: warns once, nil. Reserved keys warn once and
|
|
680
|
+
# are dropped. nil when nothing is left.
|
|
671
681
|
def sampling_map(value, where)
|
|
672
682
|
return nil if value.nil?
|
|
673
683
|
|
|
@@ -13,8 +13,8 @@ module Samagotchi
|
|
|
13
13
|
# when the retries run out.
|
|
14
14
|
class EmptyAnswerRetry
|
|
15
15
|
MAX = 3
|
|
16
|
-
#
|
|
17
|
-
# the retry runs at this temperature unless one is configured.
|
|
16
|
+
# The same (or greedy) decoding on a near-identical prompt would likely
|
|
17
|
+
# loop again: the retry runs at this temperature unless one is configured.
|
|
18
18
|
TEMPERATURE = 0.6
|
|
19
19
|
# A generation cut at the provider's output cap (finish_reason length)
|
|
20
20
|
# is retried unless the context is this full.
|