samagotchi 0.10.0 → 0.11.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/docs/architecture.md +19 -0
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +44 -11
  6. data/docs/guardrails.md +3 -1
  7. data/docs/hooks.md +3 -2
  8. data/docs/internals/context-telemetry.md +4 -2
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  12. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +10 -3
  13. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  15. data/lib/samagotchi/bundles/skills/plugin.rb +8 -17
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/client.rb +21 -1
  19. data/lib/samagotchi/config.rb +44 -40
  20. data/lib/samagotchi/context_status.rb +17 -16
  21. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  22. data/lib/samagotchi/engine.rb +64 -20
  23. data/lib/samagotchi/host_registry.rb +50 -90
  24. data/lib/samagotchi/kernel_loop.rb +288 -305
  25. data/lib/samagotchi/llm/chat_loop.rb +78 -87
  26. data/lib/samagotchi/model_profile.rb +22 -3
  27. data/lib/samagotchi/model_ref.rb +60 -0
  28. data/lib/samagotchi/prompt_history.rb +91 -0
  29. data/lib/samagotchi/recap_setup.rb +15 -3
  30. data/lib/samagotchi/self_report.rb +11 -6
  31. data/lib/samagotchi/send_command.rb +8 -8
  32. data/lib/samagotchi/session.rb +9 -2
  33. data/lib/samagotchi/session_commands.rb +10 -12
  34. data/lib/samagotchi/session_manager.rb +20 -3
  35. data/lib/samagotchi/sessions_command.rb +57 -51
  36. data/lib/samagotchi/steer.rb +23 -0
  37. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  39. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  40. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  41. data/lib/samagotchi/terminal_ui.rb +9 -8
  42. data/lib/samagotchi/tool_response.rb +67 -0
  43. data/lib/samagotchi/tool_runner.rb +6 -1
  44. data/lib/samagotchi/tools/delegate.rb +4 -3
  45. data/lib/samagotchi/tools/memory.rb +8 -6
  46. data/lib/samagotchi/version.rb +1 -1
  47. data/lib/samagotchi/web/app.rb +28 -2
  48. data/lib/samagotchi/web/public/app.js +29 -2
  49. data/lib/samagotchi/web/public/data.js +9 -1
  50. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  51. data/lib/samagotchi/worker.rb +2 -1
  52. metadata +6 -1
@@ -9,13 +9,16 @@ require_relative "openai_chat"
9
9
  require_relative "native_tool_normalizer"
10
10
  require_relative "../kernel_loop"
11
11
  require_relative "../answer_display"
12
+ require_relative "../context_status"
12
13
  require_relative "../context_window"
13
14
  require_relative "../context_note"
14
15
  require_relative "../tool_runner"
16
+ require_relative "../tool_response"
15
17
  require_relative "../tool_declarations"
16
18
  require_relative "../vision_context"
17
19
  require_relative "../log"
18
20
  require_relative "../empty_answer_retry"
21
+ require_relative "../steer"
19
22
  require_relative "../turn_note"
20
23
  require_relative "../thinking"
21
24
 
@@ -188,14 +191,6 @@ module Samagotchi
188
191
  nil
189
192
  end
190
193
 
191
- # The status line's context value for +used_tokens+ of +window_tokens+
192
- # (KernelLoop#context_display), or nil.
193
- def context_display(used_tokens:, window_tokens:)
194
- return nil unless @kernel.respond_to?(:context_display)
195
-
196
- @kernel.context_display(used_tokens: used_tokens, window_tokens: window_tokens)
197
- end
198
-
199
194
  # A hook failing must not break the turn (as in ToolRunner).
200
195
  def fire_hook(name, event)
201
196
  hooks = @kernel.hooks if @kernel.respond_to?(:hooks)
@@ -218,9 +213,7 @@ module Samagotchi
218
213
  message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
219
214
  message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
220
215
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
221
- message[:tool_params] = entry[:tool_params] if entry[:tool_params]
222
- message[:tool_labels] = entry[:tool_labels] if entry[:tool_labels]
223
- message[:tool_diffs] = entry[:tool_diffs] if entry[:tool_diffs]
216
+ ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
224
217
  message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
225
218
  message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
226
219
  ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
@@ -320,8 +313,8 @@ module Samagotchi
320
313
  # usage: the text, and each image's estimate (not its base64).
321
314
  @prompt_text = conversation.sum("") { |entry| entry[:content].to_s }
