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
@@ -9,6 +9,7 @@ require_relative "../steer_cut"
9
9
  require_relative "../memory_bundle/index_size"
10
10
  require_relative "../prompt_note"
11
11
  require_relative "../prompt_files"
12
+ require_relative "../session_config"
12
13
  require_relative "../tools/task_record"
13
14
  require_relative "model_segments_text"
14
15
 
@@ -477,14 +478,17 @@ module Samagotchi
477
478
  end.join("\n")
478
479
  end
479
480
 
480
- RECAP_OFF_TEXT = "recap is off (recap: false in config.yml, or SAMAGOTCHI_RECAP_ENABLED=false)"
481
+ RECAP_OFF_TEXT = "recap is off (recap: false in config.yml, or SAMAGOTCHI_RECAP_ENABLED=false; /recap on turns it on here)"
482
+ # Off by the session's own recap.enabled (/recap off, --set recap.enabled=false).
483
+ RECAP_SESSION_OFF_TEXT = "recap is off for this session (/recap on turns it on, /recap default goes back to config.yml)"
481
484
 
482
485
  # What /recap says (the REPL's words, attached mode's too): the saved
483
486
  # recap, then what came of asking for a new one.
484
487
  # @param saved [Hash, nil] {text:, turns_since:}
485
488
  # @param request [Symbol, String, nil] IdleRecap#request_now's answer
486
- def recap_command_text(enabled:, saved: nil, request: nil, min_user_turns: nil)
487
- return RECAP_OFF_TEXT unless enabled
489
+ # @param session_off [Boolean] off by the session's own setting
490
+ def recap_command_text(enabled:, saved: nil, request: nil, min_user_turns: nil, session_off: false)
491
+ return session_off ? RECAP_SESSION_OFF_TEXT : RECAP_OFF_TEXT unless enabled
488
492
 
489
493
  saved = saved&.transform_keys(&:to_sym)
490
494
  lines = []
@@ -640,6 +644,9 @@ module Samagotchi
640
644
  # A chat host's only: a native one ignores it (the summary is nil).
641
645
  history = setting_stats_text(snapshot[:reasoning_history])
642
646
  lines << "reasoning history: #{history}" if history
647
+ # The session's own settings (chi --set; JSON keys from a worker).
648
+ settings = snapshot[:settings]
649
+ lines << "settings: #{SessionConfig.text(settings)}" if settings.is_a?(Hash) && !settings.empty?
643
650
  if snapshot[:served_model]
644
651
  asked = snapshot[:served_model_for]
645
652
  differs = !snapshot[:served_expected_by] && ServedModel.differs?(asked, snapshot[:served_model])
@@ -102,7 +102,7 @@ module Samagotchi
102
102
  # @param toolkit [Toolkit, nil] --toolkit/--tool/--no-tool: a new
103
103
  # session's toolkit (config's toolkit.default without); a resumed one
104
104
  # keeps its own, with a line naming +toolkit_flags+ (the flags given)
105
- # @param bundle_settings [Hash] --set's values (BundleSessionSettings):
105
+ # @param settings [SessionConfig::Settings, nil] --set's values:
106
106
  # a new session's own; a resumed one keeps its own (--set is in
107
107
  # +toolkit_flags+ then)
108
108
  # @param parent_answers [String, nil] --parent-answers: a new session's
@@ -110,12 +110,12 @@ module Samagotchi
110
110
  # +toolkit_flags+ then)
111
111
  def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
112
112
  spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
113
- appended_prompt_files: [], toolkit: nil, toolkit_flags: [], bundle_settings: {}, parent_answers: nil)
113
+ appended_prompt_files: [], toolkit: nil, toolkit_flags: [], settings: nil, parent_answers: nil)
114
114
  @scratch = scratch
115
115
  @parent_answers = parent_answers
116
116
  @toolkit = toolkit
117
117
  @toolkit_flags = toolkit_flags
