samagotchi 0.6.0 → 0.8.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 +72 -1
- data/README.md +7 -3
- data/docs/architecture.md +4 -4
- data/docs/cli.md +19 -4
- data/docs/configuration.md +20 -0
- data/docs/desktop.md +49 -0
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +2 -2
- data/docs/internals/context-telemetry.md +3 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +7 -5
- data/docs/releasing.md +5 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/archive_store.rb +3 -3
- data/lib/samagotchi/atomic_file.rb +34 -0
- data/lib/samagotchi/bootstrap/bundles.rb +37 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
- data/lib/samagotchi/bootstrap/probe.rb +9 -5
- data/lib/samagotchi/bootstrap_command.rb +78 -8
- data/lib/samagotchi/bridge/pending_card.rb +2 -3
- data/lib/samagotchi/bridge.rb +56 -116
- data/lib/samagotchi/bridge_client.rb +7 -11
- data/lib/samagotchi/bundle_command.rb +81 -21
- data/lib/samagotchi/bundle_needs.rb +2 -11
- data/lib/samagotchi/bundles/core/manifest.yml +11 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
- data/lib/samagotchi/bundles/system/identity.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
- data/lib/samagotchi/client.rb +3 -23
- data/lib/samagotchi/config.rb +11 -11
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +255 -0
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +7 -63
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +233 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +25 -11
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +6 -7
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -7
- data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
- data/lib/samagotchi/hooks/loader.rb +2 -1
- data/lib/samagotchi/hooks/registry.rb +21 -0
- data/lib/samagotchi/image_store.rb +2 -3
- data/lib/samagotchi/kernel_loop.rb +21 -283
- data/lib/samagotchi/live_versions.rb +9 -13
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/native_backend.rb +8 -8
- data/lib/samagotchi/llm/openai_chat.rb +4 -17
- data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
- data/lib/samagotchi/log_path.rb +2 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
- data/lib/samagotchi/memory_bundle/installer.rb +5 -7
- data/lib/samagotchi/memory_bundle/listing.rb +27 -20
- data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
- data/lib/samagotchi/memory_bundle/profile.rb +145 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
- data/lib/samagotchi/memory_bundle/source.rb +2 -0
- data/lib/samagotchi/memory_bundle/status.rb +1 -2
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/note_command.rb +5 -4
- data/lib/samagotchi/owner_lock.rb +0 -6
- data/lib/samagotchi/paths.rb +32 -0
- data/lib/samagotchi/plugin/api.rb +3 -6
- data/lib/samagotchi/plugin/context.rb +2 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/recap_store.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +2 -1
- data/lib/samagotchi/self_report.rb +2 -3
- data/lib/samagotchi/send_command.rb +3 -2
- data/lib/samagotchi/session.rb +41 -143
- data/lib/samagotchi/session_inbox.rb +175 -0
- data/lib/samagotchi/session_manager.rb +62 -363
- data/lib/samagotchi/session_metrics.rb +2 -3
- data/lib/samagotchi/session_retention.rb +259 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
- data/lib/samagotchi/terminal_ui.rb +10 -20
- data/lib/samagotchi/tools/memory.rb +3 -2
- data/lib/samagotchi/tools/send_note.rb +4 -3
- data/lib/samagotchi/tools/task_runtime.rb +2 -3
- data/lib/samagotchi/turn_note.rb +13 -2
- data/lib/samagotchi/update_command.rb +10 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +44 -76
- data/lib/samagotchi/web/info_probe.rb +33 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +25 -10
- data/lib/samagotchi/web/server.rb +8 -11
- data/lib/samagotchi/web/session_hub.rb +8 -7
- data/lib/samagotchi/web/session_summary.rb +4 -3
- data/lib/samagotchi/web/token.rb +2 -5
- data/lib/samagotchi/worker.rb +34 -20
- data/lib/samagotchi/worker_sidecar.rb +42 -0
- metadata +15 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -71,10 +71,9 @@ module Samagotchi
|
|
|
71
71
|
# Used by specs and inspection.
|
|
72
72
|
def self.system_prompt_for(profile)
|
|
73
73
|
profile = ModelProfile.normalize(profile) unless profile.is_a?(ModelProfile)
|
|
74
|
-
new(
|
|
74
|
+
new(profile: profile, plugins: false).assist_system_prompt
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
-
# @param mode [Symbol] :assist (harness is single-mode; memory-reliant; kwarg kept for compat, ignored)
|
|
78
77
|
# @param client [Client, nil] defaults to Client.new
|
|
79
78
|
# @param profile [ModelProfile, Symbol, String, nil]
|
|
80
79
|
# @param session_id [String, nil] resume an existing session
|
|
@@ -89,9 +88,8 @@ module Samagotchi
|
|
|
89
88
|
# @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
|
|
90
89
|
# @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
|
|
91
90
|
# delegate (a child would outlive it) nor plugin fork
|
|
92
|
-
def initialize(
|
|
91
|
+
def initialize(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,
|
|
93
92
|
plugins: true, scratch: false)
|
|
94
|
-
@mode = mode.to_sym
|
|
95
93
|
@scratch = scratch
|
|
96
94
|
@chat_backend = nil
|
|
97
95
|
@chat_backend_mutex = Mutex.new
|
|
@@ -518,7 +516,7 @@ module Samagotchi
|
|
|
518
516
|
# The array is replaced, not mutated, and the append and the event
|
|
519
517
|
# happen under the event lock, so a joining UI sees both or neither.
|
|
520
518
|
# The caller saves the session.
|
|
521
|
-
# @param note [Hash]
|
|
519
|
+
# @param note [Hash] SessionInbox.read_note's shape
|
|
522
520
|
# @return [Hash, nil] the message, or nil when the conversation already
|
|
523
521
|
# holds this note (a note file re-read after a crash)
|
|
524
522
|
def add_context_note(session, note)
|
|
@@ -1278,7 +1276,7 @@ module Samagotchi
|
|
|
1278
1276
|
return false unless ctrl && !ctrl.cancelled?
|
|
1279
1277
|
|
|
1280
1278
|
hook_notify("stopped the turn: #{reason}", :warn, hook)
|
|
1281
|
-
ctrl.cancel!(:hook)
|
|
1279
|
+
ctrl.cancel!(:hook, { by: hook_source(hook), reason: reason.to_s })
|
|
1282
1280
|
end
|
|
1283
1281
|
private :hook_stop_turn
|
|
1284
1282
|
|
|
@@ -1645,6 +1643,7 @@ module Samagotchi
|
|
|
1645
1643
|
conversation << note if conversation
|
|
1646
1644
|
elsif canceled && conversation
|
|
1647
1645
|
conversation << TurnNote.cancelled(result.cancellation_reason, seconds: turn_seconds.call,
|
|
1646
|
+
stopped_by: effective_controller.detail,
|
|
1648
1647
|
shown: TurnNote.interrupted_tail?(conversation),
|
|
1649
1648
|
running_tasks: Tools::TaskRuntime.running_created_in(conversation))
|
|
1650
1649
|
replace_session_messages(session, conversation)
|
|
@@ -1959,7 +1958,7 @@ module Samagotchi
|
|
|
1959
1958
|
def load_hooks_from_bundles
|
|
1960
1959
|
require_relative "memory_bundle/provenance"
|
|
1961
1960
|
settings = bundle_settings
|
|
1962
|
-
MemoryBundle::Provenance.
|
|
1961
|
+
MemoryBundle::Provenance.each_installed(holding: :hooks) do |bundle_name, data|
|
|
1963
1962
|
if data[:error]
|
|
1964
1963
|
Log.warn(:hooks, "bundle_manifest_invalid", echo: "[samagotchi:hooks] bundle '#{bundle_name}': #{data[:error]}; its hooks are not loaded",
|
|
1965
1964
|
bundle: bundle_name)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "digest"
|
|
4
3
|
require "yaml"
|
|
5
4
|
|
|
6
5
|
require_relative "config"
|
|
@@ -117,7 +116,7 @@ module Samagotchi
|
|
|
117
116
|
def bundle_rules
|
|
118
117
|
require_relative "memory_bundle/provenance"
|
|
119
118
|
rules = []
|
|
120
|
-
MemoryBundle::Provenance.
|
|
119
|
+
MemoryBundle::Provenance.each_installed(holding: :guardrails) do |bundle_name, data|
|
|
121
120
|
if data[:error]
|
|
122
121
|
Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{data[:error]}", bundle: bundle_name)
|
|
123
122
|
@failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
|
|
@@ -130,9 +129,7 @@ module Samagotchi
|
|
|
130
129
|
begin
|
|
131
130
|
raise Guardrails::Rules::ParseError, "the file is missing" unless File.file?(path)
|
|
132
131
|
|
|
133
|
-
|
|
134
|
-
actual = Digest::SHA256.hexdigest(File.binread(path))
|
|
135
|
-
if expected != actual
|
|
132
|
+
unless MemoryBundle::Provenance.sha_matches?(path, meta.is_a?(Hash) ? meta[:sha256] : nil)
|
|
136
133
|
raise Guardrails::Rules::ParseError, "its sha256 differs from the installed one (edited after install? reinstall the bundle)"
|
|
137
134
|
end
|
|
138
135
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "time"
|
|
6
|
+
require_relative "../atomic_file"
|
|
6
7
|
require_relative "../log"
|
|
7
8
|
require_relative "approval"
|
|
8
9
|
|
|
@@ -163,13 +164,7 @@ module Samagotchi
|
|
|
163
164
|
with_lock do
|
|
164
165
|
list = read(locked: true)
|
|
165
166
|
yield list
|
|
166
|
-
|
|
167
|
-
begin
|
|
168
|
-
File.write(tmp, JSON.pretty_generate(list))
|
|
169
|
-
File.rename(tmp, @path)
|
|
170
|
-
ensure
|
|
171
|
-
FileUtils.rm_f(tmp)
|
|
172
|
-
end
|
|
167
|
+
AtomicFile.write(@path, JSON.pretty_generate(list))
|
|
173
168
|
end
|
|
174
169
|
end
|
|
175
170
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "registry"
|
|
4
4
|
require "digest"
|
|
5
5
|
require_relative "../log"
|
|
6
|
+
require_relative "../memory_bundle/provenance"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# Namespace that wraps hook plugin classes per-bundle. Each bundle's hooks are
|
|
@@ -79,12 +80,14 @@ module Samagotchi
|
|
|
79
80
|
|
|
80
81
|
begin
|
|
81
82
|
plugin = instantiate(bundle_name, basename, file, settings: settings)
|
|
83
|
+
log_failure = Hooks.failure_log_gate(event_sym)
|
|
82
84
|
|
|
83
85
|
registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: priority) do |event|
|
|
84
86
|
begin
|
|
85
87
|
plugin.call(event)
|
|
86
88
|
rescue Exception => e
|
|
87
|
-
|
|
89
|
+
# A veto always applies; only the log line is throttled.
|
|
90
|
+
handle_error(bundle_name, basename, on_error, fail_closed, event, e) if fail_closed || log_failure.call
|
|
88
91
|
end
|
|
89
92
|
end
|
|
90
93
|
loaded += 1
|
|
@@ -99,10 +102,10 @@ module Samagotchi
|
|
|
99
102
|
# Why the file doesn't match the sha256 recorded at install, or nil.
|
|
100
103
|
# With none recorded, only a required hook counts as a mismatch.
|
|
101
104
|
def sha_mismatch(file, recorded, required:)
|
|
102
|
-
expected =
|
|
105
|
+
expected = MemoryBundle::Provenance.recorded_sha(recorded)
|
|
103
106
|
return (required ? "no sha256 recorded for it (reinstall the bundle)" : nil) if expected.empty?
|
|
104
107
|
|
|
105
|
-
actual =
|
|
108
|
+
actual = MemoryBundle::Provenance.file_sha(file)
|
|
106
109
|
return nil if actual == expected
|
|
107
110
|
|
|
108
111
|
"its sha256 #{actual[0, 12]}… differs from the installed #{expected[0, 12]}… (edited after install? reinstall the bundle)"
|
|
@@ -60,6 +60,7 @@ module Samagotchi
|
|
|
60
60
|
definitions.each do |defn|
|
|
61
61
|
begin
|
|
62
62
|
plugin = load_plugin(hooks_dir, defn[:path], settings: defn[:settings])
|
|
63
|
+
log_failure = Hooks.failure_log_gate(defn[:event_type])
|
|
63
64
|
# Persistent: config hooks must fire on every turn, not be wiped
|
|
64
65
|
# by Engine#run_turn's per-turn clear_hooks after turn 1.
|
|
65
66
|
registry.register_persistent(defn[:event_type].to_sym, label: "#{defn[:path]} (config)") do |event|
|
|
@@ -69,7 +70,7 @@ module Samagotchi
|
|
|
69
70
|
if defn[:required] && defn[:event_type] == "before_tool_call"
|
|
70
71
|
deny_for_raise(event, defn[:path], e)
|
|
71
72
|
else
|
|
72
|
-
handle_error(defn[:on_error] || "skip", defn[:path], e)
|
|
73
|
+
handle_error(defn[:on_error] || "skip", defn[:path], e) if log_failure.call
|
|
73
74
|
end
|
|
74
75
|
end
|
|
75
76
|
end
|
|
@@ -15,6 +15,27 @@ module Samagotchi
|
|
|
15
15
|
takes_settings ? klass.new(settings.is_a?(Hash) ? settings : {}) : klass.new
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# A stream hook (:generation_progress) fires ~once a second: a broken
|
|
19
|
+
# one logs its failure once a minute, not on every fire.
|
|
20
|
+
FAILED_STREAM_HOOK_LOG_SECONDS = 60
|
|
21
|
+
|
|
22
|
+
# One hook's failure-log gate: called on each failure, it says whether
|
|
23
|
+
# to log this one (a :generation_progress hook's at most once a
|
|
24
|
+
# minute, any other hook's every time).
|
|
25
|
+
# @param event [Symbol, String] the hook's event
|
|
26
|
+
# @return [Proc] -> Boolean
|
|
27
|
+
def self.failure_log_gate(event)
|
|
28
|
+
quiet_for = event.to_s == "generation_progress" ? FAILED_STREAM_HOOK_LOG_SECONDS : 0
|
|
29
|
+
logged_at = nil
|
|
30
|
+
lambda do
|
|
31
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
32
|
+
next false if logged_at && now - logged_at < quiet_for
|
|
33
|
+
|
|
34
|
+
logged_at = now
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
18
39
|
# What a hook can do beyond reading its event: the Engine's
|
|
19
40
|
# callables, each given the hook's label. +notify+ takes
|
|
20
41
|
# (text:, level:, hook:) and shows one line to the user; +ask_user+
|
|
@@ -5,6 +5,7 @@ require "fileutils"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "tmpdir"
|
|
8
|
+
require_relative "atomic_file"
|
|
8
9
|
require_relative "config"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -384,9 +385,7 @@ module Samagotchi
|
|
|
384
385
|
path = File.join(session_dir.to_s, file)
|
|
385
386
|
unless File.file?(path)
|
|
386
387
|
FileUtils.mkdir_p(File.dirname(path))
|
|
387
|
-
|
|
388
|
-
File.binwrite(tmp, bytes)
|
|
389
|
-
File.rename(tmp, path)
|
|
388
|
+
AtomicFile.write(path, bytes)
|
|
390
389
|
end
|
|
391
390
|
{ file: file, mime: MIME.fetch(info.format), width: info.width, height: info.height,
|
|
392
391
|
bytes: bytes.bytesize, name: name, source: source.to_s }
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative "model_profile"
|
|
4
4
|
require_relative "tool_call_parser"
|
|
5
5
|
require_relative "config"
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "context_status"
|
|
7
7
|
require_relative "context_window"
|
|
8
8
|
require_relative "prompt"
|
|
9
9
|
require_relative "prompt_literal_guard"
|
|
@@ -37,20 +37,6 @@ module Samagotchi
|
|
|
37
37
|
# - Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
|
|
38
38
|
class KernelLoop
|
|
39
39
|
Result = Struct.new(:output, :conversation, :exhausted, :pending_tool_calls, :tool_activity, :canceled, :cancellation_reason, :context_status, keyword_init: true) do
|
|
40
|
-
def to_s
|
|
41
|
-
output.to_s
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
alias to_str to_s
|
|
45
|
-
|
|
46
|
-
def ==(other)
|
|
47
|
-
if other.is_a?(self.class)
|
|
48
|
-
super
|
|
49
|
-
else
|
|
50
|
-
to_s == other
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
40
|
def exhausted?
|
|
55
41
|
exhausted
|
|
56
42
|
end
|
|
@@ -66,25 +52,6 @@ module Samagotchi
|
|
|
66
52
|
def canceled?
|
|
67
53
|
canceled
|
|
68
54
|
end
|
|
69
|
-
|
|
70
|
-
# Struct/Enumerable defines include? with collection semantics, but the
|
|
71
|
-
# historical KernelLoop#run contract returned a String. Keep include?
|
|
72
|
-
# aligned with String#include? for backward compatibility.
|
|
73
|
-
def include?(needle)
|
|
74
|
-
to_s.include?(needle)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Keep compatibility with existing callers/specs that treat run() as a
|
|
78
|
-
# plain string (e.g., include?, match, start_with?).
|
|
79
|
-
def method_missing(name, *args, &block)
|
|
80
|
-
return to_s.public_send(name, *args, &block) if to_s.respond_to?(name)
|
|
81
|
-
|
|
82
|
-
super
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def respond_to_missing?(name, include_private = false)
|
|
86
|
-
to_s.respond_to?(name, include_private) || super
|
|
87
|
-
end
|
|
88
55
|
end
|
|
89
56
|
|
|
90
57
|
# The built-in tool classes (Tools::Builtins registers them).
|
|
@@ -105,16 +72,6 @@ module Samagotchi
|
|
|
105
72
|
def ask_user_question(call) = @kernel.__send__(:handle_ask_user_question, call)
|
|
106
73
|
end
|
|
107
74
|
|
|
108
|
-
CONTEXT_STATUS_PREFIX = "CONTEXT_STATUS"
|
|
109
|
-
# The model's own line about its context (a tail system message, kind
|
|
110
|
-
# CONTEXT_LINE_KIND), left once per rise into a bucket whose guidance
|
|
111
|
-
# asks for a change: from the second threshold (40% by default) up.
|
|
112
|
-
CONTEXT_LINE_PREFIX = "[CONTEXT: "
|
|
113
|
-
CONTEXT_LINE_KIND = "context"
|
|
114
|
-
CONTEXT_GUIDANCE_FROM_RANK = 2
|
|
115
|
-
|
|
116
|
-
DEFAULT_CONTEXT_CHARS_PER_TOKEN = 4.0
|
|
117
|
-
DEFAULT_CONTEXT_THRESHOLDS = [20, 40, 60, 80].freeze
|
|
118
75
|
DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
|
|
119
76
|
QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT = 2
|
|
120
77
|
QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT = "Continue the previous assistant message by finishing the open <tool_call> XML block. Output only the remaining XML needed to complete the tool call."
|
|
@@ -204,7 +161,7 @@ module Samagotchi
|
|
|
204
161
|
@current_model_name = resolved_model_name
|
|
205
162
|
|
|
206
163
|
conversation = prepare_conversation(messages)
|
|
207
|
-
|
|
164
|
+
context = ContextStatus.new(conversation: conversation)
|
|
208
165
|
exhausted = false
|
|
209
166
|
pending_tool_calls = false
|
|
210
167
|
tool_activity = []
|
|
@@ -213,7 +170,6 @@ module Samagotchi
|
|
|
213
170
|
empty_retries = 0
|
|
214
171
|
empty_retry_limit = EmptyAnswerRetry.limit
|
|
215
172
|
@retry_generation = false
|
|
216
|
-
context_status = nil
|
|
217
173
|
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
218
174
|
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
219
175
|
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
@@ -228,9 +184,9 @@ module Samagotchi
|
|
|
228
184
|
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
|
|
229
185
|
image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(conversation)
|
|
230
186
|
context_window = ContextWindow.resolve(client: @client, model: resolved_model_name)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (line =
|
|
187
|
+
emit_context_status_event(on_stream_event, context, prompt, iteration_index: iteration_index, window: context_window,
|
|
188
|
+
image_tokens: image_tokens)
|
|
189
|
+
if (line = context.take_guidance)
|
|
234
190
|
# The model's own copy, on the tail (the prompt cache keeps its
|
|
235
191
|
# prefix), then the prompt again with it.
|
|
236
192
|
conversation << line
|
|
@@ -246,8 +202,8 @@ module Samagotchi
|
|
|
246
202
|
profile_source: @profile_source
|
|
247
203
|
)
|
|
248
204
|
served_model = nil
|
|
249
|
-
# This generation's own server counts (the run-long
|
|
250
|
-
#
|
|
205
|
+
# This generation's own server counts (the run-long ones in
|
|
206
|
+
# `context` can be an earlier one's).
|
|
251
207
|
generation_usage = nil
|
|
252
208
|
# The thinking this generation streamed, for the log (a stuck
|
|
253
209
|
# thinking generation shows as thinking_chars=N content_length=…).
|
|
@@ -266,7 +222,7 @@ module Samagotchi
|
|
|
266
222
|
cancel_controller: cancel_controller,
|
|
267
223
|
model_name: resolved_model_name,
|
|
268
224
|
on_chunk: lambda { |chunk|
|
|
269
|
-
generation_usage =
|
|
225
|
+
generation_usage = context.capture(chunk[:payload]) || generation_usage
|
|
270
226
|
# llama.cpp names the loaded model in the stream's last payload.
|
|
271
227
|
named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
|
|
272
228
|
served_model = named if named.is_a?(String) && !named.strip.empty?
|
|
@@ -333,14 +289,11 @@ module Samagotchi
|
|
|
333
289
|
# No retry left: the turn ends as cancelled (hook), with nothing
|
|
334
290
|
# salvaged and without the spent nudge.
|
|
335
291
|
drop_last_empty_retry!(conversation)
|
|
336
|
-
cancel_controller.cancel!(:hook)
|
|
292
|
+
cancel_controller.cancel!(:hook, cut)
|
|
337
293
|
emit_stream_event(on_stream_event, type: :generation_cancelled, iteration: iteration_index + 1, reason: :hook)
|
|
338
294
|
return cancelled_result(conversation, tool_activity: tool_activity, reason: :hook, partial_assistant_text: "")
|
|
339
295
|
end
|
|
340
|
-
|
|
341
|
-
# only what the turn appended since (answer, tool results).
|
|
342
|
-
context_state[:counted] = { chars: prompt.length, image_tokens: image_tokens } if generation_usage
|
|
343
|
-
refresh_context_display(context_state, generation_usage, context_window)
|
|
296
|
+
context.generation_done(generation_usage, prompt_chars: prompt.length, image_tokens: image_tokens, window: context_window)
|
|
344
297
|
emit_stream_event(
|
|
345
298
|
on_stream_event,
|
|
346
299
|
type: :generation_completed,
|
|
@@ -465,7 +418,7 @@ module Samagotchi
|
|
|
465
418
|
tool_activity: tool_activity,
|
|
466
419
|
canceled: false,
|
|
467
420
|
cancellation_reason: nil,
|
|
468
|
-
context_status:
|
|
421
|
+
context_status: context.display
|
|
469
422
|
)
|
|
470
423
|
rescue StandardError => e
|
|
471
424
|
LLM::FailedTurn.attach(e, conversation && duplicate_conversation(conversation))
|
|
@@ -667,227 +620,16 @@ module Samagotchi
|
|
|
667
620
|
Log.debug(:model, event, payload: payload, model: @current_model_name, **fields)
|
|
668
621
|
end
|
|
669
622
|
|
|
670
|
-
# Estimate context usage for this iteration's prompt
|
|
671
|
-
# gate fires, surface it to stream consumers as a
|
|
672
|
-
#
|
|
673
|
-
#
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
def emit_context_status_event(on_stream_event, prompt, iteration_index:, state:, window: nil, image_tokens: 0)
|
|
678
|
-
return nil unless context_status_enabled?
|
|
679
|
-
|
|
680
|
-
usage = estimate_context_usage(prompt, server_usage: state[:server_usage], window: window, image_tokens: image_tokens,
|
|
681
|
-
counted: state[:counted])
|
|
682
|
-
bucket = context_status_bucket(usage[:estimated_pct])
|
|
683
|
-
# The status line's value, every iteration; the gate below decides
|
|
684
|
-
# only the event and the model's guidance line.
|
|
685
|
-
state[:display] = { est_pct: usage[:estimated_pct], bucket: bucket }
|
|
686
|
-
emit_status = should_emit_context_status?(state: state, bucket: bucket, iteration_index: iteration_index)
|
|
687
|
-
previous_bucket = state[:last_bucket]
|
|
688
|
-
state[:last_bucket] = bucket
|
|
689
|
-
return nil unless emit_status
|
|
690
|
-
|
|
691
|
-
state[:guidance] = context_guidance_message(usage: usage, bucket: bucket) if guidance_due?(previous_bucket, bucket)
|
|
692
|
-
|
|
693
|
-
status_message = context_status_message(usage: usage, bucket: bucket, source: usage[:source])
|
|
694
|
-
emit_stream_event(
|
|
695
|
-
on_stream_event,
|
|
696
|
-
type: :context_status,
|
|
697
|
-
iteration: iteration_index + 1,
|
|
698
|
-
status: status_message,
|
|
699
|
-
usage: usage,
|
|
700
|
-
bucket: bucket,
|
|
701
|
-
source: usage[:source]
|
|
702
|
-
)
|
|
703
|
-
dump_log("context_status", status_message, iteration: iteration_index + 1, bucket: bucket)
|
|
704
|
-
{ est_pct: usage[:estimated_pct], bucket: bucket }
|
|
705
|
-
end
|
|
706
|
-
|
|
707
|
-
# @return [Hash, nil] the payload's normalized counts, when it has any
|
|
708
|
-
def capture_server_usage(payload, state)
|
|
709
|
-
normalized = ContextUsage.normalize(payload)
|
|
710
|
-
state[:server_usage] = normalized if normalized
|
|
711
|
-
normalized
|
|
712
|
-
end
|
|
713
|
-
|
|
714
|
-
# After a generation: the status line's value from what the server
|
|
715
|
-
# reported for it (prompt + answer), so a turn's value counts its last
|
|
716
|
-
# answer. Without counts the pre-generation estimate stays.
|
|
717
|
-
def refresh_context_display(state, usage, window)
|
|
718
|
-
return unless usage
|
|
719
|
-
|
|
720
|
-
display = context_display(used_tokens: usage[:total_tokens],
|
|
721
|
-
window_tokens: usage[:context_window_tokens] || window&.tokens)
|
|
722
|
-
state[:display] = display if display
|
|
723
|
-
end
|
|
724
|
-
|
|
725
|
-
def initial_context_status_state(conversation)
|
|
726
|
-
{ last_bucket: extract_last_context_status_bucket(conversation) }
|
|
727
|
-
end
|
|
728
|
-
|
|
729
|
-
# The bucket of the last status line the conversation holds: the model's
|
|
730
|
-
# own line, or a legacy session's injected telemetry.
|
|
731
|
-
def extract_last_context_status_bucket(conversation)
|
|
732
|
-
message = conversation.reverse.find do |entry|
|
|
733
|
-
content = entry[:content].to_s
|
|
734
|
-
entry[:role] == "system" && (content.start_with?(CONTEXT_STATUS_PREFIX) || content.start_with?(CONTEXT_LINE_PREFIX))
|
|
735
|
-
end
|
|
736
|
-
return nil unless message
|
|
737
|
-
|
|
738
|
-
match = message[:content].match(/\bbucket=([a-z0-9_]+)/)
|
|
739
|
-
match && match[1]
|
|
740
|
-
end
|
|
741
|
-
|
|
742
|
-
def context_status_enabled?
|
|
743
|
-
Samagotchi::Config.get("context.status") != false
|
|
744
|
-
end
|
|
745
|
-
|
|
746
|
-
# `window` is this iteration's ContextWindow::Resolved (resolved here when
|
|
747
|
-
# not given). A window the stream payload reports itself still wins.
|
|
748
|
-
# +image_tokens+: the images' estimate (their base64 is not in +prompt+).
|
|
749
|
-
# +counted+: the prompt the server's prompt_tokens counted ({chars:,
|
|
750
|
-
# image_tokens:}); what +prompt+ has on top of it (the answer, tool
|
|
751
|
-
# results since) is added as an estimate, so the value doesn't read low
|
|
752
|
-
# during a long tool loop. A prompt shorter than that one (trimmed) or
|
|
753
|
-
# none known: the server's count alone.
|
|
754
|
-
def estimate_context_usage(prompt, server_usage: nil, window: nil, image_tokens: 0, counted: nil)
|
|
755
|
-
window ||= ContextWindow.resolve(client: @client, model: @current_model_name)
|
|
756
|
-
window_source = window.source
|
|
757
|
-
if server_usage && server_usage[:context_window_tokens]
|
|
758
|
-
window_source = :server
|
|
759
|
-
end
|
|
760
|
-
|
|
761
|
-
if server_usage && server_usage[:prompt_tokens]
|
|
762
|
-
window_tokens = server_usage[:context_window_tokens] || window.tokens
|
|
763
|
-
estimated_used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt, image_tokens, counted)
|
|
764
|
-
estimated_remaining_tokens = [window_tokens - estimated_used_tokens, 0].max
|
|
765
|
-
estimated_pct = (estimated_used_tokens.to_f / window_tokens) * 100.0
|
|
766
|
-
|
|
767
|
-
return {
|
|
768
|
-
window_tokens: window_tokens,
|
|
769
|
-
window_source: window_source,
|
|
770
|
-
estimated_used_tokens: estimated_used_tokens,
|
|
771
|
-
estimated_remaining_tokens: estimated_remaining_tokens,
|
|
772
|
-
estimated_pct: estimated_pct,
|
|
773
|
-
source: "server"
|
|
774
|
-
}
|
|
775
|
-
end
|
|
776
|
-
|
|
777
|
-
window_tokens = window.tokens
|
|
778
|
-
estimated_used_tokens = (prompt.length / context_chars_per_token).ceil + image_tokens
|
|
779
|
-
estimated_remaining_tokens = [window_tokens - estimated_used_tokens, 0].max
|
|
780
|
-
estimated_pct = (estimated_used_tokens.to_f / window_tokens) * 100.0
|
|
781
|
-
|
|
782
|
-
{
|
|
783
|
-
window_tokens: window_tokens,
|
|
784
|
-
window_source: window_source,
|
|
785
|
-
estimated_used_tokens: estimated_used_tokens,
|
|
786
|
-
estimated_remaining_tokens: estimated_remaining_tokens,
|
|
787
|
-
estimated_pct: estimated_pct,
|
|
788
|
-
source: "estimate"
|
|
789
|
-
}
|
|
790
|
-
end
|
|
791
|
-
|
|
792
|
-
# The estimate for what +prompt+ added since the +counted+ one.
|
|
793
|
-
def appended_tokens(prompt, image_tokens, counted)
|
|
794
|
-
return 0 unless counted
|
|
795
|
-
|
|
796
|
-
chars = prompt.length - counted[:chars]
|
|
797
|
-
return 0 unless chars.positive?
|
|
798
|
-
|
|
799
|
-
(chars / context_chars_per_token).ceil + [image_tokens - counted[:image_tokens].to_i, 0].max
|
|
800
|
-
end
|
|
801
|
-
|
|
802
|
-
def context_chars_per_token
|
|
803
|
-
value = Samagotchi::Config.get("context.chars_per_token").to_f
|
|
804
|
-
value.positive? ? value : DEFAULT_CONTEXT_CHARS_PER_TOKEN
|
|
805
|
-
end
|
|
806
|
-
|
|
807
|
-
def context_status_thresholds
|
|
808
|
-
raw = Samagotchi::Config.get("context.status_thresholds").to_s
|
|
809
|
-
parsed = raw.split(",").map { |value| value.strip.to_i }.select { |value| value.between?(1, 99) }.uniq.sort
|
|
810
|
-
parsed.empty? ? DEFAULT_CONTEXT_THRESHOLDS : parsed
|
|
811
|
-
end
|
|
812
|
-
|
|
813
|
-
def context_status_cadence
|
|
814
|
-
[Samagotchi::Config.get("context.status_cadence").to_i, 0].max
|
|
815
|
-
end
|
|
816
|
-
|
|
817
|
-
# 0 for the bucket under the first threshold, then one per threshold.
|
|
818
|
-
def bucket_rank(bucket)
|
|
819
|
-
return 0 if bucket.nil? || bucket.to_s.start_with?("under")
|
|
820
|
-
|
|
821
|
-
(context_status_thresholds.index(bucket.to_s.delete_suffix("plus").to_i) || -1) + 1
|
|
822
|
-
end
|
|
823
|
-
|
|
824
|
-
# A rise (never a fall or a cadence tick) into a bucket whose guidance
|
|
825
|
-
# asks for a change. With no previous bucket (a first turn, a resumed
|
|
826
|
-
# session with no line yet), the first bucket counts as a rise from 0.
|
|
827
|
-
def guidance_due?(previous, bucket)
|
|
828
|
-
rank = bucket_rank(bucket)
|
|
829
|
-
rank >= CONTEXT_GUIDANCE_FROM_RANK && rank > bucket_rank(previous)
|
|
830
|
-
end
|
|
831
|
-
|
|
832
|
-
def context_guidance_message(usage:, bucket:)
|
|
833
|
-
how = usage[:source].to_s == "server" ? "as the server reports" : "estimated"
|
|
834
|
-
{ role: "system", kind: CONTEXT_LINE_KIND,
|
|
835
|
-
content: "#{CONTEXT_LINE_PREFIX}about #{usage[:estimated_pct].to_f.round}% of the context window is in use " \
|
|
836
|
-
"(#{how}; bucket=#{bucket}). #{context_status_guidance(bucket)}]" }
|
|
837
|
-
end
|
|
838
|
-
|
|
839
|
-
def context_status_bucket(estimated_pct)
|
|
840
|
-
thresholds = context_status_thresholds
|
|
841
|
-
bucket = "under#{thresholds.first}"
|
|
842
|
-
thresholds.each do |threshold|
|
|
843
|
-
bucket = "#{threshold}plus" if estimated_pct >= threshold
|
|
844
|
-
end
|
|
845
|
-
bucket
|
|
846
|
-
end
|
|
847
|
-
|
|
848
|
-
def should_emit_context_status?(state:, bucket:, iteration_index:)
|
|
849
|
-
last_bucket = state[:last_bucket]
|
|
850
|
-
below_threshold_bucket = "under#{context_status_thresholds.first}"
|
|
851
|
-
bucket_changed = if last_bucket.nil?
|
|
852
|
-
bucket != below_threshold_bucket
|
|
853
|
-
else
|
|
854
|
-
bucket != last_bucket
|
|
855
|
-
end
|
|
856
|
-
|
|
857
|
-
cadence = context_status_cadence
|
|
858
|
-
cadence_due = cadence.positive? && ((iteration_index + 1) % cadence).zero?
|
|
859
|
-
bucket_changed || cadence_due
|
|
860
|
-
end
|
|
623
|
+
# Estimate context usage for this iteration's prompt (ContextStatus#observe)
|
|
624
|
+
# and, when the emit gate fires, surface it to stream consumers as a
|
|
625
|
+
# :context_status event. The telemetry is not for the model (it used to
|
|
626
|
+
# be injected as a synthetic system message).
|
|
627
|
+
def emit_context_status_event(on_stream_event, context, prompt, iteration_index:, window:, image_tokens: 0)
|
|
628
|
+
event = context.observe(prompt, iteration_index: iteration_index, window: window, image_tokens: image_tokens)
|
|
629
|
+
return unless event
|
|
861
630
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
"%<prefix>s window_tokens=%<window>d window_src=%<window_src>s est_used_tokens=%<used>d est_remaining_tokens=%<remaining>d est_pct=%<pct>.1f bucket=%<bucket>s thresholds=%<thresholds>s src=%<src>s guidance=%<guidance>s",
|
|
865
|
-
prefix: CONTEXT_STATUS_PREFIX,
|
|
866
|
-
window: usage[:window_tokens],
|
|
867
|
-
window_src: usage[:window_source] || "default",
|
|
868
|
-
used: usage[:estimated_used_tokens],
|
|
869
|
-
remaining: usage[:estimated_remaining_tokens],
|
|
870
|
-
pct: usage[:estimated_pct],
|
|
871
|
-
bucket: bucket,
|
|
872
|
-
thresholds: context_status_thresholds.join(","),
|
|
873
|
-
src: source,
|
|
874
|
-
guidance: context_status_guidance(bucket)
|
|
875
|
-
)
|
|
876
|
-
end
|
|
877
|
-
|
|
878
|
-
def context_status_guidance(bucket)
|
|
879
|
-
case bucket
|
|
880
|
-
when "under20", "20plus"
|
|
881
|
-
"context healthy — proceed normally"
|
|
882
|
-
when "40plus"
|
|
883
|
-
"context moderate — prefer targeted and range reads over full-file dumps"
|
|
884
|
-
when "60plus"
|
|
885
|
-
"context elevated — be concise, prefer range reads, avoid re-reading large files"
|
|
886
|
-
when "80plus"
|
|
887
|
-
"context critical — summarize aggressively, avoid large outputs, delegate broad work to subagents"
|
|
888
|
-
else
|
|
889
|
-
"context healthy — proceed normally"
|
|
890
|
-
end
|
|
631
|
+
emit_stream_event(on_stream_event, type: :context_status, iteration: iteration_index + 1, **event)
|
|
632
|
+
dump_log("context_status", event[:status], iteration: iteration_index + 1, bucket: event[:bucket])
|
|
891
633
|
end
|
|
892
634
|
|
|
893
635
|
public
|
|
@@ -904,11 +646,7 @@ module Samagotchi
|
|
|
904
646
|
# of +window_tokens+; nil without both, or with context.status off. The
|
|
905
647
|
# chat loop builds its value with it too.
|
|
906
648
|
def context_display(used_tokens:, window_tokens:)
|
|
907
|
-
|
|
908
|
-
return nil unless ContextWindow.positive_integer?(used_tokens) && ContextWindow.positive_integer?(window_tokens)
|
|
909
|
-
|
|
910
|
-
pct = (used_tokens.to_f / window_tokens) * 100.0
|
|
911
|
-
{ est_pct: pct, bucket: context_status_bucket(pct) }
|
|
649
|
+
ContextStatus.new.display_for(used_tokens: used_tokens, window_tokens: window_tokens)
|
|
912
650
|
end
|
|
913
651
|
|
|
914
652
|
# Per-profile parse strategy. Rebuilt when the active profile changes
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "net/http"
|
|
5
3
|
require "socket"
|
|
6
4
|
require_relative "session"
|
|
7
5
|
require_relative "version"
|
|
6
|
+
require_relative "worker_sidecar"
|
|
7
|
+
require_relative "web/info_probe"
|
|
8
8
|
|
|
9
9
|
module Samagotchi
|
|
10
10
|
# Which chi versions the running processes run, for `chi update`: session
|
|
@@ -22,13 +22,12 @@ module Samagotchi
|
|
|
22
22
|
|
|
23
23
|
# @return [Array<Worker>] the workers whose Bridge answers, by session id
|
|
24
24
|
def workers(state_dir: Session.default_state_dir)
|
|
25
|
-
Dir[File.join(state_dir, "*",
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
Dir[File.join(state_dir, "*", WorkerSidecar::FILE)].sort.filter_map do |path|
|
|
26
|
+
session_dir = File.dirname(path)
|
|
27
|
+
sidecar = WorkerSidecar.read(session_dir)
|
|
28
|
+
next unless sidecar && listening?(sidecar.port)
|
|
28
29
|
|
|
29
|
-
Worker.new(session_id: File.basename(
|
|
30
|
-
rescue JSON::ParserError, SystemCallError
|
|
31
|
-
nil
|
|
30
|
+
Worker.new(session_id: File.basename(session_dir), version: sidecar.version)
|
|
32
31
|
end
|
|
33
32
|
end
|
|
34
33
|
|
|
@@ -46,11 +45,8 @@ module Samagotchi
|
|
|
46
45
|
# The /api/info of a chi web on host:port, or nil when nothing (or not
|
|
47
46
|
# chi web) answers.
|
|
48
47
|
def web_info(host, port, timeout: WEB_TIMEOUT)
|
|
49
|
-
|
|
50
|
-
info
|
|
51
|
-
info.is_a?(Hash) && info["app"] == "chi-web" ? info : nil
|
|
52
|
-
rescue StandardError
|
|
53
|
-
nil
|
|
48
|
+
info = Web::InfoProbe.call(host, port, timeout: timeout)
|
|
49
|
+
info.is_a?(Hash) ? info : nil
|
|
54
50
|
end
|
|
55
51
|
|
|
56
52
|
def listening?(port, host: "127.0.0.1")
|
|
@@ -423,7 +423,7 @@ module Samagotchi
|
|
|
423
423
|
end
|
|
424
424
|
index = @conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
|
|
425
425
|
@conversation.delete_at(index) if index
|
|
426
|
-
@cancel_controller.cancel!(:hook)
|
|
426
|
+
@cancel_controller.cancel!(:hook, response.cut)
|
|
427
427
|
canceled(iteration, :hook)
|
|
428
428
|
end
|
|
429
429
|
|