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
|
@@ -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
|
-
|
|
487
|
-
|
|
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
|
|
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: [],
|
|
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
|
-
@
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
432
|
+
SessionConfig.check!(values, bundles: { @name => @manifest.session_settings },
|
|
433
433
|
types: { @name => @manifest.session_setting_types }, flag: "session_settings")
|
|
434
|
-
|
|
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
|
|
11
|
-
#
|
|
12
|
-
# link) with /bin/sh: writing a new body costs
|
|
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 = "
|
|
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)
|
|
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)
|
|
16
|
-
|
|
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)
|
|
96
|
-
#
|
|
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.
|
|
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
|
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
-
#
|
|
668
|
-
# {
|
|
669
|
-
#
|
|
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
|
-
|
|
672
|
-
rescue
|
|
673
|
-
return error_response(400, "
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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: {
|