118
- @bundle_settings = bundle_settings
118
+ @session_settings = settings
119
119
  @thinking_word = thinking
120
120
  @prompt = prompt
121
121
  @llm_context_words = llm_context
@@ -227,7 +227,7 @@ module Samagotchi
227
227
  @turn_flow = TurnFlow.new(engine: @engine)
228
228
  # A recap written while a continue offer waits says the turn stopped
229
229
  # unfinished. (Answering at the prompt is typing: activity already.)
230
- @engine.recap&.awaiting_continue = -> { @turn_flow.awaiting_continue? }
230
+ @engine.recap_awaiting_continue = -> { @turn_flow.awaiting_continue? }
231
231
  @commands = SessionCommands.new(engine: @engine, turn_flow: @turn_flow, default_model: @default_model_name,
232
232
  registry: @engine.command_registry)
233
233
  # Runtime --model flag or resumed session: switch the Engine (client,
@@ -285,7 +285,7 @@ module Samagotchi
285
285
  muted_memory_names: @muted_memory_names,
286
286
  scratch: @scratch,
287
287
  setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured,
288
- bundle_settings: @bundle_settings, parent_answers: @parent_answers)
288
+ settings: @session_settings, parent_answers: @parent_answers)
289
289
  ))
290
290
  # --llm-context*: the session's own values, over a resumed one's.
291
291
  # Saved at once on a resumed one: leaving before a turn keeps them.
@@ -890,6 +890,8 @@ module Samagotchi
890
890
  show_command_result(command)
891
891
  refresh_status_row(clear_unknown_context: true) if command.changed.include?(:model)
892
892
  refresh_status_row if command.changed.include?(:title)
893
+ # /recap on|off|default doesn't save itself (a worker saves a changed command).
894
+ save_session(session) if command.changed.include?(:recap)
893
895
  persist_recent_history(input) if command.shell
894
896
  return
895
897
  end
@@ -1180,7 +1182,7 @@ module Samagotchi
1180
1182
  # chat moved on (it prints when it arrives, BetweenTurns#flush_recap).
1181
1183
  def handle_recap_command
1182
1184
  recap = @engine.recap
1183
- return recap_command_text(enabled: false) unless recap
1185
+ return recap_command_text(enabled: false, session_off: @engine.recap_override == false) unless recap
1184
1186
 
1185
1187
  saved = @engine.saved_recap
1186
1188
  request = @engine.turn_running? ? :busy : @engine.request_recap
@@ -429,9 +429,10 @@ module Samagotchi
429
429
  # of another type than it gives the key
430
430
  def setup_with(session_settings, setup)
431
431
  values = { @name => stringify(session_settings) }
