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
|
@@ -4,6 +4,7 @@ require_relative "llm_context_override"
|
|
|
4
4
|
require_relative "llm_context_strategy"
|
|
5
5
|
require_relative "llm_context_edit"
|
|
6
6
|
require_relative "llm_context_stale"
|
|
7
|
+
require_relative "cache_confirm"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
# /llm-context, the session's own LLM context values
|
|
@@ -18,7 +19,9 @@ module Samagotchi
|
|
|
18
19
|
# system prompt (a full cache break); stale turned on stages the reads
|
|
19
20
|
# already superseded as one batch under the apply rule; a layer turned
|
|
20
21
|
# off sends its stubs whole again (the originals stay in the session, so
|
|
21
|
-
# turning it back on brings them back).
|
|
22
|
+
# turning it back on brings them back). The two that restart the prompt
|
|
23
|
+
# cache (forget on or off, stubs sent whole again) ask first
|
|
24
|
+
# (CacheConfirm, #cache_cost); --yes (or -y) applies them.
|
|
22
25
|
class LLMContextCommand
|
|
23
26
|
NAME = LLMContextOverride::COMMAND
|
|
24
27
|
USAGE = "usage: #{NAME} [strategy <none|stale,forget|default>] [apply <payoff|next_request|turn_end|default>] " \
|
|
@@ -33,20 +36,49 @@ module Samagotchi
|
|
|
33
36
|
@save = save
|
|
34
37
|
end
|
|
35
38
|
|
|
36
|
-
# @param args [String] what follows /llm-context
|
|
37
|
-
# @
|
|
39
|
+
# @param args [String] what follows /llm-context (--yes anywhere)
|
|
40
|
+
# @param confirm [Boolean] ask before a change that restarts the prompt
|
|
41
|
+
# cache (CacheConfirm, #cache_cost); false applies it at once (chi
|
|
42
|
+
# send, a delegate, a start flag)
|
|
43
|
+
# @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
|
|
44
|
+
# whether the values changed, and the confirm it asks for (nothing
|
|
45
|
+
# changed then)
|
|
38
46
|
# @raise [ArgumentError] a line that isn't one (its message and USAGE)
|
|
39
|
-
def run(args)
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
def run(args, confirm: true)
|
|
48
|
+
# Before #words: it refuses --yes, and "reset --yes" isn't RESET.
|
|
49
|
+
args, yes = CacheConfirm.split(args, downcase: false)
|
|
50
|
+
return [listing(@engine.llm_context_explained), false, nil] if args.empty?
|
|
42
51
|
|
|
43
52
|
session = @engine.session or raise ArgumentError, "no session yet"
|
|
44
53
|
before = @engine.llm_context_explained
|
|
45
54
|
updated = args.casecmp?(RESET) ? LLMContextOverride.new : LLMContextOverride.update(@engine.llm_context_override, words(args))
|
|
55
|
+
ask = confirm && !yes ? ask(session, before, updated, args) : nil
|
|
56
|
+
return ask if ask
|
|
57
|
+
|
|
46
58
|
@engine.llm_context_override = updated
|
|
47
59
|
@save.call(session)
|
|
48
60
|
after = @engine.llm_context_explained
|
|
49
|
-
[[listing(after), *notes(session, before&.resolved, after&.resolved)].join("\n"), true]
|
|
61
|
+
[[listing(after), *notes(session, before&.resolved, after&.resolved)].join("\n"), true, nil]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# What a change from +before+ to +after+ (Resolved) costs the prompt
|
|
65
|
+
# cache, nil for nothing worth asking about: forget on or off changes
|
|
66
|
+
# the tool list and the system prompt (:full); a layer turned off while
|
|
67
|
+
# it has applied stubs sends them whole again (:history). Stale turned
|
|
68
|
+
# on only stages a batch under the apply rule (its break comes later,
|
|
69
|
+
# as designed), and the apply rule and budget only decide when a break
|
|
70
|
+
# comes: a switch to next_request with staged edits pending applies
|
|
71
|
+
# them at the next request, a break the old rule makes by the end of
|
|
72
|
+
# the next turn anyway.
|
|
73
|
+
# @return [Symbol, nil]
|
|
74
|
+
def cache_cost(session, before, after)
|
|
75
|
+
return nil unless before && after
|
|
76
|
+
|
|
77
|
+
was = before.active_layers
|
|
78
|
+
now = after.active_layers
|
|
79
|
+
return :full if was.include?(:forget) != now.include?(:forget)
|
|
80
|
+
|
|
81
|
+
:history if (was - now).any? { |layer| applied_count(session.messages, layer).positive? }
|
|
50
82
|
end
|
|
51
83
|
|
|
52
84
|
# The values, where each came from, and what the session sets itself.
|
|
@@ -66,6 +98,20 @@ module Samagotchi
|
|
|
66
98
|
|
|
67
99
|
private
|
|
68
100
|
|
|
101
|
+
# The reply that asks before a costly change (CacheConfirm), nil when
|
|
102
|
+
# it needn't.
|
|
103
|
+
def ask(session, before, updated, args)
|
|
104
|
+
would = @engine.llm_context_explained(own: updated)
|
|
105
|
+
was = before&.resolved
|
|
106
|
+
now = would&.resolved
|
|
107
|
+
cost = cache_cost(session, was, now)
|
|
108
|
+
step = CacheConfirm.ask(line: "#{NAME} #{args}", cost: cost, changed: !cost.nil?, engine: @engine)
|
|
109
|
+
return nil unless step
|
|
110
|
+
|
|
111
|
+
costs = notes(session, was, now).drop(1).reject { |note| note.start_with?("stale:") }
|
|
112
|
+
[[listing(before), "This change restarts the prompt cache:", *costs, step.text].join("\n"), false, step]
|
|
113
|
+
end
|
|
114
|
+
|
|
69
115
|
# "strategy stale stale forget apply turn_end budget 64k" as fields
|
|
70
116
|
# and their words; a strategy takes the words up to the next field.
|
|
71
117
|
def words(args)
|
|
@@ -55,7 +55,8 @@ module Samagotchi
|
|
|
55
55
|
COMMAND_WORDS = { strategy: "strategy", apply: "apply", budget_tokens: "budget" }.freeze
|
|
56
56
|
|
|
57
57
|
# The /llm-context line that sets +words+ (field => value as typed,
|
|
58
|
-
# checked by .update first): what a flag on an existing session runs
|
|
58
|
+
# checked by .update first): what a flag on an existing session runs,
|
|
59
|
+
# with --yes (typing the flag is the choice: no confirm step).
|
|
59
60
|
# @return [String]
|
|
60
61
|
def self.command_line(words)
|
|
61
62
|
parts = words.map do |field, word|
|
|
@@ -63,7 +64,7 @@ module Samagotchi
|
|
|
63
64
|
word = parse_strategy(word).then { |layers| layers.empty? ? "none" : layers.join(",") } if field == :strategy && !word.casecmp?(DEFAULT_WORD)
|
|
64
65
|
"#{COMMAND_WORDS.fetch(field)} #{word}"
|
|
65
66
|
end
|
|
66
|
-
[COMMAND, *parts].join(" ")
|
|
67
|
+
[COMMAND, *parts, "--yes"].join(" ")
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
# A session file's "llm_context" (or an override already): nil when
|
|
@@ -7,6 +7,7 @@ require_relative "../commands/registry"
|
|
|
7
7
|
require_relative "service"
|
|
8
8
|
require_relative "tool_result"
|
|
9
9
|
require_relative "tool_spec"
|
|
10
|
+
require_relative "prompt_sections"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
12
13
|
module Plugin
|
|
@@ -45,7 +46,9 @@ module Samagotchi
|
|
|
45
46
|
"ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0",
|
|
46
47
|
"ctx.usage" => "0.100.0", "ctx.sessions.stop" => "0.34.0", "ctx.turn_author" => "0.106.0",
|
|
47
48
|
"ctx.sessions.review" => "0.107.0", "ctx.sessions.send(delivery:)" => "0.64.0",
|
|
48
|
-
"chi.toolkit(prompt:)" => "0.72.0", "ctx.ask_model(model:)" => "0.80.0", "chi.toolkit(groups:)" => "0.121.0"
|
|
49
|
+
"chi.toolkit(prompt:)" => "0.72.0", "ctx.ask_model(model:)" => "0.80.0", "chi.toolkit(groups:)" => "0.121.0",
|
|
50
|
+
"chi.prompt_section" => "0.132.0", "chi.prompt_changed" => "0.132.0", "ctx.cache_confirm" => "0.132.0",
|
|
51
|
+
"ctx.model_names" => "0.132.0"
|
|
49
52
|
}.freeze
|
|
50
53
|
|
|
51
54
|
# @param bundle [String]
|
|
@@ -65,6 +68,7 @@ module Samagotchi
|
|
|
65
68
|
@services = []
|
|
66
69
|
@inits = []
|
|
67
70
|
@toolkits = []
|
|
71
|
+
@prompt_sections = []
|
|
68
72
|
@peer_sources = []
|
|
69
73
|
@tool_source = nil
|
|
70
74
|
@committed = false
|
|
@@ -279,6 +283,42 @@ module Samagotchi
|
|
|
279
283
|
nil
|
|
280
284
|
end
|
|
281
285
|
|
|
286
|
+
# Text this session's system prompt carries (docs/plugins.md, Prompt
|
|
287
|
+
# sections): the block gets the Context and returns a String (at most
|
|
288
|
+
# PromptSections::MAX characters) or nil for none. It runs when the
|
|
289
|
+
# prompt is built (a session's first turn, after /model or a
|
|
290
|
+
# #prompt_changed!), never per request, on whatever thread builds:
|
|
291
|
+
# keep it quick (no I/O beyond reading a small file) and give the same
|
|
292
|
+
# text for the same inputs. A raise, a non-String or text over the
|
|
293
|
+
# limit keeps the section's last good text (one warn).
|
|
294
|
+
# @param name [String] a-z, 0-9, _ and -; unique in the bundle
|
|
295
|
+
def prompt_section(name, &block)
|
|
296
|
+
raise ArgumentError, "prompt_section #{name.inspect} needs a block" unless block
|
|
297
|
+
|
|
298
|
+
name = name.to_s
|
|
299
|
+
unless name.match?(PromptSections::NAME)
|
|
300
|
+
raise ArgumentError, "prompt section name #{name.inspect} must be a-z, 0-9, _ and - (at most 32)"
|
|
301
|
+
end
|
|
302
|
+
raise ArgumentError, "this chi has no prompt sections (plugins: false)" unless @registries.prompt_sections
|
|
303
|
+
if @prompt_sections.any? { |item| item[:name] == name } || @registries.prompt_sections.registered?(@bundle, name)
|
|
304
|
+
raise ArgumentError, "prompt section #{name} is registered twice"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
@prompt_sections << { name: name, block: block }
|
|
308
|
+
nil
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Say this session's prompt sections would now give other text (a
|
|
312
|
+
# setting the plugin keeps changed): the system prompt is built again
|
|
313
|
+
# at its next build (the next turn's start; a running turn keeps the
|
|
314
|
+
# prompt it started with). That restarts the model server's prompt
|
|
315
|
+
# cache, so a command that changes a section asks first
|
|
316
|
+
# (Context#cache_confirm) and calls this only once it applies.
|
|
317
|
+
def prompt_changed!
|
|
318
|
+
@registries.prompt_changed&.call
|
|
319
|
+
nil
|
|
320
|
+
end
|
|
321
|
+
|
|
282
322
|
# Publish tools into the session's tool index instead of declaring
|
|
283
323
|
# them one by one (docs/plugins.md, "Publishing tools to the index"):
|
|
284
324
|
# the model searches them with find_tools and calls one with
|
|
@@ -393,6 +433,9 @@ module Samagotchi
|
|
|
393
433
|
end
|
|
394
434
|
end
|
|
395
435
|
@registries.toolkits&.concat(@toolkits)
|
|
436
|
+
@prompt_sections.each do |item|
|
|
437
|
+
@registries.prompt_sections.add(bundle: @bundle, name: item[:name], context: context, &item[:block])
|
|
438
|
+
end
|
|
396
439
|
@registries.tool_index.add(@tool_source) if @tool_source
|
|
397
440
|
@peer_sources.each { |item| @registries.peer_sources.declare(item[:name], label: item[:label], bundle: @bundle) }
|
|
398
441
|
@committed = true
|
|
@@ -401,6 +444,7 @@ module Samagotchi
|
|
|
401
444
|
# @return [Hash] how many of each it registered (for the log)
|
|
402
445
|
def counts = { commands: @commands.size, annotations: @annotations.size, tools: @tools.size, hooks: @hooks.size,
|
|
403
446
|
services: @services.size, inits: @inits.size, toolkits: @toolkits.size,
|
|
447
|
+
prompt_sections: @prompt_sections.size,
|
|
404
448
|
tool_sources: @tool_source ? 1 : 0, peer_sources: @peer_sources.size }
|
|
405
449
|
|
|
406
450
|
# Each annotation's command is one #command staged.
|
|
@@ -8,6 +8,7 @@ require_relative "../session_metrics/usage"
|
|
|
8
8
|
require_relative "../guardrails/context"
|
|
9
9
|
require_relative "../hook_question"
|
|
10
10
|
require_relative "../idle_client"
|
|
11
|
+
require_relative "../cache_confirm"
|
|
11
12
|
require_relative "service"
|
|
12
13
|
require_relative "side_question"
|
|
13
14
|
|
|
@@ -58,10 +59,15 @@ module Samagotchi
|
|
|
58
59
|
# plugin handler: +handler+) until the turn has ended.
|
|
59
60
|
# +cost+ returns the session's spend now (SessionMetrics::Cost),
|
|
60
61
|
# +usage+ its work now (SessionMetrics::Usage).
|
|
62
|
+
# +cache_confirm+ takes (line, changed) and returns a
|
|
63
|
+
# CacheConfirm::Confirm when the change must ask first (CacheConfirm.ask
|
|
64
|
+
# with cost :full), else nil. +model_names+ returns the names the
|
|
65
|
+
# session's model is looked up under (EffectiveModel#names).
|
|
61
66
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
62
67
|
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
63
68
|
:steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
|
|
64
|
-
:cancel_controller, :cost, :usage, :turn_author,
|
|
69
|
+
:cancel_controller, :cost, :usage, :turn_author, :cache_confirm, :model_names,
|
|
70
|
+
keyword_init: true)
|
|
65
71
|
|
|
66
72
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
67
73
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -164,6 +170,14 @@ module Samagotchi
|
|
|
164
170
|
# current_model_only names it
|
|
165
171
|
def model_key = @host.model_key&.call
|
|
166
172
|
|
|
173
|
+
# The names the session's model is looked up under now, as chi's own
|
|
174
|
+
# per-model config is (models: maps, ConfigFile.model_setting): as
|
|
175
|
+
# typed (an alias), the alias's target, the resolved ref ("host:id"),
|
|
176
|
+
# without the host, the bare id the host serves; deduped, first the
|
|
177
|
+
# most specific. Compare them downcased, first hit, as chi does.
|
|
178
|
+
# @return [Array<String>] frozen; [] when the model doesn't resolve
|
|
179
|
+
def model_names = Array(@host.model_names&.call).map(&:to_s).freeze
|
|
180
|
+
|
|
167
181
|
# @return [SessionMetrics::Cost] the session's spend so far, read now:
|
|
168
182
|
# #total, #reported, #estimated, #side (Float USD) and #known?
|
|
169
183
|
# (docs/plugins.md); zero and known with no session
|
|
@@ -215,6 +229,25 @@ module Samagotchi
|
|
|
215
229
|
Array(@host.load_tools&.call(names.map(&:to_s), @bundle))
|
|
216
230
|
end
|
|
217
231
|
|
|
232
|
+
# Whether a change the plugin's command is about to make (its prompt
|
|
233
|
+
# section's text: chi.prompt_changed!) must ask first. nil: apply it
|
|
234
|
+
# now (nothing changes, no model reply yet, a sender no one answers
|
|
235
|
+
# for: chi send, a delegate, chi -p, the line had --yes, or not
|
|
236
|
+
# inside a command). Else a CacheConfirm::Confirm: change nothing and
|
|
237
|
+
# return [text, confirm] from the command, so the TUI prefills the
|
|
238
|
+
# line and the web shows Apply (it sends +line+ with --yes). The cost
|
|
239
|
+
# is a full re-read (the section is in the system prompt's stable
|
|
240
|
+
# part); the text says how big and, on api: anthropic, that preserved
|
|
241
|
+
# thinking is dropped.
|
|
242
|
+
# @param line [String] the command line that applies it, without --yes
|
|
243
|
+
# @param changed [Boolean] whether the section's text would change
|
|
244
|
+
# @return [CacheConfirm::Confirm, nil]
|
|
245
|
+
def cache_confirm(line, changed:)
|
|
246
|
+
return nil unless CacheConfirm.asking?
|
|
247
|
+
|
|
248
|
+
@host.cache_confirm&.call(line.to_s, changed ? true : false)
|
|
249
|
+
end
|
|
250
|
+
|
|
218
251
|
# Whether the session is a `chi scratch` one (deleted when it ends).
|
|
219
252
|
def scratch? = !!@host.scratch&.call
|
|
220
253
|
|
|
@@ -20,9 +20,12 @@ module Samagotchi
|
|
|
20
20
|
# +init+ adds a chi.init task (Engine#add_init_task); +toolkits+ is the
|
|
21
21
|
# Engine's list of the plugins' toolkits (chi.toolkit: Toolkit::Kit);
|
|
22
22
|
# +tool_index+ the Engine's Tools::ToolIndex (chi.tool_source);
|
|
23
|
-
# +peer_sources+ the Engine's PeerSources (chi.peer_source)
|
|
23
|
+
# +peer_sources+ the Engine's PeerSources (chi.peer_source);
|
|
24
|
+
# +prompt_sections+ the Engine's PromptSections (chi.prompt_section),
|
|
25
|
+
# +prompt_changed+ rebuilds its system prompts (chi.prompt_changed!).
|
|
24
26
|
Registries = Struct.new(:commands, :tools, :hooks, :context_for, :tools_changed, :services, :stage_tools, :init,
|
|
25
|
-
:toolkits, :tool_index, :peer_sources,
|
|
27
|
+
:toolkits, :tool_index, :peer_sources, :prompt_sections, :prompt_changed,
|
|
28
|
+
keyword_init: true)
|
|
26
29
|
|
|
27
30
|
# Loads installed bundles' plugins (manifest plugin: {file:, sha256:})
|
|
28
31
|
# into an Engine's registries (docs/plugins.md).
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../log"
|
|
4
|
+
require_relative "../toolkit"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Plugin
|
|
8
|
+
# The system-prompt text plugins add (chi.prompt_section): one per
|
|
9
|
+
# (bundle, name), each a block given the plugin's Context that returns
|
|
10
|
+
# a String or nil (no section). SystemPrompt runs them inside a build
|
|
11
|
+
# only (lazy, once per build key until a reset), never per request, on
|
|
12
|
+
# whatever thread builds: a block must be quick and thread-safe.
|
|
13
|
+
#
|
|
14
|
+
# Prompt-cache contract (docs/internals/prompt-caching.md): sections go
|
|
15
|
+
# sorted by (bundle, name), so the text is the same for the same
|
|
16
|
+
# inputs. A block that raises, returns something other than a String
|
|
17
|
+
# or nil, or text over MAX characters keeps the section's last good
|
|
18
|
+
# text for this session (a skip on a transient error would break the
|
|
19
|
+
# cache twice), with one warn; with no good text yet the section is
|
|
20
|
+
# left out.
|
|
21
|
+
class PromptSections
|
|
22
|
+
# As a kit prompt's (Toolkit::PROMPT_MAX): a cached prefix, not a document.
|
|
23
|
+
MAX = Toolkit::PROMPT_MAX
|
|
24
|
+
NAME = /\A[a-z][a-z0-9_-]{0,31}\z/
|
|
25
|
+
|
|
26
|
+
Section = Data.define(:bundle, :name, :block, :context)
|
|
27
|
+
|
|
28
|
+
# A section as one build holds it.
|
|
29
|
+
Built = Data.define(:bundle, :name, :text) do
|
|
30
|
+
# "From <bundle> (<name>):\n<text>", as the prompt has it.
|
|
31
|
+
def prompt_text = "From #{bundle} (#{name}):\n#{text}"
|
|
32
|
+
|
|
33
|
+
# /stats' record (Engine#stats_snapshot's prompt_sections).
|
|
34
|
+
def to_stats = { bundle: bundle, name: name }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
@sections = []
|
|
39
|
+
@mutex = Mutex.new
|
|
40
|
+
# [session id, bundle, name] => the last good text (nil: none)
|
|
41
|
+
@last_good = {}
|
|
42
|
+
@warned = Set.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @raise [ArgumentError] a (bundle, name) already added
|
|
46
|
+
def add(bundle:, name:, context:, &block)
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
if @sections.any? { |section| section.bundle == bundle && section.name == name }
|
|
49
|
+
raise ArgumentError, "prompt section #{name} is registered twice (bundle #{bundle})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@sections << Section.new(bundle: bundle.to_s, name: name.to_s, block: block, context: context)
|
|
53
|
+
@sections.sort_by! { |section| [section.bundle, section.name] }
|
|
54
|
+
end
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Boolean]
|
|
59
|
+
def any? = @mutex.synchronize { @sections.any? }
|
|
60
|
+
|
|
61
|
+
# Whether +bundle+ already has a section +name+.
|
|
62
|
+
def registered?(bundle, name)
|
|
63
|
+
@mutex.synchronize { @sections.any? { |section| section.bundle == bundle.to_s && section.name == name.to_s } }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Run every block for +session_id+ and keep what they gave.
|
|
67
|
+
# @param session_id [String, nil] whose last good text a failure keeps
|
|
68
|
+
# @return [Array<Built>] in prompt order, the sections with text
|
|
69
|
+
def render(session_id)
|
|
70
|
+
sections = @mutex.synchronize { @sections.dup }
|
|
71
|
+
sections.filter_map do |section|
|
|
72
|
+
text = evaluate(section, session_id)
|
|
73
|
+
Built.new(bundle: section.bundle, name: section.name, text: text) if text
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The prompt text of +built+ (#render), nil without any.
|
|
78
|
+
# @param built [Array<Built>]
|
|
79
|
+
# @return [String, nil]
|
|
80
|
+
def self.text(built)
|
|
81
|
+
built.empty? ? nil : built.map(&:prompt_text).join("\n\n")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def evaluate(section, session_id)
|
|
87
|
+
key = [session_id, section.bundle, section.name]
|
|
88
|
+
value = section.block.call(section.context)
|
|
89
|
+
problem = problem_of(value)
|
|
90
|
+
if problem
|
|
91
|
+
keep_last_good(section, key, problem)
|
|
92
|
+
else
|
|
93
|
+
text = value.nil? ? nil : value.strip.freeze
|
|
94
|
+
text = nil if text && text.empty?
|
|
95
|
+
@mutex.synchronize { @last_good[key] = text }
|
|
96
|
+
text
|
|
97
|
+
end
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
keep_last_good(section, key, "#{e.class}: #{e.message}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def problem_of(value)
|
|
103
|
+
return nil if value.nil?
|
|
104
|
+
return "returned #{value.class}, not a String or nil" unless value.is_a?(String)
|
|
105
|
+
|
|
106
|
+
"is over #{MAX} characters" if value.strip.length > MAX
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# One warn per section, session and problem; the last good text, or nil.
|
|
110
|
+
def keep_last_good(section, key, problem)
|
|
111
|
+
kept = @mutex.synchronize do
|
|
112
|
+
first = @warned.add?([*key, problem])
|
|
113
|
+
[@last_good[key], first]
|
|
114
|
+
end
|
|
115
|
+
text, first = kept
|
|
116
|
+
if first
|
|
117
|
+
Log.warn(:plugins, "prompt_section_failed", bundle: section.bundle, section: section.name,
|
|
118
|
+
error: problem.to_s[0, 200], kept: !text.nil?)
|
|
119
|
+
end
|
|
120
|
+
text
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -210,6 +210,7 @@ module Samagotchi
|
|
|
210
210
|
chi toolkit report [--days N] grade toolkit suggestions against the tools sessions used
|
|
211
211
|
chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
|
|
212
212
|
chi models [--format json] [TEXT] the models --model takes, from every host
|
|
213
|
+
chi home [set [--ruby PATH] | doctor] the Ruby every chi launch boots
|
|
213
214
|
chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
|
|
214
215
|
chi --remote NAME <send|answer|note|context|sessions|share|review> … those commands on that machine (or CHI_REMOTE=NAME)
|
|
215
216
|
chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT] a session on that machine, in this terminal
|
|
@@ -26,7 +26,7 @@ module Samagotchi
|
|
|
26
26
|
# bin/chi subcommands that act on this machine: never sessions. chi
|
|
27
27
|
# remote (this machine's list of remotes) runs here under a remote too
|
|
28
28
|
# (Dispatch); the others are refused there.
|
|
29
|
-
MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit remote].freeze
|
|
29
|
+
MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit remote home].freeze
|
|
30
30
|
|
|
31
31
|
# "command", "command sub" or either with a flag (its long name) →
|
|
32
32
|
# SUPPORTED or LOCAL_ONLY. A LOCAL_ONLY command or subcommand lists
|
|
@@ -5,6 +5,7 @@ require_relative "model_profile"
|
|
|
5
5
|
require_relative "served_model"
|
|
6
6
|
require_relative "prompt_note"
|
|
7
7
|
require_relative "turn_flow"
|
|
8
|
+
require_relative "cache_confirm"
|
|
8
9
|
require_relative "tools/execute"
|
|
9
10
|
require_relative "tools/builtins"
|
|
10
11
|
require_relative "tools/context_read"
|
|
@@ -61,7 +62,7 @@ module Samagotchi
|
|
|
61
62
|
|
|
62
63
|
# Commands that may ask before a change that restarts the prompt cache
|
|
63
64
|
# (CacheConfirm): #run passes them its confirm flag.
|
|
64
|
-
CONFIRMING = %i[thinking reasoning_history toolkit].freeze
|
|
65
|
+
CONFIRMING = %i[model llm_context thinking reasoning_history toolkit].freeze
|
|
65
66
|
|
|
66
67
|
# @!attribute status [Symbol] :ok, or :error when the line was refused
|
|
67
68
|
# @!attribute output [String, nil] what to tell the user
|
|
@@ -302,7 +303,7 @@ module Samagotchi
|
|
|
302
303
|
text = line.to_s.strip
|
|
303
304
|
entry = @registry.lookup(text)
|
|
304
305
|
return nil unless entry&.handler
|
|
305
|
-
return run_bundle_command(entry, text) unless entry.source == "core"
|
|
306
|
+
return run_bundle_command(entry, text, confirm: confirm) unless entry.source == "core"
|
|
306
307
|
|
|
307
308
|
# Only commands that may ask take the flag.
|
|
308
309
|
return send(entry.id, text, confirm: confirm) if CONFIRMING.include?(entry.id)
|
|
@@ -355,16 +356,32 @@ module Samagotchi
|
|
|
355
356
|
# The resolved ref (what the session stores and --alias/--default write).
|
|
356
357
|
def model_name = @engine.effective_model_ref
|
|
357
358
|
|
|
358
|
-
# A bundle plugin's command: its handler gets the text after the name
|
|
359
|
-
# what it returns is the output (nil: nothing to show),
|
|
360
|
-
#
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
359
|
+
# A bundle plugin's command: its handler gets the text after the name
|
|
360
|
+
# (--yes kept); what it returns is the output (nil: nothing to show),
|
|
361
|
+
# or [output, CacheConfirm::Confirm or nil] for a change that asks
|
|
362
|
+
# first (ctx.cache_confirm), and a raise is an error result. The
|
|
363
|
+
# sender's +confirm+ (false once the line has --yes) reaches
|
|
364
|
+
# ctx.cache_confirm through the thread (CacheConfirm.asking).
|
|
365
|
+
def run_bundle_command(entry, text, confirm: false)
|
|
366
|
+
args = text.delete_prefix(entry.name).strip
|
|
367
|
+
_, yes = CacheConfirm.split(args)
|
|
368
|
+
output, ask = bundle_reply(CacheConfirm.asking(confirm && !yes) { entry.handler.call(args) })
|
|
369
|
+
reply(output, confirm: ask)
|
|
364
370
|
rescue StandardError => e
|
|
365
371
|
reply("#{entry.name}: #{e.class}: #{e.message}", status: :error)
|
|
366
372
|
end
|
|
367
373
|
|
|
374
|
+
# A plugin command's return value as [output, confirm]: a String (or
|
|
375
|
+
# anything, shown as text) or nil, or [text, Confirm/nil].
|
|
376
|
+
def bundle_reply(value)
|
|
377
|
+
if value.is_a?(Array) && value.size == 2 && (value[1].nil? || value[1].is_a?(CacheConfirm::Confirm))
|
|
378
|
+
text, ask = value
|
|
379
|
+
return [text.nil? ? nil : text.to_s, ask]
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
[value.nil? ? nil : value.to_s, nil]
|
|
383
|
+
end
|
|
384
|
+
|
|
368
385
|
def help_group(entry)
|
|
369
386
|
return 2 if entry.local
|
|
370
387
|
|
|
@@ -583,13 +600,13 @@ module Samagotchi
|
|
|
583
600
|
|
|
584
601
|
# /llm-context (LLMContextCommand); :llm_context in changed when it set
|
|
585
602
|
# the session's values.
|
|
586
|
-
def llm_context(text)
|
|
587
|
-
output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
|
|
588
|
-
|
|
603
|
+
def llm_context(text, confirm: true)
|
|
604
|
+
output, changed, ask = LLMContextCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
|
|
605
|
+
.run(text.delete_prefix(LLM_CONTEXT_COMMAND), confirm: confirm)
|
|
589
606
|
# What the model has of each item is the next turn's strategy's: the
|
|
590
607
|
# working set goes out again (sent only when it differs).
|
|
591
608
|
@engine.announce_working_set if changed
|
|
592
|
-
reply(output, changed: changed ? [:llm_context] : [])
|
|
609
|
+
reply(output, changed: changed ? [:llm_context] : [], confirm: ask)
|
|
593
610
|
rescue ArgumentError => e
|
|
594
611
|
reply(e.message, status: :error)
|
|
595
612
|
end
|
|
@@ -672,16 +689,23 @@ module Samagotchi
|
|
|
672
689
|
@save.call(session)
|
|
673
690
|
end
|
|
674
691
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
692
|
+
# /model; a switch to another model asks first (CacheConfirm, cost
|
|
693
|
+
# :full: the new model has no cache of the conversation), --yes applies.
|
|
694
|
+
def model(text, confirm: true)
|
|
695
|
+
suffix, yes = CacheConfirm.split(text.delete_prefix(MODEL_COMMAND), downcase: false)
|
|
696
|
+
output, switched, ask = model_command("#{MODEL_COMMAND} #{suffix}", confirm: confirm && !yes)
|
|
697
|
+
reply(output, changed: switched ? [:model] : [], confirm: ask)
|
|
678
698
|
rescue ModelProfile::MissingModel => e
|
|
679
699
|
# A model qualified with an unknown host: nothing switched.
|
|
680
700
|
reply(e.message, status: :error)
|
|
681
701
|
end
|
|
682
702
|
|
|
683
|
-
# @
|
|
684
|
-
|
|
703
|
+
# @param input [String] the /model line, --yes taken out
|
|
704
|
+
# @param confirm [Boolean] a switch to another model asks first
|
|
705
|
+
# @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the message,
|
|
706
|
+
# whether the model changed, and the confirm it asks for (nothing
|
|
707
|
+
# changed then)
|
|
708
|
+
def model_command(input, confirm: false)
|
|
685
709
|
suffix = input.delete_prefix(MODEL_COMMAND).strip
|
|
686
710
|
if suffix.empty?
|
|
687
711
|
return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{steer_cut_after_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if @default_model.nil? || model_name == @engine.model_ref_for(@default_model)
|
|
@@ -735,6 +759,9 @@ module Samagotchi
|
|
|
735
759
|
invalid = ConfigFile.model_alias_error(alias_name, arg)
|
|
736
760
|
return ["invalid alias: #{invalid}", false] if invalid
|
|
737
761
|
|
|
762
|
+
ask = confirm && model_ask(arg, input)
|
|
763
|
+
return ask if ask
|
|
764
|
+
|
|
738
765
|
switch_model(arg, persist_default: persist_default)
|
|
739
766
|
|
|
740
767
|
begin
|
|
@@ -756,10 +783,16 @@ module Samagotchi
|
|
|
756
783
|
return ["--default cannot be combined with clear/default/none/off", false] if persist_default
|
|
757
784
|
raise ModelProfile::MissingModel, "no default model is configured (set default.model in config.yml, or /model <name> --default)" if @default_model.nil?
|
|
758
785
|
|
|
786
|
+
ask = confirm && model_ask(@default_model, input)
|
|
787
|
+
return ask if ask
|
|
788
|
+
|
|
759
789
|
switch_model(@default_model)
|
|
760
790
|
return ["runtime model reset to #{model_name}#{model_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", true]
|
|
761
791
|
end
|
|
762
792
|
|
|
793
|
+
ask = confirm && model_ask(arg, input)
|
|
794
|
+
return ask if ask
|
|
795
|
+
|
|
763
796
|
switch_model(arg, persist_default: persist_default)
|
|
764
797
|
if persist_default
|
|
765
798
|
["runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}#{reasoning_history_note}#{notes_note}", true]
|
|
@@ -769,6 +802,20 @@ module Samagotchi
|
|
|
769
802
|
end
|
|
770
803
|
end
|
|
771
804
|
|
|
805
|
+
# The reply that asks before switching to +name+ (CacheConfirm), nil
|
|
806
|
+
# when it needn't: the name is checked first (a typo errors at once,
|
|
807
|
+
# MissingModel), and its resolved ref against the current one (the same
|
|
808
|
+
# model costs nothing; any other restarts the whole cache).
|
|
809
|
+
# @param line [String] the /model line as typed (its flags too), --yes taken out
|
|
810
|
+
def model_ask(name, line)
|
|
811
|
+
ref = @engine.model_switch_ref(name)
|
|
812
|
+
step = CacheConfirm.ask(line: line, cost: :full, changed: ref != model_name, engine: @engine)
|
|
813
|
+
return nil unless step
|
|
814
|
+
|
|
815
|
+
["runtime model: #{model_name}\nSwitching to #{ref} restarts the prompt cache: the new model has no cache of " \
|
|
816
|
+
"this conversation.\n#{step.text}", false, step]
|
|
817
|
+
end
|
|
818
|
+
|
|
772
819
|
# Engine owns the switch (alias resolution, profile, kernel, client and
|
|
773
820
|
# the optional default persist); the session keeps the new model.
|
|
774
821
|
def switch_model(name, persist_default: false)
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "rbconfig"
|
|
8
|
+
require_relative "chi_home"
|
|
8
9
|
require_relative "child_env"
|
|
9
10
|
require_relative "client_id"
|
|
10
11
|
require_relative "analytics_file"
|
|
@@ -1252,16 +1253,50 @@ module Samagotchi
|
|
|
1252
1253
|
# as it is read, so an rspec run by the worker's execute never forks.
|
|
1253
1254
|
WORKER_DETACH_ENV = "SAMAGOTCHI_WORKER_DETACH"
|
|
1254
1255
|
|
|
1255
|
-
|
|
1256
|
+
# The boot string's rescue under a chi home: one line in the spawner's
|
|
1257
|
+
# log file (SAMAGOTCHI_LOG_FILE, set by spawn_options), LogLine's shape,
|
|
1258
|
+
# then exit. Single-quoted: the #{} run in the worker. SID is replaced.
|
|
1259
|
+
# rubocop:disable-next Lint/InterpolationCheck -- the worker interpolates these, not this process
|
|
1260
|
+
HOME_BOOT_FAILED = 'rescue ScriptError, StandardError => e; f = ENV["SAMAGOTCHI_LOG_FILE"].to_s; begin; ' \
|
|
1261
|
+
'File.open(f, "a") { |io| io.puts(Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ") + ' \
|
|
1262
|
+
'" ERROR worker pid=#{Process.pid} sid=SID boot_failed ruby=#{RbConfig.ruby.inspect} error=" + ' \
|
|
1263
|
+
"e.message.lines.first.to_s.strip.inspect) } unless f.empty?; rescue SystemCallError; end; exit 1"
|
|
1264
|
+
|
|
1265
|
+
def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec,
|
|
1266
|
+
home: ChiHome.current(gem_spec: gem_spec))
|
|
1256
1267
|
boot = "require 'samagotchi/session_manager'; " \
|
|
1257
1268
|
"Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
|
|
1258
1269
|
return [RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-e", boot] unless gem_spec
|
|
1270
|
+
return home_worker_command(session_id, state_dir: state_dir, gem_spec: gem_spec, home: home) if home
|
|
1259
1271
|
|
|
1260
1272
|
activate = "begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
|
|
1261
1273
|
"ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{gem_spec.version}'; end"
|
|
1262
1274
|
[RbConfig.ruby, "-e", "#{activate}; #{boot}"]
|
|
1263
1275
|
end
|
|
1264
1276
|
|
|
1277
|
+
# With a chi home (ChiHome.current, docs/architecture.md "How chi
|
|
1278
|
+
# launches itself"): the home's ruby, `Gem.paths` set to its gems in the
|
|
1279
|
+
# boot string (no GEM_* in the worker's env), the newest chi there, else
|
|
1280
|
+
# the fallback pin (ChiHome.fallback_version). A home that still can't
|
|
1281
|
+
# load chi (a broken install) logs HOME_BOOT_FAILED and exits: never a
|
|
1282
|
+
# silent spawn timeout.
|
|
1283
|
+
private_class_method def self.home_worker_command(session_id, state_dir:, gem_spec:, home:)
|
|
1284
|
+
newest = home.newest_version
|
|
1285
|
+
if newest && newest < Gem::Version.new(gem_spec.version.to_s)
|
|
1286
|
+
ChiHome.warn_once(:older_than_spawner, home: newest.to_s, spawner: gem_spec.version.to_s)
|
|
1287
|
+
end
|
|
1288
|
+
fallback = ChiHome.fallback_version(home, gem_spec)
|
|
1289
|
+
activate = if fallback
|
|
1290
|
+
"begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
|
|
1291
|
+
"ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{fallback}'; end"
|
|
1292
|
+
else
|
|
1293
|
+
"gem 'samagotchi', '>= 0.a'"
|
|
1294
|
+
end
|
|
1295
|
+
home.boot_argv(run: "begin; #{activate}; require 'samagotchi/session_manager'; " \
|
|
1296
|
+
"#{HOME_BOOT_FAILED.sub("SID", session_id)}; end; " \
|
|
1297
|
+
"Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})")
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1265
1300
|
private_class_method def self.spawn_worker_for_session(session, state_dir:)
|
|
1266
1301
|
session_dir = Session.session_dir(session.id, state_dir: state_dir)
|
|
1267
1302
|
FileUtils.mkdir_p(session_dir)
|
|
@@ -61,9 +61,12 @@ module Samagotchi
|
|
|
61
61
|
|
|
62
62
|
# #turn_llm_context with where each value came from
|
|
63
63
|
# (LLMContextStrategy.explain), nil when it can't be resolved.
|
|
64
|
+
# @param own [LLMContextOverride, nil] the session's own values to
|
|
65
|
+
# resolve with (the ones a change would set: /llm-context's confirm)
|
|
64
66
|
# @return [LLMContextStrategy::Explained, nil]
|
|
65
|
-
def llm_context_explained
|
|
66
|
-
|
|
67
|
+
def llm_context_explained(own: :session)
|
|
68
|
+
args = own == :session ? llm_context_session_args : own&.resolve_args || {}
|
|
69
|
+
model_setting(nil) { |model| model.llm_context_explained(args) }
|
|
67
70
|
end
|
|
68
71
|
|
|
69
72
|
# #llm_context_explained's summary (Explained#summary), nil when it
|