322
315
  @image_tokens = ImagePlan.estimated_tokens(conversation)
323
- @empty_retries = 0
324
- @empty_retry_limit = EmptyAnswerRetry.limit
316
+ @empty_retry = EmptyAnswerRetry.new
317
+ @context = ContextStatus.new(conversation: conversation)
325
318
  end
326
319
 
327
320
  EMPTY_ANSWER = "(the model returned an empty answer)"
@@ -346,10 +339,15 @@ module Samagotchi
346
339
 
347
340
  last_text = @loop.strip_model_thought(response.text)
348
341
  if response.tool_calls.empty?
342
+ # Only whitespace is no answer either (as in the native loop).
343
+ empty = last_text.strip.empty?
349
344
  # Kept before a merge too: the model answers the merged line
350
345
  # knowing what it just said.
351
- @conversation << with_thinking({ role: "model", content: last_text }, response) unless last_text.empty?
352
- retry_empty = last_text.empty? && retry_empty_answer?(iteration, response)
346
+ @conversation << with_thinking({ role: "model", content: last_text }, response) unless empty
347
+ retry_empty = empty &&
348
+ @empty_retry.retry_empty?(iteration: iteration, cancelled: @cancel_controller&.cancelled?,
349
+ finish_reason: response.finish_reason,
350
+ used_tokens: response.usage&.total_tokens, window_tokens: @window&.tokens)
353
351
  # An empty answer that will be retried is no answer site: a
354
352
  # plugin's steer joins the retry instead of being dropped, and
355
353
  # queued input (a user's line, a steer) goes in place of the nudge.
@@ -358,7 +356,7 @@ module Samagotchi
358
356
 
359
357
  # Shown, not saved: an empty answer (content "" + stop, seen from
360
358
  # a remote host) would otherwise end the turn with nothing.
361
- last_text = EMPTY_ANSWER if last_text.empty?
359
+ last_text = EMPTY_ANSWER if empty
362
360
  exhausted = false
363
361
  break
364
362
  end
@@ -376,29 +374,12 @@ module Samagotchi
376
374
 
377
375
  private
378
376
 
379
- # A retry is left, and the answer wasn't cut short by a full context
380
- # (a length stop while thinking is retried: a thinking loop cut by
381
- # the provider's output cap, not a full window).
382
- def retry_empty_answer?(iteration, response)
383
- return false if @empty_retries >= @empty_retry_limit || @cancel_controller&.cancelled?
384
-
385
- if response.finish_reason.to_s == "length" &&
386
- EmptyAnswerRetry.context_full?(response.usage&.total_tokens, @window&.tokens)
387
- Log.info(:turn, "empty_answer_not_retried", iteration: iteration, why: "context full")
388
- return false
389
- end
390
- true
391
- end
392
-
393
377
  # The hidden nudge before the next generation (EmptyAnswerRetry),
394
378
  # which runs at the retry temperature. Returns true.
395
379
  def nudge_empty_answer(iteration, response)
396
- @empty_retries += 1
397
- @retry_generation = true
398
- emit(type: :empty_answer_retry, iteration: iteration, attempt: @empty_retries, of: @empty_retry_limit,
399
- finish_reason: response.finish_reason, thinking_chars: response.reasoning.to_s.length)
400
- @conversation << TurnNote.empty_retry
401
- true
380
+ @empty_retry.nudge!(@conversation, TurnNote.empty_retry,
381
+ emit: method(:emit), iteration: iteration, finish_reason: response.finish_reason,
382
+ thinking_chars: response.reasoning.to_s.length)
402
383
  end
403
384
 
404
385
  # A generation a plugin cut (stop_generation) is an empty answer made
@@ -410,19 +391,15 @@ module Samagotchi
410
391
  def after_cut(iteration, response)
411
392
  return canceled(iteration, @cancel_controller.reason) if @cancel_controller.cancelled?
412
393
 
413
- if @empty_retries < @empty_retry_limit
394
+ if @empty_retry.left?
414
395
  return :retry if inject_pending_input(iteration)
415
396
 
