samagotchi 0.54.0 → 0.56.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 +50 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +6 -2
- data/docs/cli.md +29 -7
- data/docs/configuration.md +113 -8
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/context-telemetry.md +23 -5
- data/docs/internals/engine.md +54 -18
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/sessions.md +2 -2
- data/docs/testing.md +23 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cache_confirm.rb +51 -0
- data/lib/samagotchi/client_id.rb +12 -0
- data/lib/samagotchi/config.rb +9 -3
- data/lib/samagotchi/engine.rb +102 -8
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- data/lib/samagotchi/host_config.rb +6 -3
- data/lib/samagotchi/host_registry.rb +4 -3
- 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/reasoning_history.rb +132 -0
- data/lib/samagotchi/reasoning_history_command.rb +107 -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 +11 -0
- 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_row_fields.rb +46 -0
- data/lib/samagotchi/tool_runner.rb +7 -11
- data/lib/samagotchi/tools/builtins.rb +6 -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 +48 -4
- data/lib/samagotchi/web/capabilities.rb +13 -9
- data/lib/samagotchi/web/editor.rb +38 -0
- data/lib/samagotchi/web/message_parts.rb +5 -10
- data/lib/samagotchi/web/public/activity.js +33 -18
- data/lib/samagotchi/web/public/app.js +256 -1133
- data/lib/samagotchi/web/public/commands.js +269 -0
- data/lib/samagotchi/web/public/composer.js +105 -0
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +11 -3
- data/lib/samagotchi/web/public/info_bar.js +80 -0
- data/lib/samagotchi/web/public/level_chip.js +174 -0
- data/lib/samagotchi/web/public/lists_view.js +725 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
- data/lib/samagotchi/web/public/model_pick.js +1 -1
- data/lib/samagotchi/web/public/question_cards.js +2 -1
- data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
- data/lib/samagotchi/web/public/refs.js +134 -0
- data/lib/samagotchi/web/public/restore.js +81 -0
- data/lib/samagotchi/web/public/session_state.js +57 -0
- data/lib/samagotchi/web/public/stream_handlers.js +332 -0
- data/lib/samagotchi/web/public/thinking_chip.js +38 -144
- data/lib/samagotchi/web/public/turn_events.js +17 -258
- data/lib/samagotchi/web/public/turn_html.js +5 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/worker.rb +17 -5
- metadata +18 -2
- data/lib/samagotchi/web/public/stable_html.js +0 -18
|
@@ -371,12 +371,13 @@ module Samagotchi
|
|
|
371
371
|
|
|
372
372
|
# `chi -p "/models" --non-interactive`: the command's output; a refused
|
|
373
373
|
# one fails the run (bin/chi exits 1). The session is saved only when
|
|
374
|
-
# the command changed it.
|
|
374
|
+
# the command changed it. Nobody is there to answer a confirm: a
|
|
375
|
+
# costly /thinking or /reasoning-history applies at once.
|
|
375
376
|
# @return [Symbol, nil] :turn_failed when the command was refused
|
|
376
377
|
def run_one_shot_command(session)
|
|
377
378
|
line = @prompt.strip
|
|
378
379
|
result = begin
|
|
379
|
-
@commands.run(line)
|
|
380
|
+
@commands.run(line, confirm: false)
|
|
380
381
|
rescue StandardError => e
|
|
381
382
|
SessionCommands::Result.new(status: :error, output: "#{line.split.first}: #{e.message}", changed: [])
|
|
382
383
|
end
|
|
@@ -789,11 +790,14 @@ module Samagotchi
|
|
|
789
790
|
|
|
790
791
|
# /model, /models, !rollback, !cmd, /continue (shared with workers);
|
|
791
792
|
# an anytime command's cards print as it shows them (btw's
|
|
792
|
-
# "thinking…" before the answer).
|
|
793
|
+
# "thinking…" before the answer). Piped input has nobody to answer a
|
|
794
|
+
# confirm (the next line is already written): a costly /thinking or
|
|
795
|
+
# /reasoning-history applies at once.
|
|
796
|
+
confirm = !piped_input?
|
|
793
797
|
command = if command_registry.anytime?(input)
|
|
794
|
-
@engine.running_anytime { @commands.run(input) }
|
|
798
|
+
@engine.running_anytime { @commands.run(input, confirm: confirm) }
|
|
795
799
|
else
|
|
796
|
-
@commands.run(input)
|
|
800
|
+
@commands.run(input, confirm: confirm)
|
|
797
801
|
end
|
|
798
802
|
if command
|
|
799
803
|
show_command_result(command)
|
|
@@ -874,7 +878,9 @@ module Samagotchi
|
|
|
874
878
|
session.save unless Session.exist?(session.id)
|
|
875
879
|
end
|
|
876
880
|
|
|
877
|
-
# A !cmd shows its own output; everything else is the model> line.
|
|
881
|
+
# A !cmd shows its own output; everything else is the model> line. A
|
|
882
|
+
# change that asks first (CacheConfirm) puts its --yes line in the next
|
|
883
|
+
# input: Enter applies it.
|
|
878
884
|
def show_command_result(result)
|
|
879
885
|
if result.shell
|
|
880
886
|
@surface.commit(result.output)
|
|
@@ -882,6 +888,7 @@ module Samagotchi
|
|
|
882
888
|
elsif !result.output.nil?
|
|
883
889
|
@surface.commit("\nmodel> #{result.output}")
|
|
884
890
|
end
|
|
891
|
+
queue_input_prefill(result.confirm.line) if result.confirm && !piped_input?
|
|
885
892
|
sync_model_mirrors if result.changed.include?(:model)
|
|
886
893
|
end
|
|
887
894
|
|
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
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tool_activity"
|
|
4
|
+
require_relative "tool_view"
|
|
5
|
+
require_relative "file_ref"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# The fields a tool row shows beyond its params, built from the call in
|
|
9
|
+
# one place for every path: the live tool_call_started (ToolRunner#run)
|
|
10
|
+
# and a reload from the saved call (Web::MessageParts#tool_part). The
|
|
11
|
+
# bridge's snapshot and replay (Bridge::TurnAccumulator) copy them by
|
|
12
|
+
# these lists, so a new field is added here and travels everywhere.
|
|
13
|
+
#
|
|
14
|
+
# They travel in two places:
|
|
15
|
+
# * ACTIVITY_KEYS: top-level on tool_call_started, inside +activity+ on
|
|
16
|
+
# tool_call_completed (ToolActivity.tool_activity_event builds that
|
|
17
|
+
# activity itself, for the TUI and the metrics);
|
|
18
|
+
# * EVENT_KEYS: top-level on both, so a UI that missed the start (a
|
|
19
|
+
# replay gap) builds its row from the completed event.
|
|
20
|
+
#
|
|
21
|
+
# params and label stay where they are: their sources differ per path
|
|
22
|
+
# (the registry's preview, a plugin's label, the saved shown params).
|
|
23
|
+
module ToolRowFields
|
|
24
|
+
ACTIVITY_KEYS = %i[title called_as].freeze
|
|
25
|
+
EVENT_KEYS = %i[view ref].freeze
|
|
26
|
+
KEYS = (ACTIVITY_KEYS + EVENT_KEYS).freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# @param cwd [String, nil] the session's working directory (the title is
|
|
31
|
+
# relative to it when inside it)
|
|
32
|
+
# @return [Hash] title (a few words: a project-relative path, a command
|
|
33
|
+
# without its "cd … &&"), called_as (the name the model called it by
|
|
34
|
+
# when that was an alias's: bash), view (ToolView: the full command)
|
|
35
|
+
# and ref (FileRef: the file a file tool touched), each only when
|
|
36
|
+
# there is one
|
|
37
|
+
def for(tool_name, call, cwd:)
|
|
38
|
+
{
|
|
39
|
+
title: ToolActivity.tool_title(tool_name, call, cwd: cwd),
|
|
40
|
+
called_as: call[:called_as],
|
|
41
|
+
view: ToolView.for(tool_name, call)&.to_h,
|
|
42
|
+
ref: FileRef.for(tool_name, call, cwd: cwd)&.to_h
|
|
43
|
+
}.compact
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "tool_activity"
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "tool_row_fields"
|
|
5
5
|
require_relative "guardrails"
|
|
6
6
|
require_relative "vision_context"
|
|
7
7
|
require_relative "log"
|
|
@@ -54,14 +54,10 @@ module Samagotchi
|
|
|
54
54
|
started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
|
|
55
55
|
tool: call[:name], call: call.dup, params: params }
|
|
56
56
|
started[:label] = label if label
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
started[:called_as] = call[:called_as] if call[:called_as]
|
|
62
|
-
# The full command for a richer UI (params stays cut at 80).
|
|
63
|
-
view = ToolView.for(call[:name], call)&.to_h
|
|
64
|
-
started[:view] = view if view
|
|
57
|
+
# Its row fields (ToolRowFields: title, called_as, view, ref); the worker runs in
|
|
58
|
+
# its session's working directory.
|
|
59
|
+
fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
|
|
60
|
+
started.merge!(fields)
|
|
65
61
|
emit(on_stream_event, started)
|
|
66
62
|
|
|
67
63
|
# The ask comes after tool_call_started: the UI shows the tool line,
|
|
@@ -102,8 +98,8 @@ module Samagotchi
|
|
|
102
98
|
completed[:diff] = diff if diff
|
|
103
99
|
completed[:waited_ms] = waited_ms if waited_ms
|
|
104
100
|
# Also here: a UI that missed the start (a replay gap) builds its row
|
|
105
|
-
# from this event.
|
|
106
|
-
completed
|
|
101
|
+
# from this event (its title is in the activity).
|
|
102
|
+
completed.merge!(fields.slice(*ToolRowFields::EVENT_KEYS))
|
|
107
103
|
emit(on_stream_event, completed)
|
|
108
104
|
# After the completed event: a hook's card (check-in, skills) prints
|
|
109
105
|
# under the call's tool row, not before it.
|
|
@@ -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).
|
|
@@ -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"
|
|
@@ -40,6 +41,7 @@ require_relative "../context_providers"
|
|
|
40
41
|
require_relative "../recap_store"
|
|
41
42
|
require_relative "markdown_renderer"
|
|
42
43
|
require_relative "capabilities"
|
|
44
|
+
require_relative "editor"
|
|
43
45
|
require_relative "message_parts"
|
|
44
46
|
require_relative "session_summary"
|
|
45
47
|
require_relative "../log"
|
|
@@ -88,6 +90,9 @@ module Samagotchi
|
|
|
88
90
|
# @param annotate_presets [String, Array] the quick replies next to
|
|
89
91
|
# Annotate (web.annotate_presets, "|"-separated); "" shows none. The
|
|
90
92
|
# page parses them (annotate_presets.js).
|
|
93
|
+
# @param editor [String] what a ref opens on this machine (web.editor:
|
|
94
|
+
# a preset or a URL template, Web::Editor); only a local viewer gets
|
|
95
|
+
# it (#viewer_editor)
|
|
91
96
|
# @param hub [SessionHub, nil] the session projection GET /api/sessions
|
|
92
97
|
# lists and GET /api/events streams from (Server always builds one);
|
|
93
98
|
# without one both routes answer 503
|
|
@@ -102,6 +107,7 @@ module Samagotchi
|
|
|
102
107
|
def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
|
|
103
108
|
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
|
|
104
109
|
annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
110
|
+
editor: Config::BY_KEY["web.editor"].default,
|
|
105
111
|
events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
|
|
106
112
|
registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
|
|
107
113
|
@manager = manager || SessionManager
|
|
@@ -117,6 +123,7 @@ module Samagotchi
|
|
|
117
123
|
@markdown_renderer = MarkdownRenderer.new(enabled: markdown)
|
|
118
124
|
@view = view
|
|
119
125
|
@annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
|
|
126
|
+
@editor_url = Editor.template(editor)
|
|
120
127
|
@hub = hub
|
|
121
128
|
@events_heartbeat = events_heartbeat
|
|
122
129
|
@events_queue = events_queue
|
|
@@ -212,6 +219,23 @@ module Samagotchi
|
|
|
212
219
|
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
213
220
|
end
|
|
214
221
|
|
|
222
|
+
# The payload's capabilities (Capabilities, per request) and, next to
|
|
223
|
+
# them, editor_url: the URL template a ref opens (web.editor), nil
|
|
224
|
+
# when this viewer can't open one.
|
|
225
|
+
def viewer_capabilities(req)
|
|
226
|
+
editor_url = @editor_url && local_viewer?(req) ? @editor_url : nil
|
|
227
|
+
{ capabilities: Capabilities.for(@markdown_renderer, editor: !editor_url.nil?).to_h, editor_url: editor_url }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# A viewer on this machine, whose editor can open the session's
|
|
231
|
+
# paths: a loopback peer, or this Mac on its own LAN address (the LAN
|
|
232
|
+
# URL or its QR code opened here). The phone isn't. An SSH tunnel or
|
|
233
|
+
# a local reverse proxy looks like loopback too (a known gap).
|
|
234
|
+
def local_viewer?(req)
|
|
235
|
+
peer = req.get_header("REMOTE_ADDR").to_s
|
|
236
|
+
LOOPBACK_PEERS.include?(peer) || (!@lan.nil? && peer == @lan[:ip])
|
|
237
|
+
end
|
|
238
|
+
|
|
215
239
|
LOOPBACK_NAMES = %w[127.0.0.1 [::1] localhost].freeze
|
|
216
240
|
LOOPBACK_PEERS = %w[127.0.0.1 ::1 ::ffff:127.0.0.1].freeze
|
|
217
241
|
|
|
@@ -494,7 +518,7 @@ module Samagotchi
|
|
|
494
518
|
end
|
|
495
519
|
|
|
496
520
|
# The thinking level +model+ starts at, with where it came from (the
|
|
497
|
-
# start page's think chip;
|
|
521
|
+
# start page's think chip; SettingExplained#summary, no session).
|
|
498
522
|
def add_thinking(model, target, names, settings)
|
|
499
523
|
model[:thinking] = Thinking.explain(target, names: names, models: settings).summary
|
|
500
524
|
rescue StandardError
|
|
@@ -538,7 +562,7 @@ module Samagotchi
|
|
|
538
562
|
end
|
|
539
563
|
|
|
540
564
|
# A session's thinking level with where it came from and its own
|
|
541
|
-
# (
|
|
565
|
+
# (SettingExplained#summary), as its worker would resolve it; nil
|
|
542
566
|
# when it can't be. Config and the registry only: no network.
|
|
543
567
|
def thinking_for(session)
|
|
544
568
|
registry = host_registry
|
|
@@ -550,6 +574,22 @@ module Samagotchi
|
|
|
550
574
|
nil
|
|
551
575
|
end
|
|
552
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
|
+
|
|
553
593
|
# Built again when config.yml's hosts: changed (compared as read: the
|
|
554
594
|
# YAML read is mtime-cached), so the model picker lists a host added
|
|
555
595
|
# while chi web runs. An injected registry (specs) stays.
|
|
@@ -797,6 +837,10 @@ module Samagotchi
|
|
|
797
837
|
session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
|
|
798
838
|
# The thinking level the next turn runs at (the think chip), the same way.
|
|
799
839
|
session_json = session_json.merge(thinking: (snapshot && snapshot["thinking"]) || thinking_for(session))
|
|
840
|
+
# Its reasoning history (the reasoning chip): the worker's (nil on a
|
|
841
|
+
# native host), else the server's reading (an older worker, none).
|
|
842
|
+
history = snapshot&.key?("reasoning_history") ? snapshot["reasoning_history"] : reasoning_history_for(session)
|
|
843
|
+
session_json = session_json.merge(reasoning_history: history)
|
|
800
844
|
raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
|
|
801
845
|
timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
|
|
802
846
|
json_response(200, {
|
|
@@ -821,7 +865,7 @@ module Samagotchi
|
|
|
821
865
|
# names its plugins' too.
|
|
822
866
|
commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
|
|
823
867
|
markdown_warning: @markdown_renderer.warning,
|
|
824
|
-
|
|
868
|
+
**viewer_capabilities(req),
|
|
825
869
|
pending_question: pending,
|
|
826
870
|
last_event_seq: last_event_seq,
|
|
827
871
|
# The stream cursor `<seq>-<epoch>`: a later worker resets it
|
|
@@ -881,7 +925,7 @@ module Samagotchi
|
|
|
881
925
|
session: session_json,
|
|
882
926
|
messages: messages,
|
|
883
927
|
markdown_warning: @markdown_renderer.warning,
|
|
884
|
-
|
|
928
|
+
**viewer_capabilities(req),
|
|
885
929
|
timing: timing
|
|
886
930
|
})
|
|
887
931
|
rescue ArgumentError => e
|
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
module Web
|
|
5
5
|
# What this web page renders that a notice may stand in for
|
|
6
|
-
# (Engine#hook_notify's fallback_for:)
|
|
7
|
-
# it as +capabilities+; the page leaves out
|
|
8
|
-
# names a capability that is true here.
|
|
9
|
-
# is rendered as markdown, so its links
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
6
|
+
# (Engine#hook_notify's fallback_for:), and what this viewer can do. The
|
|
7
|
+
# session and tail JSON carry it as +capabilities+; the page leaves out
|
|
8
|
+
# a notice whose fallback_for names a capability that is true here.
|
|
9
|
+
# +display+: the answer's display is rendered as markdown, so its links
|
|
10
|
+
# are links (web.markdown on and commonmarker installed). +editor+: this
|
|
11
|
+
# viewer can open a path in this machine's editor (a viewer on this
|
|
12
|
+
# machine, web.editor not none); per request, the URL template goes next
|
|
13
|
+
# to it as the payload's editor_url. A later capability is a new member.
|
|
14
|
+
# (The member hides Object#display, which nothing calls on it.)
|
|
15
|
+
Capabilities = Data.define(:display, :editor) do # rubocop:disable Lint/DataDefineOverride
|
|
13
16
|
# @param markdown_renderer [MarkdownRenderer]
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
# @param editor [Boolean] this request's viewer can open refs
|
|
18
|
+
def self.for(markdown_renderer, editor: false)
|
|
19
|
+
new(display: markdown_renderer.available? == true, editor: editor == true)
|
|
16
20
|
end
|
|
17
21
|
end
|
|
18
22
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../log"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Web
|
|
7
|
+
# web.editor: the URL a ref's click opens in this machine's editor, as a
|
|
8
|
+
# template with {path} (the file's absolute path, each segment encoded
|
|
9
|
+
# by the page) and an optional {line}. A preset name, or a template of
|
|
10
|
+
# one's own; "none" (or an unknown name) turns refs off.
|
|
11
|
+
module Editor
|
|
12
|
+
PRESETS = {
|
|
13
|
+
"vscode" => "vscode://file{path}:{line}",
|
|
14
|
+
"vscode-insiders" => "vscode-insiders://file{path}:{line}",
|
|
15
|
+
"cursor" => "cursor://file{path}:{line}",
|
|
16
|
+
"zed" => "zed://file{path}:{line}"
|
|
17
|
+
}.freeze
|
|
18
|
+
NONE = "none"
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param value [String, nil] the web.editor setting
|
|
23
|
+
# @return [String, nil] the URL template; nil for none. A value that
|
|
24
|
+
# is neither a preset nor has {path} logs a warning and is none.
|
|
25
|
+
def template(value)
|
|
26
|
+
text = value.to_s.strip
|
|
27
|
+
return text if text.include?("{path}")
|
|
28
|
+
|
|
29
|
+
name = text.downcase
|
|
30
|
+
return nil if name.empty? || name == NONE
|
|
31
|
+
return PRESETS[name] if PRESETS.key?(name)
|
|
32
|
+
|
|
33
|
+
Log.warn(:web, "editor_unknown", value: text)
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative "../model_profile"
|
|
4
4
|
require_relative "../tool_call_parser"
|
|
5
5
|
require_relative "../tool_activity"
|
|
6
|
-
require_relative "../
|
|
6
|
+
require_relative "../tool_row_fields"
|
|
7
7
|
require_relative "../llm/native_tool_normalizer"
|
|
8
8
|
require_relative "../tools/builtins"
|
|
9
9
|
require_relative "../image_store"
|
|
@@ -231,15 +231,10 @@ module Samagotchi
|
|
|
231
231
|
name = call[:name].to_s
|
|
232
232
|
params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
|
|
233
233
|
part = { tool: name, params: params.to_s }
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
#
|
|
237
|
-
|
|
238
|
-
part[:called_as] = call[:called_as] if call[:called_as]
|
|
239
|
-
# Built from the saved call (never saved itself), so older sessions
|
|
240
|
-
# get it too.
|
|
241
|
-
view = ToolView.for(name, call)&.to_h
|
|
242
|
-
part[:view] = view if view
|
|
234
|
+
# Its row fields (ToolRowFields: title, called_as, view, ref), built from the saved
|
|
235
|
+
# call (never saved themselves), so older sessions get them too. The
|
|
236
|
+
# saved call is rebuilt by BuiltinCalls, which sets called_as again.
|
|
237
|
+
part.merge!(ToolRowFields.for(name, call, cwd: cwd))
|
|
243
238
|
part[:label] = label if label.is_a?(String) && !label.empty?
|
|
244
239
|
unless output.nil?
|
|
245
240
|
part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
|