samagotchi 0.131.0 → 0.132.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 +47 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +32 -1
- data/docs/cli.md +29 -8
- data/docs/configuration.md +62 -6
- data/docs/desktop.md +15 -6
- data/docs/guardrails.md +3 -3
- data/docs/internals/anthropic.md +3 -2
- data/docs/internals/engine.md +4 -4
- data/docs/internals/prompt-caching.md +26 -6
- data/docs/memory.md +16 -2
- data/docs/plugins.md +68 -1
- data/lib/samagotchi/bootstrap_command.rb +35 -6
- data/lib/samagotchi/bundles/model-notes/manifest.yml +4 -1
- data/lib/samagotchi/bundles/model-notes/model_notes_tone.md +3 -0
- data/lib/samagotchi/bundles/model-notes/notes/README.md +14 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cache_confirm.rb +18 -0
- data/lib/samagotchi/chi_home.rb +311 -0
- data/lib/samagotchi/desktop/macos.rb +75 -19
- data/lib/samagotchi/engine.rb +35 -4
- data/lib/samagotchi/engine_snapshots.rb +5 -0
- data/lib/samagotchi/gem_update.rb +19 -10
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/protected_paths.rb +6 -3
- data/lib/samagotchi/guardrails/verdict.rb +1 -1
- data/lib/samagotchi/home_command.rb +149 -0
- data/lib/samagotchi/installed_versions.rb +5 -3
- data/lib/samagotchi/llm_context_command.rb +53 -7
- data/lib/samagotchi/llm_context_override.rb +3 -2
- data/lib/samagotchi/plugin/api.rb +45 -1
- data/lib/samagotchi/plugin/context.rb +34 -1
- data/lib/samagotchi/plugin/loader.rb +5 -2
- data/lib/samagotchi/plugin/prompt_sections.rb +124 -0
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_commands.rb +64 -17
- data/lib/samagotchi/session_manager.rb +36 -1
- data/lib/samagotchi/session_model_settings.rb +5 -2
- data/lib/samagotchi/system_prompt.rb +34 -3
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/testing/bundle.rb +22 -3
- data/lib/samagotchi/testing/host.rb +22 -1
- data/lib/samagotchi/testing/recorder.rb +6 -1
- data/lib/samagotchi/testing/recording_api.rb +1 -1
- data/lib/samagotchi/update_command.rb +65 -7
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +11 -1
- data/lib/samagotchi/web/public/index.html +6 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +4 -3
- data/lib/samagotchi/web/public/model_pick.js +26 -0
- data/lib/samagotchi/web/public/model_picker.js +3 -1
- data/lib/samagotchi/web/public/start_page.js +14 -2
- data/lib/samagotchi/web/server.rb +27 -8
- data/lib/samagotchi/web/update_check.rb +3 -2
- metadata +5 -1
|
@@ -14,6 +14,7 @@ require_relative "memory_bundle/index_size"
|
|
|
14
14
|
require_relative "thinking"
|
|
15
15
|
require_relative "prompt_files"
|
|
16
16
|
require_relative "participant_mode"
|
|
17
|
+
require_relative "plugin/prompt_sections"
|
|
17
18
|
|
|
18
19
|
module Samagotchi
|
|
19
20
|
# The system prompt an Engine gives its model: the base prompt (tool
|
|
@@ -57,9 +58,12 @@ module Samagotchi
|
|
|
57
58
|
# @param participants [#call] → ParticipantMode::PAIR / REVIEW while the
|
|
58
59
|
# session has participants (SessionParticipants#prompt_mode; true reads
|
|
59
60
|
# as review), nil or false with none (the #participants_section line)
|
|
61
|
+
# @param sections [#call] → Array<Plugin::PromptSections::Built>, the
|
|
62
|
+
# plugins' prompt sections (chi.prompt_section) run for this build
|
|
60
63
|
def initialize(profile:, tools:, session:, thinking:, model: -> {}, model_notes: -> { [] }, memories: [],
|
|
61
|
-
muted_memory_names: [], appended_files: [], participants: -> { false })
|
|
64
|
+
muted_memory_names: [], appended_files: [], participants: -> { false }, sections: -> { [] })
|
|
62
65
|
@profile_lookup = profile
|
|
66
|
+
@sections_lookup = sections
|
|
63
67
|
@participants_lookup = participants
|
|
64
68
|
@model_lookup = model
|
|
65
69
|
@model_notes_lookup = model_notes
|
|
@@ -97,19 +101,23 @@ module Samagotchi
|
|
|
97
101
|
@index_sizes ||= {}
|
|
98
102
|
@notes_loaded ||= {}
|
|
99
103
|
@files_loaded ||= {}
|
|
104
|
+
@sections_loaded ||= {}
|
|
100
105
|
unless @built.key?(key)
|
|
101
106
|
@section_notes = nil
|
|
102
107
|
@section_files = []
|
|
108
|
+
@section_plugins = []
|
|
103
109
|
@built[key] = system_prompt_with_index(
|
|
104
110
|
assist_system_prompt(chat: chat, layers: layers), chat: chat, thinking: thinking, layers: layers
|
|
105
111
|
)
|
|
106
112
|
@index_sizes[key] = @measured_index
|
|
107
113
|
@notes_loaded[key] = @section_notes || []
|
|
108
114
|
@files_loaded[key] = @section_files.freeze
|
|
115
|
+
@sections_loaded[key] = @section_plugins.freeze
|
|
109
116
|
end
|
|
110
117
|
@memory_index = @index_sizes[key]
|
|
111
118
|
@prompt_notes = @notes_loaded[key]
|
|
112
119
|
@prompt_files = @files_loaded[key]
|
|
120
|
+
@prompt_sections = @sections_loaded[key]
|
|
113
121
|
@built[key]
|
|
114
122
|
end
|
|
115
123
|
|
|
@@ -135,6 +143,12 @@ module Samagotchi
|
|
|
135
143
|
# @return [Array<String>]
|
|
136
144
|
def prompt_files = @prompt_files || []
|
|
137
145
|
|
|
146
|
+
# The plugins' prompt sections the prompt #build returned last holds
|
|
147
|
+
# (Plugin::PromptSections::Built, prompt order), [] before a build and
|
|
148
|
+
# after #reset!.
|
|
149
|
+
# @return [Array<Plugin::PromptSections::Built>]
|
|
150
|
+
def prompt_sections = @prompt_sections || []
|
|
151
|
+
|
|
138
152
|
# Drops the built prompts: the next #build reads the profile, tools,
|
|
139
153
|
# indexes and memories again (a model or profile switch, changed tools).
|
|
140
154
|
def reset!
|
|
@@ -144,6 +158,8 @@ module Samagotchi
|
|
|
144
158
|
@prompt_notes = nil
|
|
145
159
|
@files_loaded = nil
|
|
146
160
|
@prompt_files = nil
|
|
161
|
+
@sections_loaded = nil
|
|
162
|
+
@prompt_sections = nil
|
|
147
163
|
@stable_lengths = nil
|
|
148
164
|
end
|
|
149
165
|
|
|
@@ -298,7 +314,8 @@ module Samagotchi
|
|
|
298
314
|
# prefix): what every session shares first, what changes least before
|
|
299
315
|
# what changes more, and the per-session lines last. The global parts
|
|
300
316
|
# (identity, preloads) come before the project's AGENTS.md, the
|
|
301
|
-
# toolkit's prompt text after the appended files, the
|
|
317
|
+
# toolkit's prompt text after the appended files, then the plugins'
|
|
318
|
+
# prompt sections, the memory indexes (a
|
|
302
319
|
# memory write changes them) after all of them; the model, the location
|
|
303
320
|
# and the session close the prompt (Gemma's tool declarations still
|
|
304
321
|
# follow them).
|
|
@@ -311,7 +328,7 @@ module Samagotchi
|
|
|
311
328
|
"System memories:\n#{indexes[:system]}"
|
|
312
329
|
].join("\n\n")
|
|
313
330
|
stable = [thinking_token + base, not_loaded_line(layers), rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
|
|
314
|
-
project_specific_description, appended_section, kit_prompt_section, memory_sections]
|
|
331
|
+
project_specific_description, appended_section, kit_prompt_section, plugin_prompt_section, memory_sections]
|
|
315
332
|
stable_text = stable.compact.join("\n")
|
|
316
333
|
volatile = [participants_section, current_model, project_location, current_session].compact
|
|
317
334
|
return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
|
|
@@ -362,6 +379,20 @@ module Samagotchi
|
|
|
362
379
|
registry.respond_to?(:toolkit) ? registry.toolkit&.prompt : nil
|
|
363
380
|
end
|
|
364
381
|
|
|
382
|
+
# The plugins' prompt sections (chi.prompt_section), right after the
|
|
383
|
+
# kit prompt and before the memory indexes: a plugin's text depends on
|
|
384
|
+
# its settings and the model, not on a memory write. Each is
|
|
385
|
+
# "From <bundle> (<name>):\n<text>", sorted by (bundle, name)
|
|
386
|
+
# (Plugin::PromptSections). nil with none: the prompt is what it was
|
|
387
|
+
# before prompt sections.
|
|
388
|
+
def plugin_prompt_section
|
|
389
|
+
built = Array(@sections_lookup.call)
|
|
390
|
+
@section_plugins = built
|
|
391
|
+
Plugin::PromptSections.text(built)
|
|
392
|
+
rescue StandardError
|
|
393
|
+
nil
|
|
394
|
+
end
|
|
395
|
+
|
|
365
396
|
def with_tail_declarations(prompt, chat:, layers:)
|
|
366
397
|
chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(layers)
|
|
367
398
|
end
|
|
@@ -87,9 +87,10 @@ module Samagotchi
|
|
|
87
87
|
toolkit_flags: toolkit_flags, settings: settings, parent_answers: parent_answers)
|
|
88
88
|
end
|
|
89
89
|
commands = []
|
|
90
|
-
|
|
90
|
+
# --yes on /model and /thinking: typing the flag is the choice (no
|
|
91
|
+
# confirm step); its applied reply ends the launch's command.
|
|
92
|
+
commands << "/model #{model} --yes" if model && (attach || resume)
|
|
91
93
|
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
92
|
-
# --yes: typing the flag is the choice (no confirm step).
|
|
93
94
|
commands << "#{ThinkingCommand::NAME} #{thinking} --yes" if (attach || resume) && thinking
|
|
94
95
|
first_command = commands.size > 1 ? commands : commands.first
|
|
95
96
|
surface = open_surface
|
|
@@ -728,6 +728,9 @@ module Samagotchi
|
|
|
728
728
|
# The files whose text is in the prompt (SystemPrompt#prompt_files).
|
|
729
729
|
files = prompt_files_text(snapshot[:prompt_files])
|
|
730
730
|
lines << "prompt files: #{files}" if files
|
|
731
|
+
# The plugins' prompt sections it holds (chi.prompt_section): name (bundle).
|
|
732
|
+
sections = Array(snapshot[:prompt_sections]).map { |item| "#{item[:name]} (#{item[:bundle]})" }
|
|
733
|
+
lines << "prompt sections: #{sections.join(", ")}" unless sections.empty?
|
|
731
734
|
# The toolkit's prompt text (Engine#stats_snapshot's kit_prompt).
|
|
732
735
|
if (kit = snapshot[:kit_prompt])
|
|
733
736
|
lines << "kit prompt: #{kit[:kit]} (~#{kit[:tokens]} tokens)"
|
|
@@ -101,7 +101,7 @@ module Samagotchi
|
|
|
101
101
|
Plugin::Registries.new(
|
|
102
102
|
commands: SessionCommands.register_builtins(Commands::Registry.new), tools: Tools::Builtins.registry,
|
|
103
103
|
services: Plugin::Services.new, stage_tools: ->(*) {}, toolkits: [], tool_index: Tools::ToolIndex.new,
|
|
104
|
-
peer_sources: PeerSources.new, **fields
|
|
104
|
+
peer_sources: PeerSources.new, prompt_sections: Plugin::PromptSections.new, **fields
|
|
105
105
|
)
|
|
106
106
|
end
|
|
107
107
|
end
|
|
@@ -138,6 +138,9 @@ module Samagotchi
|
|
|
138
138
|
attr_reader :errors
|
|
139
139
|
# @return [Integer] how often chi was told the tools changed
|
|
140
140
|
attr_reader :tools_changes
|
|
141
|
+
# @return [Integer] how often chi was told the prompt sections changed
|
|
142
|
+
# (chi.prompt_changed!)
|
|
143
|
+
attr_reader :prompt_changes
|
|
141
144
|
|
|
142
145
|
def initialize(dir, settings: {}, host: {}, session_settings: {}, sandbox: nil)
|
|
143
146
|
@dir = File.expand_path(dir)
|
|
@@ -163,6 +166,7 @@ module Samagotchi
|
|
|
163
166
|
@errors = []
|
|
164
167
|
@lenient = false
|
|
165
168
|
@tools_changes = 0
|
|
169
|
+
@prompt_changes = 0
|
|
166
170
|
@contexts = {}
|
|
167
171
|
@registries = build_registries
|
|
168
172
|
end
|
|
@@ -261,12 +265,16 @@ module Samagotchi
|
|
|
261
265
|
# Run a command line ("/name args") as chi does: the first matching
|
|
262
266
|
# command, given the text after its name. Returns the handler's value
|
|
263
267
|
# (a raise propagates: chi would show it as an error result).
|
|
264
|
-
|
|
268
|
+
# @param confirm [Boolean] sent by a person at a web tab or a TUI, who
|
|
269
|
+
# can answer ctx.cache_confirm (false: chi send, a delegate); a line
|
|
270
|
+
# with --yes never asks, as in chi
|
|
271
|
+
def command(line, confirm: false)
|
|
265
272
|
entry = commands.lookup(line)
|
|
266
273
|
raise ArgumentError, "no command matches #{line.inspect}" unless entry
|
|
267
274
|
raise ArgumentError, "#{entry.name} is chi's own command, not the bundle's" if entry.source == "core"
|
|
268
275
|
|
|
269
|
-
|
|
276
|
+
args = line.to_s.strip.delete_prefix(entry.name).strip
|
|
277
|
+
failing { CacheConfirm.asking(confirm && !CacheConfirm.split(args).last) { entry.handler.call(args) } }
|
|
270
278
|
end
|
|
271
279
|
|
|
272
280
|
# Call a registered tool as chi's dispatch does: its handler gets the
|
|
@@ -324,6 +332,16 @@ module Samagotchi
|
|
|
324
332
|
# @return [PeerSources]
|
|
325
333
|
def peer_sources = @registries.peer_sources
|
|
326
334
|
|
|
335
|
+
# The prompt sections as chi's next prompt build runs them, for the
|
|
336
|
+
# session now (its id: a failure keeps that session's last good text).
|
|
337
|
+
# @return [Array<Plugin::PromptSections::Built>]
|
|
338
|
+
def prompt_sections = @registries.prompt_sections.render(@recorder[:session_id])
|
|
339
|
+
|
|
340
|
+
# The text they add to the system prompt, as chi puts it there; nil
|
|
341
|
+
# with none.
|
|
342
|
+
# @return [String, nil]
|
|
343
|
+
def prompt_text = Plugin::PromptSections.text(prompt_sections)
|
|
344
|
+
|
|
327
345
|
# The bundle's own command entries.
|
|
328
346
|
def bundle_commands = commands.entries.select { |entry| entry.source == @name }
|
|
329
347
|
# The bundle's own tool entries.
|
|
@@ -401,6 +419,7 @@ module Samagotchi
|
|
|
401
419
|
env: ENV, sessions: @sessions, http_transport: @http)
|
|
402
420
|
},
|
|
403
421
|
tools_changed: -> { @tools_changes += 1 },
|
|
422
|
+
prompt_changed: -> { @prompt_changes += 1 },
|
|
404
423
|
stage_tools: ->(_bundle, specs, context) { @staged_tools << StagedTools.new(specs: specs, context: context) },
|
|
405
424
|
init: lambda { |_bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
|
|
406
425
|
@inits << Init.new(label: label, plugin_label: plugin_label, provides_tools: provides_tools, quiet: quiet,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../cancellation_controller"
|
|
4
|
+
require_relative "../cache_confirm"
|
|
4
5
|
require_relative "../plugin/context"
|
|
5
6
|
require_relative "../session_metrics/cost"
|
|
6
7
|
require_relative "../session_metrics/usage"
|
|
@@ -12,6 +13,14 @@ module Samagotchi
|
|
|
12
13
|
# with every member set, its values read from the Recorder at each call
|
|
13
14
|
# and its actions landing in it.
|
|
14
15
|
module Host
|
|
16
|
+
# What CacheConfirm.ask reads of an Engine, from the Recorder's
|
|
17
|
+
# history: and context_tokens:.
|
|
18
|
+
CacheEngine = Struct.new(:history, :tokens) do
|
|
19
|
+
def history? = history ? true : false
|
|
20
|
+
def cache_restart_tokens = tokens
|
|
21
|
+
def cache_restart_anthropic? = false
|
|
22
|
+
end
|
|
23
|
+
|
|
15
24
|
module_function
|
|
16
25
|
|
|
17
26
|
# @param recorder [Recorder]
|
|
@@ -37,6 +46,7 @@ module Samagotchi
|
|
|
37
46
|
},
|
|
38
47
|
model_name: -> { recorder[:model_name] },
|
|
39
48
|
model_key: -> { recorder[:model_key] },
|
|
49
|
+
model_names: -> { recorder[:model_names] || model_names(recorder[:model_name]) },
|
|
40
50
|
state_dir: -> { recorder[:state_dir] || Session.default_state_dir },
|
|
41
51
|
scratch: -> { recorder[:scratch] },
|
|
42
52
|
setup: -> { recorder[:setup] },
|
|
@@ -52,10 +62,21 @@ module Samagotchi
|
|
|
52
62
|
cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil },
|
|
53
63
|
cost: -> { cost(recorder[:cost]) },
|
|
54
64
|
usage: -> { usage(recorder[:usage]) },
|
|
55
|
-
turn_author: -> { recorder[:turn_author] }
|
|
65
|
+
turn_author: -> { recorder[:turn_author] },
|
|
66
|
+
cache_confirm: lambda { |line, changed|
|
|
67
|
+
CacheConfirm.ask(line: line, cost: :full, changed: changed,
|
|
68
|
+
engine: CacheEngine.new(recorder[:history], recorder[:context_tokens]))
|
|
69
|
+
}
|
|
56
70
|
)
|
|
57
71
|
end
|
|
58
72
|
|
|
73
|
+
# ctx.model_names for a model ref when the test sets none: the ref,
|
|
74
|
+
# then its id without the host ("test:model" → test:model, model).
|
|
75
|
+
def model_names(ref)
|
|
76
|
+
ref = ref.to_s
|
|
77
|
+
[ref, ref.split(":", 2).last].reject(&:empty?).uniq
|
|
78
|
+
end
|
|
79
|
+
|
|
59
80
|
# The parts a test may give bundle.set(cost: {...}).
|
|
60
81
|
COST_PARTS = %i[reported estimated side known].freeze
|
|
61
82
|
|
|
@@ -43,7 +43,12 @@ module Samagotchi
|
|
|
43
43
|
# callable giving one.
|
|
44
44
|
usage: nil,
|
|
45
45
|
# ctx.turn_author and the hooks' author: (a participant's "p:<id>"), nil for the owner.
|
|
46
|
-
turn_author: nil
|
|
46
|
+
turn_author: nil,
|
|
47
|
+
# ctx.cache_confirm: whether the session has a model reply (a cache
|
|
48
|
+
# to restart), and the context size its confirm names (nil: none).
|
|
49
|
+
history: false, context_tokens: nil,
|
|
50
|
+
# ctx.model_names: nil gives model_name and its id without the host.
|
|
51
|
+
model_names: nil
|
|
47
52
|
}.freeze
|
|
48
53
|
|
|
49
54
|
attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :tool_loads, :answers,
|
|
@@ -11,7 +11,7 @@ module Samagotchi
|
|
|
11
11
|
# The real Plugin::Api, also recording each declaration in order
|
|
12
12
|
# (#declarations), for a spec of what #register declares as written.
|
|
13
13
|
class RecordingApi < Plugin::Api
|
|
14
|
-
DECLARATIONS = %i[command annotation tool on service init toolkit tool_source peer_source].freeze
|
|
14
|
+
DECLARATIONS = %i[command annotation tool on service init toolkit tool_source peer_source prompt_section].freeze
|
|
15
15
|
|
|
16
16
|
# @return [Array<Declaration>]
|
|
17
17
|
def declarations = (@declarations ||= [])
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "cli/command"
|
|
4
4
|
require_relative "cli/flags"
|
|
5
|
+
require_relative "chi_home"
|
|
5
6
|
require_relative "config"
|
|
6
7
|
require_relative "desktop"
|
|
7
8
|
require_relative "gem_update"
|
|
@@ -57,6 +58,10 @@ module Samagotchi
|
|
|
57
58
|
# Hidden: the old version, passed by the chi that installed this one.
|
|
58
59
|
f.value "--gem-from"
|
|
59
60
|
end
|
|
61
|
+
# "1" in a chi update another chi exec'd into the chi home (#to_home):
|
|
62
|
+
# it doesn't exec again. An env marker, not a flag: the home's chi may
|
|
63
|
+
# be older than the one exec'ing it, and refuse a flag it doesn't know.
|
|
64
|
+
IN_HOME_ENV = "SAMAGOTCHI_UPDATE_IN_HOME"
|
|
60
65
|
# The switches the newer gem's update gets again (hand_over).
|
|
61
66
|
HANDED_OVER = { "--no-gem" => :no_gem, "--no-bundles" => :no_bundles, "--no-desktop" => :no_desktop,
|
|
62
67
|
"--no-register" => :no_register, "--to" => :to, "--json" => :json }.freeze
|
|
@@ -73,11 +78,16 @@ module Samagotchi
|
|
|
73
78
|
# @param platform [#call] register: → a Desktop::MacOS-like object
|
|
74
79
|
# @param exec [#call] argv → replaces this process (the new chi's update)
|
|
75
80
|
# @param bundler [Boolean] running under Bundler (bundle exec)
|
|
81
|
+
# @param home [#current, #record_first!] the chi home (ChiHome)
|
|
82
|
+
# @param env [Hash] IN_HOME_ENV is taken out of it
|
|
76
83
|
def initialize(argv, stdout: $stdout, stderr: $stderr, gem_spec: InstalledGem.spec,
|
|
77
84
|
shipped_dir: MemoryBundle::SourceNormalizer::SHIPPED_DIR, platform: nil,
|
|
78
85
|
supported: Desktop.supported?, state_dir: Session.default_state_dir, web: nil,
|
|
79
|
-
gem_update:
|
|
80
|
-
@
|
|
86
|
+
gem_update: nil, exec: nil, bundler: nil, home: ChiHome, env: ENV)
|
|
87
|
+
@home = home
|
|
88
|
+
@in_home = env.delete(IN_HOME_ENV) == "1"
|
|
89
|
+
# Built after the home is known: its install env goes into the home.
|
|
90
|
+
@gem_update_given = gem_update
|
|
81
91
|
@exec = exec || ->(argv) { Kernel.exec(*argv) }
|
|
82
92
|
@bundler = bundler.nil? ? (defined?(::Bundler) || !ENV["BUNDLE_GEMFILE"].to_s.empty?) : bundler
|
|
83
93
|
@argv = argv.dup
|
|
@@ -102,6 +112,11 @@ module Samagotchi
|
|
|
102
112
|
end
|
|
103
113
|
return usage_error("invalid version #{options[:to]}") if options[:to] && !Gem::Version.correct?(options[:to])
|
|
104
114
|
|
|
115
|
+
# The home's chi decides the rest (--to's floor included); never from
|
|
116
|
+
# a checkout (ChiHome.current is nil there).
|
|
117
|
+
@run_home = @gem_spec && @home.current(gem_spec: @gem_spec)
|
|
118
|
+
return to_home(options) if away_from_home?(options)
|
|
119
|
+
|
|
105
120
|
if options[:to] && !options[:gem_from] && Gem::Version.new(options[:to]) <= Gem::Version.new(VERSION)
|
|
106
121
|
@stderr.puts("chi update: #{options[:to]} is not newer than the running chi (#{VERSION})")
|
|
107
122
|
return 1
|
|
@@ -113,6 +128,12 @@ module Samagotchi
|
|
|
113
128
|
return 1
|
|
114
129
|
end
|
|
115
130
|
|
|
131
|
+
# No home yet: the first record, once this chi's own checks passed.
|
|
132
|
+
@run_home ||= record_home(options)
|
|
133
|
+
return to_home(options) if away_from_home?(options)
|
|
134
|
+
|
|
135
|
+
@gem_update = @gem_update_given ||
|
|
136
|
+
GemUpdate.new(gem_bin: ChiHome.gem_bin(@run_home), install_env: ChiHome.install_env(@run_home))
|
|
116
137
|
@dry_run = options[:dry_run]
|
|
117
138
|
gem = gem_row(options)
|
|
118
139
|
rows = [gem, system_bundle_row, *bundle_rows(options), desktop_row(options), *live_rows].compact
|
|
@@ -130,6 +151,42 @@ module Samagotchi
|
|
|
130
151
|
|
|
131
152
|
def command_name = "chi update"
|
|
132
153
|
|
|
154
|
+
# Whether this run is on another Ruby than the chi home's, and not one
|
|
155
|
+
# another chi exec'd or handed over to.
|
|
156
|
+
def away_from_home?(options) = @run_home && !@in_home && !options[:gem_from] && !@run_home.running_here?
|
|
157
|
+
|
|
158
|
+
# The first chi home (ChiHome.record_first!: the helper's Ruby or this
|
|
159
|
+
# one, whichever has the newer chi), said once. Not in a dry run, a run
|
|
160
|
+
# another chi exec'd or handed over to, nor under Bundler.
|
|
161
|
+
def record_home(options)
|
|
162
|
+
return nil if options[:dry_run] || @in_home || options[:gem_from] || @bundler
|
|
163
|
+
|
|
164
|
+
recorded = @home.record_first!(gem_spec: @gem_spec, launch_path: helper_launch_path, bundler: false)
|
|
165
|
+
notice(options, "chi home set to #{recorded.ruby} (chi home set to change)") if recorded
|
|
166
|
+
recorded
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# A chi on another Ruby than the home's updates the home: exec its chi
|
|
170
|
+
# (newer?, --to and the bundle rows are then the home's), once
|
|
171
|
+
# (IN_HOME_ENV).
|
|
172
|
+
def to_home(options)
|
|
173
|
+
notice(options, "updating the chi home (#{@run_home.ruby})")
|
|
174
|
+
@stdout.flush
|
|
175
|
+
@exec.call([{ IN_HOME_ENV => "1" }, *@run_home.boot_argv(run: ChiHome::CHI_BOOT), "--", "update", *@argv])
|
|
176
|
+
0 # only when exec is a spec's stand-in
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def notice(options, text) = (options[:json] ? @stderr : @stdout).puts(text)
|
|
180
|
+
|
|
181
|
+
def helper_launch_path
|
|
182
|
+
return nil unless @supported
|
|
183
|
+
|
|
184
|
+
helper = @platform.call(register: false)
|
|
185
|
+
helper.respond_to?(:launch_path) ? helper.launch_path : nil
|
|
186
|
+
rescue StandardError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
|
|
133
190
|
# @return [Hash, Integer] the options, or the usage error's exit status
|
|
134
191
|
# (every error reads "unknown option", a --gem-from without its value too)
|
|
135
192
|
def parse
|
|
@@ -181,16 +238,17 @@ module Samagotchi
|
|
|
181
238
|
hand_over(options, row)
|
|
182
239
|
end
|
|
183
240
|
|
|
184
|
-
# exec the
|
|
185
|
-
# Without
|
|
241
|
+
# exec the newest installed samagotchi: the chi home's boot, else the
|
|
242
|
+
# wrapper. Without either (installed with --bindir), this version syncs.
|
|
186
243
|
def hand_over(options, row)
|
|
187
|
-
wrapper = InstalledGem.wrapper(@gem_spec)
|
|
188
|
-
return done(row, :update, "run chi update again to sync with it") unless wrapper
|
|
244
|
+
wrapper = InstalledGem.wrapper(@gem_spec) unless @run_home
|
|
245
|
+
return done(row, :update, "run chi update again to sync with it") unless @run_home || wrapper
|
|
189
246
|
|
|
190
247
|
passed = options[:to] ? ["--to", options[:to]] : []
|
|
191
248
|
passed += HANDED_OVER.except("--to").select { |_, key| options[key] }.keys
|
|
249
|
+
args = ["update", "--no-gem", "--gem-from", VERSION, *passed]
|
|
192
250
|
@stdout.flush
|
|
193
|
-
@exec.call(
|
|
251
|
+
@exec.call(@run_home ? @run_home.boot_argv(run: ChiHome::CHI_BOOT) + ["--", *args] : [RbConfig.ruby, wrapper, *args])
|
|
194
252
|
done(row, :update) # only when exec is a spec's stand-in
|
|
195
253
|
end
|
|
196
254
|
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -78,6 +78,7 @@ require_relative "proxy_gate"
|
|
|
78
78
|
require_relative "../log"
|
|
79
79
|
require_relative "../paths"
|
|
80
80
|
require_relative "../redacted_inspect"
|
|
81
|
+
require_relative "../chi_home"
|
|
81
82
|
require_relative "../child_env"
|
|
82
83
|
require_relative "../installed_gem"
|
|
83
84
|
require_relative "../live_versions"
|
|
@@ -759,8 +760,17 @@ module Samagotchi
|
|
|
759
760
|
error_response(503, "update_start_failed", "couldn't start chi update")
|
|
760
761
|
end
|
|
761
762
|
|
|
763
|
+
# The chi update child: with a chi home, the home's chi (its gem dirs
|
|
764
|
+
# are what new workers activate); else this Ruby and the wrapper.
|
|
765
|
+
def update_argv(version, home: ChiHome.current)
|
|
766
|
+
args = ["update", "--to", version, "--no-desktop", "--json"]
|
|
767
|
+
return home.boot_argv(run: ChiHome::CHI_BOOT) + ["--", *args] if home
|
|
768
|
+
|
|
769
|
+
[RbConfig.ruby, InstalledGem.wrapper, *args]
|
|
770
|
+
end
|
|
771
|
+
|
|
762
772
|
def perform_update(version)
|
|
763
|
-
argv =
|
|
773
|
+
argv = update_argv(version)
|
|
764
774
|
said = nil
|
|
765
775
|
output, success = @update_runner.call(argv, env: ChildEnv.unbundled,
|
|
766
776
|
progress: lambda { |line|
|
|
@@ -607,6 +607,10 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
|
|
|
607
607
|
#lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
|
|
608
608
|
/* The model picker (the start page, and a fresh chain link: start_page.js syncModelPick): reads as a label, "host:model ▾", left of Start; a button under a drawn caret that opens a searchable list (model_picker.js). Its name ellipsizes within max-width. */
|
|
609
609
|
#composer .actions-col .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;min-height:28px;padding:0 22px 0 8px;border-radius:8px;overflow:hidden;background:transparent;color:var(--text-muted);font-size:12px;line-height:28px;text-align:left;text-overflow:ellipsis;transition:background .12s ease,color .12s ease}
|
|
610
|
+
/* The start page's row stays one row (Start never wraps): the model name gives way, the chips keep their whole text (on a phone, below, they ellipsize too). Scoped off the session composer and a fresh link's button. */
|
|
611
|
+
body:not(.has-session) #composer .actions-col{flex-wrap:nowrap}
|
|
612
|
+
body:not(.has-session) #composer .actions-col .model-pick{flex:0 1 auto;min-width:0}
|
|
613
|
+
body:not(.has-session) #composer .actions-col .llmctx-new{flex-shrink:0}
|
|
610
614
|
#composer .actions-col .model-pick:hover,#composer .actions-col .model-pick[aria-expanded="true"]{filter:none;background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
|
|
611
615
|
#composer .actions-col .model-pick[hidden],body.has-session #composer .actions-col .model-pick{display:none}
|
|
612
616
|
.model-pick-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
@@ -623,8 +627,8 @@ body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){dis
|
|
|
623
627
|
#composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
|
|
624
628
|
/* The new session's thinking chip, after it: shorter text, so a smaller share of the row. */
|
|
625
629
|
#composer .actions-col .think-new{max-width:min(25%,140px)}
|
|
626
|
-
/* Phones (the key hint is gone there):
|
|
627
|
-
@media(max-width:600px){#composer .actions-col .model-pick{flex:0 1 auto;min-width:0;max-width:100%}#composer .actions-col .
|
|
630
|
+
/* Phones (the key hint is gone there): still one row, with tighter gaps. The model name gives way first (20x the chips' shrink weight) down to a few characters (min-width), then the chips ellipsize too. */
|
|
631
|
+
@media(max-width:600px){#composer .actions-col .model-pick{flex:0 1 auto;min-width:0;max-width:100%}body:not(.has-session) #composer .actions-col{gap:4px}body:not(.has-session) #composer .actions-col .model-pick{flex-shrink:20;min-width:min(84px,20vw)}body:not(.has-session) #composer .actions-col .llmctx-new{flex-shrink:1;max-width:100%}}
|
|
628
632
|
/* The model search: opaque like #commandList (a list over the conversation
|
|
629
633
|
must stay readable), above the composer, or below it (.down) when the
|
|
630
634
|
start page leaves no room above, or over the composer (.over) when
|
|
@@ -85,14 +85,15 @@ export function chipTemplate(summary) {
|
|
|
85
85
|
// the form opened with) only the fields the user changed go, so a value the
|
|
86
86
|
// form can't show is never unset by a Set; null when nothing changed.
|
|
87
87
|
// Without it every field goes, "default" for one left to the model, and all
|
|
88
|
-
// of them default is a reset.
|
|
88
|
+
// of them default is a reset. With --yes: the form states what a change
|
|
89
|
+
// costs, so a Set is the choice (no confirm step), as level_chip.js's.
|
|
89
90
|
export function commandLine({ strategy = DEFAULT, apply = DEFAULT, budget = "" } = {}, initial = null) {
|
|
90
91
|
const words = { strategy, apply, budget: String(budget).trim() || DEFAULT };
|
|
91
92
|
const was = initial && { strategy: initial.strategy, apply: initial.apply, budget: String(initial.budget).trim() || DEFAULT };
|
|
92
93
|
const fields = Object.keys(words).filter((field) => !was || words[field] !== was[field]);
|
|
93
94
|
if (!fields.length) return null;
|
|
94
|
-
if (!was && fields.every((field) => words[field] === DEFAULT)) return "/llm-context reset";
|
|
95
|
-
return `/llm-context ${fields.map((field) => `${field} ${words[field]}`).join(" ")}`;
|
|
95
|
+
if (!was && fields.every((field) => words[field] === DEFAULT)) return "/llm-context reset --yes";
|
|
96
|
+
return `/llm-context ${fields.map((field) => `${field} ${words[field]}`).join(" ")} --yes`;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
// A budget as typed ("64000", "64k", "off", "0") in tokens, 0 for off;
|
|
@@ -58,6 +58,32 @@ export function modelRows(payload) {
|
|
|
58
58
|
});
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// A host-less server fallback may still carry "host:org/id".
|
|
62
|
+
function shortNameCandidate(row) {
|
|
63
|
+
const name = String(row?.name || row?.id || "");
|
|
64
|
+
let host = row?.host ? String(row.host) : "";
|
|
65
|
+
let id = String(row?.id || name);
|
|
66
|
+
if (!host) {
|
|
67
|
+
const colon = name.indexOf(":");
|
|
68
|
+
const slash = name.indexOf("/");
|
|
69
|
+
if (colon > 0 && slash > colon) {
|
|
70
|
+
host = name.slice(0, colon);
|
|
71
|
+
id = name.slice(colon + 1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const model = id.slice(id.lastIndexOf("/") + 1) || name;
|
|
75
|
+
const labelHost = host && (!row?.isDefaultHost || !row?.host) ? `${host} · ` : "";
|
|
76
|
+
return { name, label: `${labelHost}${model}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A compact label for the chosen model; list rows and all stored/API names
|
|
80
|
+
// remain full.
|
|
81
|
+
export function shortName(row, rows = []) {
|
|
82
|
+
const candidate = shortNameCandidate(row);
|
|
83
|
+
const clashes = rows.filter((other) => shortNameCandidate(other).label === candidate.label).length;
|
|
84
|
+
return clashes > 1 ? candidate.name : candidate.label;
|
|
85
|
+
}
|
|
86
|
+
|
|
61
87
|
const byId = (a, b) => (b.configured === true) - (a.configured === true) ||
|
|
62
88
|
a.id.localeCompare(b.id, "en", { sensitivity: "base" });
|
|
63
89
|
|
|
@@ -13,6 +13,7 @@ import { moveSelection } from "./command_complete.js";
|
|
|
13
13
|
import { readStored, writeStored } from "./storage.js";
|
|
14
14
|
import {
|
|
15
15
|
MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows, rowNote, rowTitle,
|
|
16
|
+
shortName,
|
|
16
17
|
} from "./model_pick.js";
|
|
17
18
|
import { html, nothing, render as draw } from "./vendor/lit-html.js";
|
|
18
19
|
|
|
@@ -63,7 +64,8 @@ export function createModelPicker({ button, panel, input, list, composer, center
|
|
|
63
64
|
// app.js', carries the host's warning).
|
|
64
65
|
function setChosen(name) {
|
|
65
66
|
chosen = name;
|
|
66
|
-
|
|
67
|
+
const row = rows.find((item) => item.name === name);
|
|
68
|
+
button.querySelector(".model-pick-name").textContent = row ? shortName(row, rows) : name;
|
|
67
69
|
button.classList.toggle("unavailable", rows.some((r) => r.name === name && r.unavailable === true));
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -131,6 +131,7 @@ export function createStartPage({
|
|
|
131
131
|
else {
|
|
132
132
|
renderNewChatLlmContext();
|
|
133
133
|
renderNewChatThinking();
|
|
134
|
+
syncStartPickLabel();
|
|
134
135
|
}
|
|
135
136
|
focusPrompt();
|
|
136
137
|
},
|
|
@@ -154,6 +155,12 @@ export function createStartPage({
|
|
|
154
155
|
pendingPickId = id;
|
|
155
156
|
pickRanEarly = false;
|
|
156
157
|
}
|
|
158
|
+
function syncStartPickLabel() {
|
|
159
|
+
const fullName = modelPicker.chosen() || "";
|
|
160
|
+
pickEl.title = fullName ? `${startPickTitle} · ${fullName}` : startPickTitle;
|
|
161
|
+
pickEl.setAttribute("aria-label", fullName
|
|
162
|
+
? `Model for the new session: ${fullName}` : "Model for the new session");
|
|
163
|
+
}
|
|
157
164
|
// The button's name, tooltip and label for what the page shows: a fresh
|
|
158
165
|
// link's model, else the start page's (whose choice stays as it is).
|
|
159
166
|
// Toggles the fresh-link class (the CSS shows the button in a session
|
|
@@ -179,8 +186,12 @@ export function createStartPage({
|
|
|
179
186
|
pickEl.title = "The model this link starts on";
|
|
180
187
|
pickEl.setAttribute("aria-label", "Model this link starts on");
|
|
181
188
|
} else {
|
|
182
|
-
|
|
183
|
-
|
|
189
|
+
if (open) {
|
|
190
|
+
pickEl.title = startPickTitle;
|
|
191
|
+
pickEl.setAttribute("aria-label", "Model for the new session");
|
|
192
|
+
} else {
|
|
193
|
+
syncStartPickLabel();
|
|
194
|
+
}
|
|
184
195
|
}
|
|
185
196
|
}
|
|
186
197
|
// A command_ran with a model (stream_handlers.js): while a pick is in
|
|
@@ -192,6 +203,7 @@ export function createStartPage({
|
|
|
192
203
|
// Leaving a session: the start page's remembered model again (no fetch).
|
|
193
204
|
function restoreStartPick() {
|
|
194
205
|
modelPicker.restore(readStored(MODEL_KEY));
|
|
206
|
+
syncStartPickLabel();
|
|
195
207
|
renderNewChatLlmContext();
|
|
196
208
|
renderNewChatThinking();
|
|
197
209
|
}
|
|
@@ -22,6 +22,7 @@ require_relative "update_check"
|
|
|
22
22
|
require_relative "token"
|
|
23
23
|
require_relative "tunnel"
|
|
24
24
|
require_relative "../config"
|
|
25
|
+
require_relative "../chi_home"
|
|
25
26
|
require_relative "../child_env"
|
|
26
27
|
require_relative "../installed_gem"
|
|
27
28
|
require_relative "../paths"
|
|
@@ -219,25 +220,43 @@ module Samagotchi
|
|
|
219
220
|
# No line before the exec: one on a hung-up terminal raises EIO, and
|
|
220
221
|
# the restart would end in a dead chi web; the log says it.
|
|
221
222
|
def self.reexec(argv, provider: nil, plan: nil, exec: nil)
|
|
222
|
-
|
|
223
|
-
raise ArgumentError, "RubyGems chi wrapper not found"
|
|
223
|
+
command = reexec_command(argv)
|
|
224
|
+
raise ArgumentError, "RubyGems chi wrapper not found" unless command
|
|
224
225
|
|
|
225
226
|
env = ChildEnv.unbundled.merge(
|
|
226
227
|
RESTARTED_ENV => "1",
|
|
227
228
|
ADOPT_PIDS_ENV => SessionManager.live_spawn_pids.join(",")
|
|
228
229
|
)
|
|
229
|
-
Samagotchi::Log.info(:web, "restart", program:
|
|
230
|
+
Samagotchi::Log.info(:web, "restart", program: command[1] == "-e" ? "home:#{command[0]}" : command[1])
|
|
230
231
|
runner = exec || ->(environment, *args) { Kernel.exec(environment, *args) }
|
|
231
|
-
runner.call(env,
|
|
232
|
+
runner.call(env, *command)
|
|
232
233
|
rescue SystemCallError, ArgumentError => e
|
|
233
234
|
tunnel_down(provider, plan) if provider && plan
|
|
234
235
|
warn "Error: couldn't restart chi web: #{e.message}"
|
|
235
236
|
1
|
|
236
237
|
end
|
|
237
238
|
|
|
238
|
-
#
|
|
239
|
-
# newest
|
|
240
|
-
#
|
|
239
|
+
# A restart's argv: with a chi home, its boot (ChiHome::CHI_BOOT: the
|
|
240
|
+
# newest chi in the home; a bare wrapper would activate the newest in
|
|
241
|
+
# whatever GEM_* the env has, off the home); else this Ruby and
|
|
242
|
+
# #reexec_program. A home whose newest chi is older than this one is
|
|
243
|
+
# passed over (logged): the restart argv has this chi's flags, which
|
|
244
|
+
# an older one may not know. nil when there is nothing to exec.
|
|
245
|
+
# @return [Array<String>, nil]
|
|
246
|
+
def self.reexec_command(argv, home: ChiHome.current)
|
|
247
|
+
if home && home.newest_version && home.newest_version < Gem::Version.new(Samagotchi::VERSION)
|
|
248
|
+
Samagotchi::Log.warn(:web, "restart_home_older", home: home.newest_version.to_s, version: Samagotchi::VERSION)
|
|
249
|
+
home = nil
|
|
250
|
+
end
|
|
251
|
+
return home.boot_argv(run: ChiHome::CHI_BOOT) + ["--", *argv] if home
|
|
252
|
+
|
|
253
|
+
program = reexec_program
|
|
254
|
+
program.to_s.empty? ? nil : [RbConfig.ruby, program, *argv]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# What a restart execs without a chi home: the installed gem's wrapper
|
|
258
|
+
# (it activates the newest installed chi), this bin/chi in a checkout;
|
|
259
|
+
# nil for a gem installed without one (--bindir).
|
|
241
260
|
def self.reexec_program
|
|
242
261
|
gem_spec = InstalledGem.spec
|
|
243
262
|
gem_spec ? InstalledGem.wrapper(gem_spec) : $PROGRAM_NAME
|
|
@@ -354,7 +373,7 @@ module Samagotchi
|
|
|
354
373
|
app.restart_web = lambda do
|
|
355
374
|
restart_mutex.synchronize do
|
|
356
375
|
next false if restart_requested
|
|
357
|
-
next :no_wrapper
|
|
376
|
+
next :no_wrapper unless reexec_command([])
|
|
358
377
|
|
|
359
378
|
restart_requested = true
|
|
360
379
|
restart_thread = Thread.new do
|