samagotchi 0.100.1 → 0.102.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 +14 -10
- data/docs/architecture.md +10 -2
- data/docs/cli.md +100 -5
- data/docs/configuration.md +26 -4
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -0
- data/docs/internals/llm-context-forget.md +14 -0
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/working-set.md +205 -0
- data/docs/plugins.md +6 -3
- data/docs/sessions.md +28 -11
- data/docs/testing.md +8 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +9 -3
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +92 -53
- data/lib/samagotchi/engine.rb +153 -4
- data/lib/samagotchi/host_keys.rb +35 -18
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +12 -3
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/send_command.rb +14 -11
- data/lib/samagotchi/session.rb +19 -12
- data/lib/samagotchi/session_commands.rb +44 -2
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_setup.rb +9 -9
- data/lib/samagotchi/sessions_command.rb +6 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/testing/fake_executable.rb +6 -16
- data/lib/samagotchi/testing/fake_executable_launcher +6 -0
- data/lib/samagotchi/tool_response.rb +10 -6
- data/lib/samagotchi/tool_runner.rb +25 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +76 -11
- data/lib/samagotchi/web/public/app.js +35 -3
- data/lib/samagotchi/web/public/chip_popover.js +56 -17
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +7 -0
- data/lib/samagotchi/web/public/index.html +145 -1
- data/lib/samagotchi/web/public/info_bar.js +16 -4
- data/lib/samagotchi/web/public/info_bar_model.js +7 -3
- data/lib/samagotchi/web/public/refs.js +10 -1
- data/lib/samagotchi/web/public/stage_model.js +6 -5
- data/lib/samagotchi/web/public/stage_view.js +34 -2
- data/lib/samagotchi/web/public/stream_handlers.js +10 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/turn_view.js +16 -4
- data/lib/samagotchi/web/public/working_set.js +612 -0
- data/lib/samagotchi/web/public/working_set_rail.js +278 -0
- data/lib/samagotchi/web/public/working_set_view.js +541 -0
- data/lib/samagotchi/web/server.rb +6 -4
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/working_set.rb +238 -0
- data/lib/samagotchi/working_set_command.rb +119 -0
- data/lib/samagotchi/working_set_steer.rb +250 -0
- metadata +11 -2
- data/lib/samagotchi/bundle_session_settings.rb +0 -251
data/lib/samagotchi/config.rb
CHANGED
|
@@ -22,6 +22,10 @@ module Samagotchi
|
|
|
22
22
|
autoload :ModelSetting, File.expand_path("model_settings", __dir__)
|
|
23
23
|
# The per-model/host seconds a cut waits (like Thinking for the level).
|
|
24
24
|
autoload :SteerCutAfter, File.expand_path("steer_cut_after", __dir__)
|
|
25
|
+
# A refused --set (Config.coerce! raises its Error), and recap.sentences'
|
|
26
|
+
# check (IdleRecap::RecapPrompt): both load on first use.
|
|
27
|
+
autoload :SessionConfig, File.expand_path("session_config", __dir__)
|
|
28
|
+
autoload :IdleRecap, File.expand_path("idle_recap", __dir__)
|
|
25
29
|
|
|
26
30
|
# Unified configuration registry implementing the implicit convention:
|
|
27
31
|
# ENV SAMAGOTCHI_ATTR (UPPER + prefix + _ = nesting)
|
|
@@ -39,8 +43,12 @@ module Samagotchi
|
|
|
39
43
|
module Config
|
|
40
44
|
# invalid: what an enum's invalid value falls to, when not the default
|
|
41
45
|
# (guardrails.parent_answers: the strictest).
|
|
46
|
+
# session: true for a key a session may set for itself (chi --set KEY=VALUE,
|
|
47
|
+
# SessionConfig; its value is read through Config.get(key, session:)),
|
|
48
|
+
# check: a lambda that takes a typed value and returns nil, or the
|
|
49
|
+
# "takes ..." phrase a refused --set names (recap.sentences' N or N-M).
|
|
42
50
|
Entry = Struct.new(:key, :yaml_path, :type, :default, :expose, :enum_values, :yaml_aliases, :lifecycle, :invalid,
|
|
43
|
-
keyword_init: true) do
|
|
51
|
+
:session, :check, keyword_init: true) do
|
|
44
52
|
def env_key
|
|
45
53
|
"SAMAGOTCHI_" + yaml_path.map { |p| p.upcase }.join("_")
|
|
46
54
|
end
|
|
@@ -64,6 +72,9 @@ module Samagotchi
|
|
|
64
72
|
def config_exposed?
|
|
65
73
|
expose.include?(:config)
|
|
66
74
|
end
|
|
75
|
+
|
|
76
|
+
# Whether a session may set the key for itself (chi --set).
|
|
77
|
+
def session? = session == true
|
|
67
78
|
end
|
|
68
79
|
|
|
69
80
|
# Section names: lower alnum only, no _ or -.
|
|
@@ -93,7 +104,9 @@ module Samagotchi
|
|
|
93
104
|
# off on local ones. hosts.<name>.first_token_timeout wins.
|
|
94
105
|
Entry.new(key: "server.first_token_timeout", yaml_path: %w[server first_token_timeout], type: :integer, default: nil, expose: %i[env config], lifecycle: :worker_start),
|
|
95
106
|
|
|
96
|
-
|
|
107
|
+
# Both session-settable (chi --set recap.enabled=false): RecapSetup reads them through the session's layer.
|
|
108
|
+
Entry.new(key: "recap.enabled", yaml_path: %w[recap enabled], type: :bool, default: nil, expose: %i[env config], lifecycle: :worker_start,
|
|
109
|
+
session: true),
|
|
97
110
|
Entry.new(key: "recap.model", yaml_path: %w[recap model], type: :string, default: nil, expose: %i[env config cli], lifecycle: :worker_start),
|
|
98
111
|
Entry.new(key: "recap.base_url", yaml_path: %w[recap base_url], type: :string, default: nil, expose: %i[env config cli], lifecycle: :worker_start),
|
|
99
112
|
Entry.new(key: "recap.host_ref", yaml_path: %w[recap host_ref], type: :string, default: nil, expose: %i[env config cli], yaml_aliases: %w[host], lifecycle: :worker_start),
|
|
@@ -101,7 +114,9 @@ module Samagotchi
|
|
|
101
114
|
Entry.new(key: "recap.timeout", yaml_path: %w[recap timeout], type: :float, default: nil, expose: %i[env config cli], lifecycle: :worker_start),
|
|
102
115
|
Entry.new(key: "recap.min_user_turns", yaml_path: %w[recap min_user_turns], type: :integer, default: nil, expose: %i[env config cli], lifecycle: :worker_start),
|
|
103
116
|
# "N-M" or "N" sentences (1-10); unset = 2-4. Parsed by RecapPrompt.sentences_range.
|
|
104
|
-
Entry.new(key: "recap.sentences", yaml_path: %w[recap sentences], type: :string, default: nil, expose: %i[env config cli], lifecycle: :worker_start
|
|
117
|
+
Entry.new(key: "recap.sentences", yaml_path: %w[recap sentences], type: :string, default: nil, expose: %i[env config cli], lifecycle: :worker_start,
|
|
118
|
+
session: true,
|
|
119
|
+
check: ->(value) { "N or N-M (1-#{IdleRecap::RecapPrompt::MAX_SENTENCES})" unless IdleRecap::RecapPrompt.sentences_range(value) }),
|
|
105
120
|
|
|
106
121
|
# chi broadcast (Broadcast::Recipients, Broadcast::Tags): a session that ended no turn in this many hours (and
|
|
107
122
|
# no worker or REPL runs) gets no broadcast; a ticket id in the note and in a session's branch or prompts
|
|
@@ -289,6 +304,9 @@ module Samagotchi
|
|
|
289
304
|
Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli], lifecycle: :web_start),
|
|
290
305
|
# The page's view of a turn: stage (the running turn pinned above the composer) or turn (one block of generations, the live one at the bottom); ?view= overrides it per page load.
|
|
291
306
|
Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn], lifecycle: :web_start),
|
|
307
|
+
# The stage view's working set (files, memories and URLs the turn brought into context): a rail in the stage, a
|
|
308
|
+
# footprint under each finished turn and a "set N" chip in place of the mem chip; ?working_set=1|0 overrides it per page load.
|
|
309
|
+
Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: false, expose: %i[env config cli], lifecycle: :web_start),
|
|
292
310
|
# Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
|
|
293
311
|
Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli], lifecycle: :web_start),
|
|
294
312
|
# What a file ref (a tool row's file, a diff line) opens on this machine: a preset (vscode, vscode-insiders, cursor, zed, none) or a URL template with {path} and an optional {line} (Web::Editor).
|
|
@@ -305,7 +323,7 @@ module Samagotchi
|
|
|
305
323
|
|
|
306
324
|
# Top-level maps and lists read by their own code, whose entry names and
|
|
307
325
|
# contents are the user's: ConfigFile.model_aliases / preloaded_memories /
|
|
308
|
-
# appended_prompt_files, Hooks::Loader,
|
|
326
|
+
# appended_prompt_files, Hooks::Loader, ConfigFile.bundle_settings.
|
|
309
327
|
FREE_FORM_MAPS = %w[model_aliases hooks bundles memories append_system_prompt].freeze
|
|
310
328
|
# Maps of named entries and the keys an entry may hold
|
|
311
329
|
# (ConfigFile.hosts_config, ConfigFile.model_settings).
|
|
@@ -321,6 +339,10 @@ module Samagotchi
|
|
|
321
339
|
# And toolkit's kits (Toolkit.kits).
|
|
322
340
|
SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze, "toolkit" => %w[kits].freeze }.freeze
|
|
323
341
|
|
|
342
|
+
# What a :bool value reads as (Config.coerce!), YAML 1.1's words and 1/0.
|
|
343
|
+
BOOL_WORDS = { "1" => true, "true" => true, "yes" => true, "on" => true,
|
|
344
|
+
"0" => false, "false" => false, "no" => false, "off" => false }.freeze
|
|
345
|
+
|
|
324
346
|
# Fast lookup maps
|
|
325
347
|
BY_KEY = ENTRIES.each_with_object({}) { |e, h| h[e.key] = e }.freeze
|
|
326
348
|
|
|
@@ -339,58 +361,70 @@ module Samagotchi
|
|
|
339
361
|
ENTRIES.select(&:cli_exposed?)
|
|
340
362
|
end
|
|
341
363
|
|
|
342
|
-
#
|
|
343
|
-
def
|
|
364
|
+
# The entries a session may set for itself (Entry#session?).
|
|
365
|
+
def session_entries
|
|
366
|
+
ENTRIES.select(&:session?)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# +raw+ as +entry+'s type, strictly: a value of another shape raises.
|
|
370
|
+
# :string as given (a YAML list joined with |), :integer, :float,
|
|
371
|
+
# :bool (true/yes/on/1, false/no/off/0/""), :enum in its canonical
|
|
372
|
+
# case; nil for nil, and for a blank value of any other type than
|
|
373
|
+
# :string and :bool. #coerce is its lenient twin (config.yml, env).
|
|
374
|
+
# @raise [SessionConfig::Error] "KEY takes WHAT, not RAW"
|
|
375
|
+
def coerce!(entry, raw)
|
|
344
376
|
return nil if raw.nil?
|
|
377
|
+
# A YAML list for a "|"-separated setting (web.annotate_presets).
|
|
378
|
+
# As given, trailing spaces included ("Please ").
|
|
379
|
+
return raw.is_a?(Array) ? raw.join("|") : raw.to_s if entry.type == :string
|
|
345
380
|
|
|
346
|
-
|
|
347
|
-
if entry.type == :
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
381
|
+
str = raw.to_s.strip
|
|
382
|
+
return false if entry.type == :bool && str.empty?
|
|
383
|
+
return nil if str.empty?
|
|
384
|
+
|
|
385
|
+
value = case entry.type
|
|
386
|
+
when :integer then Integer(str, exception: false)
|
|
387
|
+
when :float then Float(str, exception: false)
|
|
388
|
+
when :bool then BOOL_WORDS[str.downcase]
|
|
389
|
+
when :enum then entry.enum_values.find { |v| v.downcase == str.downcase }
|
|
390
|
+
else str
|
|
391
|
+
end
|
|
392
|
+
raise SessionConfig::Error, "#{entry.key} takes #{takes(entry)}, not #{raw.to_s.inspect}" if value.nil?
|
|
393
|
+
|
|
394
|
+
value
|
|
395
|
+
end
|
|
351
396
|
|
|
352
|
-
|
|
397
|
+
# What +entry+'s type takes, as a refusal names it.
|
|
398
|
+
def takes(entry)
|
|
399
|
+
case entry.type
|
|
400
|
+
when :integer then "an integer"
|
|
401
|
+
when :float then "a number"
|
|
402
|
+
when :bool then "true or false"
|
|
403
|
+
when :enum then "#{entry.enum_values[0..-2].join(", ")} or #{entry.enum_values.last}"
|
|
404
|
+
else "text"
|
|
353
405
|
end
|
|
354
|
-
|
|
355
|
-
return nil if str.empty? && entry.type != :string
|
|
406
|
+
end
|
|
356
407
|
|
|
408
|
+
# +raw+ as +entry+'s type (#coerce!), leniently: a value of another
|
|
409
|
+
# shape warns and falls back (the default; false for a bool; an
|
|
410
|
+
# enum's +invalid+ when it has one).
|
|
411
|
+
def coerce(entry, raw)
|
|
412
|
+
coerce!(entry, raw)
|
|
413
|
+
rescue SessionConfig::Error
|
|
357
414
|
case entry.type
|
|
358
|
-
when :string
|
|
359
|
-
str
|
|
360
415
|
when :integer
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
Log.warn(:config, "invalid_value", echo: "Warning: invalid integer for #{entry.key} (#{entry.env_key}): #{raw.inspect} — using default", key: entry.key)
|
|
364
|
-
return entry.default
|
|
365
|
-
end
|
|
366
|
-
end
|
|
416
|
+
Log.warn(:config, "invalid_value", echo: "Warning: invalid integer for #{entry.key} (#{entry.env_key}): #{raw.inspect} — using default", key: entry.key)
|
|
417
|
+
entry.default
|
|
367
418
|
when :float
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
Log.warn(:config, "invalid_value", echo: "Warning: invalid float for #{entry.key}: #{raw.inspect}", key: entry.key)
|
|
371
|
-
return entry.default
|
|
372
|
-
end
|
|
373
|
-
val
|
|
419
|
+
Log.warn(:config, "invalid_value", echo: "Warning: invalid float for #{entry.key}: #{raw.inspect}", key: entry.key)
|
|
420
|
+
entry.default
|
|
374
421
|
when :bool
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
when "0", "false", "no", "off", "" then false
|
|
378
|
-
else
|
|
379
|
-
Log.warn(:config, "invalid_value", echo: "Warning: invalid bool for #{entry.key}: #{raw.inspect} — treating as false", key: entry.key)
|
|
380
|
-
false
|
|
381
|
-
end
|
|
382
|
-
when :enum
|
|
383
|
-
lowered = str.downcase
|
|
384
|
-
allowed = entry.enum_values.map(&:downcase)
|
|
385
|
-
unless allowed.include?(lowered)
|
|
386
|
-
using = entry.invalid ? "using #{entry.invalid}" : "using default"
|
|
387
|
-
Log.warn(:config, "invalid_value", echo: "Warning: invalid value for #{entry.key}: #{raw.inspect} (allowed: #{entry.enum_values.join(", ")}) — #{using}", key: entry.key)
|
|
388
|
-
return entry.invalid || entry.default
|
|
389
|
-
end
|
|
390
|
-
# return canonical casing from enum_values
|
|
391
|
-
entry.enum_values.find { |v| v.downcase == lowered }
|
|
422
|
+
Log.warn(:config, "invalid_value", echo: "Warning: invalid bool for #{entry.key}: #{raw.inspect} — treating as false", key: entry.key)
|
|
423
|
+
false
|
|
392
424
|
else
|
|
393
|
-
|
|
425
|
+
using = entry.invalid ? "using #{entry.invalid}" : "using default"
|
|
426
|
+
Log.warn(:config, "invalid_value", echo: "Warning: invalid value for #{entry.key}: #{raw.inspect} (allowed: #{entry.enum_values.join(", ")}) — #{using}", key: entry.key)
|
|
427
|
+
entry.invalid || entry.default
|
|
394
428
|
end
|
|
395
429
|
end
|
|
396
430
|
|
|
@@ -403,10 +437,14 @@ module Samagotchi
|
|
|
403
437
|
end
|
|
404
438
|
|
|
405
439
|
# Same as #resolve, plus the layer the value came from:
|
|
406
|
-
# :cli, :env, :file or :default.
|
|
407
|
-
|
|
440
|
+
# :session, :cli, :env, :file or :default. +session+ is a session's
|
|
441
|
+
# own values (SessionConfig::Settings#core, typed): a session-settable
|
|
442
|
+
# key's value there goes first, over the CLI (a worker gets its CLI
|
|
443
|
+
# as env, and the session's value was given for this session).
|
|
444
|
+
def resolve_with_origin(key, file_data: nil, env: ENV, cli_overrides: {}, session: nil)
|
|
408
445
|
entry = find_by_key(key)
|
|
409
446
|
raise ArgumentError, "unknown config key: #{key}" unless entry
|
|
447
|
+
return [session[entry.key], :session] if entry.session? && session&.key?(entry.key)
|
|
410
448
|
|
|
411
449
|
# CLI wins
|
|
412
450
|
if cli_overrides.key?(entry.key)
|
|
@@ -520,8 +558,9 @@ module Samagotchi
|
|
|
520
558
|
end
|
|
521
559
|
end
|
|
522
560
|
|
|
523
|
-
|
|
524
|
-
|
|
561
|
+
# The live value of +key+; +session+ as #resolve_with_origin takes it.
|
|
562
|
+
def get(key, session: nil)
|
|
563
|
+
get_with_origin(key, session: session).first
|
|
525
564
|
end
|
|
526
565
|
|
|
527
566
|
# default.max_tokens: the most tokens one generation may produce, or
|
|
@@ -542,8 +581,8 @@ module Samagotchi
|
|
|
542
581
|
end
|
|
543
582
|
|
|
544
583
|
# [value, origin] for the live value #get returns; origin is one of
|
|
545
|
-
# :cli, :env, :file, :default.
|
|
546
|
-
def get_with_origin(key)
|
|
584
|
+
# :session, :cli, :env, :file, :default.
|
|
585
|
+
def get_with_origin(key, session: nil)
|
|
547
586
|
entry = find_by_key(key)
|
|
548
587
|
raise ArgumentError, "unknown config key: #{key}" unless entry
|
|
549
588
|
|
|
@@ -552,7 +591,7 @@ module Samagotchi
|
|
|
552
591
|
# overrides captured via reload!
|
|
553
592
|
path = Samagotchi::ConfigFile.global_path rescue nil
|
|
554
593
|
file_data = Samagotchi::ConfigFile.read_yaml(path: path) if path
|
|
555
|
-
resolve_with_origin(entry.key, file_data: file_data, env: ENV, cli_overrides: cli_overrides)
|
|
594
|
+
resolve_with_origin(entry.key, file_data: file_data, env: ENV, cli_overrides: cli_overrides, session: session)
|
|
556
595
|
end
|
|
557
596
|
|
|
558
597
|
# Problems with the keys of a parsed config.yml, one message per key the
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -84,6 +84,7 @@ require_relative "relay_desk"
|
|
|
84
84
|
require_relative "guardrail_wiring"
|
|
85
85
|
require_relative "plugin_tasks"
|
|
86
86
|
require_relative "prompt_warmup"
|
|
87
|
+
require_relative "working_set"
|
|
87
88
|
|
|
88
89
|
module Samagotchi
|
|
89
90
|
# Engine owns the core agent logic: system prompt construction, tool
|
|
@@ -106,6 +107,8 @@ module Samagotchi
|
|
|
106
107
|
|
|
107
108
|
# What memory_write answers in a scratch session.
|
|
108
109
|
SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
|
|
110
|
+
# The session setting /recap on|off|default writes (#recap_override=).
|
|
111
|
+
RECAP_ENABLED_KEY = "recap.enabled"
|
|
109
112
|
|
|
110
113
|
# @param client [Client, nil] defaults to Client.new
|
|
111
114
|
# @param profile [ModelProfile, Symbol, String, nil]
|
|
@@ -231,9 +234,9 @@ module Samagotchi
|
|
|
231
234
|
# across UIs.
|
|
232
235
|
@turn_state.restart_clock!
|
|
233
236
|
@side_client_mutex = Mutex.new
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
+
# Kept for a rebuild under a bound session's own recap values.
|
|
238
|
+
@recap_kwarg = recap
|
|
239
|
+
@recap = build_recap
|
|
237
240
|
# One shared poller for the whole idle layer (reminders + optional
|
|
238
241
|
# recap). Both jobs read the activity seam above; the scheduler owns
|
|
239
242
|
# the single background thread and isolates per-job failures.
|
|
@@ -785,7 +788,7 @@ module Samagotchi
|
|
|
785
788
|
# UIs need in the event log, emitted outside any turn's stream.
|
|
786
789
|
ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
|
|
787
790
|
command_queued command_ran context_added card hook_notice guardrail_warning
|
|
788
|
-
plugin_init_started plugin_init_finished].freeze
|
|
791
|
+
plugin_init_started plugin_init_finished working_set_updated].freeze
|
|
789
792
|
|
|
790
793
|
# Put a transport-level event into the ordered event log. Unlike turn
|
|
791
794
|
# events it reaches only persistent observers (no turn sink, no memory
|
|
@@ -806,6 +809,34 @@ module Samagotchi
|
|
|
806
809
|
@session_observer.synchronize(&)
|
|
807
810
|
end
|
|
808
811
|
|
|
812
|
+
# The working set's :working_set_updated, announced between turns
|
|
813
|
+
# (after a command changed what the next request sends), when it
|
|
814
|
+
# differs from the last one emitted (#emit_working_set). Under the
|
|
815
|
+
# event lock, so it is ordered with the change's own events.
|
|
816
|
+
def announce_working_set(session = @session)
|
|
817
|
+
return unless session
|
|
818
|
+
|
|
819
|
+
synchronize_events { emit_working_set(nil, session, session.messages) }
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# The strategy the working set is built under now, as LLMContextView
|
|
823
|
+
# takes it: the running turn's, else the next turn's (#emit_working_set).
|
|
824
|
+
# /working-set's ops act under it.
|
|
825
|
+
# @return [Symbol, Array<Symbol>]
|
|
826
|
+
def working_set_strategy
|
|
827
|
+
working_set_llm_context&.strategy || LLMContextView::NONE
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# Replace the current session's conversation, between turns (a
|
|
831
|
+
# /working-set op's copy: #clone_messages, then its edits).
|
|
832
|
+
# @param messages [Array<Hash>]
|
|
833
|
+
# @return [Array<Hash>] the session's messages
|
|
834
|
+
def replace_messages(messages)
|
|
835
|
+
raise ArgumentError, "no current session" unless @session
|
|
836
|
+
|
|
837
|
+
replace_session_messages(@session, messages)
|
|
838
|
+
end
|
|
839
|
+
|
|
809
840
|
# Add a context note to the session's conversation, between turns: a
|
|
810
841
|
# tail system message the next turn sees, announced as :context_added.
|
|
811
842
|
# The array is replaced, not mutated, and the append and the event
|
|
@@ -1130,6 +1161,8 @@ module Samagotchi
|
|
|
1130
1161
|
# price_host: the effective model's host, whose hosts.<name>.models
|
|
1131
1162
|
# prices the cost estimate names. kit_prompt: the next turn's kit
|
|
1132
1163
|
# prompt text ({kit:, tokens:}), only when its toolkit has one.
|
|
1164
|
+
# settings: the session's own settings ({"recap.enabled" => false}),
|
|
1165
|
+
# only when it has some.
|
|
1133
1166
|
# @return [Hash] @metrics.snapshot, filled in
|
|
1134
1167
|
def stats_snapshot
|
|
1135
1168
|
snapshot = @metrics.snapshot
|
|
@@ -1144,6 +1177,9 @@ module Samagotchi
|
|
|
1144
1177
|
price_host: model.host_name)
|
|
1145
1178
|
kit_prompt = kit_prompt_stats
|
|
1146
1179
|
snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
|
|
1180
|
+
# The session's own settings (chi --set), as --set names them.
|
|
1181
|
+
settings = @session&.settings&.flat || {}
|
|
1182
|
+
snapshot = snapshot.merge(settings: settings) unless settings.empty?
|
|
1147
1183
|
unless snapshot.dig(:context, :window_tokens)
|
|
1148
1184
|
window = current_context_window(model)
|
|
1149
1185
|
if window
|
|
@@ -1459,6 +1495,7 @@ module Samagotchi
|
|
|
1459
1495
|
synchronize_events do
|
|
1460
1496
|
kept, event = yield(seconds)
|
|
1461
1497
|
replace_session_messages(turn.session, kept) if kept
|
|
1498
|
+
emit_working_set(turn.on_event, turn.session, turn.session.messages)
|
|
1462
1499
|
turn.session.status = Session::STATUS_IDLE
|
|
1463
1500
|
record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
|
|
1464
1501
|
emit_event(turn.on_event, turn.tag(event))
|
|
@@ -1720,6 +1757,8 @@ module Samagotchi
|
|
|
1720
1757
|
@session = session
|
|
1721
1758
|
# One session per REPL/worker process: its records carry this sid.
|
|
1722
1759
|
Log.session_id = session.id if session&.id
|
|
1760
|
+
# Its own recap values (chi --set recap.enabled=false) rebuild the job.
|
|
1761
|
+
rebuild_recap!
|
|
1723
1762
|
# A woken worker's /stats and status line count the turns before it.
|
|
1724
1763
|
bind_metrics(session)
|
|
1725
1764
|
@used_memories.absorb(session)
|
|
@@ -1750,6 +1789,66 @@ module Samagotchi
|
|
|
1750
1789
|
# @return [IdleRecap, nil] the idle recap detector, or nil when disabled
|
|
1751
1790
|
attr_reader :recap
|
|
1752
1791
|
|
|
1792
|
+
# Whether a continue offer waits (the Worker's or the REPL's TurnFlow),
|
|
1793
|
+
# for the recap job: this one and any rebuilt one (IdleRecap#awaiting_continue=).
|
|
1794
|
+
def recap_awaiting_continue=(callable)
|
|
1795
|
+
@recap_awaiting_continue = callable
|
|
1796
|
+
@recap&.awaiting_continue = callable
|
|
1797
|
+
end
|
|
1798
|
+
|
|
1799
|
+
# The idle recap job (RecapSetup.build) under the current session's own
|
|
1800
|
+
# recap values, which it records.
|
|
1801
|
+
# @return [IdleRecap, nil]
|
|
1802
|
+
def build_recap
|
|
1803
|
+
@recap_built_for = session_recap_values
|
|
1804
|
+
recap = RecapSetup.build(@recap_kwarg, engine: self, host_registry: @host_registry,
|
|
1805
|
+
session_target: -> { session_model_recap_target },
|
|
1806
|
+
session_id: -> { @session&.id }, state_dir: -> { session_state_dir },
|
|
1807
|
+
session_config: -> { @session&.settings&.core })
|
|
1808
|
+
recap.awaiting_continue = @recap_awaiting_continue if recap && @recap_awaiting_continue
|
|
1809
|
+
recap
|
|
1810
|
+
end
|
|
1811
|
+
private :build_recap
|
|
1812
|
+
|
|
1813
|
+
# The bound session's own recap.* values ({} for none).
|
|
1814
|
+
def session_recap_values
|
|
1815
|
+
(@session&.settings&.core || {}).select { |key, _| key.start_with?("recap.") }
|
|
1816
|
+
end
|
|
1817
|
+
private :session_recap_values
|
|
1818
|
+
|
|
1819
|
+
# A new recap job when the bound session's own recap values differ from
|
|
1820
|
+
# the ones the current job was built under (none at start): the old one
|
|
1821
|
+
# is dropped (an attempt in flight goes stale) and the scheduler swaps
|
|
1822
|
+
# it, keeping its one thread. Binding the same session again changes
|
|
1823
|
+
# nothing (the REPL binds it twice).
|
|
1824
|
+
def rebuild_recap!
|
|
1825
|
+
return if session_recap_values == @recap_built_for
|
|
1826
|
+
|
|
1827
|
+
old = @recap
|
|
1828
|
+
old&.invalidate!
|
|
1829
|
+
@recap = build_recap
|
|
1830
|
+
@idle_scheduler&.replace_job(old, @recap)
|
|
1831
|
+
end
|
|
1832
|
+
|
|
1833
|
+
# /recap on|off|default: the bound session's own recap.enabled (true,
|
|
1834
|
+
# false; nil removes it, so config.yml's applies again), and the recap
|
|
1835
|
+
# job rebuilt under it at once. It doesn't save: the command says
|
|
1836
|
+
# changed: [:recap] and its host saves (Worker#run_command).
|
|
1837
|
+
# @param enabled [Boolean, nil]
|
|
1838
|
+
# @raise [ArgumentError] no session is bound
|
|
1839
|
+
def recap_override=(enabled)
|
|
1840
|
+
raise ArgumentError, "no session" unless @session
|
|
1841
|
+
|
|
1842
|
+
core = @session.settings.core.dup
|
|
1843
|
+
enabled.nil? ? core.delete(RECAP_ENABLED_KEY) : core[RECAP_ENABLED_KEY] = enabled
|
|
1844
|
+
@session.settings = @session.settings.with(core: core)
|
|
1845
|
+
rebuild_recap!
|
|
1846
|
+
end
|
|
1847
|
+
|
|
1848
|
+
# The bound session's own recap.enabled (/recap on|off, --set), or nil.
|
|
1849
|
+
# @return [Boolean, nil]
|
|
1850
|
+
def recap_override = @session&.settings&.core&.fetch(RECAP_ENABLED_KEY, nil)
|
|
1851
|
+
|
|
1753
1852
|
# Write the recap now, before the session is left (IdleRecap#write_now).
|
|
1754
1853
|
# @return [String, nil] the recap written, nil when none was (recap off,
|
|
1755
1854
|
# nothing new, an error, the timeout)
|
|
@@ -2835,11 +2934,61 @@ module Samagotchi
|
|
|
2835
2934
|
|
|
2836
2935
|
event = with_generation_report(event) if event[:type] == :generation_completed
|
|
2837
2936
|
emit_event(on_event, event)
|
|
2937
|
+
# What this request sends: both loops start a generation after
|
|
2938
|
+
# their apply_llm_context!.
|
|
2939
|
+
emit_working_set(on_event, @session, live_conversation) if event[:type] == :generation_started
|
|
2838
2940
|
watch.feed(**progress) if watch && progress
|
|
2839
2941
|
@thinking_notices.generation_completed(event) if event[:type] == :generation_completed
|
|
2840
2942
|
end
|
|
2841
2943
|
end
|
|
2842
2944
|
|
|
2945
|
+
# The working set (WorkingSet) as the model has it, as a
|
|
2946
|
+
# :working_set_updated event ({items:, at:}): at each request (the
|
|
2947
|
+
# stream handler's :generation_started, from the running turn's
|
|
2948
|
+
# conversation) and at turn end (#end_turn, from the session's kept
|
|
2949
|
+
# messages). Only when it differs from the last one emitted for this
|
|
2950
|
+
# session, and never while it is empty — except one empty event,
|
|
2951
|
+
# remembered, when the last one for this session had items (a
|
|
2952
|
+
# rollback removed the tool calls), so a tool-free turn emits none
|
|
2953
|
+
# and the next empty request sends nothing more. The web overlays it
|
|
2954
|
+
# on the rows' set; other UIs ignore it.
|
|
2955
|
+
#
|
|
2956
|
+
# Between turns (#announce_working_set: no +on_event+) it is
|
|
2957
|
+
# announced, under the next turn's strategy.
|
|
2958
|
+
def emit_working_set(on_event, session, conversation)
|
|
2959
|
+
return unless session && conversation
|
|
2960
|
+
|
|
2961
|
+
snapshot = WorkingSet.build(conversation, cwd: session.working_directory, strategy: working_set_strategy)
|
|
2962
|
+
|
|
2963
|
+
items = snapshot.items.map(&:to_h)
|
|
2964
|
+
last = @working_set_emitted
|
|
2965
|
+
if items.empty?
|
|
2966
|
+
return unless last && last[0] == session.id && !last[1].empty?
|
|
2967
|
+
elsif last == [session.id, items]
|
|
2968
|
+
return
|
|
2969
|
+
end
|
|
2970
|
+
|
|
2971
|
+
@working_set_emitted = [session.id, items]
|
|
2972
|
+
event = { type: :working_set_updated, items: items, at: snapshot.at }
|
|
2973
|
+
on_event ? emit_event(on_event, event) : announce_or_hold(event)
|
|
2974
|
+
rescue StandardError => e
|
|
2975
|
+
Log.warn(:turn, "working_set_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
2976
|
+
end
|
|
2977
|
+
private :emit_working_set
|
|
2978
|
+
|
|
2979
|
+
# The strategy the set is built under: the running turn's
|
|
2980
|
+
# (TurnSettings#llm_context: what its requests send); between turns
|
|
2981
|
+
# the next turn's (as #warm_up_next_turn: the turn's settings keep the
|
|
2982
|
+
# last turn's, stale after a /llm-context or /model change), the last
|
|
2983
|
+
# turn's when it can't be resolved.
|
|
2984
|
+
def working_set_llm_context
|
|
2985
|
+
turn = @kernel.respond_to?(:turn_settings) ? @kernel.turn_settings&.llm_context : nil
|
|
2986
|
+
return turn if turn_running?
|
|
2987
|
+
|
|
2988
|
+
@model_settings.turn_llm_context || turn
|
|
2989
|
+
end
|
|
2990
|
+
private :working_set_llm_context
|
|
2991
|
+
|
|
2843
2992
|
# A :generation_completed with the generation's decode speed (`speed:`
|
|
2844
2993
|
# {decode_tps:, source:}, nil without one), the session's running
|
|
2845
2994
|
# token totals (`tokens:`, the snapshot's block) and its per-model rows
|
data/lib/samagotchi/host_keys.rb
CHANGED
|
@@ -21,16 +21,22 @@ module Samagotchi
|
|
|
21
21
|
# Reads one generic password through /usr/bin/security (by absolute
|
|
22
22
|
# path: the helper's PATH isn't the shell's) in its own process group,
|
|
23
23
|
# killed at the deadline: a locked keychain or an access dialog must not
|
|
24
|
-
# hang a turn.
|
|
25
|
-
#
|
|
24
|
+
# hang a turn. The deadline covers the spawn too (macOS can hold a first
|
|
25
|
+
# exec for minutes while it scans the binary): the run is a thread the
|
|
26
|
+
# caller gives up on, and a pid that turns up late is killed and reaped
|
|
27
|
+
# there. nil on a miss, a non-zero exit, a timeout, no binary, or off
|
|
28
|
+
# macOS.
|
|
26
29
|
class Keychain
|
|
27
30
|
SECURITY = "/usr/bin/security"
|
|
28
31
|
TIMEOUT = 5
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
# +spawner+: how the run starts (Process.spawn's arguments; specs stall it).
|
|
34
|
+
def initialize(security: SECURITY, timeout: TIMEOUT, host_os: RbConfig::CONFIG["host_os"],
|
|
35
|
+
spawner: Process.method(:spawn))
|
|
31
36
|
@security = security
|
|
32
37
|
@timeout = timeout
|
|
33
38
|
@macos = host_os.to_s.include?("darwin")
|
|
39
|
+
@spawner = spawner
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
# @return [String, nil]
|
|
@@ -45,33 +51,44 @@ module Samagotchi
|
|
|
45
51
|
private
|
|
46
52
|
|
|
47
53
|
def run(name)
|
|
48
|
-
|
|
49
|
-
pid =
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
stop(pid, waiter)
|
|
55
|
-
Log.warn(:config, "keychain_timeout", name: name, seconds: @timeout)
|
|
56
|
-
return nil
|
|
54
|
+
lock = Mutex.new
|
|
55
|
+
pid = nil
|
|
56
|
+
late = false
|
|
57
|
+
worker = Thread.new do
|
|
58
|
+
Thread.current.report_on_exception = false
|
|
59
|
+
security(name) { |spawned| lock.synchronize { late ? stop(spawned) : pid = spawned } }
|
|
57
60
|
end
|
|
58
|
-
return
|
|
61
|
+
return worker.value if worker.join(@timeout)
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
lock.synchronize do
|
|
64
|
+
late = true
|
|
65
|
+
stop(pid) if pid
|
|
66
|
+
end
|
|
67
|
+
Log.warn(:config, "keychain_timeout", name: name, seconds: @timeout)
|
|
68
|
+
nil
|
|
62
69
|
rescue SystemCallError
|
|
63
70
|
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# One run, start to reap; yields the pid as soon as there is one.
|
|
74
|
+
def security(name)
|
|
75
|
+
reader, writer = IO.pipe
|
|
76
|
+
pid = @spawner.call(@security, "find-generic-password", "-s", SERVICE, "-a", name, "-w",
|
|
77
|
+
in: File::NULL, out: writer, err: File::NULL, pgroup: true)
|
|
78
|
+
writer.close
|
|
79
|
+
yield pid
|
|
80
|
+
key = reader.read.to_s.chomp
|
|
81
|
+
_, status = Process.wait2(pid)
|
|
82
|
+
status.success? && !key.strip.empty? ? key : nil
|
|
64
83
|
ensure
|
|
65
84
|
writer&.close unless writer&.closed?
|
|
66
85
|
reader&.close
|
|
67
86
|
end
|
|
68
87
|
|
|
69
|
-
def stop(pid
|
|
88
|
+
def stop(pid)
|
|
70
89
|
Process.kill(:KILL, -pid)
|
|
71
90
|
rescue SystemCallError
|
|
72
91
|
nil
|
|
73
|
-
ensure
|
|
74
|
-
waiter.join(1)
|
|
75
92
|
end
|
|
76
93
|
end
|
|
77
94
|
|
|
@@ -25,7 +25,7 @@ module Samagotchi
|
|
|
25
25
|
raise ArgumentError, "IdleScheduler requires an engine" unless engine
|
|
26
26
|
|
|
27
27
|
@engine = engine
|
|
28
|
-
@jobs = jobs.compact
|
|
28
|
+
@jobs = jobs.compact.freeze
|
|
29
29
|
@thread = nil
|
|
30
30
|
@stopped = false
|
|
31
31
|
end
|
|
@@ -49,6 +49,21 @@ module Samagotchi
|
|
|
49
49
|
@thread = nil
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# Swap the job +old+ for +new+ (Engine#session= rebuilding the recap):
|
|
53
|
+
# nil +old+ adds +new+, nil +new+ drops +old+. The list is replaced
|
|
54
|
+
# whole, so a tick on the thread steps the old list or the new one; a
|
|
55
|
+
# running scheduler keeps its one thread, and one with no jobs yet
|
|
56
|
+
# starts it once #start is called.
|
|
57
|
+
def replace_job(old, new)
|
|
58
|
+
jobs = if @jobs.any? { |job| job.equal?(old) }
|
|
59
|
+
@jobs.map { |job| job.equal?(old) ? new : job }
|
|
60
|
+
else
|
|
61
|
+
@jobs + [new]
|
|
62
|
+
end
|
|
63
|
+
@jobs = jobs.compact.freeze
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
52
67
|
# One step across all jobs. Public so specs can drive it deterministically.
|
|
53
68
|
def tick
|
|
54
69
|
return if @stopped
|