samagotchi 0.2.0 → 0.4.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 +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "yaml"
|
|
|
8
8
|
|
|
9
9
|
require_relative "config"
|
|
10
10
|
require_relative "context_note"
|
|
11
|
+
require_relative "steer"
|
|
11
12
|
require_relative "turn_note"
|
|
12
13
|
require_relative "model_profile"
|
|
13
14
|
require_relative "thought_stream_splitter"
|
|
@@ -15,14 +16,17 @@ require_relative "cancellation_controller"
|
|
|
15
16
|
require_relative "context_window"
|
|
16
17
|
require_relative "kernel_loop"
|
|
17
18
|
require_relative "tools/builtins"
|
|
19
|
+
require_relative "tools/task_runtime"
|
|
18
20
|
require_relative "session_commands"
|
|
19
21
|
require_relative "plugin/loader"
|
|
20
22
|
require_relative "log"
|
|
21
23
|
require_relative "log_subscriber"
|
|
24
|
+
require_relative "client"
|
|
22
25
|
require_relative "host_registry"
|
|
23
26
|
require_relative "llm/backend"
|
|
24
27
|
require_relative "llm/openai_chat"
|
|
25
28
|
require_relative "session"
|
|
29
|
+
require_relative "archive_store"
|
|
26
30
|
require_relative "session_observer"
|
|
27
31
|
require_relative "tool_declarations"
|
|
28
32
|
require_relative "session_metrics"
|
|
@@ -41,6 +45,10 @@ require_relative "served_model"
|
|
|
41
45
|
require_relative "image_store"
|
|
42
46
|
require_relative "vision_context"
|
|
43
47
|
require_relative "vision_support"
|
|
48
|
+
require_relative "sampling_settings"
|
|
49
|
+
require_relative "thinking"
|
|
50
|
+
require_relative "answer_display"
|
|
51
|
+
require_relative "edit_preview"
|
|
44
52
|
|
|
45
53
|
module Samagotchi
|
|
46
54
|
# Engine owns the core agent logic: system prompt construction, tool
|
|
@@ -74,16 +82,25 @@ module Samagotchi
|
|
|
74
82
|
# @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
|
|
75
83
|
# prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
|
|
76
84
|
DEFAULT_SYSTEM_MEMORIES = %w[identity].freeze
|
|
85
|
+
# What memory_write answers in a scratch session.
|
|
86
|
+
SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
|
|
77
87
|
|
|
78
88
|
# @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
|
|
89
|
+
# @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
|
|
90
|
+
# delegate (a child would outlive it) nor plugin fork
|
|
79
91
|
def initialize(mode: :assist, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
|
|
80
|
-
plugins: true)
|
|
92
|
+
plugins: true, scratch: false)
|
|
81
93
|
@mode = mode.to_sym
|
|
94
|
+
@scratch = scratch
|
|
82
95
|
@chat_backend = nil
|
|
83
96
|
@chat_backend_mutex = Mutex.new
|
|
97
|
+
@host_registry = host_registry || HostRegistry.new
|
|
98
|
+
# A model given here (a worker's session model) is the one it runs,
|
|
99
|
+
# so its host is checked; the config default is only checked once it
|
|
100
|
+
# is used (the REPL checks the model it starts on, #switch_model!).
|
|
84
101
|
@default_model_name = ModelProfile.required_model_name(model_name)
|
|
102
|
+
ModelProfile.check_host!(model_name, hosts: @host_registry.entries) unless model_name.to_s.strip.empty?
|
|
85
103
|
@effective_model_name = @default_model_name
|
|
86
|
-
@host_registry = host_registry || HostRegistry.new
|
|
87
104
|
# An injected client (specs) stands in for every host's client.
|
|
88
105
|
@host_registry.client_override = client if client
|
|
89
106
|
@client = @host_registry.resolve(@effective_model_name).client
|
|
@@ -105,6 +122,7 @@ module Samagotchi
|
|
|
105
122
|
# to load is announced, and a required guardrail's failure denies
|
|
106
123
|
# every tool call. Rules load now too, so their errors are announced.
|
|
107
124
|
@guardrail_failures = Guardrails::LoadFailures.new
|
|
125
|
+
@guardrail_rules_mutex = Mutex.new
|
|
108
126
|
# Plugins that failed to load: announced apart, as plugins (not
|
|
109
127
|
# guardrails: no tool call is denied for them).
|
|
110
128
|
@plugin_failures = Guardrails::LoadFailures.new
|
|
@@ -113,6 +131,12 @@ module Samagotchi
|
|
|
113
131
|
# The tools this session offers (the prompts' declarations and the
|
|
114
132
|
# kernel's dispatch): the built-ins, per Engine.
|
|
115
133
|
@tools = Tools::Builtins.registry
|
|
134
|
+
if @scratch
|
|
135
|
+
# A scratch session's children would outlive it; its memories
|
|
136
|
+
# would too (write and edit into the memories: ScratchWrites).
|
|
137
|
+
[Tools::Delegate::NAME, Tools::DelegateResult::NAME].each { |name| @tools.unregister(name) }
|
|
138
|
+
@tools[Tools::MemoryWrite::NAME].handler = ->(_call, _kctx) { SCRATCH_MEMORY_WRITE }
|
|
139
|
+
end
|
|
116
140
|
# Likewise the slash commands its SessionCommands run.
|
|
117
141
|
@command_registry = SessionCommands.register_builtins(Commands::Registry.new)
|
|
118
142
|
# Installed bundles' plugins add commands, tools and hooks to these
|
|
@@ -220,6 +244,9 @@ module Samagotchi
|
|
|
220
244
|
@activity_seq = 0
|
|
221
245
|
@turn_running = false
|
|
222
246
|
@active_cancel_controller = nil
|
|
247
|
+
# Plugin steers ({text:, source:}) waiting for the running turn's next
|
|
248
|
+
# boundary (#steer); guarded by @activity_mutex.
|
|
249
|
+
@steers = []
|
|
223
250
|
# Reminder names the interactive REPL's IdleReminders callback marked due;
|
|
224
251
|
# the REPL polls them to decide when to run a synthetic reminder turn.
|
|
225
252
|
@due_reminder_names = []
|
|
@@ -302,6 +329,58 @@ module Samagotchi
|
|
|
302
329
|
@activity_mutex.synchronize { @turn_running }
|
|
303
330
|
end
|
|
304
331
|
|
|
332
|
+
# Put +text+ into the running turn, as a UI's steering does: it joins the
|
|
333
|
+
# conversation at the loop's next iteration boundary as its own user
|
|
334
|
+
# message (kind: "steer", source:). Callable from any thread; never
|
|
335
|
+
# blocks. With no turn running it does nothing (it never starts one).
|
|
336
|
+
# True means queued, not merged: at the after-answer boundary, and when
|
|
337
|
+
# the turn ends first, it is dropped (logged).
|
|
338
|
+
# @return [Boolean] whether it was queued
|
|
339
|
+
def steer(text, source:)
|
|
340
|
+
text = text.to_s.strip
|
|
341
|
+
return false if text.empty?
|
|
342
|
+
|
|
343
|
+
@activity_mutex.synchronize do
|
|
344
|
+
return false unless @turn_running
|
|
345
|
+
|
|
346
|
+
@steers << { text: text, source: source.to_s }
|
|
347
|
+
end
|
|
348
|
+
true
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# The drain a turn's loop gets: the caller's lines (a UI's steering; nil
|
|
352
|
+
# in a --non-interactive run) and the plugin steers. At the after-answer
|
|
353
|
+
# boundary the steers are dropped: the model answered, and a nudge would
|
|
354
|
+
# only restart the turn. The steer part fails on its own, never taking
|
|
355
|
+
# the user's lines with it.
|
|
356
|
+
def turn_drain(pending_input)
|
|
357
|
+
lambda do |at_answer: false|
|
|
358
|
+
lines = pending_input ? Array(pending_input.call) : []
|
|
359
|
+
lines + take_steers(at_answer)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
private :turn_drain
|
|
363
|
+
|
|
364
|
+
def take_steers(drop)
|
|
365
|
+
steers = @activity_mutex.synchronize do
|
|
366
|
+
taken = @steers
|
|
367
|
+
@steers = []
|
|
368
|
+
taken
|
|
369
|
+
end
|
|
370
|
+
return steers unless drop
|
|
371
|
+
|
|
372
|
+
log_dropped_steers(steers, "answered")
|
|
373
|
+
[]
|
|
374
|
+
rescue StandardError
|
|
375
|
+
[]
|
|
376
|
+
end
|
|
377
|
+
private :take_steers
|
|
378
|
+
|
|
379
|
+
def log_dropped_steers(steers, why)
|
|
380
|
+
steers.each { |steer| Log.info(:turn, "steer_dropped", source: steer[:source], why: why, chars: steer[:text].length) }
|
|
381
|
+
end
|
|
382
|
+
private :log_dropped_steers
|
|
383
|
+
|
|
305
384
|
# @return [Array<String>] reminder names queued for a synthetic REPL turn
|
|
306
385
|
def due_reminder_names
|
|
307
386
|
@activity_mutex.synchronize { @due_reminder_names.dup }
|
|
@@ -341,7 +420,7 @@ module Samagotchi
|
|
|
341
420
|
snapshot = @activity_mutex.synchronize { @session&.messages }
|
|
342
421
|
return "[]" if snapshot.nil?
|
|
343
422
|
|
|
344
|
-
JSON.generate(
|
|
423
|
+
JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
|
|
345
424
|
end
|
|
346
425
|
|
|
347
426
|
# Emit a :recap_ready event (additive slot) carrying the generated recap
|
|
@@ -775,7 +854,7 @@ module Samagotchi
|
|
|
775
854
|
def switch_model!(model_name, persist_default: false)
|
|
776
855
|
# Resolve alias first (alias may point to qualified ref)
|
|
777
856
|
aliased = ConfigFile.resolve_model_alias(model_name)
|
|
778
|
-
resolved = ModelProfile.required_model_name(aliased)
|
|
857
|
+
resolved = ModelProfile.check_host!(ModelProfile.required_model_name(aliased), hosts: @host_registry.entries)
|
|
779
858
|
@effective_model_name = resolved
|
|
780
859
|
bare = bare_model_name(resolved)
|
|
781
860
|
@model_lookup_names = [model_name, aliased, resolved]
|
|
@@ -795,10 +874,6 @@ module Samagotchi
|
|
|
795
874
|
resolved
|
|
796
875
|
end
|
|
797
876
|
|
|
798
|
-
def reset_model!
|
|
799
|
-
switch_model!(@default_model_name)
|
|
800
|
-
end
|
|
801
|
-
|
|
802
877
|
# ── Hooks API ──────────────────────────────────────────────────────────────
|
|
803
878
|
|
|
804
879
|
# Register a hook callback for a named lifecycle event.
|
|
@@ -869,8 +944,6 @@ module Samagotchi
|
|
|
869
944
|
clear_due_reminder_names!
|
|
870
945
|
due
|
|
871
946
|
end
|
|
872
|
-
# Alias for backward compatibility.
|
|
873
|
-
alias maybe_inject_reminders collect_due_reminders
|
|
874
947
|
|
|
875
948
|
# @return [Boolean] whether any reminder is due now (the store's view,
|
|
876
949
|
# which #collect_due_reminders would inject), regardless of the REPL queue
|
|
@@ -892,9 +965,12 @@ module Samagotchi
|
|
|
892
965
|
target = @host_registry.resolve(@effective_model_name)
|
|
893
966
|
served, served_for = served_model_for(snapshot, target: target)
|
|
894
967
|
snapshot = snapshot.merge(served_model: served, served_model_for: served_for)
|
|
895
|
-
unless snapshot
|
|
968
|
+
unless snapshot.dig(:context, :window_tokens)
|
|
896
969
|
window = current_context_window(target)
|
|
897
|
-
|
|
970
|
+
if window
|
|
971
|
+
snapshot = snapshot.merge(context: snapshot[:context].merge(window_tokens: window.tokens,
|
|
972
|
+
window_source: window.source.to_s))
|
|
973
|
+
end
|
|
898
974
|
end
|
|
899
975
|
# The chat loop uses no prompt profile: drop one a native turn reported.
|
|
900
976
|
return snapshot.except(:profile, :profile_source) if target.entry.chat?
|
|
@@ -906,10 +982,38 @@ module Samagotchi
|
|
|
906
982
|
snapshot
|
|
907
983
|
end
|
|
908
984
|
|
|
985
|
+
# The status line's ctx from the saved context, for a worker that has run
|
|
986
|
+
# no turn yet (it woke after an idle exit): nil when either count is
|
|
987
|
+
# unknown or context.status is off.
|
|
988
|
+
def saved_context_status(context)
|
|
989
|
+
return nil unless context && @kernel.respond_to?(:context_display)
|
|
990
|
+
|
|
991
|
+
@kernel.context_display(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
|
|
992
|
+
end
|
|
993
|
+
|
|
909
994
|
# The effective model is on a chat host (api: openai), whose loop uses
|
|
910
995
|
# no prompt profile.
|
|
911
996
|
def chat_model? = @host_registry.resolve(@effective_model_name).entry.chat?
|
|
912
997
|
|
|
998
|
+
# "temperature=0.6 (hosts.work)" for /model: the effective model's
|
|
999
|
+
# configured sampling, nil when none.
|
|
1000
|
+
def sampling_summary
|
|
1001
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
1002
|
+
SamplingSettings.summary(target, names: model_lookup_names(target))
|
|
1003
|
+
rescue StandardError
|
|
1004
|
+
nil
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
# "off (models: qwen)" for /model: the effective model's thinking level
|
|
1008
|
+
# and where it came from, nil when none is set.
|
|
1009
|
+
def thinking_summary
|
|
1010
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
1011
|
+
level, source = Thinking.resolve(target, names: model_lookup_names(target))
|
|
1012
|
+
source ? "#{level} (#{source})" : nil
|
|
1013
|
+
rescue StandardError
|
|
1014
|
+
nil
|
|
1015
|
+
end
|
|
1016
|
+
|
|
913
1017
|
# The model the server serves for the current model, and the name asked
|
|
914
1018
|
# for: what the last generation of that name reported, else llama.cpp's
|
|
915
1019
|
# model_alias (/props, one short cached probe; not with probe: false),
|
|
@@ -941,12 +1045,13 @@ module Samagotchi
|
|
|
941
1045
|
# :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
|
|
942
1046
|
def session_state_snapshot
|
|
943
1047
|
served_pair = served_model(probe: false)
|
|
1048
|
+
metrics = @metrics.snapshot
|
|
944
1049
|
{
|
|
945
1050
|
status: @session&.status,
|
|
946
1051
|
message_count: (@session&.messages || []).size,
|
|
947
1052
|
last_prompt: @session&.last_prompt,
|
|
948
1053
|
event_seq: @session_observer&.event_count,
|
|
949
|
-
metrics:
|
|
1054
|
+
metrics: metrics,
|
|
950
1055
|
pending_question: @question_mutex.synchronize { @pending_question&.dup },
|
|
951
1056
|
used_memory_names: @used_memory_mutex.synchronize { @used_memory_names.dup },
|
|
952
1057
|
preloaded_memory_names: preloaded_memory_names,
|
|
@@ -955,7 +1060,7 @@ module Samagotchi
|
|
|
955
1060
|
model_name: @effective_model_name,
|
|
956
1061
|
served_model: served_pair[0],
|
|
957
1062
|
served_model_for: served_pair[1],
|
|
958
|
-
context_status: @last_context_status&.dup,
|
|
1063
|
+
context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
|
|
959
1064
|
recap_enabled: !@recap.nil?,
|
|
960
1065
|
recap_min_user_turns: @recap&.min_user_turns,
|
|
961
1066
|
recap_inactivity_seconds: @recap&.inactivity&.to_i
|
|
@@ -1034,6 +1139,9 @@ module Samagotchi
|
|
|
1034
1139
|
base.empty? ? nil : base
|
|
1035
1140
|
end
|
|
1036
1141
|
|
|
1142
|
+
# A memory read (memory_read, or read of a memories/*.md file) as it
|
|
1143
|
+
# starts: its names join used_memory_names.
|
|
1144
|
+
# @return [Array<String>, nil] the names this call read, nil for any other event
|
|
1037
1145
|
def capture_used_memory_from_event(event)
|
|
1038
1146
|
return unless event.is_a?(Hash) && event[:type] == :tool_call_started
|
|
1039
1147
|
|
|
@@ -1044,6 +1152,7 @@ module Samagotchi
|
|
|
1044
1152
|
return if names.empty?
|
|
1045
1153
|
|
|
1046
1154
|
add_used_memory_names(names)
|
|
1155
|
+
names
|
|
1047
1156
|
end
|
|
1048
1157
|
|
|
1049
1158
|
# ── Guardrails ─────────────────────────────────────────────────────────────
|
|
@@ -1069,6 +1178,14 @@ module Samagotchi
|
|
|
1069
1178
|
# its own.
|
|
1070
1179
|
attr_writer :session_state_dir
|
|
1071
1180
|
|
|
1181
|
+
# The metrics load the session's saved records from its state dir, once.
|
|
1182
|
+
def bind_metrics(session)
|
|
1183
|
+
return unless session.respond_to?(:id) && session.id
|
|
1184
|
+
|
|
1185
|
+
@metrics.state_dir = session_state_dir
|
|
1186
|
+
@metrics.session_id = session.id
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1072
1189
|
def session_state_dir = @session_state_dir || Session.default_state_dir
|
|
1073
1190
|
|
|
1074
1191
|
# What a Bridge adds to a plugin's ctx.messages while a turn runs (the
|
|
@@ -1085,33 +1202,60 @@ module Samagotchi
|
|
|
1085
1202
|
# The gate's core checks, in order.
|
|
1086
1203
|
def guardrail_checks
|
|
1087
1204
|
rules = guardrail_rules
|
|
1088
|
-
[@guardrail_failures, rules.hook_asks, guardrail_protected_paths,
|
|
1205
|
+
[@guardrail_failures, rules.hook_asks, guardrail_protected_paths, (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch),
|
|
1206
|
+
rules].compact
|
|
1089
1207
|
end
|
|
1090
1208
|
|
|
1091
1209
|
# The YAML rules: config.yml's `guardrails:` section (rules, disable) and
|
|
1092
1210
|
# installed bundles'. One that doesn't parse is a required load failure
|
|
1093
|
-
# (every call is denied).
|
|
1211
|
+
# (every call is denied). Read again when one of those files changed
|
|
1212
|
+
# (a stat of each per tool call), so a long-lived worker follows edits.
|
|
1094
1213
|
# @return [Guardrails::Rules]
|
|
1095
1214
|
def guardrail_rules
|
|
1096
|
-
@
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
raise Guardrails::Rules::ParseError, "guardrails must be a mapping" unless section.nil? || section.is_a?(Hash)
|
|
1103
|
-
|
|
1104
|
-
rules = Guardrails::Rules.parse(section && section["rules"], source: "config")
|
|
1105
|
-
disable = Guardrails::Rules.parse_disable(section && section["disable"])
|
|
1106
|
-
rescue Guardrails::Rules::ParseError => e
|
|
1107
|
-
Log.warn(:guardrails, "config_rules_invalid", echo: "[samagotchi:guardrails] config.yml guardrails rules: #{e.message}")
|
|
1108
|
-
@guardrail_failures.add("rules in config.yml", e.message, required: true)
|
|
1215
|
+
@guardrail_rules_mutex.synchronize do
|
|
1216
|
+
stamp = guardrail_rules_stamp
|
|
1217
|
+
if @guardrail_rules.nil? || stamp != @guardrail_rules_stamp
|
|
1218
|
+
@guardrail_failures.drop(:rules)
|
|
1219
|
+
@guardrail_rules = load_guardrail_rules
|
|
1220
|
+
@guardrail_rules_stamp = stamp
|
|
1109
1221
|
end
|
|
1110
|
-
|
|
1111
|
-
enabled: Samagotchi::Config.get("guardrails.enabled") != false)
|
|
1222
|
+
@guardrail_rules
|
|
1112
1223
|
end
|
|
1113
1224
|
end
|
|
1114
1225
|
|
|
1226
|
+
# [path, mtime, size] of config.yml and every installed bundle's
|
|
1227
|
+
# manifest.json and guardrails/ file.
|
|
1228
|
+
def guardrail_rules_stamp
|
|
1229
|
+
require_relative "memory_bundle/provenance"
|
|
1230
|
+
paths = [Samagotchi::ConfigFile.global_path] +
|
|
1231
|
+
Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "{manifest.json,guardrails/*}")]
|
|
1232
|
+
paths.compact.sort.map do |path|
|
|
1233
|
+
stat = File.stat(path)
|
|
1234
|
+
[path, stat.mtime.to_r, stat.size]
|
|
1235
|
+
rescue SystemCallError
|
|
1236
|
+
[path]
|
|
1237
|
+
end
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
def load_guardrail_rules
|
|
1241
|
+
section = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
|
|
1242
|
+
section = section["guardrails"] if section.is_a?(Hash)
|
|
1243
|
+
rules = []
|
|
1244
|
+
disable = []
|
|
1245
|
+
begin
|
|
1246
|
+
raise Guardrails::Rules::ParseError, "guardrails must be a mapping" unless section.nil? || section.is_a?(Hash)
|
|
1247
|
+
|
|
1248
|
+
rules = Guardrails::Rules.parse(section && section["rules"], source: "config")
|
|
1249
|
+
disable = Guardrails::Rules.parse_disable(section && section["disable"])
|
|
1250
|
+
rescue Guardrails::Rules::ParseError => e
|
|
1251
|
+
Log.warn(:guardrails, "config_rules_invalid", echo: "[samagotchi:guardrails] config.yml guardrails rules: #{e.message}")
|
|
1252
|
+
@guardrail_failures.add("rules in config.yml", e.message, required: true, group: :rules)
|
|
1253
|
+
end
|
|
1254
|
+
Guardrails::Rules.new(rules + bundle_guardrail_rules, disable: disable,
|
|
1255
|
+
enabled: Samagotchi::Config.get("guardrails.enabled") != false)
|
|
1256
|
+
end
|
|
1257
|
+
private :guardrail_rules_stamp, :load_guardrail_rules
|
|
1258
|
+
|
|
1115
1259
|
# Installed bundles' guardrails/*.yml, by bundle name then file name.
|
|
1116
1260
|
# A file that is missing, changed since install (sha256) or doesn't
|
|
1117
1261
|
# parse is a required load failure.
|
|
@@ -1121,7 +1265,7 @@ module Samagotchi
|
|
|
1121
1265
|
MemoryBundle::Provenance.each_installed_with_guardrails do |bundle_name, data|
|
|
1122
1266
|
if data[:error]
|
|
1123
1267
|
Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{data[:error]}", bundle: bundle_name)
|
|
1124
|
-
@guardrail_failures.add("rules (bundle #{bundle_name})", data[:error], required: true)
|
|
1268
|
+
@guardrail_failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
|
|
1125
1269
|
next
|
|
1126
1270
|
end
|
|
1127
1271
|
dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
|
|
@@ -1143,14 +1287,14 @@ module Samagotchi
|
|
|
1143
1287
|
rules.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
|
|
1144
1288
|
rescue Guardrails::Rules::ParseError, Psych::Exception => e
|
|
1145
1289
|
Log.warn(:guardrails, "rules_file_invalid", echo: "[samagotchi:guardrails] #{what}: #{e.message}", bundle: bundle_name, file: basename.to_s)
|
|
1146
|
-
@guardrail_failures.add(what, e.message, required: true)
|
|
1290
|
+
@guardrail_failures.add(what, e.message, required: true, group: :rules)
|
|
1147
1291
|
end
|
|
1148
1292
|
end
|
|
1149
1293
|
end
|
|
1150
1294
|
rules
|
|
1151
1295
|
rescue StandardError => e
|
|
1152
1296
|
Log.error(:guardrails, "bundle_rules_failed", echo: "[samagotchi:guardrails] failed to read installed bundles' rules: #{e.class}: #{e.message}", error: e.class.name)
|
|
1153
|
-
@guardrail_failures.add("bundle rules", "#{e.class}: #{e.message}", required: true)
|
|
1297
|
+
@guardrail_failures.add("bundle rules", "#{e.class}: #{e.message}", required: true, group: :rules)
|
|
1154
1298
|
rules || []
|
|
1155
1299
|
end
|
|
1156
1300
|
|
|
@@ -1224,17 +1368,62 @@ module Samagotchi
|
|
|
1224
1368
|
|
|
1225
1369
|
# A plugin tool is asked about by its label, as its row shows it.
|
|
1226
1370
|
label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools)
|
|
1227
|
-
|
|
1371
|
+
# verdict.call is the call that will run (a hook may have replaced it).
|
|
1372
|
+
payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call))
|
|
1228
1373
|
Guardrails::Approval.settle(verdict, open_question(payload), payload[:approval][:scopes])
|
|
1229
1374
|
end
|
|
1230
1375
|
|
|
1376
|
+
# The dry-run diff of an edit/write call for its approval; a preview
|
|
1377
|
+
# that fails only leaves the diff out, it never denies the call.
|
|
1378
|
+
def approval_preview(call)
|
|
1379
|
+
EditPreview.for(call)
|
|
1380
|
+
rescue StandardError => e
|
|
1381
|
+
Log.warn(:guardrails, "edit_preview_failed", error: "#{e.class}: #{e.message}")
|
|
1382
|
+
nil
|
|
1383
|
+
end
|
|
1384
|
+
private :approval_preview
|
|
1385
|
+
|
|
1231
1386
|
# The conversation as a hook may read it: a frozen array of copied
|
|
1232
1387
|
# messages, so a hook cannot change what the turn sends or stores.
|
|
1233
1388
|
def hook_messages(messages)
|
|
1234
|
-
|
|
1389
|
+
AnswerDisplay.strip_all(messages).map(&:dup).freeze
|
|
1235
1390
|
end
|
|
1236
1391
|
private :hook_messages
|
|
1237
1392
|
|
|
1393
|
+
# How the turn ended, on the session (Session#last_turn): the caller's
|
|
1394
|
+
# save puts it in the file the session hub watches.
|
|
1395
|
+
def record_last_turn(session, outcome, seconds, origin)
|
|
1396
|
+
client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
|
|
1397
|
+
source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
|
|
1398
|
+
elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
|
|
1399
|
+
else "client"
|
|
1400
|
+
end
|
|
1401
|
+
session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
|
|
1402
|
+
"seconds" => seconds.round(1), "origin" => source }
|
|
1403
|
+
end
|
|
1404
|
+
private :record_last_turn
|
|
1405
|
+
|
|
1406
|
+
# Keep what the after_turn hooks presented as the answer's `display`
|
|
1407
|
+
# and tell the observers (:answer_display), after the turn_completed
|
|
1408
|
+
# they already had: the web re-reads the answer then. A turn_completed
|
|
1409
|
+
# with display_pending (after_turn hooks were about to run) always gets
|
|
1410
|
+
# one, `display: nil` when nothing was presented, so the web holds the
|
|
1411
|
+
# answer's pop until then and never swaps it after. Terminals are not
|
|
1412
|
+
# sinks of it; they printed the answer already. The session is saved by
|
|
1413
|
+
# the caller, as after every turn.
|
|
1414
|
+
def store_answer_display(session, display, pending: false)
|
|
1415
|
+
synchronize_events do
|
|
1416
|
+
messages = Array(session.messages)
|
|
1417
|
+
if display.changed? && messages.last.equal?(display.target)
|
|
1418
|
+
replace_session_messages(session, messages[0...-1] + [display.target.merge(AnswerDisplay::KEY => display.text)])
|
|
1419
|
+
@session_observer.notify({ type: :answer_display, display: display.text })
|
|
1420
|
+
elsif pending
|
|
1421
|
+
@session_observer.notify({ type: :answer_display, display: nil })
|
|
1422
|
+
end
|
|
1423
|
+
end
|
|
1424
|
+
end
|
|
1425
|
+
private :store_answer_display
|
|
1426
|
+
|
|
1238
1427
|
# ── The hook runtime (Hooks::Runtime) ─────────────────────────────────────
|
|
1239
1428
|
|
|
1240
1429
|
# The three things a hook can do beyond reading its event. Each gets the
|
|
@@ -1243,11 +1432,19 @@ module Samagotchi
|
|
|
1243
1432
|
Hooks::Runtime.new(
|
|
1244
1433
|
notify: ->(text:, level:, hook:) { hook_notify(text, level, hook) },
|
|
1245
1434
|
ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { hook_ask_user(question, options, header, allow_freeform, hook) },
|
|
1246
|
-
stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) }
|
|
1435
|
+
stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) },
|
|
1436
|
+
steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) }
|
|
1247
1437
|
)
|
|
1248
1438
|
end
|
|
1249
1439
|
private :hook_runtime
|
|
1250
1440
|
|
|
1441
|
+
# What a steer is attributed to: the bundle of a bundle hook's label
|
|
1442
|
+
# ("<file> (bundle <name>)"), the label of any other.
|
|
1443
|
+
def hook_source(hook)
|
|
1444
|
+
hook.to_s[/\(bundle (.+)\)\z/, 1] || hook.to_s
|
|
1445
|
+
end
|
|
1446
|
+
private :hook_source
|
|
1447
|
+
|
|
1251
1448
|
# One line to the user (:hook_notice). During a turn it is a turn
|
|
1252
1449
|
# event: the turn's sink (the REPL) and the observers (bridge, log).
|
|
1253
1450
|
# Outside one (a plugin's command at the prompt) it is announced with
|
|
@@ -1321,45 +1518,24 @@ module Samagotchi
|
|
|
1321
1518
|
@question_mutex.synchronize { @pending_question&.dup }
|
|
1322
1519
|
end
|
|
1323
1520
|
|
|
1324
|
-
#
|
|
1325
|
-
#
|
|
1326
|
-
#
|
|
1327
|
-
#
|
|
1521
|
+
# Ask the model's question (ask_user_question): the kernel's
|
|
1522
|
+
# question_handler, called on the turn thread with the payload
|
|
1523
|
+
# Tools::AskUserQuestion.validate made. Opens it (#open_question) and
|
|
1524
|
+
# returns the answer as JSON for the tool result.
|
|
1328
1525
|
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
|
|
1329
1526
|
# @return [String] normalized answer JSON
|
|
1330
1527
|
def request_question(payload)
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
if options.empty? && payload[:options].is_a?(String)
|
|
1337
|
-
options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(payload[:options].to_s)
|
|
1338
|
-
end
|
|
1339
|
-
if question.empty? || options.empty?
|
|
1340
|
-
return JSON.generate({ error: "invalid question", detail: "question and 2-8 options required (got #{options.size})" })
|
|
1341
|
-
end
|
|
1342
|
-
# Dumb-model salvage: allow single option (don't hard error, just render what we have)
|
|
1343
|
-
if options.size == 1
|
|
1344
|
-
# keep as is
|
|
1345
|
-
elsif options.size < 2
|
|
1346
|
-
return JSON.generate({ error: "invalid question", detail: "question and 2-8 options required (got #{options.size})" })
|
|
1347
|
-
end
|
|
1348
|
-
if options.size > 8
|
|
1349
|
-
options = options.first(8)
|
|
1350
|
-
end
|
|
1351
|
-
|
|
1352
|
-
clean_header = strip_wire_tokens(payload[:header])
|
|
1353
|
-
result = open_question(
|
|
1354
|
-
question: question,
|
|
1355
|
-
options: options,
|
|
1356
|
-
header: clean_header.empty? ? nil : clean_header,
|
|
1357
|
-
multi_select: !!payload[:multi_select],
|
|
1358
|
-
allow_freeform: !!payload[:allow_freeform]
|
|
1359
|
-
)
|
|
1528
|
+
result = open_question(**payload.slice(:question, :options, :header),
|
|
1529
|
+
multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform])
|
|
1530
|
+
# Dismissed (the card's dismiss, Esc): an answer of its own, not a
|
|
1531
|
+
# tool failure the model learns to avoid the tool from.
|
|
1532
|
+
result = { dismissed: true, id: result[:id], note: QUESTION_DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
|
|
1360
1533
|
result.is_a?(String) ? result : JSON.generate(result)
|
|
1361
1534
|
end
|
|
1362
1535
|
|
|
1536
|
+
QUESTION_DISMISSED_NOTE = "The user dismissed the question without answering. Go on with your best judgement, " \
|
|
1537
|
+
"or ask in your reply if you can't."
|
|
1538
|
+
|
|
1363
1539
|
# Open a question for the UIs and wait for its answer. Emits
|
|
1364
1540
|
# :question_requested, persists it to the session, and BLOCKS until
|
|
1365
1541
|
# answer_question / cancel_question wakes it (or the turn is cancelled).
|
|
@@ -1379,7 +1555,7 @@ module Samagotchi
|
|
|
1379
1555
|
# Persist to session file for WEB stub + resume (generic for all UIs)
|
|
1380
1556
|
if @session
|
|
1381
1557
|
@session.pending_question = pending.dup
|
|
1382
|
-
begin; @session.save; rescue StandardError; nil; end
|
|
1558
|
+
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1383
1559
|
end
|
|
1384
1560
|
# Generic emit for all UIs (TUI, WEB, Bridge, future). Observers that
|
|
1385
1561
|
# stash this event (e.g. TerminalUI handle_question_event) will
|
|
@@ -1402,7 +1578,7 @@ module Samagotchi
|
|
|
1402
1578
|
@pending_question = nil
|
|
1403
1579
|
if @session
|
|
1404
1580
|
@session.pending_question = nil
|
|
1405
|
-
begin; @session.save; rescue StandardError; nil; end
|
|
1581
|
+
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1406
1582
|
end
|
|
1407
1583
|
emit_event(nil, { type: :question_answered, id: id, answer: ans })
|
|
1408
1584
|
return ans
|
|
@@ -1413,7 +1589,7 @@ module Samagotchi
|
|
|
1413
1589
|
@pending_question = nil
|
|
1414
1590
|
if @session
|
|
1415
1591
|
@session.pending_question = nil
|
|
1416
|
-
begin; @session.save; rescue StandardError; nil; end
|
|
1592
|
+
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1417
1593
|
end
|
|
1418
1594
|
emit_event(nil, { type: :question_answered, id: id, answer: sync_res })
|
|
1419
1595
|
return sync_res
|
|
@@ -1431,7 +1607,7 @@ module Samagotchi
|
|
|
1431
1607
|
@question_mutex.synchronize { @pending_question = nil }
|
|
1432
1608
|
if @session
|
|
1433
1609
|
@session.pending_question = nil
|
|
1434
|
-
begin; @session.save; rescue StandardError; nil; end
|
|
1610
|
+
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1435
1611
|
end
|
|
1436
1612
|
return { error: "no answer", detail: "handler failed to capture selection", id: id }
|
|
1437
1613
|
end
|
|
@@ -1453,7 +1629,7 @@ module Samagotchi
|
|
|
1453
1629
|
@pending_question = nil
|
|
1454
1630
|
if @session
|
|
1455
1631
|
@session.pending_question = nil
|
|
1456
|
-
begin; @session.save; rescue StandardError; nil; end
|
|
1632
|
+
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1457
1633
|
end
|
|
1458
1634
|
emit_event(nil, { type: :question_cancelled, id: id, reason: active_cancel_controller.reason.to_s })
|
|
1459
1635
|
return { error: "cancelled", reason: active_cancel_controller.reason.to_s, id: id }
|
|
@@ -1464,7 +1640,7 @@ module Samagotchi
|
|
|
1464
1640
|
@question_mutex.synchronize { @pending_question = nil }
|
|
1465
1641
|
if @session
|
|
1466
1642
|
@session.pending_question = nil
|
|
1467
|
-
begin; @session.save; rescue StandardError; nil; end
|
|
1643
|
+
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1468
1644
|
end
|
|
1469
1645
|
if answer
|
|
1470
1646
|
emit_event(nil, { type: :question_answered, id: id, answer: answer })
|
|
@@ -1512,14 +1688,12 @@ module Samagotchi
|
|
|
1512
1688
|
@question_answer = answer
|
|
1513
1689
|
@question_cv.broadcast
|
|
1514
1690
|
answer
|
|
1691
|
+
end.tap do
|
|
1692
|
+
# A human answered: the session is back in the lists (ArchiveStore).
|
|
1693
|
+
ArchiveStore.user_input(@session&.id, state_dir: session_state_dir)
|
|
1515
1694
|
end
|
|
1516
1695
|
end
|
|
1517
1696
|
|
|
1518
|
-
def strip_wire_tokens(text)
|
|
1519
|
-
text.to_s.gsub(/<\|[^|]*\|>/, "").gsub(/<\||\|>/, "").strip
|
|
1520
|
-
end
|
|
1521
|
-
private :strip_wire_tokens
|
|
1522
|
-
|
|
1523
1697
|
def set_question_sync_handler(&block)
|
|
1524
1698
|
@question_sync_handler = block
|
|
1525
1699
|
end
|
|
@@ -1555,10 +1729,15 @@ module Samagotchi
|
|
|
1555
1729
|
# prompt prefix, and the server's KV cache for it, stay stable.
|
|
1556
1730
|
# @param target [HostRegistry::ModelTarget, nil]
|
|
1557
1731
|
# @return [String]
|
|
1732
|
+
# The native prompt depends on the thinking level too (Gemma's token,
|
|
1733
|
+
# Qwen's turn preamble): a level change builds it again.
|
|
1558
1734
|
def system_prompt(target = nil)
|
|
1559
|
-
|
|
1735
|
+
target ||= @host_registry.resolve(@effective_model_name)
|
|
1736
|
+
chat = target.entry.chat?
|
|
1737
|
+
level = chat ? nil : thinking_level(target)
|
|
1560
1738
|
@system_prompts ||= {}
|
|
1561
|
-
@system_prompts[chat] ||= system_prompt_with_index(assist_system_prompt(chat: chat
|
|
1739
|
+
@system_prompts[[chat, level]] ||= system_prompt_with_index(assist_system_prompt(chat: chat, thinking: level),
|
|
1740
|
+
chat: chat, thinking: level)
|
|
1562
1741
|
end
|
|
1563
1742
|
|
|
1564
1743
|
# @return [Session] current session (Engine owns create/resume)
|
|
@@ -1586,6 +1765,8 @@ module Samagotchi
|
|
|
1586
1765
|
@session = session
|
|
1587
1766
|
# One session per REPL/worker process: its records carry this sid.
|
|
1588
1767
|
Log.session_id = session.id if session.respond_to?(:id) && session.id
|
|
1768
|
+
# A woken worker's /stats and status line count the turns before it.
|
|
1769
|
+
bind_metrics(session)
|
|
1589
1770
|
sync_used_memories_from_session(session)
|
|
1590
1771
|
end
|
|
1591
1772
|
|
|
@@ -1616,7 +1797,7 @@ module Samagotchi
|
|
|
1616
1797
|
|
|
1617
1798
|
messages = @activity_mutex.synchronize { @session&.messages } || []
|
|
1618
1799
|
covered = state[:covered].to_i
|
|
1619
|
-
turns_since = Array(messages).drop(covered).count { |m|
|
|
1800
|
+
turns_since = Array(messages).drop(covered).count { |m| Steer.prompt?(m) }
|
|
1620
1801
|
{ text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
|
|
1621
1802
|
end
|
|
1622
1803
|
|
|
@@ -1710,9 +1891,12 @@ module Samagotchi
|
|
|
1710
1891
|
# stay unchanged for callers that don't pass it.
|
|
1711
1892
|
with_origin = origin ? ->(event) { event.merge(origin: origin) } : ->(event) { event }
|
|
1712
1893
|
begin
|
|
1894
|
+
# A Stop cuts the /props probes this thread makes for the turn
|
|
1895
|
+
# (window, served model, vision) instead of waiting their timeout.
|
|
1896
|
+
probe_cancel_before = Client.swap_probe_cancel(effective_controller)
|
|
1713
1897
|
image_refs, image_error = turn_image_refs(session, continue ? [] : images)
|
|
1714
1898
|
# Emit turn_started event
|
|
1715
|
-
|
|
1899
|
+
bind_metrics(session)
|
|
1716
1900
|
turn_started = { type: :turn_started, session_id: session.id, prompt: prompt }
|
|
1717
1901
|
turn_started[:continue] = true if continue
|
|
1718
1902
|
turn_started[:images] = image_refs unless image_refs.empty?
|
|
@@ -1722,6 +1906,11 @@ module Samagotchi
|
|
|
1722
1906
|
# Before anything of the turn is kept or a reminder is used up.
|
|
1723
1907
|
vision = turn_vision(session)
|
|
1724
1908
|
@kernel.vision = vision if @kernel.respond_to?(:vision=)
|
|
1909
|
+
@kernel.sampling = turn_sampling if @kernel.respond_to?(:sampling=)
|
|
1910
|
+
thinking_target = @host_registry.resolve(@effective_model_name)
|
|
1911
|
+
@turn_thinking = [thinking_level(thinking_target), thinking_target]
|
|
1912
|
+
@kernel.thinking = @turn_thinking.first if @kernel.respond_to?(:thinking=)
|
|
1913
|
+
announce_thinking_level(*@turn_thinking)
|
|
1725
1914
|
refuse_images!(vision) unless image_refs.empty?
|
|
1726
1915
|
announce_guardrail_failures(on_event)
|
|
1727
1916
|
# Plugins' slow setup that brings tools (an MCP server's first
|
|
@@ -1785,7 +1974,7 @@ module Samagotchi
|
|
|
1785
1974
|
cancel_controller: effective_controller,
|
|
1786
1975
|
model_name: bare_for_backend,
|
|
1787
1976
|
max_tool_output_chars: max_tool_output_chars,
|
|
1788
|
-
pending_input: pending_input
|
|
1977
|
+
pending_input: turn_drain(pending_input)
|
|
1789
1978
|
)
|
|
1790
1979
|
|
|
1791
1980
|
# Persist deduped used memories onto the session for Web + reload.
|
|
@@ -1818,24 +2007,31 @@ module Samagotchi
|
|
|
1818
2007
|
# placeholder text) is a turn the model should know ended that way.
|
|
1819
2008
|
empty = !canceled && !resumable &&
|
|
1820
2009
|
(response.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?))
|
|
2010
|
+
# after_turn hooks run below and may present the answer (the web
|
|
2011
|
+
# holds its pop until it knows).
|
|
2012
|
+
display_pending = !canceled && @hooks.any?(:after_turn)
|
|
1821
2013
|
synchronize_events do
|
|
1822
2014
|
if empty
|
|
1823
2015
|
# The placeholder is for the UIs (a new array: it must not leak
|
|
1824
2016
|
# into the result); the note is for the model, so it goes on the
|
|
1825
2017
|
# result's conversation too, which the REPL keeps as-is.
|
|
2018
|
+
# A retry's nudge at the tail goes: this note says it all.
|
|
1826
2019
|
note = TurnNote.empty
|
|
1827
|
-
|
|
2020
|
+
conversation&.replace(TurnNote.without_trailing(conversation))
|
|
2021
|
+
saved = TurnNote.without_trailing(conversation || session.messages)
|
|
1828
2022
|
saved << { role: "model", content: "[No response]" } if response.strip.empty?
|
|
1829
2023
|
replace_session_messages(session, saved + [note])
|
|
1830
2024
|
conversation << note if conversation
|
|
1831
2025
|
elsif canceled && conversation
|
|
1832
2026
|
conversation << TurnNote.cancelled(result.cancellation_reason, seconds: turn_seconds.call,
|
|
1833
|
-
shown: TurnNote.interrupted_tail?(conversation)
|
|
2027
|
+
shown: TurnNote.interrupted_tail?(conversation),
|
|
2028
|
+
running_tasks: Tools::TaskRuntime.running_created_in(conversation))
|
|
1834
2029
|
replace_session_messages(session, conversation)
|
|
1835
2030
|
elsif conversation
|
|
1836
2031
|
replace_session_messages(session, conversation)
|
|
1837
2032
|
end
|
|
1838
2033
|
session.status = Session::STATUS_IDLE
|
|
2034
|
+
record_last_turn(session, canceled ? "canceled" : "completed", turn_seconds.call, origin)
|
|
1839
2035
|
if canceled
|
|
1840
2036
|
emit_event(on_event, with_origin.call({
|
|
1841
2037
|
type: :turn_canceled,
|
|
@@ -1847,16 +2043,22 @@ module Samagotchi
|
|
|
1847
2043
|
emit_event(on_event, with_origin.call({
|
|
1848
2044
|
type: :turn_completed,
|
|
1849
2045
|
result: result,
|
|
1850
|
-
turn_summary: turn_summary(result)
|
|
2046
|
+
turn_summary: turn_summary(result),
|
|
2047
|
+
display_pending: display_pending
|
|
1851
2048
|
}))
|
|
1852
2049
|
end
|
|
1853
2050
|
end
|
|
1854
|
-
@metrics.persist
|
|
2051
|
+
@metrics.persist(state_dir: session_state_dir)
|
|
1855
2052
|
|
|
1856
2053
|
# Fire :after_turn hook (runs even on cancel/success), with a read-only
|
|
1857
|
-
# copy of the conversation the turn stored
|
|
1858
|
-
|
|
1859
|
-
|
|
2054
|
+
# copy of the conversation the turn stored, and event[:present] for
|
|
2055
|
+
# a display version of the answer (AnswerDisplay).
|
|
2056
|
+
display = AnswerDisplay.new(session.messages)
|
|
2057
|
+
after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
|
|
2058
|
+
messages: hook_messages(session.messages) }
|
|
2059
|
+
after_turn[:present] = display.presenter(after_turn)
|
|
2060
|
+
@hooks.fire(:after_turn, after_turn)
|
|
2061
|
+
store_answer_display(session, display, pending: display_pending)
|
|
1860
2062
|
|
|
1861
2063
|
# Fire :session_end after every turn (turn-level lifecycle)
|
|
1862
2064
|
@hooks.fire(:session_end, { type: :session_end, session_id: session.id })
|
|
@@ -1865,11 +2067,18 @@ module Samagotchi
|
|
|
1865
2067
|
rescue Interrupt
|
|
1866
2068
|
effective_controller.cancel!(:ctrl_c)
|
|
1867
2069
|
synchronize_events do
|
|
1868
|
-
|
|
2070
|
+
# Only the pre-turn messages survive here, so tasks this turn
|
|
2071
|
+
# started are missed (plan wait-stop §6).
|
|
2072
|
+
if messages
|
|
2073
|
+
note = TurnNote.cancelled(:ctrl_c, seconds: turn_seconds.call,
|
|
2074
|
+
running_tasks: Tools::TaskRuntime.running_created_in(messages))
|
|
2075
|
+
replace_session_messages(session, TurnNote.replace_trailing(messages, note))
|
|
2076
|
+
end
|
|
1869
2077
|
session.status = Session::STATUS_IDLE
|
|
2078
|
+
record_last_turn(session, "canceled", turn_seconds.call, origin)
|
|
1870
2079
|
emit_event(on_event, with_origin.call({ type: :turn_canceled, cancellation_reason: :ctrl_c }))
|
|
1871
2080
|
end
|
|
1872
|
-
@metrics.persist
|
|
2081
|
+
@metrics.persist(state_dir: session_state_dir)
|
|
1873
2082
|
raise
|
|
1874
2083
|
rescue StandardError => e
|
|
1875
2084
|
# Keep what the turn got to (the prompt plus the loop's completed
|
|
@@ -1885,14 +2094,15 @@ module Samagotchi
|
|
|
1885
2094
|
end
|
|
1886
2095
|
replace_session_messages(session, kept) if kept
|
|
1887
2096
|
session.status = Session::STATUS_IDLE
|
|
1888
|
-
|
|
2097
|
+
record_last_turn(session, "failed", turn_seconds.call, origin)
|
|
2098
|
+
begin; session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1889
2099
|
failed = { type: :turn_failed, error_class: e.class.name, message: e.message }
|
|
1890
2100
|
# A provider error says what kind it is, for one line per kind in the UIs.
|
|
1891
2101
|
if e.is_a?(LLM::ProviderError)
|
|
1892
2102
|
failed.merge!(error_kind: e.kind, retryable: e.retryable?, host: e.host, summary: e.summary)
|
|
1893
2103
|
end
|
|
1894
2104
|
emit_event(on_event, with_origin.call(failed))
|
|
1895
|
-
@metrics.persist
|
|
2105
|
+
@metrics.persist(state_dir: session_state_dir)
|
|
1896
2106
|
raise
|
|
1897
2107
|
ensure
|
|
1898
2108
|
# A completed turn is activity: release the turn flag and advance the
|
|
@@ -1900,10 +2110,13 @@ module Samagotchi
|
|
|
1900
2110
|
# treats the just-finished turn as activity and re-arms its window.
|
|
1901
2111
|
# Always runs, even if an exception occurred.
|
|
1902
2112
|
set_turn_running(false)
|
|
1903
|
-
|
|
2113
|
+
Client.swap_probe_cancel(probe_cancel_before)
|
|
2114
|
+
left = @activity_mutex.synchronize do
|
|
1904
2115
|
@active_cancel_controller = nil
|
|
1905
2116
|
@turn_event_sink = nil
|
|
2117
|
+
@steers.tap { @steers = [] }
|
|
1906
2118
|
end
|
|
2119
|
+
log_dropped_steers(left, "turn_ended")
|
|
1907
2120
|
record_activity
|
|
1908
2121
|
# Clear hooks so they remain turn-scoped and never leak into the next turn.
|
|
1909
2122
|
clear_hooks
|
|
@@ -1925,7 +2138,84 @@ module Samagotchi
|
|
|
1925
2138
|
def turn_vision(session)
|
|
1926
2139
|
target = @host_registry.resolve(@effective_model_name)
|
|
1927
2140
|
VisionContext.new(session_dir: Session.session_dir(session.id, state_dir: session_state_dir),
|
|
1928
|
-
capability:
|
|
2141
|
+
capability: lambda {
|
|
2142
|
+
VisionSupport.for(target, profile: profile, adapter: vision_adapter(target), names: model_lookup_names(target))
|
|
2143
|
+
})
|
|
2144
|
+
end
|
|
2145
|
+
|
|
2146
|
+
# The effective model's request parameters (hosts: and models:
|
|
2147
|
+
# sampling), read per turn: /model can change the model between turns.
|
|
2148
|
+
def turn_sampling
|
|
2149
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
2150
|
+
SamplingSettings.for(target, names: model_lookup_names(target))
|
|
2151
|
+
rescue StandardError
|
|
2152
|
+
SamplingSettings::EMPTY
|
|
2153
|
+
end
|
|
2154
|
+
|
|
2155
|
+
# The effective model's thinking level (Thinking.resolve), read each turn.
|
|
2156
|
+
def turn_thinking
|
|
2157
|
+
thinking_level(@host_registry.resolve(@effective_model_name))
|
|
2158
|
+
rescue StandardError
|
|
2159
|
+
Thinking::DEFAULT
|
|
2160
|
+
end
|
|
2161
|
+
|
|
2162
|
+
# An effort on a native host, which has no knob for it: said once per
|
|
2163
|
+
# session and host.
|
|
2164
|
+
def announce_thinking_level(level, target)
|
|
2165
|
+
return if target.entry.chat? || Thinking.native(level, profile).honoured
|
|
2166
|
+
|
|
2167
|
+
thinking_notice_once(:unsupported, target, :info,
|
|
2168
|
+
"#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
|
|
2169
|
+
"thinking stays as the model has it")
|
|
2170
|
+
rescue StandardError
|
|
2171
|
+
nil
|
|
2172
|
+
end
|
|
2173
|
+
|
|
2174
|
+
# Thinking off, and the model thought anyway: logged each time, said
|
|
2175
|
+
# once per session and host.
|
|
2176
|
+
def check_thinking_honoured(event)
|
|
2177
|
+
level, target = @turn_thinking
|
|
2178
|
+
chars = event[:thinking_chars].to_i
|
|
2179
|
+
return unless level == :off && chars.positive? && target
|
|
2180
|
+
|
|
2181
|
+
Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
|
|
2182
|
+
thinking_notice_once(:not_honoured, target, :warn,
|
|
2183
|
+
"off wasn't honoured by #{target.bare_model} on #{target.entry.name} " \
|
|
2184
|
+
"(#{chars} chars of thinking); a sampling: override on the host or model may turn it off " \
|
|
2185
|
+
"(see Thinking in docs/configuration.md)")
|
|
2186
|
+
rescue StandardError
|
|
2187
|
+
nil
|
|
2188
|
+
end
|
|
2189
|
+
|
|
2190
|
+
# The host refused the level's request fields (gpt-oss can't turn
|
|
2191
|
+
# thinking off) and the chat loop sent the request without them: said
|
|
2192
|
+
# once per session and host, standing in for the not-honoured notice.
|
|
2193
|
+
def thinking_refused(event)
|
|
2194
|
+
_level, target = @turn_thinking
|
|
2195
|
+
return unless target
|
|
2196
|
+
|
|
2197
|
+
Log.warn(:model, "thinking_refused", level: event[:level], host: target.entry.name, model: event[:model],
|
|
2198
|
+
detail: event[:detail])
|
|
2199
|
+
(@thinking_notices ||= Set.new) << [@session&.id, target.entry.name, :not_honoured]
|
|
2200
|
+
thinking_notice_once(:refused, target, :warn,
|
|
2201
|
+
"#{target.entry.name} refused thinking: #{event[:level]} for #{event[:model]} (#{event[:detail]}); " \
|
|
2202
|
+
"sent without it, so thinking stays as the model has it")
|
|
2203
|
+
rescue StandardError
|
|
2204
|
+
nil
|
|
2205
|
+
end
|
|
2206
|
+
|
|
2207
|
+
def thinking_notice_once(kind, target, level, text)
|
|
2208
|
+
key = [@session&.id, target.entry.name, kind]
|
|
2209
|
+
return unless (@thinking_notices ||= Set.new).add?(key)
|
|
2210
|
+
|
|
2211
|
+
hook_notify(text, level, "thinking")
|
|
2212
|
+
end
|
|
2213
|
+
|
|
2214
|
+
# +target+'s thinking level (Thinking.resolve).
|
|
2215
|
+
def thinking_level(target)
|
|
2216
|
+
Thinking.resolve(target, names: model_lookup_names(target)).first
|
|
2217
|
+
rescue StandardError
|
|
2218
|
+
Thinking::DEFAULT
|
|
1929
2219
|
end
|
|
1930
2220
|
|
|
1931
2221
|
def vision_adapter(target)
|
|
@@ -1987,29 +2277,6 @@ module Samagotchi
|
|
|
1987
2277
|
replace_session_messages(@session, clone_messages(checkpoint))
|
|
1988
2278
|
end
|
|
1989
2279
|
|
|
1990
|
-
# Backward-compatible: runs a prompt through the kernel loop without event forwarding.
|
|
1991
|
-
# @param session [Session]
|
|
1992
|
-
# @param prompt [String]
|
|
1993
|
-
# @return [String] model response text
|
|
1994
|
-
def process_prompt_through_kernel(session, prompt)
|
|
1995
|
-
result = run_turn(session, prompt)
|
|
1996
|
-
response = result.respond_to?(:text) ? result.text.to_s : result.to_s
|
|
1997
|
-
if response.strip.empty?
|
|
1998
|
-
session.messages << { role: "model", content: "[No response]" }
|
|
1999
|
-
"[No response]"
|
|
2000
|
-
else
|
|
2001
|
-
response
|
|
2002
|
-
end
|
|
2003
|
-
end
|
|
2004
|
-
|
|
2005
|
-
# Public entrypoint for background session workers.
|
|
2006
|
-
# @param session [Session]
|
|
2007
|
-
# @param prompt [String]
|
|
2008
|
-
# @return [String] model response
|
|
2009
|
-
def process_background_prompt(session:, prompt:)
|
|
2010
|
-
process_prompt_through_kernel(session, prompt)
|
|
2011
|
-
end
|
|
2012
|
-
|
|
2013
2280
|
# Clone a messages array (shallow dup of each element).
|
|
2014
2281
|
# @param messages [Array<Hash>]
|
|
2015
2282
|
# @return [Array<Hash>]
|
|
@@ -2050,6 +2317,12 @@ module Samagotchi
|
|
|
2050
2317
|
require_relative "memory_bundle/provenance"
|
|
2051
2318
|
settings = bundle_settings
|
|
2052
2319
|
MemoryBundle::Provenance.each_installed_holding_hooks do |bundle_name, data|
|
|
2320
|
+
if data[:error]
|
|
2321
|
+
Log.warn(:hooks, "bundle_manifest_invalid", echo: "[samagotchi:hooks] bundle '#{bundle_name}': #{data[:error]}; its hooks are not loaded",
|
|
2322
|
+
bundle: bundle_name)
|
|
2323
|
+
@guardrail_failures.add("hooks (bundle #{bundle_name})", data[:error], required: false)
|
|
2324
|
+
next
|
|
2325
|
+
end
|
|
2053
2326
|
bundle_dir = File.join(MemoryBundle::Provenance.bundles_dir, bundle_name)
|
|
2054
2327
|
hooks_dir = File.join(bundle_dir, "hooks")
|
|
2055
2328
|
if (data[:trust_level] || "experimental").to_s == "experimental"
|
|
@@ -2161,11 +2434,14 @@ module Samagotchi
|
|
|
2161
2434
|
(task = current_init_task) ? task.cancelled? : @activity_mutex && active_cancel_controller&.cancelled?
|
|
2162
2435
|
},
|
|
2163
2436
|
card: ->(**card) { show_card(**card) },
|
|
2437
|
+
steer: ->(text, source) { steer(text, source: source) },
|
|
2438
|
+
stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
|
|
2164
2439
|
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
|
|
2165
2440
|
ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
|
|
2166
2441
|
},
|
|
2167
2442
|
model_name: -> { @session&.model_name || @effective_model_name },
|
|
2168
|
-
state_dir: -> { session_state_dir }
|
|
2443
|
+
state_dir: -> { session_state_dir },
|
|
2444
|
+
scratch: -> { @scratch }
|
|
2169
2445
|
)
|
|
2170
2446
|
end
|
|
2171
2447
|
|
|
@@ -2175,7 +2451,7 @@ module Samagotchi
|
|
|
2175
2451
|
# with the event log held, so a turn is in exactly one of the two.
|
|
2176
2452
|
def plugin_messages
|
|
2177
2453
|
synchronize_events do
|
|
2178
|
-
messages = messages_checkpoint
|
|
2454
|
+
messages = AnswerDisplay.strip_all(messages_checkpoint)
|
|
2179
2455
|
first = messages.first
|
|
2180
2456
|
messages = messages.drop(1) if first && first[:role].to_s == "system" && first[:kind].to_s.empty?
|
|
2181
2457
|
running = turn_running? && @running_turn_messages ? Array(@running_turn_messages.call) : []
|
|
@@ -2394,13 +2670,18 @@ module Samagotchi
|
|
|
2394
2670
|
event = event.merge(text: delta[:text], thinking: delta[:thinking]) if enrich == :always
|
|
2395
2671
|
end
|
|
2396
2672
|
end
|
|
2673
|
+
# The chat loop asked again without the thinking fields: a notice,
|
|
2674
|
+
# not an event of its own.
|
|
2675
|
+
next thinking_refused(event) if event[:type] == :thinking_refused
|
|
2676
|
+
|
|
2397
2677
|
emit_event(on_event, event)
|
|
2678
|
+
check_thinking_honoured(event) if event[:type] == :generation_completed
|
|
2398
2679
|
end
|
|
2399
2680
|
end
|
|
2400
2681
|
|
|
2401
2682
|
def emit_event(on_event, event)
|
|
2402
2683
|
# Capture used memories synchronously in the turn thread.
|
|
2403
|
-
begin
|
|
2684
|
+
read_names = begin
|
|
2404
2685
|
capture_used_memory_from_event(event)
|
|
2405
2686
|
rescue StandardError
|
|
2406
2687
|
nil
|
|
@@ -2419,6 +2700,11 @@ module Samagotchi
|
|
|
2419
2700
|
# Persistent subscribers: receive a copy with a locally-monotonic
|
|
2420
2701
|
# `event_seq`, fan out with per-subscriber error isolation.
|
|
2421
2702
|
@session_observer.notify(event)
|
|
2703
|
+
# Every memory read, after its tool_call_started: the whole list for
|
|
2704
|
+
# the UIs' memory line, and the names this call read (read_names).
|
|
2705
|
+
return unless read_names
|
|
2706
|
+
|
|
2707
|
+
emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names, read_names: read_names })
|
|
2422
2708
|
end
|
|
2423
2709
|
|
|
2424
2710
|
# The window as the target's loop would see it (see ChatLoop#context_window:
|
|
@@ -2462,10 +2748,14 @@ module Samagotchi
|
|
|
2462
2748
|
end
|
|
2463
2749
|
|
|
2464
2750
|
target = @host_registry.resolve(@effective_model_name)
|
|
2465
|
-
|
|
2751
|
+
ModelProfile.resolve(names: model_lookup_names(target), entry: target.entry, client: target.client, bare_model: target.bare_model)
|
|
2752
|
+
end
|
|
2753
|
+
|
|
2754
|
+
# The names a models: entry may be under: as typed (maybe an alias), the
|
|
2755
|
+
# part after a host prefix, alias-resolved, bare.
|
|
2756
|
+
def model_lookup_names(target)
|
|
2466
2757
|
typed = @model_lookup_names.first
|
|
2467
|
-
|
|
2468
|
-
ModelProfile.resolve(names: names.compact, entry: target.entry, client: target.client, bare_model: target.bare_model)
|
|
2758
|
+
(@model_lookup_names + [@host_registry.parse_qualified_model(typed).last, target.bare_model]).compact
|
|
2469
2759
|
end
|
|
2470
2760
|
|
|
2471
2761
|
# Everything that holds a profile follows the resolution: the kernel's
|
|
@@ -2510,9 +2800,11 @@ module Samagotchi
|
|
|
2510
2800
|
|
|
2511
2801
|
# Only Qwen has an explicit thinking-close marker, so only Qwen can
|
|
2512
2802
|
# reliably have this preamble parsed back out of its thinking block.
|
|
2513
|
-
|
|
2803
|
+
# With thinking off there is no thinking to begin with it.
|
|
2804
|
+
def turn_preamble_instruction(thinking = nil)
|
|
2514
2805
|
return "" unless profile.name == "qwen36"
|
|
2515
2806
|
return "" if Samagotchi::Config.get("thinking.turn_preamble") == false
|
|
2807
|
+
return "" if (thinking || turn_thinking) == :off
|
|
2516
2808
|
|
|
2517
2809
|
"\nTurn preamble: as the very first line of your thinking, write \"TURN: \" followed by a short present-tense action phrase (max 8 words) describing what you are about to do, e.g. \"TURN: reading project config\". Then continue reasoning normally.\n"
|
|
2518
2810
|
end
|
|
@@ -2521,12 +2813,13 @@ module Samagotchi
|
|
|
2521
2813
|
|
|
2522
2814
|
# @param chat [Boolean] for the chat loop: no tool declarations, call
|
|
2523
2815
|
# syntax or turn preamble (its tools go as schemas with each request)
|
|
2524
|
-
|
|
2816
|
+
# @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
|
|
2817
|
+
def assist_system_prompt(chat: false, thinking: nil)
|
|
2525
2818
|
return chat_system_prompt if chat
|
|
2526
2819
|
|
|
2527
2820
|
declarations = tool_declarations
|
|
2528
2821
|
hint = tool_call_hint
|
|
2529
|
-
turn_preamble = turn_preamble_instruction
|
|
2822
|
+
turn_preamble = turn_preamble_instruction(thinking)
|
|
2530
2823
|
|
|
2531
2824
|
<<~SYS
|
|
2532
2825
|
You are Chi (pronounced "chee"), the friendly name for the Samagotchi assistant harness. You have access to the following tools:
|
|
@@ -2606,15 +2899,12 @@ module Samagotchi
|
|
|
2606
2899
|
|
|
2607
2900
|
# @param chat [Boolean] no Gemma thinking token (the chat API's template
|
|
2608
2901
|
# decides about thinking)
|
|
2609
|
-
|
|
2902
|
+
# @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
|
|
2903
|
+
def system_prompt_with_index(base, chat: false, thinking: nil)
|
|
2610
2904
|
project_index = read_memory_index("project")
|
|
2611
2905
|
system_index = read_memory_index("system")
|
|
2612
2906
|
project_description = project_specific_description
|
|
2613
|
-
thinking_token =
|
|
2614
|
-
"<|think|>\n"
|
|
2615
|
-
else
|
|
2616
|
-
""
|
|
2617
|
-
end
|
|
2907
|
+
thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
|
|
2618
2908
|
memory_sections = [
|
|
2619
2909
|
"Project memories:\n#{project_index}",
|
|
2620
2910
|
"System memories:\n#{system_index}"
|
|
@@ -2661,6 +2951,8 @@ module Samagotchi
|
|
|
2661
2951
|
end
|
|
2662
2952
|
|
|
2663
2953
|
merged = Array(baseline).dup
|
|
2954
|
+
# For the warning when one can't be loaded: it names where it came from.
|
|
2955
|
+
@config_memories = merged.dup
|
|
2664
2956
|
Array(cli_memories).each do |raw|
|
|
2665
2957
|
raw.to_s.split(",").map(&:strip).reject(&:empty?).each do |name|
|
|
2666
2958
|
merged << name unless merged.include?(name)
|
|
@@ -2693,7 +2985,8 @@ module Samagotchi
|
|
|
2693
2985
|
scope, actual_name = split_memory_scope(name)
|
|
2694
2986
|
body = Tools::MemoryRead.call(actual_name, scope: scope)
|
|
2695
2987
|
if body.start_with?("Error:")
|
|
2696
|
-
|
|
2988
|
+
source = Array(@config_memories).include?(raw) ? "memory '#{name}' (from config memories:)" : "--memory '#{name}'"
|
|
2989
|
+
Log.warn(:memory, "preload_failed", echo: "Warning: #{source} could not be loaded (#{body})", memory: name)
|
|
2697
2990
|
next
|
|
2698
2991
|
end
|
|
2699
2992
|
# Record activated names so the UI can echo them in the sticky
|
|
@@ -2716,8 +3009,9 @@ module Samagotchi
|
|
|
2716
3009
|
@activated_memory_names ||= []
|
|
2717
3010
|
end
|
|
2718
3011
|
|
|
2719
|
-
#
|
|
2720
|
-
|
|
3012
|
+
# The base prompt (specs, plugins' declarations) and the --memory names
|
|
3013
|
+
# the TerminalUI mirrors into its status line.
|
|
3014
|
+
public :assist_system_prompt, :activated_memory_names
|
|
2721
3015
|
|
|
2722
3016
|
def split_memory_scope(raw)
|
|
2723
3017
|
value = raw.to_s.strip
|