432
- BundleSessionSettings.check!(values, bundles: { @name => @manifest.session_settings },
432
+ SessionConfig.check!(values, bundles: { @name => @manifest.session_settings },
433
433
  types: { @name => @manifest.session_setting_types }, flag: "session_settings")
434
- (setup || SessionSetup.new).with(bundle_settings: values)
434
+ setup ||= SessionSetup.new
435
+ setup.with(settings: setup.settings.with(bundles: values))
435
436
  end
436
437
 
437
438
  def stringify(value) = Sandbox.stringify(value)
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "tmpdir"
5
4
 
6
5
  module Samagotchi
7
6
  module Testing
8
7
  # Fake commands for a test's PATH (Sandbox#fake_executable), cheap to
9
8
  # exec: macOS checks a new executable file on its first exec (about
10
- # 0.5–1 s, once per file). A fake is a symlink to one launcher, made
11
- # once per process, that runs the fake's body (<name>.sh beside the
12
- # link) with /bin/sh: writing a new body costs nothing.
9
+ # 0.5–1 s, once per file; minutes on a busy machine). A fake is a
10
+ # symlink to the checked-in launcher, which runs the fake's body
11
+ # (<name>.sh beside the link) with /bin/sh: writing a new body costs
12
+ # nothing.
13
13
  module FakeExecutable
14
- LAUNCHER = "#!/bin/sh\nexec /bin/sh \"$0.sh\" \"$@\"\n"
14
+ LAUNCHER = File.expand_path("fake_executable_launcher", __dir__)
15
15
 
16
16
  module_function
17
17
 
@@ -26,17 +26,7 @@ module Samagotchi
26
26
  path
27
27
  end
28
28
 
29
- def launcher
30
- @launcher ||= begin
31
- dir = Dir.mktmpdir("chi-fake-exec")
32
- owner = Process.pid
33
- at_exit { FileUtils.rm_rf(dir) if Process.pid == owner }
34
- File.join(dir, "launcher").tap do |path|
35
- File.write(path, LAUNCHER)
36
- File.chmod(0o755, path)
37
- end
38
- end
39
- end
29
+ def launcher = LAUNCHER
40
30
  end
41
31
  end
42
32
  end
@@ -0,0 +1,6 @@
1
+ #!/bin/sh
2
+ # What every FakeExecutable (and spec/support/fake_executables.rb) fake
3
+ # links to: runs the fake's body, <link>.sh, with /bin/sh. Checked in, not
4
+ # written per process: macOS scans a just-written executable at its first
5
+ # exec, which under a busy run can stall for minutes.
6
+ exec /bin/sh "$0.sh" "$@"
@@ -10,10 +10,11 @@ module Samagotchi
10
10
  module ToolResponse
11
11
  SEPARATOR = "\n\n---\n\n"
12
12
  # Saved with a result for the web's reload (a plugin tool's params line,
13
- # label and row refs, an edit/write's diff) and for the LLMContextView (each
13
+ # label and row refs, an edit/write's diff), for the LLMContextView (each
14
14
  # run's chi-owned id, ToolIds, and a strategy's edits by id,
15
- # LLMContextEdit); the prompt never renders them.
16
- SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_refs tool_ids edits].freeze
15
+ # LLMContextEdit) and for WorkingSet (a file run's stamp, ToolRun#stamp);
16
+ # the prompt never renders them.
17
+ SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_refs tool_ids tool_stamps edits].freeze
17
18
  # The tools a chat result loaded (#single), saved with it.
18
19
  LOADED = :loaded
19
20
  # A run's "[name]" lead: the name, then a newline, a space or the end.
@@ -84,7 +85,7 @@ module Samagotchi
84
85
  entry[:image_counts] = runs.map { |run| Array(run.images).size }
85
86
  end
86
87
  saved = { tool_params: runs.map(&:shown_params), tool_labels: runs.map(&:shown_label),
87
- tool_diffs: runs.map(&:diff), tool_refs: runs.map(&:refs) }
88
+ tool_diffs: runs.map(&:diff), tool_refs: runs.map(&:refs), tool_stamps: runs.map(&:stamp) }
88
89
  saved.each { |key, values| entry[key] = values if values.any? }
89
90
  entry[:tool_ids] = ids
90
91
  entry
@@ -92,8 +93,10 @@ module Samagotchi
92
93
 
93
94
  # The chat loop's entry for one call: its capped output, paired with the
94
95
  # call by +tool_call_id+, and its chi-owned id (a one-id +ids+, as
95
- # #joined's tool_ids); a find_tools hit's +loaded+ names (saved on every
96
- # chat backend: a wire that loads tools renders them, LLM::AnthropicWire).
96
+ # #joined's tool_ids) and its stamp (a one-stamp tool_stamps, likewise:
97
+ # read by the run's place); a find_tools hit's +loaded+ names (saved on
98
+ # every chat backend: a wire that loads tools renders them,
99
+ # LLM::AnthropicWire).
97
100
  def single(run, tool_call_id:, ids:)
98
101
  entry = { role: "tool_response", content: run.capped_output, tool_call_id: tool_call_id }
99
102
  entry[:images] = run.images if run.images&.any?