416
- @empty_retries += 1
417
- @retry_generation = true
418
- emit(type: :empty_answer_retry, iteration: iteration, attempt: @empty_retries, of: @empty_retry_limit,
419
- finish_reason: response.finish_reason, thinking_chars: response.reasoning.to_s.length,
420
- stopped_by: response.cut[:by])
421
- @conversation << TurnNote.cut_retry(response.cut[:by], response.cut[:reason])
397
+ @empty_retry.nudge!(@conversation, TurnNote.cut_retry(response.cut[:by], response.cut[:reason]),
398
+ emit: method(:emit), iteration: iteration, finish_reason: response.finish_reason,
399
+ thinking_chars: response.reasoning.to_s.length, stopped_by: response.cut[:by])
422
400
  return :retry
423
401
  end
424
- index = @conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
425
- @conversation.delete_at(index) if index
402
+ @empty_retry.drop_nudge!(@conversation)
426
403
  @cancel_controller.cancel!(:hook, response.cut)
427
404
  canceled(iteration, :hook)
428
405
  end
@@ -440,8 +417,8 @@ module Samagotchi
440
417
  # text] when it was cancelled.
441
418
  def generate(iteration)
442
419
  window = @window = @loop.context_window(@model_name)
443
- retry_generation = @retry_generation
444
- @retry_generation = false
420
+ observe_context(iteration, window)
421
+ retry_generation = @empty_retry.take_sampling!
445
422
  emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
446
423
  context_window_source: window&.source)
447
424
  @loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
@@ -517,13 +494,49 @@ module Samagotchi
517
494
  !error.context_overflow? && !@loop.thinking_fields(@model_name).empty?
518
495
  end
519
496
 
520
- # The status line's value from the server's counts for this request
521
- # (prompt + answer); without them the last value stays.
497
+ # Before a request, as the native loop: estimate how full the window
498
+ # is (the server's last count plus what the turn appended since),
499
+ # emit :context_status on a bucket change, and put the model's own
500
+ # line on the tail on a rise that asks it for a change. The estimate
501
+ # counts the conversation's text and tool calls, not the tool
502
+ # schemas (the server's count does).
503
+ def observe_context(iteration, window)
504
+ return unless window
505
+
506
+ @request_image_tokens = ImagePlan.estimated_tokens(@conversation)
507
+ event = @context.observe(prompt_chars, iteration_index: iteration - 1, window: window,
508
+ image_tokens: @request_image_tokens)
509
+ emit(type: :context_status, iteration: iteration, **event) if event
510
+ if (line = @context.take_guidance)
511
+ @conversation << line
512
+ end
513
+ @request_chars = prompt_chars
514
+ end
515
+
516
+ # The conversation's text as the request carries it: contents (a
517
+ # parts list's text parts) and the tool calls' names and arguments.
518
+ def prompt_chars
519
+ @conversation.sum do |entry|
520
+ content = entry[:content]
521
+ chars = if content.is_a?(Array)
522
+ content.sum { |part| part.is_a?(Hash) ? (part[:text] || part["text"]).to_s.length : 0 }
523
+ else
524
+ content.to_s.length
525
+ end
526
+ chars + Array(entry[:tool_calls]).sum { |call| call[:name].to_s.length + call[:arguments].to_json.length }
527
+ end
528
+ end
529
+
530
+ # The server's counts for this request (prompt + answer): the status
531
+ # line's value, and where the next estimate starts. Without them the
532
+ # estimate stays.
522
533
  def record_context_status(usage, window)
523
- return unless usage.source == :server && window
534
+ return unless usage.source == :server
524
535
 
525
- display = @loop.context_display(used_tokens: usage.total_tokens, window_tokens: window.tokens)
526
- @context_status = display if display
536
+ counts = @context.capture({ "usage" => { "prompt_tokens" => usage.prompt_tokens,
537
+ "completion_tokens" => usage.completion_tokens } })
538
+ @context.generation_done(counts, prompt_chars: @request_chars.to_i, image_tokens: @request_image_tokens.to_i,
539
+ window: window)
527
540
  end
528
541
 
529
542
  # The model's answer at debug level, as the native loop dumps its
@@ -539,44 +552,22 @@ module Samagotchi
539
552
  served_model: response.model, tool_calls: calls.empty? ? nil : calls.join(","))
540
553
  end
541
554
 
555
+ # Each call's result goes on the conversation as it finishes, paired
556
+ # with its call (ToolResponse.single: the capped output).
542
557
  def dispatch(tool_calls, iteration, cap)
