samagotchi 0.12.0 → 0.14.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 +116 -1
- data/README.md +20 -7
- data/bin/chi +9 -9
- data/docs/architecture.md +4 -2
- data/docs/cli.md +34 -10
- data/docs/configuration.md +79 -15
- data/docs/desktop.md +10 -6
- data/docs/guardrails.md +74 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +31 -16
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge/card_store.rb +94 -6
- data/lib/samagotchi/bridge.rb +14 -2
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +81 -24
- data/lib/samagotchi/context_window.rb +30 -5
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +54 -17
- data/lib/samagotchi/guardrails/parent_approvals.rb +158 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +31 -7
- data/lib/samagotchi/kernel_loop.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -3
- data/lib/samagotchi/llm/openai_chat.rb +9 -1
- data/lib/samagotchi/llm/turn_settings.rb +7 -5
- data/lib/samagotchi/memory_bundle/installer.rb +12 -0
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_profile.rb +30 -16
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/send_command.rb +50 -75
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_manager.rb +40 -7
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +11 -3
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +53 -14
- data/lib/samagotchi/terminal_ui.rb +30 -3
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +14 -2
- data/lib/samagotchi/tools/delegate_wait.rb +32 -13
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/memory.rb +3 -3
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/utf8_default.rb +29 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -10
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +6 -1
- data/lib/samagotchi/web/public/app.js +39 -6
- data/lib/samagotchi/web/public/index.html +8 -1
- data/lib/samagotchi/web/public/notify.js +16 -3
- data/lib/samagotchi/web/public/question_cards.js +3 -1
- data/lib/samagotchi/web/public/sessions_list.js +37 -0
- data/lib/samagotchi/web/public/turn_view.js +6 -9
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +3 -11
- metadata +8 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../paths"
|
|
5
|
+
require_relative "../hooks/loader"
|
|
6
|
+
require_relative "approval"
|
|
7
|
+
require_relative "protected_paths"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module Guardrails
|
|
11
|
+
# What a parent agent may allow on an approval (guardrails.parent_approvals,
|
|
12
|
+
# config.yml only): off, a deny only; once, "Allow once" too, never a
|
|
13
|
+
# wider scope. `chi answer` checks it before it posts; the worker checks
|
|
14
|
+
# it again for an answer marked as chi answer's (CLIENT_ID), with its own
|
|
15
|
+
# config. A convention for an honest but eager parent, not a security
|
|
16
|
+
# boundary: any local process can post to the Bridge, with or without
|
|
17
|
+
# the marker.
|
|
18
|
+
#
|
|
19
|
+
# An option allows when Approval.settle would allow it: its index is
|
|
20
|
+
# below the approval's scopes. The scope is read by index, never by
|
|
21
|
+
# label. When the scopes are missing or don't fit the options, it fails
|
|
22
|
+
# closed: only a last option labelled Deny denies.
|
|
23
|
+
#
|
|
24
|
+
# Whatever the setting, an approval for a call on chi's own config dir
|
|
25
|
+
# (config.yml, memories/.bundles' rules), the hooks dir or the approval
|
|
26
|
+
# store is the user's alone (:protected): "Allow once" on a config.yml
|
|
27
|
+
# rewrite would be "allow everything" from then on.
|
|
28
|
+
module ParentApprovals
|
|
29
|
+
KEY = "guardrails.parent_approvals"
|
|
30
|
+
# The client id chi answer posts its answers with.
|
|
31
|
+
CLIENT_ID = "cli:answer"
|
|
32
|
+
# Set by chi's execute and task_create for the commands they run: a
|
|
33
|
+
# chi started there answers as a parent (#parent_process?).
|
|
34
|
+
PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
|
|
35
|
+
# Environment variables that say an agent runs this process: Claude
|
|
36
|
+
# Code's, the AI_AGENT convention, Codex CLI's (CODEX_THREAD_ID, which
|
|
37
|
+
# Codex sets on every command its shell tool runs), and chi's own.
|
|
38
|
+
# They survive a PTY wrapper (script, expect). The docs list them too
|
|
39
|
+
# (guardrails.md, sub-agent.md).
|
|
40
|
+
AGENT_MARKERS = ["CLAUDECODE", "AI_AGENT", "CODEX_THREAD_ID", PARENT_SESSION_ENV].freeze
|
|
41
|
+
# Rules that ask about chi's own config and hooks: the core
|
|
42
|
+
# ProtectedPaths asks and the guardrails bundle's shell rule.
|
|
43
|
+
PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi].freeze
|
|
44
|
+
# A shell command naming chi's config, hooks or guardrails (the
|
|
45
|
+
# guardrails bundle's shell-touches-chi, less .git/hooks).
|
|
46
|
+
CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles}
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# Whether an answer typed into this process is a parent agent's (so
|
|
51
|
+
# this setting applies to it): stdin isn't a terminal (a pipe, a file,
|
|
52
|
+
# /dev/null), or an agent marker is set. A person at chi --attach or
|
|
53
|
+
# the REPL has a terminal and no marker.
|
|
54
|
+
def parent_process?(env: ENV, stdin: $stdin)
|
|
55
|
+
return true if AGENT_MARKERS.any? { |key| !env.fetch(key, "").to_s.strip.empty? }
|
|
56
|
+
|
|
57
|
+
!(stdin.respond_to?(:tty?) && stdin.tty?)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# This process's setting: "once" or "off" (anything else is off).
|
|
61
|
+
def setting
|
|
62
|
+
Config.get(KEY).to_s == "once" ? "once" : "off"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Why a parent may not give this answer, or nil.
|
|
66
|
+
# @param pending [Hash] the pending question (symbol or string keys)
|
|
67
|
+
# @param indices [Array<Integer, nil>] the selected options' places
|
|
68
|
+
# @param setting [String, nil] "off" or "once"
|
|
69
|
+
# @return [Symbol, nil] :protected (chi's own config: the user's
|
|
70
|
+
# alone), :off (no allow at all), :once_only, or nil
|
|
71
|
+
def refusal(pending, indices, setting:)
|
|
72
|
+
return nil unless approval?(pending)
|
|
73
|
+
|
|
74
|
+
scopes = scopes(pending)
|
|
75
|
+
allows = Array(indices).reject { |index| deny?(pending, scopes, index) }
|
|
76
|
+
return nil if allows.empty?
|
|
77
|
+
return :protected if protected?(pending)
|
|
78
|
+
return :off unless setting.to_s == "once"
|
|
79
|
+
return nil if scopes && allows.all? { |index| index.is_a?(Integer) && index >= 0 && scopes[index] == "once" }
|
|
80
|
+
|
|
81
|
+
:once_only
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Whether +pending+ asks about a call on chi's own config, hooks,
|
|
85
|
+
# guardrail rules or approvals: by its rule, its paths or its command
|
|
86
|
+
# (the dirs as this process's environment and config.yml name them).
|
|
87
|
+
def protected?(pending)
|
|
88
|
+
facts = fetch(pending, :approval)
|
|
89
|
+
return false unless facts.is_a?(Hash)
|
|
90
|
+
return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
|
|
91
|
+
return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
|
|
92
|
+
|
|
93
|
+
dirs = chi_dirs
|
|
94
|
+
paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
|
|
95
|
+
return true if paths.any? { |path| dirs.any? { |dir| ProtectedPaths.within?(ProtectedPaths.real(path), dir) } }
|
|
96
|
+
|
|
97
|
+
command = fetch(facts, :command).to_s
|
|
98
|
+
return false if command.empty?
|
|
99
|
+
|
|
100
|
+
command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(ProtectedPaths.real(dir)) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# chi's config dir, the hooks dir and the approval store's dir.
|
|
104
|
+
def chi_dirs
|
|
105
|
+
hooks = begin Hooks::Loader.hooks_dir rescue nil end
|
|
106
|
+
[ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails")]
|
|
107
|
+
.compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The refusal as a line for the parent: deny, and tell the user.
|
|
111
|
+
# Never how to allow it (the web, chi --attach): that is the user's.
|
|
112
|
+
# @param reason [Symbol] what #refusal returned
|
|
113
|
+
# @param typed [Boolean] the answer was typed at a ? prompt (the
|
|
114
|
+
# REPL), not given to chi answer
|
|
115
|
+
def message(reason, typed: false)
|
|
116
|
+
deny = typed ? "deny it (n; WHY), and tell your user" : "deny it (--option Deny --text WHY), and tell your user"
|
|
117
|
+
case reason
|
|
118
|
+
when :protected then "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{deny}"
|
|
119
|
+
when :off then "allowing a tool call is up to the user: #{deny}"
|
|
120
|
+
else "only Allow once (#{KEY}: once) can be given here: #{deny}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# An approval: its kind says so, or it carries approval facts.
|
|
125
|
+
def approval?(pending)
|
|
126
|
+
fetch(pending, :kind).to_s == Approval::KIND || !fetch(pending, :approval).nil?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The offered scopes, in option order, or nil when they are missing,
|
|
130
|
+
# empty, not strings, or leave no option after them for Deny.
|
|
131
|
+
def scopes(pending)
|
|
132
|
+
facts = fetch(pending, :approval)
|
|
133
|
+
scopes = facts.is_a?(Hash) ? fetch(facts, :scopes) : nil
|
|
134
|
+
return nil unless scopes.is_a?(Array) && !scopes.empty? && scopes.all?(String)
|
|
135
|
+
return nil unless scopes.size < options(pending).size
|
|
136
|
+
|
|
137
|
+
scopes
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def deny?(pending, scopes, index)
|
|
141
|
+
return false unless index.is_a?(Integer) && index >= 0
|
|
142
|
+
|
|
143
|
+
options = options(pending)
|
|
144
|
+
return index >= scopes.size && index < options.size if scopes
|
|
145
|
+
|
|
146
|
+
index == options.size - 1 && options[index].to_s == Approval::DENY
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def options(pending) = Array(fetch(pending, :options))
|
|
150
|
+
|
|
151
|
+
def fetch(hash, key)
|
|
152
|
+
return nil unless hash.is_a?(Hash)
|
|
153
|
+
|
|
154
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -4,6 +4,8 @@ require_relative "registry"
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require_relative "../log"
|
|
6
6
|
require_relative "../memory_bundle/provenance"
|
|
7
|
+
require_relative "../memory_bundle/manifest"
|
|
8
|
+
require_relative "../version"
|
|
7
9
|
|
|
8
10
|
module Samagotchi
|
|
9
11
|
# Namespace that wraps hook plugin classes per-bundle. Each bundle's hooks are
|
|
@@ -28,7 +30,8 @@ module Samagotchi
|
|
|
28
30
|
# fails to load, or its file's sha256 differs from the one recorded at
|
|
29
31
|
# install, the failure is reported and the gate denies every tool call.
|
|
30
32
|
# Any hook whose sha256 differs is not loaded (reinstall the bundle
|
|
31
|
-
# after editing a hook by hand)
|
|
33
|
+
# after editing a hook by hand), nor any of a bundle whose requires_chi
|
|
34
|
+
# this chi doesn't meet.
|
|
32
35
|
#
|
|
33
36
|
# The +on_error+ policy is applied per hook at fire time (Hooks.wrap):
|
|
34
37
|
# - "fail_closed" (on :before_tool_call): a raising guardrail denies
|
|
@@ -48,10 +51,14 @@ module Samagotchi
|
|
|
48
51
|
# @param settings [Hash] the bundle's section of config.yml
|
|
49
52
|
# `bundles:` (string keys); a hook class whose initialize takes an
|
|
50
53
|
# argument gets it
|
|
54
|
+
# @param requires_chi [String, nil] the bundle's requirement; when
|
|
55
|
+
# this chi doesn't meet it no hook loads (each is reported, as the
|
|
56
|
+
# plugin loader does)
|
|
51
57
|
# @return [Integer] number of hooks successfully registered
|
|
52
|
-
def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {})
|
|
58
|
+
def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {}, requires_chi: nil)
|
|
53
59
|
return 0 unless metadata.is_a?(Hash)
|
|
54
60
|
|
|
61
|
+
too_old = MemoryBundle::Manifest.requires_chi_failure(requires_chi, Samagotchi::VERSION)
|
|
55
62
|
loaded = 0
|
|
56
63
|
metadata.each do |raw_basename, raw_meta|
|
|
57
64
|
basename = raw_basename.to_s
|
|
@@ -72,7 +79,7 @@ module Samagotchi
|
|
|
72
79
|
failures&.add(what, "the file is missing", required: true) if fail_closed
|
|
73
80
|
next
|
|
74
81
|
end
|
|
75
|
-
if (mismatch = sha_mismatch(file, meta.(:sha256), required: fail_closed))
|
|
82
|
+
if (mismatch = too_old || sha_mismatch(file, meta.(:sha256), required: fail_closed))
|
|
76
83
|
Log.warn(:hooks, "bundle_hook_mismatch", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' not loaded: #{mismatch}", bundle: bundle_name, hook: basename.to_s)
|
|
77
84
|
failures&.add(what, mismatch, required: fail_closed)
|
|
78
85
|
next
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "ipaddr"
|
|
3
4
|
require_relative "config"
|
|
4
5
|
require_relative "model_ref"
|
|
5
6
|
require_relative "client"
|
|
@@ -21,7 +22,6 @@ module Samagotchi
|
|
|
21
22
|
class HostRegistry
|
|
22
23
|
CACHE_TTL_SECONDS = 60
|
|
23
24
|
REMOTE_CACHE_TTL_SECONDS = 600
|
|
24
|
-
LIST_TIMEOUT_SECONDS = 3
|
|
25
25
|
# Seconds a remote host's stream may take to show something (a queued
|
|
26
26
|
# free model on OpenRouter can send only keep-alives for minutes).
|
|
27
27
|
REMOTE_FIRST_TOKEN_TIMEOUT = 120
|
|
@@ -32,9 +32,12 @@ module Samagotchi
|
|
|
32
32
|
# first_token_timeout: the configured first-token limit (see #first_token_limit);
|
|
33
33
|
# vision: the configured true/false (VisionSupport), nil when unset;
|
|
34
34
|
# sampling: the configured request parameters (SamplingSettings), nil when unset;
|
|
35
|
-
# thinking: the configured level (Thinking), nil when unset
|
|
35
|
+
# thinking: the configured level (Thinking), nil when unset;
|
|
36
|
+
# remote: the configured true/false (#remote?), nil when unset;
|
|
37
|
+
# window_tokens: the configured context window (ContextWindow.setting), nil when unset.
|
|
36
38
|
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
|
|
37
|
-
:first_token_timeout, :vision, :sampling, :thinking,
|
|
39
|
+
:first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
|
|
40
|
+
keyword_init: true) do
|
|
38
41
|
# Talks the OpenAI chat API (the chat loop); nil and raw apis use the
|
|
39
42
|
# raw-prompt loop.
|
|
40
43
|
def chat? = api == :openai
|
|
@@ -46,9 +49,12 @@ module Samagotchi
|
|
|
46
49
|
# configured, else the root's /v1.
|
|
47
50
|
def openai_base_url = url || "#{root_url}/v1"
|
|
48
51
|
|
|
49
|
-
# A provider on the network rather than a local server:
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
+
# A provider on the network rather than a local server: hosts.<name>.remote
|
|
53
|
+
# when set, else by its address (HostRegistry.remote_address?). An API
|
|
54
|
+
# key doesn't decide: a llama.cpp on the LAN can have one. A remote
|
|
55
|
+
# host's model list is cached longer, it gets a first-token limit, and
|
|
56
|
+
# it isn't asked for llama.cpp's /props.
|
|
57
|
+
def remote? = remote.nil? ? HostRegistry.remote_address?(scheme, host) : remote
|
|
52
58
|
|
|
53
59
|
def models_ttl = remote? ? REMOTE_CACHE_TTL_SECONDS : CACHE_TTL_SECONDS
|
|
54
60
|
|
|
@@ -90,7 +96,8 @@ module Samagotchi
|
|
|
90
96
|
entry = HostEntry.new(name: key.to_s.downcase, host: cfg[:host], port: cfg[:port].to_i, transport: transport,
|
|
91
97
|
api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
|
|
92
98
|
profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
|
|
93
|
-
vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking]
|
|
99
|
+
vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking],
|
|
100
|
+
remote: cfg[:remote], window_tokens: cfg[:window_tokens])
|
|
94
101
|
entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
|
|
95
102
|
first_token_timeout: entry.first_token_limit, name: entry.name,
|
|
96
103
|
api_key_env: entry.api_key_env, env: env)
|
|
@@ -114,6 +121,23 @@ module Samagotchi
|
|
|
114
121
|
@entries
|
|
115
122
|
end
|
|
116
123
|
|
|
124
|
+
# Loopback, private (RFC 1918, IPv6 unique local) and link-local nets.
|
|
125
|
+
LOCAL_NETS = %w[127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10
|
|
126
|
+
::1/128 fc00::/7 fe80::/10].map { |net| IPAddr.new(net) }.freeze
|
|
127
|
+
|
|
128
|
+
# Whether an address is remote: https, or an http IP address outside
|
|
129
|
+
# the local nets (LOCAL_NETS). An http host named by a name (localhost,
|
|
130
|
+
# box, mac.local, gpu.lan) counts as local: chi doesn't look names up.
|
|
131
|
+
def self.remote_address?(scheme, host)
|
|
132
|
+
return true if scheme.to_s == "https"
|
|
133
|
+
|
|
134
|
+
address = IPAddr.new(host.to_s.delete_prefix("[").delete_suffix("]"))
|
|
135
|
+
address = address.native if address.ipv4_mapped?
|
|
136
|
+
LOCAL_NETS.none? { |net| net.family == address.family && net.include?(address) }
|
|
137
|
+
rescue IPAddr::Error
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
|
|
117
141
|
# server.first_token_timeout, or nil when unset or unreadable.
|
|
118
142
|
def self.configured_first_token_timeout
|
|
119
143
|
Config.get("server.first_token_timeout")
|
|
@@ -271,7 +271,7 @@ module Samagotchi
|
|
|
271
271
|
def prepare_request(turn)
|
|
272
272
|
prompt, images = format_prompt(turn)
|
|
273
273
|
image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(turn.conversation)
|
|
274
|
-
window = ContextWindow.resolve(client: @client, model: turn.model_name)
|
|
274
|
+
window = ContextWindow.resolve(client: @client, model: turn.model_name, setting: @turn_settings.window_setting)
|
|
275
275
|
emit_context_status_event(turn.on_stream_event, turn.context, prompt, iteration_index: turn.iteration - 1,
|
|
276
276
|
window: window, image_tokens: image_tokens)
|
|
277
277
|
if (line = turn.context.take_guidance)
|
|
@@ -172,11 +172,13 @@ module Samagotchi
|
|
|
172
172
|
|
|
173
173
|
# The window for +model+: the kernel's client probes the server it
|
|
174
174
|
# points at (Engine keeps it on the effective model's host; a local
|
|
175
|
-
# llama.cpp answers /props), then the host's model list,
|
|
176
|
-
# and the default. A remote
|
|
175
|
+
# llama.cpp answers /props), then the host's model list, the model's
|
|
176
|
+
# or host's window_tokens, config, env and the default. A remote
|
|
177
|
+
# provider has no /props to probe.
|
|
177
178
|
def context_window(model)
|
|
178
179
|
client = @adapter.remote? ? nil : @kernel.client
|
|
179
|
-
ContextWindow.resolve(client: client, model: model, adapter: @adapter
|
|
180
|
+
ContextWindow.resolve(client: client, model: model, adapter: @adapter,
|
|
181
|
+
setting: (@kernel.turn_settings&.window_setting if @kernel.respond_to?(:turn_settings)))
|
|
180
182
|
rescue StandardError
|
|
181
183
|
nil
|
|
182
184
|
end
|
|
@@ -245,12 +245,19 @@ module Samagotchi
|
|
|
245
245
|
function = call["function"] || {}
|
|
246
246
|
ToolCall.new(id: call["id"], name: function["name"].to_s, arguments: self.class.parse_arguments(function["arguments"]))
|
|
247
247
|
end
|
|
248
|
-
ChatResponse.new(text: message["content"].to_s,
|
|
248
|
+
ChatResponse.new(text: OpenAIChat.drop_leading_newlines(message["content"].to_s),
|
|
249
249
|
reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
|
|
250
250
|
tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
|
|
251
251
|
finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body))
|
|
252
252
|
end
|
|
253
253
|
|
|
254
|
+
# Workaround for Splash 1.0.2/1.1.0, which keeps the "\n\n" the model
|
|
255
|
+
# writes after </think> at the start of the answer
|
|
256
|
+
# (https://github.com/incoai/splash/issues/254). Remove once Splash
|
|
257
|
+
# strips it; llama.cpp already does. Streamed, it applies to the chunks
|
|
258
|
+
# until the first text.
|
|
259
|
+
def self.drop_leading_newlines(text) = text.sub(/\A[\r\n]+/, "")
|
|
260
|
+
|
|
254
261
|
def self.served_model(payload)
|
|
255
262
|
model = payload.is_a?(Hash) ? payload["model"] : nil
|
|
256
263
|
model.is_a?(String) && !model.strip.empty? ? model : nil
|
|
@@ -372,6 +379,7 @@ module Samagotchi
|
|
|
372
379
|
@finish_reason = choice["finish_reason"] if choice["finish_reason"]
|
|
373
380
|
delta = choice["delta"].is_a?(Hash) ? choice["delta"] : {}
|
|
374
381
|
content = delta["content"].to_s
|
|
382
|
+
content = OpenAIChat.drop_leading_newlines(content) if @text.empty?
|
|
375
383
|
reasoning = (delta["reasoning_content"] || delta["reasoning"]).to_s
|
|
376
384
|
@text << content
|
|
377
385
|
@reasoning << reasoning
|
|
@@ -8,12 +8,14 @@ module Samagotchi
|
|
|
8
8
|
# ChatLoop) and ToolRunner: the turn's VisionContext (nil sends no
|
|
9
9
|
# images, placeholders instead), its request parameters
|
|
10
10
|
# (SamplingSettings.for; empty sends none), its thinking level
|
|
11
|
-
# (Thinking.resolve)
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
|
|
11
|
+
# (Thinking.resolve), the bare model id the turn runs on (every debug
|
|
12
|
+
# dump is tagged with it) and the model's or host's configured context
|
|
13
|
+
# window (ContextWindow.setting, nil for none). The Engine sets them
|
|
14
|
+
# once per turn, in Engine#generate, after the model's host is synced.
|
|
15
|
+
TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name, :window_setting) do
|
|
15
16
|
# No vision, no sampling, the default thinking, no model name.
|
|
16
|
-
def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil
|
|
17
|
+
def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil,
|
|
18
|
+
window_setting: nil)
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
end
|
|
@@ -265,6 +265,7 @@ module Samagotchi
|
|
|
265
265
|
# ── Plugin: <file> into <bundle_dir>/plugin/ (docs/plugins.md) ────
|
|
266
266
|
# Like the rules, the bundle's plugin replaces an earlier one.
|
|
267
267
|
plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
|
|
268
|
+
warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
|
|
268
269
|
|
|
269
270
|
# Files the previous version shipped and this one doesn't.
|
|
270
271
|
if @upgrade && existing_provenance && existing_provenance[:files]
|
|
@@ -467,6 +468,17 @@ module Samagotchi
|
|
|
467
468
|
end
|
|
468
469
|
end
|
|
469
470
|
|
|
471
|
+
# The hook loader skips every hook of a bundle whose requires_chi this
|
|
472
|
+
# chi doesn't meet (Hooks::BundleLoader); say so at install, as
|
|
473
|
+
# install_plugin does for the plugin.
|
|
474
|
+
def warn_hooks_requires_chi(manifest, hook_files)
|
|
475
|
+
return if manifest.nil? || hook_files.empty?
|
|
476
|
+
|
|
477
|
+
if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
|
|
478
|
+
@warnings << "Bundle #{@name}: its hooks won't load: #{failure}"
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
470
482
|
# Copy the manifest's plugin file into the bundle's plugin/ dir,
|
|
471
483
|
# warning when its sha256 differs from the declared one or this chi
|
|
472
484
|
# doesn't meet requires_chi (the Engine then won't load it).
|
|
@@ -43,7 +43,15 @@ module Samagotchi
|
|
|
43
43
|
}
|
|
44
44
|
end
|
|
45
45
|
{ provenance: data, scope: scope, target_dir: target_dir, files: details, plugin: plugin_status(provenance, data),
|
|
46
|
-
needs: needs_status(data) }
|
|
46
|
+
hooks_requires_failure: hooks_requires_failure(data), needs: needs_status(data) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Why none of the bundle's hooks load (this chi doesn't meet its
|
|
50
|
+
# requires_chi, Hooks::BundleLoader), or nil: it has no hooks or meets it.
|
|
51
|
+
def self.hooks_requires_failure(data)
|
|
52
|
+
return nil if (data[:hooks] || {}).empty?
|
|
53
|
+
|
|
54
|
+
Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION)
|
|
47
55
|
end
|
|
48
56
|
|
|
49
57
|
# The stored needs, each with found: from this process's PATH (the
|
|
@@ -51,6 +51,14 @@ module Samagotchi
|
|
|
51
51
|
File.basename(common) == ".git" ? File.dirname(common) : common
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# The top of the git work tree holding +cwd+: the directory with the
|
|
55
|
+
# nearest .git (a linked worktree's own checkout, unlike #project_root);
|
|
56
|
+
# nil outside any repository.
|
|
57
|
+
def work_tree_root(cwd = Dir.pwd)
|
|
58
|
+
dot_git = find_dot_git(cwd)
|
|
59
|
+
dot_git && File.dirname(dot_git)
|
|
60
|
+
end
|
|
61
|
+
|
|
54
62
|
# Is +cwd+ inside a git repository (a .git somewhere above it)?
|
|
55
63
|
def in_repo?(cwd = Dir.pwd)
|
|
56
64
|
!find_dot_git(cwd).nil?
|
|
@@ -18,11 +18,18 @@ module Samagotchi
|
|
|
18
18
|
# so every surface that reports a missing model reports it the same way).
|
|
19
19
|
UnknownHost = Class.new(MissingModel)
|
|
20
20
|
|
|
21
|
+
# Gemma 4 thinks in a channel: `<|channel>thought` … `<channel|>`. The
|
|
22
|
+
# one source for the parser, the stream splitter, the literal guard and
|
|
23
|
+
# the web's saved-message reader.
|
|
24
|
+
GEMMA_THOUGHT_CHANNEL_OPEN = "<|channel>thought"
|
|
25
|
+
GEMMA_THOUGHT_CHANNEL_CLOSE = "<channel|>"
|
|
26
|
+
|
|
21
27
|
attr_reader :name, :turn_start, :turn_end,
|
|
22
28
|
:tool_call_open, :tool_call_close,
|
|
23
29
|
:tool_response_open, :tool_response_close,
|
|
24
30
|
:string_delim,
|
|
25
31
|
:thought_open, :thought_close,
|
|
32
|
+
:thought_channel_open, :thought_channel_close,
|
|
26
33
|
:system_prefix, :user_prefix, :assistant_prefix,
|
|
27
34
|
:model_prefix,
|
|
28
35
|
:stop_sequences, :tool_decl_format,
|
|
@@ -39,6 +46,9 @@ module Samagotchi
|
|
|
39
46
|
@string_delim = config[:string_delim]
|
|
40
47
|
@thought_open = config[:thought_open]
|
|
41
48
|
@thought_close = config[:thought_close]
|
|
49
|
+
# A thinking block besides thought_open's (Gemma's channel), or nil.
|
|
50
|
+
@thought_channel_open = config[:thought_channel_open]
|
|
51
|
+
@thought_channel_close = config[:thought_channel_close]
|
|
42
52
|
@system_prefix = config[:system_prefix]
|
|
43
53
|
@user_prefix = config[:user_prefix]
|
|
44
54
|
@assistant_prefix = config[:assistant_prefix]
|
|
@@ -68,6 +78,8 @@ module Samagotchi
|
|
|
68
78
|
string_delim: '<|"|>',
|
|
69
79
|
thought_open: "<|think|>",
|
|
70
80
|
thought_close: nil,
|
|
81
|
+
thought_channel_open: GEMMA_THOUGHT_CHANNEL_OPEN,
|
|
82
|
+
thought_channel_close: GEMMA_THOUGHT_CHANNEL_CLOSE,
|
|
71
83
|
system_prefix: "",
|
|
72
84
|
user_prefix: "",
|
|
73
85
|
assistant_prefix: "",
|
|
@@ -131,8 +143,9 @@ module Samagotchi
|
|
|
131
143
|
# qualified with a host that isn't configured, instead of sending the
|
|
132
144
|
# whole ref to the default host as a model id, and when an alias after
|
|
133
145
|
# a host prefix names another host ("openrouter:tiny" with tiny:
|
|
134
|
-
# box:…)
|
|
135
|
-
# spawned session)
|
|
146
|
+
# box:…), or with a host config.yml has with enabled: false. Where a
|
|
147
|
+
# model comes in (an Engine starting or switching, a spawned session)
|
|
148
|
+
# calls it.
|
|
136
149
|
# @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
|
|
137
150
|
# entries); config.yml's by default
|
|
138
151
|
# @return [String] +model_name+
|
|
@@ -145,6 +158,9 @@ module Samagotchi
|
|
|
145
158
|
"use #{parsed.alias_name} or #{other}:#{parsed.alias_name}"
|
|
146
159
|
end
|
|
147
160
|
ref = parsed.ref
|
|
161
|
+
if !parsed.host_name && (disabled = disabled_host_prefix(ref, hosts, env))
|
|
162
|
+
raise UnknownHost, "host '#{disabled}' is disabled (enabled: false in config.yml)"
|
|
163
|
+
end
|
|
148
164
|
warn_host_slash(ref, hosts) unless parsed.host_name
|
|
149
165
|
host = Samagotchi::ConfigFile.unknown_host_prefix(ref, hosts: hosts)
|
|
150
166
|
return model_name unless host
|
|
@@ -155,6 +171,18 @@ module Samagotchi
|
|
|
155
171
|
raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(', ')}"
|
|
156
172
|
end
|
|
157
173
|
|
|
174
|
+
# The prefix of "box:x" when box is a host config.yml has with
|
|
175
|
+
# enabled: false (left out of +hosts+), else nil.
|
|
176
|
+
def self.disabled_host_prefix(ref, hosts, env)
|
|
177
|
+
prefix, rest = ref.to_s.split(":", 2)
|
|
178
|
+
return nil if rest.to_s.strip.empty?
|
|
179
|
+
|
|
180
|
+
prefix = prefix.strip.downcase
|
|
181
|
+
return nil if hosts.keys.any? { |k| k.to_s.downcase == prefix }
|
|
182
|
+
|
|
183
|
+
Samagotchi::ConfigFile.disabled_host_names(env: env).include?(prefix) ? prefix : nil
|
|
184
|
+
end
|
|
185
|
+
|
|
158
186
|
# "box/x" named host box until '/' stopped naming a host: a saved
|
|
159
187
|
# session or a hand-written ref says so once, then goes to the default host.
|
|
160
188
|
def self.warn_host_slash(ref, hosts)
|
|
@@ -193,10 +221,6 @@ module Samagotchi
|
|
|
193
221
|
DEFAULT_NAME
|
|
194
222
|
end
|
|
195
223
|
|
|
196
|
-
def self.from_env
|
|
197
|
-
from_model_name(required_model_name(nil))
|
|
198
|
-
end
|
|
199
|
-
|
|
200
224
|
# ── Resolution: which profile a model gets ───────────────────────────
|
|
201
225
|
|
|
202
226
|
NAMES = %w[qwen36 gemma4].freeze
|
|
@@ -328,16 +352,6 @@ module Samagotchi
|
|
|
328
352
|
end
|
|
329
353
|
private_class_method :probe?
|
|
330
354
|
|
|
331
|
-
# ── Thought channel support ──────────────────────────────────────────
|
|
332
|
-
|
|
333
|
-
def thought_channel_open
|
|
334
|
-
nil
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
def uses_channel_thoughts?
|
|
338
|
-
false
|
|
339
|
-
end
|
|
340
|
-
|
|
341
355
|
def uses_role_prefixes?
|
|
342
356
|
!system_prefix.empty? && !user_prefix.empty?
|
|
343
357
|
end
|