@@ -102,6 +105,7 @@ module Samagotchi
102
105
  entry[:tool_diffs] = run.diff if run.diff
103
106
  entry[:tool_refs] = run.refs if run.refs
104
107
  entry[:tool_ids] = ids
108
+ entry[:tool_stamps] = [run.stamp] if run.stamp
105
109
  entry[LOADED] = run.loaded if run.loaded&.any?
106
110
  entry
107
111
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "tool_activity"
4
4
  require_relative "tool_row_fields"
5
5
  require_relative "row_refs"
6
+ require_relative "file_ref"
6
7
  require_relative "guardrails"
7
8
  require_relative "vision_context"
8
9
  require_relative "log"
@@ -18,12 +19,14 @@ module Samagotchi
18
19
  # One call's result from ToolRunner#run, read by ToolResponse (the loops'
19
20
  # tool_response entries) and the loops' activity list. Fields a call didn't
20
21
  # produce (images, shown_params, shown_label, refs, diff, output_file,
21
- # loaded) are nil. +loaded+: the tools a find_tools hit loads
22
- # (Tools::ToolIndex::FoundText#loaded), by their wire names.
22
+ # loaded, stamp) are nil. +loaded+: the tools a find_tools hit loads
23
+ # (Tools::ToolIndex::FoundText#loaded), by their wire names. +stamp+: an
24
+ # ok read/edit/write's file right after it ran ({path:, mtime_ns:,
25
+ # size:}), what WorkingSet tells a file changed since by.
23
26
  ToolRun = Data.define(:output, :capped_output, :truncated, :activity, :images, :shown_params, :shown_label, :refs, :diff,
24
- :output_file, :loaded) do
27
+ :output_file, :loaded, :stamp) do
25
28
  def initialize(output: nil, capped_output: nil, truncated: nil, activity: nil, images: nil, shown_params: nil,
26
- shown_label: nil, refs: nil, diff: nil, output_file: nil, loaded: nil)
29
+ shown_label: nil, refs: nil, diff: nil, output_file: nil, loaded: nil, stamp: nil)
27
30
  super
28
31
  end
29
32
  end
@@ -153,11 +156,27 @@ module Samagotchi
153
156
  images: images&.any? ? images : nil,
154
157
  shown_params: params && plugin_tool?(call[:name]) ? params : nil,
155
158
  shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file,
156
- loaded: result[:loaded])
159
+ loaded: result[:loaded], stamp: status == "ok" ? file_stamp(call) : nil)
157
160
  end
158
161
 
159
162
  private
160
163
 
164
+ # An ok read/edit/write's file as it is now, right after the call:
165
+ # {path:, mtime_ns:, size:}, the path as FileRef has it (against the
166
+ # session's folder, no realpath). nil for any other tool, a path it
167
+ # can't place and a stat that fails or isn't a file's.
168
+ def file_stamp(call)
169
+ ref = FileRef.for(call[:name], call, cwd: session_cwd)
170
+ return nil unless ref
171
+
172
+ stat = File.stat(ref.path)
173
+ return nil unless stat.file?
174
+
175
+ { path: ref.path, mtime_ns: (stat.mtime.to_i * 1_000_000_000) + stat.mtime.nsec, size: stat.size }
176
+ rescue SystemCallError
177
+ nil
178
+ end
179
+
161
180
  # +output+ cut to +cap+ by its tool's policy (ToolOutputCut); an
162
181
  # execute result keeps its tail too, its whole output saved in the
163
182
  # session's folder.
@@ -310,7 +329,7 @@ module Samagotchi
310
329
  # The session's folder (the Engine's, KernelLoop#session_cwd), else the
311
330
  # process's: a bare kernel (specs) has none.
312
331
  def session_cwd
313
- cwd = @kernel.respond_to?(:session_cwd) && @kernel.session_cwd&.call
332
+ cwd = @kernel.session_cwd&.call if @kernel.respond_to?(:session_cwd)
314
333
  cwd.to_s.empty? ? Dir.pwd : cwd