543
- emit(type: :tool_dispatch_started, iteration: iteration, call_count: tool_calls.length)
544
- tool_calls.each_with_index do |tool_call, index|
545
- call = NativeToolNormalizer.normalize(tool_call)
546
- run = @loop.tool_runner.run(call, iteration: iteration, call_index: index + 1, call_count: tool_calls.length,
547
- on_stream_event: @on_stream_event, max_tool_output_chars: cap)
548
- @tool_activity << run[:activity] if run[:activity]
549
- # The chat loop feeds the model the capped output (native feeds
550
- # the full one; D-P2-6).
551
- entry = { role: "tool_response", content: run[:capped_output], tool_call_id: tool_call.id }
552
- entry[:images] = run[:images] if run[:images]&.any?
553
- # A plugin tool's params line, for the web's reload; never sent.
554
- entry[:tool_params] = run[:shown_params] if run[:shown_params]
555
- entry[:tool_labels] = run[:shown_label] if run[:shown_label]
556
- # What an edit/write changed, for the web's reload; never sent.
557
- entry[:tool_diffs] = run[:diff] if run[:diff]
558
- @conversation << entry
558
+ calls = tool_calls.map { |tool_call| NativeToolNormalizer.normalize(tool_call) }
559
+ runs = ToolResponse.run_batch(@loop.tool_runner, calls, iteration: iteration, emit: method(:emit),
560
+ on_stream_event: @on_stream_event, cap: cap) do |run, index|
561
+ @conversation << ToolResponse.single(run, tool_call_id: tool_calls[index].id)
559
562
  end
560
- emit(type: :tool_dispatch_completed, iteration: iteration, call_count: tool_calls.length)
563
+ @tool_activity.concat(ToolResponse.activities(runs))
561
564
  end
562
565
 
563
- # Queued steering joins the conversation as one user message, a
564
- # plugin's steers each as its own after it (Steer). Returns true when
565
- # there was any. After a cancel it stays queued, so it runs as the
566
- # next turn instead of dying with this one. +answer+ is the answer the
567
- # merge follows, for the UIs; given, the drain is told it is the
568
- # after-answer site (plugin steers are dropped there).
566
+ # Queued input at an iteration boundary (Steer.inject!). Returns true
567
+ # when there was any.
569
568
  def inject_pending_input(iteration, answer: nil)
570
- return false unless @pending_input
571
- return false if @cancel_controller&.cancelled?
572
-
573
- merge = Steer.merge(Steer.drain(@pending_input, at_answer: !answer.nil?))
574
- return false if merge.empty?
575
-
576
- @conversation.concat(merge.messages)
577
- emit(type: :pending_input_merged, iteration: iteration, **merge.event_fields,
578
- answer: answer.to_s.empty? ? nil : answer)
579
- true
569
+ Steer.inject!(@conversation, @pending_input, iteration: iteration, emit: method(:emit),
570
+ cancel_controller: @cancel_controller, answer: answer)
580
571
  end
581
572
 
582
573
  # The text streamed before the cancel stays, marked [interrupted],
@@ -589,13 +580,13 @@ module Samagotchi
589
580
  conversation.last[:interrupted] = true unless visible.empty?
590
581
  ModelResult.new(text: "", provider: :chat, conversation: conversation, canceled: true,
591
582
  cancellation_reason: reason, tool_activity: @tool_activity, usage: usage,
592
- context_status: @context_status)
583
+ context_status: @context.display)
593
584
  end
594
585
 
595
586
  def result(text, exhausted:)
596
587
  ModelResult.new(text: text, provider: :chat, conversation: @loop.plain(@conversation), exhausted: exhausted,
597
588
  tool_activity: @tool_activity, usage: usage, empty_answer: text == EMPTY_ANSWER,
598
- context_status: @context_status)
589
+ context_status: @context.display)
599
590
  end
600
591
 
601
592
  def usage
@@ -129,15 +129,23 @@ module Samagotchi
129
129
 
130
130
  # Raises UnknownHost when +model_name+ (or the alias it names) is
131
131
  # qualified with a host that isn't configured, instead of sending the
132
- # whole ref to the default host as a model id. Where a model comes in
133
- # (an Engine starting or switching, a spawned session) calls it.
132
+ # whole ref to the default host as a model id, and when an alias after
133
+ # a host prefix names another host ("openrouter:tiny" with tiny:
134
+ # box:…). Where a model comes in (an Engine starting or switching, a
135
+ # spawned session) calls it.
134
136
  # @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
135
137
  # entries); config.yml's by default
136
138
  # @return [String] +model_name+
137
139
  def self.check_host!(model_name, env: ENV, hosts: nil)
