samagotchi 0.55.0 → 0.57.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 +65 -1
- data/README.md +1 -1
- data/docs/architecture.md +6 -2
- data/docs/cli.md +30 -8
- data/docs/configuration.md +84 -8
- data/docs/hooks.md +2 -2
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/engine.md +14 -0
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/memory.md +1 -1
- data/docs/plugins.md +63 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
- data/lib/samagotchi/bootstrap/probe.rb +3 -0
- data/lib/samagotchi/bootstrap_command.rb +2 -0
- data/lib/samagotchi/broadcast_command.rb +2 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +51 -0
- data/lib/samagotchi/client_id.rb +12 -0
- data/lib/samagotchi/config.rb +7 -3
- data/lib/samagotchi/context_command.rb +2 -0
- data/lib/samagotchi/desktop/macos.rb +3 -0
- data/lib/samagotchi/engine.rb +102 -8
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/gem_update.rb +3 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
- data/lib/samagotchi/host_config.rb +6 -3
- data/lib/samagotchi/host_registry.rb +19 -3
- data/lib/samagotchi/installed_versions.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +99 -16
- data/lib/samagotchi/llm/chat_loop.rb +122 -71
- data/lib/samagotchi/llm/openai_chat.rb +2 -1
- data/lib/samagotchi/llm/stream_drops.rb +63 -0
- data/lib/samagotchi/llm/turn_settings.rb +9 -5
- data/lib/samagotchi/model_settings.rb +5 -2
- data/lib/samagotchi/plugin/context.rb +5 -0
- data/lib/samagotchi/reasoning_history.rb +132 -0
- data/lib/samagotchi/reasoning_history_command.rb +107 -0
- data/lib/samagotchi/redacted_inspect.rb +28 -0
- data/lib/samagotchi/self_report.rb +21 -0
- data/lib/samagotchi/session.rb +23 -2
- data/lib/samagotchi/session_commands.rb +55 -15
- data/lib/samagotchi/session_manager.rb +2 -1
- data/lib/samagotchi/session_setup.rb +7 -5
- data/lib/samagotchi/setting_explained.rb +20 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +23 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/terminal_ui.rb +13 -6
- data/lib/samagotchi/thinking.rb +20 -25
- data/lib/samagotchi/thinking_command.rb +33 -11
- data/lib/samagotchi/tool_runner.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +6 -2
- data/lib/samagotchi/tools/execute.rb +3 -2
- data/lib/samagotchi/tools/peers.rb +4 -2
- data/lib/samagotchi/tools/task_runtime.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +29 -6
- data/lib/samagotchi/web/public/app.js +44 -9
- data/lib/samagotchi/web/public/commands.js +36 -3
- data/lib/samagotchi/web/public/composer.js +5 -1
- data/lib/samagotchi/web/public/index.html +22 -5
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/level_chip.js +174 -0
- data/lib/samagotchi/web/public/model_pick.js +1 -1
- data/lib/samagotchi/web/public/question_card.js +5 -3
- data/lib/samagotchi/web/public/question_cards.js +21 -1
- data/lib/samagotchi/web/public/read_view.js +74 -0
- data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
- data/lib/samagotchi/web/public/ref_scan.js +206 -0
- data/lib/samagotchi/web/public/refs.js +19 -10
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +10 -2
- data/lib/samagotchi/web/public/thinking_chip.js +33 -138
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/web/public/turn_view.js +5 -3
- data/lib/samagotchi/web/token.rb +3 -0
- data/lib/samagotchi/worker.rb +17 -5
- metadata +13 -1
data/lib/samagotchi/thinking.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "config"
|
|
4
|
+
require_relative "model_profile"
|
|
5
|
+
require_relative "setting_explained"
|
|
4
6
|
|
|
5
7
|
module Samagotchi
|
|
6
8
|
# How much a model thinks: one level per model, host or run, mapped to each
|
|
@@ -38,21 +40,6 @@ module Samagotchi
|
|
|
38
40
|
# The source a session's own level reads as.
|
|
39
41
|
SESSION_SOURCE = "session"
|
|
40
42
|
|
|
41
|
-
# A level and where it came from (#explain): +source+ is "session",
|
|
42
|
-
# "--thinking", "SAMAGOTCHI_THINKING_LEVEL", "models: qwen",
|
|
43
|
-
# "hosts.work" or "thinking.level", nil for the default; +own+ is the
|
|
44
|
-
# session's own level, nil without one. What /thinking, /model,
|
|
45
|
-
# /stats, the snapshots and the web read.
|
|
46
|
-
Explained = Data.define(:level, :source, :own) do
|
|
47
|
-
# "low (session)", "default" with no source.
|
|
48
|
-
def label = source ? "#{level} (#{source})" : level.to_s
|
|
49
|
-
|
|
50
|
-
# The plain form the snapshots, the worker's command_ran event and
|
|
51
|
-
# the web read.
|
|
52
|
-
# @return [Hash] symbol keys
|
|
53
|
-
def summary = { level: level.to_s, source: source, own: own&.to_s }
|
|
54
|
-
end
|
|
55
|
-
|
|
56
43
|
module_function
|
|
57
44
|
|
|
58
45
|
# A session's own level from its file or a command word: :off, :low,
|
|
@@ -113,11 +100,11 @@ module Samagotchi
|
|
|
113
100
|
[DEFAULT, nil]
|
|
114
101
|
end
|
|
115
102
|
|
|
116
|
-
# #resolve, as
|
|
117
|
-
# @return [
|
|
103
|
+
# #resolve, as a SettingExplained with the session's own level beside it.
|
|
104
|
+
# @return [SettingExplained]
|
|
118
105
|
def explain(target, names:, models: nil, session: nil)
|
|
119
106
|
level, source = resolve(target, names: names, models: models, session: session)
|
|
120
|
-
|
|
107
|
+
SettingExplained.new(level: level, source: source, own: session)
|
|
121
108
|
end
|
|
122
109
|
|
|
123
110
|
# thinking.level as a level and its origin (:cli, :env, :file), or nil.
|
|
@@ -133,17 +120,25 @@ module Samagotchi
|
|
|
133
120
|
end
|
|
134
121
|
|
|
135
122
|
# What a level change costs the prompt cache on +target+ (/thinking's
|
|
136
|
-
# note;
|
|
137
|
-
# :full
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
# :
|
|
123
|
+
# note; CacheConfirm asks before the costly ones):
|
|
124
|
+
# :full Gemma 4's prompt starts with <|think|> (the native
|
|
125
|
+
# profile, or a local chat server whose model name says
|
|
126
|
+
# gemma: its template writes the same token), so the whole
|
|
127
|
+
# prompt is read again;
|
|
128
|
+
# :provider a remote chat host gets other request fields: Claude's
|
|
129
|
+
# message breakpoints and OpenAI's prefix cache are lost;
|
|
130
|
+
# :tail native Qwen, a local chat server's Qwen and the rest:
|
|
131
|
+
# only the tail changes (llama.cpp and Splash keep the rest).
|
|
142
132
|
# @param target [HostRegistry::ModelTarget]
|
|
143
133
|
# @param profile [String, nil] the native prompt profile's name
|
|
144
134
|
# @return [Symbol]
|
|
145
135
|
def cache_cost(target, profile: nil)
|
|
146
|
-
|
|
136
|
+
entry = target.entry
|
|
137
|
+
if entry&.chat?
|
|
138
|
+
return :provider if entry.remote?
|
|
139
|
+
|
|
140
|
+
return ModelProfile.inferred_profile_name(target.bare_model) == "gemma4" ? :full : :tail
|
|
141
|
+
end
|
|
147
142
|
|
|
148
143
|
profile == "gemma4" ? :full : :tail
|
|
149
144
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "cache_confirm"
|
|
3
4
|
require_relative "thinking"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
@@ -10,6 +11,7 @@ module Samagotchi
|
|
|
10
11
|
# model, its host, then thinking.level). A change takes effect at the
|
|
11
12
|
# next turn's start (the REPL and the worker run it between turns), and
|
|
12
13
|
# the reply says what it costs the prompt cache (Thinking.cache_cost).
|
|
14
|
+
# A costly change asks first (CacheConfirm); --yes (or -y) applies it.
|
|
13
15
|
class ThinkingCommand
|
|
14
16
|
NAME = "/thinking"
|
|
15
17
|
SETTABLE = (Thinking::LEVELS - [Thinking::DEFAULT]).freeze
|
|
@@ -17,35 +19,43 @@ module Samagotchi
|
|
|
17
19
|
|
|
18
20
|
COST_NOTES = {
|
|
19
21
|
full: "On this model the whole prompt is read again (Gemma's <|think|> starts it).",
|
|
20
|
-
provider: "The request's thinking fields change: a
|
|
21
|
-
"
|
|
22
|
+
provider: "The request's thinking fields change: a hosted API (OpenRouter, Claude, OpenAI) reads the whole " \
|
|
23
|
+
"conversation again.",
|
|
22
24
|
tail: "Only the prompt's tail changes: the cache keeps the rest."
|
|
23
25
|
}.freeze
|
|
24
26
|
|
|
25
|
-
# @param engine [Engine] #thinking_explained, #thinking_override(=),
|
|
26
|
-
# #session, #thinking_cache_cost
|
|
27
|
+
# @param engine [Engine] #thinking_explained(own:), #thinking_override(=),
|
|
28
|
+
# #session, #thinking_cache_cost, #history?
|
|
27
29
|
# @param save [#call] saves the session after a change
|
|
28
30
|
def initialize(engine:, save:)
|
|
29
31
|
@engine = engine
|
|
30
32
|
@save = save
|
|
31
33
|
end
|
|
32
34
|
|
|
33
|
-
# @param args [String] what follows /thinking
|
|
34
|
-
# @
|
|
35
|
+
# @param args [String] what follows /thinking (--yes anywhere)
|
|
36
|
+
# @param confirm [Boolean] ask before a costly change (CacheConfirm);
|
|
37
|
+
# false applies it at once (chi send, a delegate, a start flag)
|
|
38
|
+
# @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
|
|
39
|
+
# whether the level changed, and the confirm it asks for (nothing
|
|
40
|
+
# changed then)
|
|
35
41
|
# @raise [ArgumentError] a word that isn't a level (with USAGE)
|
|
36
|
-
def run(args)
|
|
37
|
-
word = args
|
|
38
|
-
return [listing(@engine.thinking_explained), false] if word.empty?
|
|
42
|
+
def run(args, confirm: true)
|
|
43
|
+
word, yes = CacheConfirm.split(args)
|
|
44
|
+
return [listing(@engine.thinking_explained), false, nil] if word.empty?
|
|
39
45
|
|
|
40
46
|
level = word.to_sym
|
|
41
47
|
raise ArgumentError, "unknown thinking level #{word}\n#{USAGE}" unless Thinking::LEVELS.include?(level)
|
|
42
48
|
|
|
43
49
|
session = @engine.session or raise ArgumentError, "no session yet"
|
|
50
|
+
own = SETTABLE.include?(level) ? level : nil
|
|
44
51
|
before = @engine.thinking_explained
|
|
45
|
-
|
|
52
|
+
ask = confirm && !yes ? ask(before, own, word) : nil
|
|
53
|
+
return ask if ask
|
|
54
|
+
|
|
55
|
+
@engine.thinking_override = own
|
|
46
56
|
@save.call(session)
|
|
47
57
|
after = @engine.thinking_explained
|
|
48
|
-
[[listing(after), *notes(before, after)].join("\n"), true]
|
|
58
|
+
[[listing(after), *notes(before, after)].join("\n"), true, nil]
|
|
49
59
|
end
|
|
50
60
|
|
|
51
61
|
# The level, where it came from, and what the session sets itself.
|
|
@@ -62,6 +72,18 @@ module Samagotchi
|
|
|
62
72
|
|
|
63
73
|
private
|
|
64
74
|
|
|
75
|
+
# The reply that asks before a costly change, nil when it needn't.
|
|
76
|
+
def ask(before, own, word)
|
|
77
|
+
would = @engine.thinking_explained(own: own)
|
|
78
|
+
return nil unless before && would
|
|
79
|
+
|
|
80
|
+
cost = @engine.thinking_cache_cost
|
|
81
|
+
return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
|
|
82
|
+
|
|
83
|
+
step = CacheConfirm.confirm(NAME, word)
|
|
84
|
+
[[listing(before), "Switching to #{would.level} restarts the prompt cache: #{COST_NOTES[cost]}", step.text].join("\n"), false, step]
|
|
85
|
+
end
|
|
86
|
+
|
|
65
87
|
def notes(before, after)
|
|
66
88
|
return [] unless before && after
|
|
67
89
|
return ["(no change to what the next turn runs under)"] if before.level == after.level
|
|
@@ -28,7 +28,8 @@ module Samagotchi
|
|
|
28
28
|
|
|
29
29
|
# @param kernel [KernelLoop] read lazily: Engine sets its hooks after
|
|
30
30
|
# the kernel is built.
|
|
31
|
-
# @param clock [#call] monotonic seconds, for the approval wait
|
|
31
|
+
# @param clock [#call] monotonic seconds, for the approval wait and the
|
|
32
|
+
# dispatch time (after_tool_call's elapsed_ms)
|
|
32
33
|
def initialize(kernel, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
33
34
|
@kernel = kernel
|
|
34
35
|
@clock = clock
|
|
@@ -71,7 +72,14 @@ module Samagotchi
|
|
|
71
72
|
end
|
|
72
73
|
before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
|
|
73
74
|
index_before = before.equal?(NOT_AN_EDIT) ? nil : memory_index_size(call)
|
|
74
|
-
|
|
75
|
+
elapsed_ms = nil
|
|
76
|
+
if verdict.deny?
|
|
77
|
+
result = denied(call, verdict)
|
|
78
|
+
else
|
|
79
|
+
dispatched_at = @clock.call
|
|
80
|
+
result = dispatch(call)
|
|
81
|
+
elapsed_ms = ((@clock.call - dispatched_at) * 1000).round
|
|
82
|
+
end
|
|
75
83
|
diff = file_change(call, before)
|
|
76
84
|
if diff && refresh_memory_index(call)
|
|
77
85
|
result = with_index_note(result, index_before, call)
|
|
@@ -80,6 +88,9 @@ module Samagotchi
|
|
|
80
88
|
result, images = attach_images(call, result) if result[:images]
|
|
81
89
|
|
|
82
90
|
output = scrub(result[:output].to_s)
|
|
91
|
+
# The tool's own text, for after_tool_call: no "[tool]" lead, no
|
|
92
|
+
# ran-as line, no cap.
|
|
93
|
+
tool_text = output.sub(/\A\[#{Regexp.escape(call[:name].to_s)}\](?:\n| )/, "")
|
|
83
94
|
# The gate may have replaced the call (known-names corrects a near
|
|
84
95
|
# miss): one leading line tells the model what actually ran.
|
|
85
96
|
if (changed = changed_call_line(original, call))
|
|
@@ -104,7 +115,7 @@ module Samagotchi
|
|
|
104
115
|
# After the completed event: a hook's card (check-in, skills) prints
|
|
105
116
|
# under the call's tool row, not before it.
|
|
106
117
|
fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
|
|
107
|
-
status: status || "error" })
|
|
118
|
+
status: status || "error", call: call.dup, elapsed_ms: elapsed_ms, result: tool_text })
|
|
108
119
|
|
|
109
120
|
run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
|
|
110
121
|
run[:images] = images if images&.any?
|
|
@@ -121,7 +121,8 @@ module Samagotchi
|
|
|
121
121
|
# /model), which chi self reports; unset when unknown, so a value
|
|
122
122
|
# inherited from an outer session never passes for this one. So too
|
|
123
123
|
# the session's own thinking level (SAMAGOTCHI_SESSION_THINKING, unset
|
|
124
|
-
# without one), which chi self puts first
|
|
124
|
+
# without one), which chi self puts first, and its own reasoning_history
|
|
125
|
+
# level (SAMAGOTCHI_SESSION_REASONING_HISTORY, the same way). A
|
|
125
126
|
# worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
|
|
126
127
|
# is unset: it was that run's, not the default of a chi run here.
|
|
127
128
|
def parent_env(kctx, env: ENV)
|
|
@@ -129,9 +130,11 @@ module Samagotchi
|
|
|
129
130
|
id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
|
|
130
131
|
model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
|
|
131
132
|
thinking = peers.respond_to?(:thinking) ? peers.thinking&.to_s : nil
|
|
133
|
+
history = peers.respond_to?(:reasoning_history) ? peers.reasoning_history&.to_s : nil
|
|
132
134
|
{ Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
133
135
|
SESSION_MODEL_ENV => model.empty? ? nil : model,
|
|
134
|
-
SESSION_THINKING_ENV => thinking
|
|
136
|
+
SESSION_THINKING_ENV => thinking,
|
|
137
|
+
SESSION_REASONING_HISTORY_ENV => history }.merge(cli_model_unset(env))
|
|
135
138
|
end
|
|
136
139
|
|
|
137
140
|
# The unsets that keep a `chi --model X` worker's default from passing
|
|
@@ -144,6 +147,7 @@ module Samagotchi
|
|
|
144
147
|
|
|
145
148
|
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
146
149
|
SESSION_THINKING_ENV = "SAMAGOTCHI_SESSION_THINKING"
|
|
150
|
+
SESSION_REASONING_HISTORY_ENV = "SAMAGOTCHI_SESSION_REASONING_HISTORY"
|
|
147
151
|
|
|
148
152
|
# task_create's parent_env, less the unsets of a variable the model
|
|
149
153
|
# set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
|
|
@@ -24,6 +24,8 @@ module Samagotchi
|
|
|
24
24
|
NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
|
|
25
25
|
# The first words of a command the user's Stop killed.
|
|
26
26
|
STOPPED_BY_USER = "Error: command stopped by the user"
|
|
27
|
+
# The first words of a command killed at the limit (hooks match on it).
|
|
28
|
+
TIMEOUT_PREFIX = "Error: command timed out after"
|
|
27
29
|
TIMEOUT_HINT = "(killed at the limit; for a long command use task_create, then task_wait)"
|
|
28
30
|
# How long the output may stay open once the shell has exited: a
|
|
29
31
|
# `server &` child keeps the pipes, and the read would never end.
|
|
@@ -68,7 +70,7 @@ module Samagotchi
|
|
|
68
70
|
rescue CommandTimedOut => e
|
|
69
71
|
# Keep the "Error:" first line (callers classify on it), say how to run
|
|
70
72
|
# a long command, and return whatever it printed before it was killed.
|
|
71
|
-
stopped_result("
|
|
73
|
+
stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
|
|
72
74
|
rescue CommandCancelled => e
|
|
73
75
|
stopped_result("#{STOPPED_BY_USER} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
|
|
74
76
|
rescue StandardError => e
|
|
@@ -189,7 +191,6 @@ module Samagotchi
|
|
|
189
191
|
def self.timeout_seconds
|
|
190
192
|
OutputGuardrails.config_positive_int("execute.timeout_sec", TIMEOUT_SEC)
|
|
191
193
|
end
|
|
192
|
-
private_class_method :timeout_seconds
|
|
193
194
|
|
|
194
195
|
# Resolves the working directory for the child process. An empty/absent
|
|
195
196
|
# cwd defaults to the project root (Dir.pwd), mirroring task_runtime.
|
|
@@ -9,10 +9,12 @@ module Samagotchi
|
|
|
9
9
|
# delegate's approval is relayed through (relay: nil when this session
|
|
10
10
|
# can't host one), and the model the session runs on (model_ref: the
|
|
11
11
|
# resolved ref, live after /model), and the session's own thinking level
|
|
12
|
-
# (thinking: nil without one)
|
|
12
|
+
# (thinking: nil without one) and reasoning_history level
|
|
13
|
+
# (reasoning_history: nil without one). Engine hands KernelLoop one that follows its current
|
|
13
14
|
# session (Engine::PeerView); this plain one is for specs and a kernel
|
|
14
15
|
# without an Engine.
|
|
15
|
-
Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking,
|
|
16
|
+
Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, :reasoning_history,
|
|
17
|
+
keyword_init: true) do
|
|
16
18
|
# @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
|
|
17
19
|
def cancelled?
|
|
18
20
|
cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
|
|
@@ -22,7 +22,8 @@ module Samagotchi
|
|
|
22
22
|
OUTPUT_TAIL_READ_BYTES = 16 * 1024
|
|
23
23
|
SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
|
|
24
24
|
# Set by chi (Builtins.parent_env); the model's env can't change it.
|
|
25
|
-
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
|
|
25
|
+
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
|
|
26
|
+
SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
|
|
26
27
|
# task_wait and task_get add it to a task the user stopped (the web's
|
|
27
28
|
# stop-task button), so the model doesn't rerun the job.
|
|
28
29
|
# A record whose pid isn't a process group chi started (missing, 0, 1,
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -26,6 +26,7 @@ require_relative "../llm_context_override"
|
|
|
26
26
|
require_relative "../model_catalog"
|
|
27
27
|
require_relative "../model_profile"
|
|
28
28
|
require_relative "../thinking"
|
|
29
|
+
require_relative "../reasoning_history"
|
|
29
30
|
require_relative "../sampling_settings"
|
|
30
31
|
require_relative "../served_model"
|
|
31
32
|
require_relative "../project_scope"
|
|
@@ -517,7 +518,7 @@ module Samagotchi
|
|
|
517
518
|
end
|
|
518
519
|
|
|
519
520
|
# The thinking level +model+ starts at, with where it came from (the
|
|
520
|
-
# start page's think chip;
|
|
521
|
+
# start page's think chip; SettingExplained#summary, no session).
|
|
521
522
|
def add_thinking(model, target, names, settings)
|
|
522
523
|
model[:thinking] = Thinking.explain(target, names: names, models: settings).summary
|
|
523
524
|
rescue StandardError
|
|
@@ -561,7 +562,7 @@ module Samagotchi
|
|
|
561
562
|
end
|
|
562
563
|
|
|
563
564
|
# A session's thinking level with where it came from and its own
|
|
564
|
-
# (
|
|
565
|
+
# (SettingExplained#summary), as its worker would resolve it; nil
|
|
565
566
|
# when it can't be. Config and the registry only: no network.
|
|
566
567
|
def thinking_for(session)
|
|
567
568
|
registry = host_registry
|
|
@@ -573,6 +574,22 @@ module Samagotchi
|
|
|
573
574
|
nil
|
|
574
575
|
end
|
|
575
576
|
|
|
577
|
+
# A session's reasoning_history level with where it came from and its
|
|
578
|
+
# own (SettingExplained#summary), as its worker would resolve it; nil
|
|
579
|
+
# on a native host (it ignores the level) or when it can't be. Config
|
|
580
|
+
# and the registry only: no network.
|
|
581
|
+
def reasoning_history_for(session)
|
|
582
|
+
registry = host_registry
|
|
583
|
+
name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
|
|
584
|
+
target = registry.resolve(name)
|
|
585
|
+
return nil unless target.entry&.chat?
|
|
586
|
+
|
|
587
|
+
names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
|
|
588
|
+
ReasoningHistory.explain(target, names: names, session: session.reasoning_history).summary
|
|
589
|
+
rescue StandardError
|
|
590
|
+
nil
|
|
591
|
+
end
|
|
592
|
+
|
|
576
593
|
# Built again when config.yml's hosts: changed (compared as read: the
|
|
577
594
|
# YAML read is mtime-cached), so the model picker lists a host added
|
|
578
595
|
# while chi web runs. An injected registry (specs) stays.
|
|
@@ -689,10 +706,12 @@ module Samagotchi
|
|
|
689
706
|
|
|
690
707
|
# POST /api/sessions with continues: <id, prefix or last:id>: the next
|
|
691
708
|
# link of that session's chain (SessionManager.continue_session), in its
|
|
692
|
-
# folder, on its model and with its
|
|
693
|
-
# dir, model, llm_context and
|
|
694
|
-
#
|
|
695
|
-
#
|
|
709
|
+
# folder, on its model and with its own setup (SessionSetup: llm_context,
|
|
710
|
+
# thinking and reasoning_history levels), so dir, model, llm_context and
|
|
711
|
+
# thinking are refused beside it (a create takes no reasoning_history).
|
|
712
|
+
# 409 continued (with next_id, the link that continues it already: the
|
|
713
|
+
# page opens that one), open_children (ids), folder_gone, busy, scratch
|
|
714
|
+
# or owned_by_tui.
|
|
696
715
|
def handle_continue(body, prompt, idle)
|
|
697
716
|
given = body["continues"]
|
|
698
717
|
unless given.is_a?(String) && !given.strip.empty?
|
|
@@ -820,6 +839,10 @@ module Samagotchi
|
|
|
820
839
|
session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
|
|
821
840
|
# The thinking level the next turn runs at (the think chip), the same way.
|
|
822
841
|
session_json = session_json.merge(thinking: (snapshot && snapshot["thinking"]) || thinking_for(session))
|
|
842
|
+
# Its reasoning history (the reasoning chip): the worker's (nil on a
|
|
843
|
+
# native host), else the server's reading (an older worker, none).
|
|
844
|
+
history = snapshot&.key?("reasoning_history") ? snapshot["reasoning_history"] : reasoning_history_for(session)
|
|
845
|
+
session_json = session_json.merge(reasoning_history: history)
|
|
823
846
|
raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
|
|
824
847
|
timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
|
|
825
848
|
json_response(200, {
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
chipModel as thinkingChipModel, createThinkingControl,
|
|
31
31
|
withChoice as withThinkingChoice,
|
|
32
32
|
} from "./thinking_chip.js";
|
|
33
|
+
import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
|
|
33
34
|
import { renderInfoBar } from "./info_bar.js";
|
|
34
35
|
import { createShownTexts } from "./shown_texts.js";
|
|
35
36
|
import { MODEL_KEY } from "./model_pick.js";
|
|
@@ -65,9 +66,10 @@ import {
|
|
|
65
66
|
} from "./timing.js";
|
|
66
67
|
import { createLiveTurn } from "./live_turn.js";
|
|
67
68
|
import { installRefClicks, setRefKinds } from "./refs.js";
|
|
69
|
+
import { linkRefs, touchedFiles } from "./ref_scan.js";
|
|
68
70
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
69
71
|
import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
|
|
70
|
-
import { drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
|
|
72
|
+
import { applyConfirm, drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
|
|
71
73
|
import { createComposerCommands, routeComposerLine, routeSessionLine } from "./composer.js";
|
|
72
74
|
import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
|
|
73
75
|
import { createStreamHandlers } from "./stream_handlers.js";
|
|
@@ -134,6 +136,15 @@ const thinkingControl = createThinkingControl({
|
|
|
134
136
|
running: () => liveTurn.running,
|
|
135
137
|
run: (line) => runSessionCommand(line),
|
|
136
138
|
});
|
|
139
|
+
// The open session's reasoning history chip, after the thinking one (a
|
|
140
|
+
// chat host's only): /reasoning-history runs in the session's worker.
|
|
141
|
+
const reasoningHistoryControl = createReasoningHistoryControl({
|
|
142
|
+
el: infoTextEl,
|
|
143
|
+
summary: () => (selected ? current.reasoningHistory : null),
|
|
144
|
+
session: () => selected,
|
|
145
|
+
running: () => liveTurn.running,
|
|
146
|
+
run: (line) => runSessionCommand(line),
|
|
147
|
+
});
|
|
137
148
|
// The start page's thinking chip: the picked model's level, and a level
|
|
138
149
|
// for the next new chat only (sent with its create, then dropped).
|
|
139
150
|
const thinkingNewEl = $("#thinkingNew");
|
|
@@ -763,6 +774,7 @@ function renderSessionView(data, { kept = [] } = {}) {
|
|
|
763
774
|
const activeTurnId = current.timing.activeTurn?.id || null;
|
|
764
775
|
llmContextControl.refresh();
|
|
765
776
|
thinkingControl.refresh();
|
|
777
|
+
reasoningHistoryControl.refresh();
|
|
766
778
|
if (data.messages && data.messages.length) renderHistory(data.messages);
|
|
767
779
|
else renderHistory(data.history || []);
|
|
768
780
|
restoreCommandBubbles(historyEl, kept);
|
|
@@ -826,6 +838,7 @@ function renderHistory(items) {
|
|
|
826
838
|
}
|
|
827
839
|
if (typeof items[0] === "object" && items[0].role) {
|
|
828
840
|
historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) }));
|
|
841
|
+
linkTextRefs();
|
|
829
842
|
} else {
|
|
830
843
|
historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
|
|
831
844
|
}
|
|
@@ -890,20 +903,34 @@ async function readTurnEnd() {
|
|
|
890
903
|
} catch(_){}
|
|
891
904
|
}
|
|
892
905
|
|
|
906
|
+
// The turn's answer as its rendered markdown, once it is final (the
|
|
907
|
+
// turn-end read, an answer_display re-read); then its refs.
|
|
893
908
|
function applyFinalMarkdown(messages) {
|
|
894
909
|
const message = [...messages].reverse().find(
|
|
895
910
|
(item) => item?.role === "assistant" && typeof item.html === "string",
|
|
896
911
|
);
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
const bubble = findInTurnRoots(".bubble.output")
|
|
912
|
+
const bubble = message && findInTurnRoots(".bubble.output")
|
|
900
913
|
.reverse()
|
|
901
914
|
.find((item) => showsAnswer(item, message, normalize));
|
|
902
|
-
if (
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
915
|
+
if (bubble) {
|
|
916
|
+
bubble.classList.add("markdown");
|
|
917
|
+
bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
|
|
918
|
+
bubble.innerHTML = message.html;
|
|
919
|
+
}
|
|
920
|
+
linkTextRefs();
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// The `path:line` refs in the answers' and closed steps' texts that name a
|
|
924
|
+
// file the session's rows (and cards) touched, on this page (ref_scan.js).
|
|
925
|
+
// Not mid-stream: at a turn's end and after a reload's render. A ref
|
|
926
|
+
// already made stays as it is.
|
|
927
|
+
function linkTextRefs() {
|
|
928
|
+
const roots = turnRoots();
|
|
929
|
+
const touched = touchedFiles(roots);
|
|
930
|
+
if (!touched.size) return;
|
|
931
|
+
for (const root of roots) {
|
|
932
|
+
for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, current.dir);
|
|
933
|
+
}
|
|
907
934
|
}
|
|
908
935
|
|
|
909
936
|
function seedSeenFromDom() {
|
|
@@ -1429,6 +1456,7 @@ function updateInfoBar() {
|
|
|
1429
1456
|
kids: childrenSummary({ id: selected }, allSessions),
|
|
1430
1457
|
llmContext: current.llmContext,
|
|
1431
1458
|
thinking: current.thinking,
|
|
1459
|
+
reasoningHistory: current.reasoningHistory,
|
|
1432
1460
|
// The model notes the session's prompt carried.
|
|
1433
1461
|
notes: promptNotesChip(current.promptNotes),
|
|
1434
1462
|
memories: current.usedMemories,
|
|
@@ -1596,6 +1624,7 @@ const streamHandlers = createStreamHandlers({
|
|
|
1596
1624
|
contextChips,
|
|
1597
1625
|
llmContextControl,
|
|
1598
1626
|
thinkingControl,
|
|
1627
|
+
reasoningHistoryControl,
|
|
1599
1628
|
sentIds,
|
|
1600
1629
|
earlyRestores,
|
|
1601
1630
|
stream: {
|
|
@@ -2284,6 +2313,12 @@ const annotateBar = createAnnotateBar({
|
|
|
2284
2313
|
},
|
|
2285
2314
|
});
|
|
2286
2315
|
historyEl.addEventListener("scroll", annotateBar.schedule);
|
|
2316
|
+
// A command bubble's Apply (CacheConfirm): sends the change's --yes line
|
|
2317
|
+
// once; its own bubble then says what it did.
|
|
2318
|
+
historyEl.addEventListener("click", (e) => {
|
|
2319
|
+
const button = e.target.closest?.("[data-confirm-line]");
|
|
2320
|
+
if (button) applyConfirm(button, runSessionCommand);
|
|
2321
|
+
});
|
|
2287
2322
|
turnDom.scrollEl?.addEventListener("scroll", annotateBar.schedule, true);
|
|
2288
2323
|
turnRoots().forEach((root) => installCopy(root));
|
|
2289
2324
|
|
|
@@ -148,13 +148,17 @@ export function continueLine(choice, reason = "") {
|
|
|
148
148
|
// it shows; its command_ran fills that bubble in (`commandId`). So is a
|
|
149
149
|
// command queued for after the running turn (`queued`): its command_queued
|
|
150
150
|
// (waits: "turn_end") draws it `waiting`, saying so, until it ran or was
|
|
151
|
-
// dropped (styled as failed).
|
|
151
|
+
// dropped (styled as failed). A change that restarts the prompt cache asks
|
|
152
|
+
// first (CacheConfirm): `confirm` is the line that applies it, and this
|
|
153
|
+
// tab's own bubble (`own`) gets an Apply button that sends it.
|
|
152
154
|
export const QUEUED_TEXT = "queued: runs after this turn";
|
|
155
|
+
export const APPLY_TEXT = "Apply (restarts the cache)";
|
|
153
156
|
|
|
154
157
|
export function commandView(event, myId) {
|
|
155
158
|
const own = isOwn(event.client_id, myId);
|
|
156
159
|
const waiting = event.waits === "turn_end";
|
|
157
160
|
return {
|
|
161
|
+
own,
|
|
158
162
|
label: own ? null : clientLabel(event.client_id),
|
|
159
163
|
line: event.line || "",
|
|
160
164
|
text: event.output || (waiting ? QUEUED_TEXT : ""),
|
|
@@ -166,6 +170,7 @@ export function commandView(event, myId) {
|
|
|
166
170
|
queued: waiting || event.queued === true,
|
|
167
171
|
waiting,
|
|
168
172
|
commandId: event.command_id || null,
|
|
173
|
+
confirm: typeof event.confirm?.line === "string" ? event.confirm.line : null,
|
|
169
174
|
// A card's action (Nudge…): no bubble unless it says something back.
|
|
170
175
|
hidden: event.card === true && !event.output && event.status !== "error",
|
|
171
176
|
};
|
|
@@ -183,8 +188,12 @@ export function historyHasCommandBubble(historyBubbles, line) {
|
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
// A command bubble's line and output (again, when its command_ran fills in
|
|
186
|
-
// an anytime command's bubble in place)
|
|
187
|
-
|
|
191
|
+
// an anytime command's bubble in place), and this tab's own change's Apply
|
|
192
|
+
// button when it asks first (a delegated click sends its line: app.js).
|
|
193
|
+
// Command bubbles are page-only: after a reload the button is gone, and
|
|
194
|
+
// the change is typed again.
|
|
195
|
+
export function drawCommandBubble(div, { label, line, text, busy = false, failed = false, waiting = false, commandId = null,
|
|
196
|
+
own = false, confirm = null }) {
|
|
188
197
|
div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "") + (waiting ? " queued" : "");
|
|
189
198
|
if (commandId) div.dataset.commandId = commandId;
|
|
190
199
|
div.replaceChildren();
|
|
@@ -198,6 +207,30 @@ export function drawCommandBubble(div, { label, line, text, busy = false, failed
|
|
|
198
207
|
body.textContent = text;
|
|
199
208
|
div.appendChild(body);
|
|
200
209
|
}
|
|
210
|
+
if (own && confirm) {
|
|
211
|
+
const apply = document.createElement("button");
|
|
212
|
+
apply.type = "button";
|
|
213
|
+
apply.className = "command-confirm";
|
|
214
|
+
apply.dataset.confirmLine = confirm;
|
|
215
|
+
apply.title = confirm;
|
|
216
|
+
apply.textContent = APPLY_TEXT;
|
|
217
|
+
div.appendChild(apply);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// A click on a bubble's Apply button: sends its --yes line once (the
|
|
222
|
+
// button is disabled at once), and enables it again when the line wasn't
|
|
223
|
+
// sent (+run+ resolves false or throws), so the user can try again.
|
|
224
|
+
export async function applyConfirm(button, run) {
|
|
225
|
+
if (!button || button.disabled) return;
|
|
226
|
+
button.disabled = true;
|
|
227
|
+
let sent = false;
|
|
228
|
+
try {
|
|
229
|
+
sent = await run(button.dataset.confirmLine);
|
|
230
|
+
} catch {
|
|
231
|
+
sent = false;
|
|
232
|
+
}
|
|
233
|
+
if (!sent) button.disabled = false;
|
|
201
234
|
}
|
|
202
235
|
|
|
203
236
|
// The prompt and command bubbles in the history, in order, by kind
|
|
@@ -86,14 +86,18 @@ export function createComposerCommands(deps) {
|
|
|
86
86
|
|
|
87
87
|
// A session command from a control (the llm ctx chip), not the composer:
|
|
88
88
|
// its command_ran shows its line and reply, as a typed one's would.
|
|
89
|
+
// Resolves true once the worker took it, false when it wasn't sent (no
|
|
90
|
+
// session, a refusal or a network error: the failed bubble says why).
|
|
89
91
|
async function run(line) {
|
|
90
92
|
const id = session();
|
|
91
|
-
if (!id) return;
|
|
93
|
+
if (!id) return false;
|
|
92
94
|
try {
|
|
93
95
|
await sendCommand(id, line, { clientId });
|
|
94
96
|
followWokenWorker();
|
|
97
|
+
return true;
|
|
95
98
|
} catch (e) {
|
|
96
99
|
failed(line, e.message);
|
|
100
|
+
return false;
|
|
97
101
|
}
|
|
98
102
|
}
|
|
99
103
|
|