315
334
  end
316
335
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.100.1"
4
+ VERSION = "0.102.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -25,6 +25,7 @@ require_relative "../session_commands"
25
25
  require_relative "../steer"
26
26
  require_relative "../host_registry"
27
27
  require_relative "../llm_context_override"
28
+ require_relative "../working_set"
28
29
  require_relative "../model_catalog"
29
30
  require_relative "../model_profile"
30
31
  require_relative "../session_setting"
@@ -97,6 +98,8 @@ module Samagotchi
97
98
  # freshly-spawned worker's bridge before answering POST /api/sessions.
98
99
  # @param view ["stage", "turn"] the page's view of a turn (web.view);
99
100
  # ?view=stage|turn overrides it for one page load
101
+ # @param working_set [Boolean] the stage view's working set
102
+ # (web.working_set); ?working_set=1|0 overrides it for one page load
100
103
  # @param annotate_presets [String, Array] the quick replies next to
101
104
  # Annotate (web.annotate_presets, "|"-separated); "" shows none. The
102
105
  # page parses them (annotate_presets.js).
@@ -119,7 +122,8 @@ module Samagotchi
119
122
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
120
123
  editor: Config::BY_KEY["web.editor"].default,
121
124
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
122
- registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
125
+ registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
126
+ working_set: Config::BY_KEY["web.working_set"].default)
123
127
  @manager = manager || SessionManager
124
128
  @registry = registry
125
129
  @registry_given = !registry.nil?
@@ -132,6 +136,7 @@ module Samagotchi
132
136
  @bridge_wait_timeout = bridge_wait_timeout
133
137
  @markdown_renderer = MarkdownRenderer.new(enabled: markdown)
134
138
  @view = view
139
+ @working_set = working_set
135
140
  @annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
136
141
  @editor_url = Editor.template(editor)
137
142
  @hub = hub
@@ -183,6 +188,7 @@ module Samagotchi
183
188
  ["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show, :session],
184
189
  ["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add, :session],
185
190
  ["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete, :session],
191
+ ["GET", %r{\A/api/sessions/([^/]+)/working_set\z}, :handle_working_set, :session],
186
192
  ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :session],
187
193
  ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :session]
188
194
  ].freeze
@@ -664,13 +670,13 @@ module Samagotchi
664
670
  thinking, error = request_thinking(body["thinking"])
665
671
  return error if error
666
672
 
667
- # bundle_settings: the session's own bundle settings (chi --set:
668
- # {bundle => {key => scalar}}), checked against the installed
669
- # bundles the same way.
673
+ # settings: the session's own settings, flat as chi --set names
674
+ # them ({"recap.enabled" => false, "cost-cap.cap" => 3}), checked
675
+ # the same way (SessionConfig.from_request).
670
676
  begin
671
- bundle_settings = BundleSessionSettings.from_request(body["bundle_settings"])
672
- rescue BundleSessionSettings::Error => e
673
- return error_response(400, "invalid_bundle_settings", e.message)
677
+ settings = SessionConfig.from_request(body["settings"])
678
+ rescue SessionConfig::Error => e
679
+ return error_response(400, "invalid_settings", e.message)
674
680
  end
675
681
 
676
682
  folder = dir ? { working_directory: dir } : {}
@@ -683,7 +689,7 @@ module Samagotchi
683
689
  # until that turn is saved.
684
690
  preview = idle ? body["preview"].to_s.strip : ""
685
691
  folder[:preview] = preview unless preview.empty?
686
- setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, bundle_settings: bundle_settings)
692
+ setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, settings: settings)
687
693
  folder[:setup] = setup unless setup.empty?
688
694
  begin
689
695
  session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
@@ -703,7 +709,7 @@ module Samagotchi
703
709
  # POST /api/sessions with continues: <id, prefix or last:id>: the next