138
140
  require_relative "config"
139
141
  hosts ||= Samagotchi::ConfigFile.hosts_config(env: env)
140
- ref = Samagotchi::ConfigFile.resolve_model_alias(model_name, env: env, hosts: hosts)
142
+ parsed = Samagotchi::ConfigFile.model_ref(model_name, env: env, hosts: hosts)
143
+ if (other = parsed.host_conflict)
144
+ raise UnknownHost, "alias '#{parsed.alias_name}' names host '#{other}', not '#{parsed.host_name}'; " \
145
+ "use #{parsed.alias_name} or #{other}:#{parsed.alias_name}"
146
+ end
147
+ ref = parsed.ref
148
+ warn_host_slash(ref, hosts) unless parsed.host_name
141
149
  host = Samagotchi::ConfigFile.unknown_host_prefix(ref, hosts: hosts)
142
150
  return model_name unless host
143
151
 
@@ -147,6 +155,17 @@ module Samagotchi
147
155
  raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(', ')}"
148
156
  end
149
157
 
158
+ # "box/x" named host box until '/' stopped naming a host: a saved
159
+ # session or a hand-written ref says so once, then goes to the default host.
160
+ def self.warn_host_slash(ref, hosts)
161
+ prefix, rest = ref.to_s.split("/", 2)
162
+ return if rest.to_s.empty? || !hosts.keys.map { |k| k.to_s.downcase }.include?(prefix.downcase)
163
+
164
+ Samagotchi::ConfigFile.warn_once("Warning: model '#{ref}' starts with the host '#{prefix}' and '/': only ':' names a host now, " \
165
+ "so it goes to the default host as written; use #{prefix}:#{rest} (/model #{prefix}:#{rest})")
166
+ end
167
+ private_class_method :warn_host_slash
168
+
150
169
  # One line for the user: where to set the model.
151
170
  def self.missing_model_message
