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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +14 -10
  4. data/docs/architecture.md +10 -2
  5. data/docs/cli.md +100 -5
  6. data/docs/configuration.md +26 -4
  7. data/docs/internals/bundles.md +1 -1
  8. data/docs/internals/engine.md +1 -0
  9. data/docs/internals/llm-context-forget.md +14 -0
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/internals/working-set.md +205 -0
  12. data/docs/plugins.md +6 -3
  13. data/docs/sessions.md +28 -11
  14. data/docs/testing.md +8 -1
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
  16. data/lib/samagotchi/bridge.rb +9 -3
  17. data/lib/samagotchi/bundle_command/check.rb +5 -1
  18. data/lib/samagotchi/bundle_command/try/command.rb +6 -6
  19. data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
  20. data/lib/samagotchi/bundle_command.rb +4 -4
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/commands/registry.rb +12 -1
  23. data/lib/samagotchi/config.rb +92 -53
  24. data/lib/samagotchi/engine.rb +153 -4
  25. data/lib/samagotchi/host_keys.rb +35 -18
  26. data/lib/samagotchi/idle_scheduler.rb +16 -1
  27. data/lib/samagotchi/llm_context_edit.rb +32 -10
  28. data/lib/samagotchi/llm_context_forget.rb +12 -3
  29. data/lib/samagotchi/llm_context_view.rb +19 -7
  30. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  31. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  32. data/lib/samagotchi/message_parts.rb +8 -3
  33. data/lib/samagotchi/plugin/context.rb +2 -2
  34. data/lib/samagotchi/prompt.rb +1 -1
  35. data/lib/samagotchi/recap_command.rb +68 -0
  36. data/lib/samagotchi/recap_setup.rb +27 -14
  37. data/lib/samagotchi/send_command.rb +14 -11
  38. data/lib/samagotchi/session.rb +19 -12
  39. data/lib/samagotchi/session_commands.rb +44 -2
  40. data/lib/samagotchi/session_config.rb +411 -0
  41. data/lib/samagotchi/session_setup.rb +9 -9
  42. data/lib/samagotchi/sessions_command.rb +6 -2
  43. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  44. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  45. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  46. data/lib/samagotchi/terminal_ui.rb +8 -6
  47. data/lib/samagotchi/testing/bundle.rb +3 -2
  48. data/lib/samagotchi/testing/fake_executable.rb +6 -16
  49. data/lib/samagotchi/testing/fake_executable_launcher +6 -0
  50. data/lib/samagotchi/tool_response.rb +10 -6
  51. data/lib/samagotchi/tool_runner.rb +25 -6
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +76 -11
  54. data/lib/samagotchi/web/public/app.js +35 -3
  55. data/lib/samagotchi/web/public/chip_popover.js +56 -17
  56. data/lib/samagotchi/web/public/commands.js +8 -2
  57. data/lib/samagotchi/web/public/data.js +7 -0
  58. data/lib/samagotchi/web/public/index.html +145 -1
  59. data/lib/samagotchi/web/public/info_bar.js +16 -4
  60. data/lib/samagotchi/web/public/info_bar_model.js +7 -3
  61. data/lib/samagotchi/web/public/refs.js +10 -1
  62. data/lib/samagotchi/web/public/stage_model.js +6 -5
  63. data/lib/samagotchi/web/public/stage_view.js +34 -2
  64. data/lib/samagotchi/web/public/stream_handlers.js +10 -1
  65. data/lib/samagotchi/web/public/turn_html.js +5 -0
  66. data/lib/samagotchi/web/public/turn_view.js +16 -4
  67. data/lib/samagotchi/web/public/working_set.js +612 -0
  68. data/lib/samagotchi/web/public/working_set_rail.js +278 -0
  69. data/lib/samagotchi/web/public/working_set_view.js +541 -0
  70. data/lib/samagotchi/web/server.rb +6 -4
  71. data/lib/samagotchi/worker.rb +9 -6
  72. data/lib/samagotchi/working_set.rb +238 -0
  73. data/lib/samagotchi/working_set_command.rb +119 -0
  74. data/lib/samagotchi/working_set_steer.rb +250 -0
  75. metadata +11 -2
  76. data/lib/samagotchi/bundle_session_settings.rb +0 -251
@@ -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
- Entry.new(key: "recap.enabled", yaml_path: %w[recap enabled], type: :bool, default: nil, expose: %i[env config], lifecycle: :worker_start),
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, Engine#read_bundle_settings.
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
- # Coercion helpers
343
- def coerce(entry, raw)
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
- # For string, preserve as-is (including trailing spaces like "Please ")
347
- if entry.type == :string
348
- # A YAML list for a "|"-separated setting (web.annotate_presets).
349
- str = raw.is_a?(Array) ? raw.join("|") : raw.to_s
350
- return nil if str.empty? && entry.type != :string
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
- return str
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
- str = raw.to_s.strip
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
- Integer(str, exception: false).tap do |v|
362
- if v.nil?
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
- val = Float(str, exception: false)
369
- if val.nil?
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
- case str.downcase
376
- when "1", "true", "yes", "on" then true
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
- str
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
- def resolve_with_origin(key, file_data: nil, env: ENV, cli_overrides: {})
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
- def get(key)
524
- get_with_origin(key).first
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
@@ -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
- @recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
235
- session_target: -> { session_model_recap_target },
236
- session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
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
@@ -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. nil on a miss, a non-zero exit, a timeout, no binary, or
25
- # off macOS.
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
- def initialize(security: SECURITY, timeout: TIMEOUT, host_os: RbConfig::CONFIG["host_os"])
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
- reader, writer = IO.pipe
49
- pid = Process.spawn(@security, "find-generic-password", "-s", SERVICE, "-a", name, "-w",
50
- in: File::NULL, out: writer, err: File::NULL, pgroup: true)
51
- writer.close
52
- waiter = Process.detach(pid)
53
- unless waiter.join(@timeout)
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 nil unless waiter.value.success?
61
+ return worker.value if worker.join(@timeout)
59
62
 
60
- key = reader.read.to_s.chomp
61
- key.strip.empty? ? nil : key
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, waiter)
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