704
710
  # link of that session's chain (SessionManager.continue_session), in its
705
711
  # folder and with its own setup (SessionSetup: llm_context, thinking
706
- # and reasoning_history levels, bundle settings), so dir, llm_context, thinking and bundle_settings are
712
+ # and reasoning_history levels, settings), so dir, llm_context, thinking and settings are
707
713
  # refused beside it (a create takes no reasoning_history). It runs on
708
714
  # model (or model_name) when given, else on the previous link's: one
709
715
  # whose host is gone falls back to the default, and the 201's
@@ -718,9 +724,9 @@ module Samagotchi
718
724
  unless given.is_a?(String) && !given.strip.empty?
719
725
  return error_response(400, "invalid_continues", "continues is a session id")
720
726
  end
721
- if (fields = %w[dir llm_context thinking bundle_settings].select { |key| body.key?(key) }).any?
727
+ if (fields = %w[dir llm_context thinking settings].select { |key| body.key?(key) }).any?
722
728
  return error_response(400, "invalid_continues",
723
- "continues takes the previous session's folder, llm_context, thinking and bundle_settings; " \
729
+ "continues takes the previous session's folder, llm_context, thinking and settings; " \
724
730
  "leave out #{fields.join(", ")}")
725
731
  end
726
732
 
@@ -1477,6 +1483,53 @@ module Samagotchi
1477
1483
  error_response(409, e.reason.to_s, "#{e.message}; try again in a moment")
1478
1484
  end
1479
1485
 
1486
+ # ── The working set (web.working_set) ───────────────────────────────
1487
+
1488
+ # GET /api/sessions/:id/working_set: the working set as the model has
1489
+ # it ({items:, at:, running:}, WorkingSet), what the page overlays on its rows'
1490
+ # set when it seeds a session's and when the expanded view opens (a
1491
+ # file changed while the session sat idle). A live worker's snapshot
1492
+ # messages are the truth, as #handle_show's, else the file's; the
1493
+ # strategy is the session's llm ctx chip's, the folder its working
1494
+ # directory. running: a turn runs. The snapshot's messages lag it, so
1495
+ # the answer is then the turn's last :working_set_updated (the
1496
+ # snapshot's working_set, TurnAccumulator#working_set); before the
1497
+ # turn emitted one, or from an older worker without the field, it is
1498
+ # the session's messages, behind that turn's events, and the page
1499
+ # keeps theirs (working_set.js newerSnapshot).
1500
+ def handle_working_set(_req, id)
1501
+ session = @session_class.load(id, state_dir: default_state_dir)
1502
+ live = bridge_get_json(id, "snapshot")
1503
+ running = !live&.dig("snapshot", "current_turn").nil?
1504
+ turn_set = running && running_working_set(live["snapshot"]["working_set"])
1505
+ return json_response(200, turn_set.merge(running: true)) if turn_set
1506
+
1507
+ messages = live_or_saved(live && live["snapshot"], "messages") { session.messages }
1508
+ # A snapshot's entries have string keys (paired_runs reads symbols).
1509
+ messages = Array(messages).map { |message| message.is_a?(Hash) ? message.transform_keys(&:to_sym) : message }
1510
+ llm_context = SessionSetting::LLM_CONTEXT.snapshot_value(live && live["session_state_snapshot"]) do
1511
+ SessionSetting::LLM_CONTEXT.saved_summary(session, registry: host_registry)
1512
+ end
1513
+ snapshot = WorkingSet.build(messages, cwd: session.working_directory, strategy: working_set_strategy(llm_context))
1514
+ json_response(200, snapshot.to_h.merge(running: running))
1515
+ end
1516
+
1517
+ # The running turn's set from a worker's snapshot ({"items", "at"}), as
1518
+ # {items:, at:}; nil when it has none (none emitted yet, an older worker).
1519
+ def running_working_set(set)
1520
+ return nil unless set.is_a?(Hash) && set["items"].is_a?(Array) && set["at"].is_a?(Integer)
1521
+
1522
+ { items: set["items"], at: set["at"] }
1523
+ end
1524
+
1525
+ # The strategy an llm ctx chip names ("none", "stale,forget"), as
1526
+ # LLMContextView takes it; none when there is no chip.
1527
+ def working_set_strategy(llm_context)
1528
+ named = llm_context.is_a?(Hash) ? (llm_context[:strategy] || llm_context["strategy"]).to_s : ""
1529
+ layers = named.split(",").map(&:strip).reject { |layer| layer.empty? || layer == "none" }
1530
+ layers.empty? ? LLMContextView::NONE : layers.map(&:to_sym)
1531
+ end
1532
+
1480
1533
  # ── Attached context (ContextSources): the session bar's chips ───────
