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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +16 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/configuration.md +22 -13
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/engine.md +478 -0
  9. data/docs/internals/prompt-caching.md +120 -0
  10. data/docs/plugins.md +18 -3
  11. data/docs/testing.md +12 -0
  12. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/loop-guard/plugin.rb +59 -11
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/cancellation_controller.rb +8 -0
  16. data/lib/samagotchi/client.rb +53 -3
  17. data/lib/samagotchi/command_steps.rb +233 -0
  18. data/lib/samagotchi/config.rb +14 -4
  19. data/lib/samagotchi/empty_answer_retry.rb +2 -2
  20. data/lib/samagotchi/engine.rb +73 -11
  21. data/lib/samagotchi/guardrails/shell_lex.rb +49 -5
  22. data/lib/samagotchi/idle_client.rb +14 -2
  23. data/lib/samagotchi/kernel_loop.rb +60 -6
  24. data/lib/samagotchi/llm/chat_loop.rb +18 -4
  25. data/lib/samagotchi/llm/errors.rb +26 -1
  26. data/lib/samagotchi/llm/http.rb +5 -4
  27. data/lib/samagotchi/llm/openai_chat.rb +5 -4
  28. data/lib/samagotchi/llm/prompt_cache.rb +34 -9
  29. data/lib/samagotchi/llm/usage.rb +18 -3
  30. data/lib/samagotchi/log_subscriber.rb +2 -1
  31. data/lib/samagotchi/plugin/api.rb +3 -2
  32. data/lib/samagotchi/prompt_warmup.rb +95 -0
  33. data/lib/samagotchi/session_metrics.rb +38 -4
  34. data/lib/samagotchi/system_prompt.rb +30 -9
  35. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -2
  36. data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
  37. data/lib/samagotchi/terminal_ui/event_renderer.rb +14 -2
  38. data/lib/samagotchi/terminal_ui/formatting.rb +31 -9
  39. data/lib/samagotchi/terminal_ui.rb +2 -0
  40. data/lib/samagotchi/thinking_tails.rb +104 -0
  41. data/lib/samagotchi/token_usage.rb +18 -6
  42. data/lib/samagotchi/tool_activity.rb +37 -3
  43. data/lib/samagotchi/tool_declarations.rb +14 -0
  44. data/lib/samagotchi/tool_view.rb +40 -4
  45. data/lib/samagotchi/tools/builtins.rb +4 -1
  46. data/lib/samagotchi/tools/execute.rb +2 -1
  47. data/lib/samagotchi/tools/registry.rb +11 -4
  48. data/lib/samagotchi/tools/shell.rb +32 -0
  49. data/lib/samagotchi/tools/task_runtime.rb +3 -3
  50. data/lib/samagotchi/turn_note.rb +26 -2
  51. data/lib/samagotchi/turn_state.rb +6 -0
  52. data/lib/samagotchi/turn_tally.rb +17 -6
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/public/command_view.js +74 -3
  55. data/lib/samagotchi/web/public/format.js +11 -1
  56. data/lib/samagotchi/web/public/index.html +45 -13
  57. data/lib/samagotchi/web/public/stage_model.js +5 -1
  58. data/lib/samagotchi/web/public/stage_view.js +1 -1
  59. data/lib/samagotchi/web/public/turn_view.js +3 -3
  60. data/lib/samagotchi/worker.rb +3 -0
  61. 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.2
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:27b135c462d8462313edaa1faa90f3104a1bff3c421d6965e9e71955f347d55b
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, "forget_after" => 10 }.freeze
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
- Loop = Struct.new(:period, :times, :sentences, :chars, keyword_init: true)
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
- return (short if @thinking_chars >= @min_chars) if words.length < @min_words
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.period == 1 ? "one sentence" : "#{found.period} sentences"} ×#{found.times}, " \
278
- "#{(@watch.thinking_chars / 1000.0).round}k chars, #{(event[:elapsed_ms].to_i / 1000.0).round} s"
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 (#{what})", level: :warn)
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 (#{what}): cut", level: :warn) if ctx.stop_generation("its thinking kept repeating 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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.24.0
3
+ version: 0.26.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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.
@@ -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
@@ -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" (drops chi's own temperature default). Not a map: warns once,
670
- # nil. Reserved keys warn once and are dropped. nil when nothing is left.
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
- # Greedy decoding on a near-identical prompt would likely loop again:
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.