samagotchi 0.10.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -1
- data/docs/architecture.md +19 -0
- data/docs/cli.md +24 -7
- data/docs/configuration.md +44 -11
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +3 -2
- data/docs/internals/context-telemetry.md +4 -2
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +21 -1
- data/lib/samagotchi/config.rb +44 -40
- data/lib/samagotchi/context_status.rb +17 -16
- data/lib/samagotchi/empty_answer_retry.rb +67 -8
- data/lib/samagotchi/engine.rb +64 -20
- data/lib/samagotchi/host_registry.rb +50 -90
- data/lib/samagotchi/kernel_loop.rb +288 -305
- data/lib/samagotchi/llm/chat_loop.rb +78 -87
- data/lib/samagotchi/model_profile.rb +22 -3
- data/lib/samagotchi/model_ref.rb +60 -0
- data/lib/samagotchi/prompt_history.rb +91 -0
- data/lib/samagotchi/recap_setup.rb +15 -3
- data/lib/samagotchi/self_report.rb +11 -6
- data/lib/samagotchi/send_command.rb +8 -8
- data/lib/samagotchi/session.rb +9 -2
- data/lib/samagotchi/session_commands.rb +10 -12
- data/lib/samagotchi/session_manager.rb +20 -3
- data/lib/samagotchi/sessions_command.rb +57 -51
- data/lib/samagotchi/steer.rb +23 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
- data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
- data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
- data/lib/samagotchi/terminal_ui.rb +9 -8
- data/lib/samagotchi/tool_response.rb +67 -0
- data/lib/samagotchi/tool_runner.rb +6 -1
- data/lib/samagotchi/tools/delegate.rb +4 -3
- data/lib/samagotchi/tools/memory.rb +8 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -2
- data/lib/samagotchi/web/public/app.js +29 -2
- data/lib/samagotchi/web/public/data.js +9 -1
- data/lib/samagotchi/web/public/prompt_history.js +63 -0
- data/lib/samagotchi/worker.rb +2 -1
- 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[
|
|
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
|
-
@
|
|
324
|
-
@
|
|
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
|
|
352
|
-
retry_empty =
|
|
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
|
|
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
|
-
@
|
|
397
|
-
|
|
398
|
-
|
|
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 @
|
|
394
|
+
if @empty_retry.left?
|
|
414
395
|
return :retry if inject_pending_input(iteration)
|
|
415
396
|
|
|
416
|
-
@
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
#
|
|
521
|
-
# (
|
|
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
|
|
534
|
+
return unless usage.source == :server
|
|
524
535
|
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
563
|
+
@tool_activity.concat(ToolResponse.activities(runs))
|
|
561
564
|
end
|
|
562
565
|
|
|
563
|
-
# Queued
|
|
564
|
-
#
|
|
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
|
-
|
|
571
|
-
|
|
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: @
|
|
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: @
|
|
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
|
|
133
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
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
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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,
|