1481
1534
  # A source's command isn't shown (the page may be on a phone over the
1482
1535
  # LAN), and the web adds a URL only, through an installed bundle's
@@ -2126,6 +2179,8 @@ module Samagotchi
2126
2179
  # the one serving now (an upgraded chi web after a reconnect).
2127
2180
  attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd), "version" => Samagotchi::VERSION }
2128
2181
  attrs["view"] = view_name(req.params["view"])
2182
+ # Only when on: the page reads its presence (the turn view ignores it).
2183
+ attrs["working-set"] = "1" if working_set?(req.params["working_set"])
2129
2184
  # Always there: an empty one means no presets, not the default.
2130
2185
  attrs["annotate-presets"] = @annotate_presets
2131
2186
  dir, = scope_dir(req.params["dir"])
@@ -2154,6 +2209,16 @@ module Samagotchi
2154
2209
  VIEWS.include?(param) ? param : @view
2155
2210
  end
2156
2211
 
2212
+ # The working set for this page load: ?working_set=1|0 wins, anything
2213
+ # else leaves the config's choice (web.working_set).
2214
+ def working_set?(param)
2215
+ case param
2216
+ when "1" then true
2217
+ when "0" then false
2218
+ else @working_set
2219
+ end
2220
+ end
2221
+
2157
2222
  def json_response(status, payload)
2158
2223
  body = JSON.generate(payload)
2159
2224
  [status, { "Content-Type" => "application/json; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store" }, [body]]
@@ -21,6 +21,7 @@ import {
21
21
  detachContext,
22
22
  addContext,
23
23
  renderText,
24
+ getWorkingSet,
24
25
  } from "./data.js";
25
26
  import { createContextChips } from "./context_chips.js";
26
27
  import { createLlmContextControl } from "./llm_context_chip.js";
@@ -37,6 +38,8 @@ import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
37
38
  import { createBubbles, hintEl, outputBubbleTemplate } from "./bubbles.js";
38
39
  import { createCardsPlace } from "./cards_place.js";
39
40
  import { createStageView } from "./stage_view.js";
41
+ import { createWorkingSet, groupItems } from "./working_set_view.js";
42
+ import { turnGroups } from "./timing.js";
40
43
  import { createMarkdownPlace } from "./markdown_place.js";
41
44
  import { openerFill, openTitle, restoredComposer } from "./chains.js";
42
45
  import { createTaskStopper } from "./task_stop.js";
@@ -225,10 +228,23 @@ const markdownPlace = createMarkdownPlace({
225
228
  answerRefsOf,
226
229
  });
227
230
  const { renderMarkdown, pairRefsIn, linkRefsIn, applyFinalMarkdown, linkTextRefs } = markdownPlace;