152
171
  path = begin
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A model ref as written (--model, /model, default.model, an alias's
5
+ # target): an optional host prefix and a model id or alias. Pure: it is
6
+ # given the configured hosts and aliases and asks nothing else. Routing a
7
+ # name without a host to one (the model index after /models) is
8
+ # HostRegistry's.
9
+ #
10
+ # Aliases apply once (an alias naming another alias sends that name as
11
+ # written), also after a host prefix ("box:small"). An alias whose target
12
+ # names another host than the prefix is a conflict (#host_conflict),
13
+ # refused where a model comes in (ModelProfile.check_host!).
14
+ #
15
+ # typed: the ref as given, stripped.
16
+ # host_name: the host the ref names (downcased), its own or its alias's;
17
+ # nil when it names none.
18
+ # id: the model id the host is asked for.
19
+ # alias_name: the alias applied (as typed), else nil.
20
+ # alias_host: the host the alias's target names, else nil.
21
+ ModelRef = Data.define(:typed, :host_name, :id, :alias_name, :alias_host) do
22
+ # Splits "host:model" when the prefix is a configured host and something
23
+ # follows it. Only ':' names a host: "openai/gpt-4o" is an id (OpenRouter's).
24
+ # @param hosts [Hash] configured hosts by name (any value)
25
+ # @return [Array(String, String)] [host_name or nil, the rest]
26
+ def self.split(raw, hosts:)
27
+ value = raw.to_s.strip
28
+ return [nil, value] if value.empty?
29
+
30
+ prefix, rest = value.split(":", 2)
31
+ return [nil, value] if rest.nil? || rest.strip.empty?
32
+ return [nil, value] unless (hosts || {}).keys.map { |k| k.to_s.downcase }.include?(prefix.strip.downcase)
33
+
34
+ [prefix.strip.downcase, rest.strip]
35
+ end
36
+
37
+ # @param aliases [Hash{String => String}] alias (downcased) => target
38
+ # @return [ModelRef]
39
+ def self.parse(raw, hosts:, aliases:)
40
+ value = raw.to_s.strip
41
+ host, rest = split(value, hosts: hosts)
42
+ name = host ? rest : value
43
+ target = aliases[name.downcase]
44
+ return new(typed: value, host_name: host, id: name, alias_name: nil, alias_host: nil) unless target
45
+
46
+ alias_host, alias_id = split(target, hosts: hosts)
47
+ new(typed: value, host_name: host || alias_host, id: alias_host ? alias_id : target, alias_name: name,
48
+ alias_host: alias_host)
49
+ end
50
+
51
+ # The resolved ref a session stores: "host:id", or the id when no host
52
+ # is named (routed when it runs).
53
+ def ref = host_name ? "#{host_name}:#{id}" : id
54
+
55
+ # The host the alias names when the ref's own prefix names another, else nil.
56
+ def host_conflict
57
+ alias_host if alias_host && alias_host != host_name
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ require_relative "atomic_file"
7
+ require_relative "config"
8
+ require_relative "paths"
9
+ require_relative "session_commands"
10
+
11
+ module Samagotchi
12
+ # The prompts typed at a chi prompt, kept for ↑ across launches: one
13
+ # global file (config history.file, else
14
+ # $XDG_STATE_HOME/samagotchi/history.json), a JSON array oldest first. An
15
+ # older file with one prompt per line still reads. The TUI and the web
16
+ # server both append, so appends hold <file>.lock and replace the file
17
+ # atomically (mode 0600: the web serves it to LAN token holders).
18
+ module PromptHistory
19
+ FILE = "history.json"
20
+ STATE_DIR = "samagotchi"
21
+ LIMIT = 100
22
+
23
+ module_function
24
+
25
+ # @return [String] the history file
26
+ def path
27
+ explicit = Config.get("history.file").to_s.strip
28
+ return explicit unless explicit.empty?
29
+
30
+ File.join(Paths.state_home, STATE_DIR, FILE)
31
+ end
32
+
33
+ # @return [Array<String>] the saved entries, oldest first; [] when the
34
+ # file is missing or unreadable
35
+ def entries
36
+ file = path
37
+ return [] unless File.file?(file)
38
+
39
+ raw = File.read(file)
40
+ normalize(JSON.parse(raw))
41
+ rescue JSON::ParserError
42
+ normalize(raw.to_s.lines.map(&:chomp))
43
+ rescue StandardError
44
+ []
45
+ end
46
+
47
+ # Add +line+ as the newest entry, keeping the last LIMIT. The re-read
48
+ # happens under the lock, so two writers never drop each other's line.
49
+ # The rename replaces a symlinked history.file with a regular file.
50
+ # @param line [String]
51
+ def append(line)
52
+ file = path
53
+ FileUtils.mkdir_p(File.dirname(file))
54
+ File.open("#{file}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
55
+ lock.flock(File::LOCK_EX)
56
+ list = entries << line
57
+ AtomicFile.write(file, JSON.pretty_generate(list.last(LIMIT)) + "\n", perm: 0o600)
58
+ end
59
+ end
60
+
61
+ # What changes when anyone writes the file: its mtime and size.
62
+ # @return [Array, nil] nil without a file
63
+ def signature
64
+ stat = File.stat(path)
65
+ [stat.mtime, stat.size]
66
+ rescue SystemCallError
67
+ nil
68
+ end
69
+
70
+ # The entries +now+ has after the ones +seen+ earlier: the longest end
71
+ # of +seen+ that +now+ starts with lines the two up (appends trim the
72
+ # front); when nothing lines up, all of +now+.
73
+ # @return [Array<String>]
74
+ def new_tail(seen, now)
75
+ (0..seen.size).each do |drop|
76
+ kept = seen.size - drop
77
+ return now.drop(kept) if kept <= now.size && now.first(kept) == seen.last(kept)
78
+ end
79
+ now
80
+ end
81
+
82
+ # A `!command` worth recalling; `!rollback` isn't one.
83
+ def shell_line?(line)
84
+ line.to_s.start_with?(SessionCommands::SHELL_BANG_PREFIX) && line.to_s.strip != SessionCommands::ROLLBACK_COMMAND
85
+ end
86
+
87
+ def normalize(list)
88
+ Array(list).map { |entry| entry.to_s.gsub(/\r\n?/, "\n").strip }.reject(&:empty?)
89
+ end
90
+ end
91
+ end
@@ -46,6 +46,11 @@ module Samagotchi
46
46
  if base_url.nil? && host_ref.nil? && model.nil?
47
47
  target = -> { session_target.call }
48
48
  else
49
+ # The model as any model ref (ModelRef): its alias applied once, and
50
+ # its host (own or the alias's) picks the recap host when host_ref
51
+ # doesn't.
52
+ parsed = model && host_registry.model_ref(model)
53
+ host_ref ||= parsed.host_name if parsed && base_url.nil?
49
54
  # If host_ref given, derive base_url (the host's OpenAI base) and its
50
55
  # API key variable from the host_registry entry
51
56
  api_key_env = nil
@@ -54,13 +59,20 @@ module Samagotchi
54
59
  if entry
55
60
  base_url = entry.openai_base_url
56
61
  api_key_env = entry.api_key_env
57
- # If model is host-qualified, extract bare model for recap client
58
- _, bare = host_registry.parse_qualified_model(model) if model
59
- model = bare if bare && !bare.empty?
62
+ named = parsed&.host_conflict || parsed&.host_name
63
+ if named && named != entry.name
64
+ Log.warn(:recap, "model_host_mismatch",
65
+ echo: "Warning: recap model '#{model}' names host '#{named}', not recap.host_ref '#{host_ref}'; recap disabled.",
66
+ model: model, host_ref: host_ref)
67
+ return nil
68
+ end
69
+ model = parsed.id if parsed
60
70
  else
61
71
  Log.warn(:recap, "host_ref_unknown", echo: "Warning: recap host_ref '#{host_ref}' not found in hosts:; recap disabled.", host_ref: host_ref)
62
72
  return nil
63
73
  end
74
+ elsif parsed && !parsed.host_name
75
+ model = parsed.id
64
76
  end
65
77
 
66
78
  if base_url.to_s.strip.empty? || model.to_s.strip.empty?
@@ -114,7 +114,7 @@ module Samagotchi
114
114
  # Routed without model discovery, so an unqualified name may land on
115
115
  # another host at runtime after /models.
116
116
  def loop_for(model, env)
117
- entry, = HostRegistry.new(env: env).host_for_model(model)
117
+ entry = HostRegistry.new(env: env).resolve(model).entry
118
118
  return "-" unless entry
119
119
 
120
120
  entry.chat? ? "chat (api: openai)" : "native (raw prompt)"
@@ -125,7 +125,9 @@ module Samagotchi
125
125
  # GET with the probe's short timeouts; a remote host isn't asked.
126
126
  def served_model_for(model, env)
127
127
  registry = HostRegistry.new(env: env)
128
- entry, bare = registry.host_for_model(model)
128
+ target = registry.resolve(model)
129
+ entry = target.entry
130
+ bare = target.bare_model
129
131
  return "-" unless entry
130
132
  return "reported per turn (remote host)" if entry.remote?
131
133
 
@@ -146,7 +148,9 @@ module Samagotchi
146
148
  # llama.cpp host's chat template decides at runtime.
147
149
  def profile_for(model, env)
148
150
  registry = HostRegistry.new(env: env)
149
- entry, bare = registry.host_for_model(model)
151
+ target = registry.resolve(model)
152
+ entry = target.entry
153
+ bare = target.bare_model
150
154
  return "name-based (chat API: only strips thoughts)" if entry&.chat?
151
155
 
152
156
  names = registry.lookup_names(model)
@@ -177,7 +181,9 @@ module Samagotchi
177
181
  end
178
182
 
179
183
  def host_for(model, env)
180
- entry, bare = HostRegistry.new(env: env).host_for_model(model)
184
+ target = HostRegistry.new(env: env).resolve(model)
185
+ entry = target.entry
186
+ bare = target.bare_model
181
187
  return "(no hosts configured)" unless entry
182
188
 
183
189
  suffix = bare && bare != model ? " as #{bare}" : ""
@@ -188,8 +194,7 @@ module Samagotchi
188
194
  # The variable the host's API key comes from and whether it is set; the
189
195
  # key itself is never shown.
190
196
  def api_key_for(model, env)
191
- entry, = HostRegistry.new(env: env).host_for_model(model)
192
- name = entry&.api_key_env
197
+ name = HostRegistry.new(env: env).resolve(model).entry&.api_key_env
193
198
  return "-" unless name
194
199
 
195
200
  "#{name} (#{env[name].to_s.empty? ? "unset" : "set"})"
@@ -80,16 +80,16 @@ module Samagotchi
80
80
  # takes images, decided as its worker decides before a turn with images
81
81
  # (Engine#turn_vision: VisionSupport, with models.<name>.vision under
82
82
  # the name as given (maybe an alias), the alias's target, the part
83
- # after a host prefix and the bare name, as after /model). A native
83
+ # after a host prefix and the bare name). A native
84
84
  # host whose /props doesn't answer is unknown here, not a no: the
85
85
  # worker asks again when the turn runs.
86
+ # @param typed [String, nil] the name the model was typed as (a session's model_typed)
86
87
  # @return [VisionSupport::Answer] value nil when unknown
87
- def self.vision_answer(model_name, registry: nil)
88
+ def self.vision_answer(model_name, registry: nil, typed: nil)
88
89
  registry ||= HostRegistry.new
89
90
  name = ModelProfile.required_model_name(model_name)
90
- aliased = ConfigFile.resolve_model_alias(name)
91
- target = registry.resolve(aliased)
92
- names = registry.lookup_names(name, target: target)
91
+ target = registry.resolve(name)
92
+ names = registry.lookup_names(typed.to_s.strip.empty? ? name : typed, resolved: name, target: target)
93
93
  entry = target.entry
94
94
  return VisionSupport.for(target, adapter: registry.adapter_for(entry), names: names) if entry.chat?
95
95
 
@@ -385,7 +385,7 @@ module Samagotchi
385
385
  def deliver(id, prompt)
386
386
  short = id[0, 8]
387
387
  session = Session.load(id, state_dir: @state_dir)
388
- if !@images.empty? && (refusal = vision_refusal(session.model_name))
388
+ if !@images.empty? && (refusal = vision_refusal(session.model_name, typed: session.model_typed))
389
389
  @info.puts("#{short} refused: #{refusal}")
390
390
  return false
391
391
  end
@@ -415,9 +415,9 @@ module Samagotchi
415
415
 
416
416
  # What to say when +model_name+ is known not to take images, or nil
417
417
  # (it does, or it is unknown: sent as before). Asked once per model.
418
- def vision_refusal(model_name)
418
+ def vision_refusal(model_name, typed: nil)
419
419
  @vision ||= {}
420
- answer = @vision[model_name.to_s] ||= self.class.vision_answer(model_name)
420
+ answer = @vision[[model_name.to_s, typed.to_s]] ||= self.class.vision_answer(model_name, typed: typed)
421
421
  return nil unless answer.no?
422
422
 
423
423
  name = model_name.to_s.strip
@@ -40,6 +40,10 @@ module Samagotchi
40
40
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
41
41
  # Names as given; the engine normalizes them.
42
42
  attr_accessor :preloaded_memory_names, :muted_memory_names
43
+ # The name model_name was typed as when that differs from the resolved
44
+ # ref stored in model_name (an alias: `small` for `gemma-small`), else
45
+ # nil. The models: lookup takes it (HostRegistry#lookup_names).
46
+ attr_accessor :model_typed
43
47
  # The project the session was started in (ProjectScope.root_for its
44
48
  # folder), stored because worktrees are deleted after a merge and a
45
49
  # deleted folder no longer leads to its repository. nil outside a repo,
@@ -70,11 +74,12 @@ module Samagotchi
70
74
  first_preview: "", test_run: false, pending_question: nil,
71
75
  used_memory_names: [], project_root: nil,
72
76
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
73
- last_turn: nil)
77
+ last_turn: nil, model_typed: nil)
74
78
  @id = id