231
+ // The stage view's working set (web.working_set, or ?working_set=1|0):
232
+ // what each turn brought into context, in a rail in the stage, a footprint
233
+ // under each finished turn and a "set N" chip in the mem chip's place
234
+ // (working_set_view.js). The turn view ignores it.
235
+ const workingSet = view === "stage" && document.body.dataset.workingSet === "1"
236
+ ? createWorkingSet({
237
+ historyEl, stageRoot: () => turnDom.root, state: () => current, session: () => selected, infoEl: infoTextEl,
238
+ onChange: () => updateInfoBar(), getWorkingSet,
239
+ // A drop or undrop from the list: the session's command, as typed.
240
+ sendLine: (line) => (selected ? sendCommand(selected, line, { clientId }) : null),
241
+ })
242
+ : null;
243
+ document.body.classList.toggle("working-set", !!workingSet);
228
244
  const turnDom = view === "stage"
229
245
  ? createStageView({
230
246
  ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl,
231
- renderMarkdown, linkRefs: linkRefsIn,
247
+ renderMarkdown, linkRefs: linkRefsIn, workingSet,
232
248
  })
233
249
  : createTurnView(viewArgs);
234
250
  // The history's bottom reserve and the toast's place follow the composer
@@ -862,6 +878,7 @@ function renderSessionView(data, { kept = [] } = {}) {
862
878
  reasoningHistoryControl.refresh();
863
879
  if (data.messages && data.messages.length) renderHistory(data.messages);
864
880
  else renderHistory(data.history || []);
881
+ seedWorkingSet(data);
865
882
  restoreCommandBubbles(historyEl, kept);
866
883
  // A failed turn's prompt went back to the user: shown after the
867
884
  // conversation, marked failed, as it was live (prompt_restored).
@@ -921,13 +938,27 @@ function renderSessionView(data, { kept = [] } = {}) {
921
938
  updateComposerMode();
922
939
  }
923
940
 
941
+ // The working set's turns from a session's messages, as turnHistoryNodes
942
+ // numbers them; the running turn (the replay draws it in the stage) isn't
943
+ // one of them yet.
944
+ function seedWorkingSet(data) {
945
+ if (!workingSet) return;
946
+ const items = data.messages || [];
947
+ const groups = typeof items[0] === "object" && items[0]?.role ? turnGroups(items, current.timing).filter((g) => g.kind === "turn") : [];
948
+ if (data.current_turn && groups.length) groups.pop();
949
+ workingSet.seed(groups.map((g, i) => {
950
+ const text = String(items[g.user]?.content ?? "").replace(/\s+/g, " ").trim();
951
+ return { items: groupItems(g, i), prompt: text.length > 60 ? `${text.slice(0, 59)}\u2026` : text };
952
+ }));
953
+ }
954
+
924
955
  function renderHistory(items) {
925
956
  if (!items.length) {
926
957
  historyEl.replaceChildren(hintEl("No output yet. Send a message."));
927
958
  return;
928
959
  }
929
960
  if (typeof items[0] === "object" && items[0].role) {
930
- historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images) }));
961
+ historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images), workingSet: !!workingSet }));
931
962
  linkTextRefs();
932
963
  } else {
933
964
  historyEl.replaceChildren(...items.map((c) => elementOf(outputBubbleTemplate(c))));
@@ -1088,7 +1119,7 @@ function updateInfoBar() {
1088
1119
  const { buttons, bar } = sessionBar({
1089
1120
  id: selected, state: current, sessions: allSessions, versions, cardTitle: (s) => lists.cardTitle(s),
1090
1121
  continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1091
- now: Date.now(),
1122
+ now: Date.now(), workingSet: workingSet ? workingSet.chip() : undefined,
1092
1123
  });
1093
1124
  const stopBtn = document.getElementById("infoStopBtn");
1094
1125
  const deleteBtn = document.getElementById("infoDeleteBtn");
@@ -1269,6 +1300,7 @@ const streamHandlers = createStreamHandlers({
1269
1300
  llmContextControl,
1270
1301
  thinkingControl,
1271
1302
  reasoningHistoryControl,
1303
+ workingSet,
1272
1304
  sentIds,
1273
1305
  earlyRestores,
1274
1306
  stream: {