75
79
  @metadata_version = metadata_version
76
80
  @mode = mode
77
81
  @model_name = model_name
82
+ @model_typed = model_typed
78
83
  @working_directory = working_directory
79
84
  @messages = messages
80
85
  @created_at = created_at
@@ -113,7 +118,7 @@ module Samagotchi
113
118
  # seed); [] by default
114
119
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
115
120
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
116
- scratch: false)
121
+ scratch: false, model_typed: nil)
117
122
  now = Time.now.iso8601(3)
118
123
  resolved_test = if test_run.nil?
119
124
  test_session_env?
@@ -124,6 +129,7 @@ module Samagotchi
124
129
  id: SecureRandom.uuid,
125
130
  mode: mode.to_s,
126
131
  model_name: model_name.to_s,
132
+ model_typed: model_typed,
127
133
  working_directory: working_directory.to_s,
128
134
  messages: Array(messages).map(&:dup),
129
135
  created_at: now,
@@ -173,6 +179,7 @@ module Samagotchi
173
179
  "id" => REQUIRED,
174
180
  "mode" => REQUIRED,
175
181
  "model_name" => REQUIRED,
182
+ "model_typed" => nil,
176
183
  "working_directory" => REQUIRED,
177
184
  "messages" => [],
178
185
  "created_at" => REQUIRED,