samagotchi 0.100.0 → 0.101.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 +39 -1
- data/bin/chi +1 -0
- data/docs/architecture.md +10 -1
- data/docs/cli.md +55 -0
- data/docs/configuration.md +1 -0
- data/docs/hooks.md +2 -0
- data/docs/internals/engine.md +1 -0
- data/docs/internals/working-set.md +130 -0
- data/docs/sessions.md +1 -1
- data/docs/testing.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
- data/lib/samagotchi/bundles/work-cap/plugin.rb +26 -16
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +30 -0
- data/lib/samagotchi/hooks/loader.rb +17 -2
- data/lib/samagotchi/host_keys.rb +35 -18
- 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 +49 -1
- data/lib/samagotchi/web/public/app.js +77 -11
- data/lib/samagotchi/web/public/chains.js +24 -0
- data/lib/samagotchi/web/public/chip_popover.js +56 -17
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/index.html +134 -2
- 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/markdown_place.js +47 -1
- data/lib/samagotchi/web/public/refs.js +10 -1
- data/lib/samagotchi/web/public/send_turn.js +6 -2
- data/lib/samagotchi/web/public/session_actions.js +10 -5
- 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 +6 -1
- data/lib/samagotchi/web/public/turn_view.js +37 -11
- data/lib/samagotchi/web/public/working_set.js +520 -0
- data/lib/samagotchi/web/public/working_set_rail.js +278 -0
- data/lib/samagotchi/web/public/working_set_view.js +426 -0
- data/lib/samagotchi/web/server.rb +6 -4
- data/lib/samagotchi/working_set.rb +200 -0
- metadata +7 -1
data/lib/samagotchi/host_keys.rb
CHANGED
|
@@ -21,16 +21,22 @@ module Samagotchi
|
|
|
21
21
|
# Reads one generic password through /usr/bin/security (by absolute
|
|
22
22
|
# path: the helper's PATH isn't the shell's) in its own process group,
|
|
23
23
|
# killed at the deadline: a locked keychain or an access dialog must not
|
|
24
|
-
# hang a turn.
|
|
25
|
-
#
|
|
24
|
+
# hang a turn. The deadline covers the spawn too (macOS can hold a first
|
|
25
|
+
# exec for minutes while it scans the binary): the run is a thread the
|
|
26
|
+
# caller gives up on, and a pid that turns up late is killed and reaped
|
|
27
|
+
# there. nil on a miss, a non-zero exit, a timeout, no binary, or off
|
|
28
|
+
# macOS.
|
|
26
29
|
class Keychain
|
|
27
30
|
SECURITY = "/usr/bin/security"
|
|
28
31
|
TIMEOUT = 5
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
# +spawner+: how the run starts (Process.spawn's arguments; specs stall it).
|
|
34
|
+
def initialize(security: SECURITY, timeout: TIMEOUT, host_os: RbConfig::CONFIG["host_os"],
|
|
35
|
+
spawner: Process.method(:spawn))
|
|
31
36
|
@security = security
|
|
32
37
|
@timeout = timeout
|
|
33
38
|
@macos = host_os.to_s.include?("darwin")
|
|
39
|
+
@spawner = spawner
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
# @return [String, nil]
|
|
@@ -45,33 +51,44 @@ module Samagotchi
|
|
|
45
51
|
private
|
|
46
52
|
|
|
47
53
|
def run(name)
|
|
48
|
-
|
|
49
|
-
pid =
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
stop(pid, waiter)
|
|
55
|
-
Log.warn(:config, "keychain_timeout", name: name, seconds: @timeout)
|
|
56
|
-
return nil
|
|
54
|
+
lock = Mutex.new
|
|
55
|
+
pid = nil
|
|
56
|
+
late = false
|
|
57
|
+
worker = Thread.new do
|
|
58
|
+
Thread.current.report_on_exception = false
|
|
59
|
+
security(name) { |spawned| lock.synchronize { late ? stop(spawned) : pid = spawned } }
|
|
57
60
|
end
|
|
58
|
-
return
|
|
61
|
+
return worker.value if worker.join(@timeout)
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
lock.synchronize do
|
|
64
|
+
late = true
|
|
65
|
+
stop(pid) if pid
|
|
66
|
+
end
|
|
67
|
+
Log.warn(:config, "keychain_timeout", name: name, seconds: @timeout)
|
|
68
|
+
nil
|
|
62
69
|
rescue SystemCallError
|
|
63
70
|
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# One run, start to reap; yields the pid as soon as there is one.
|
|
74
|
+
def security(name)
|
|
75
|
+
reader, writer = IO.pipe
|
|
76
|
+
pid = @spawner.call(@security, "find-generic-password", "-s", SERVICE, "-a", name, "-w",
|
|
77
|
+
in: File::NULL, out: writer, err: File::NULL, pgroup: true)
|
|
78
|
+
writer.close
|
|
79
|
+
yield pid
|
|
80
|
+
key = reader.read.to_s.chomp
|
|
81
|
+
_, status = Process.wait2(pid)
|
|
82
|
+
status.success? && !key.strip.empty? ? key : nil
|
|
64
83
|
ensure
|
|
65
84
|
writer&.close unless writer&.closed?
|
|
66
85
|
reader&.close
|
|
67
86
|
end
|
|
68
87
|
|
|
69
|
-
def stop(pid
|
|
88
|
+
def stop(pid)
|
|
70
89
|
Process.kill(:KILL, -pid)
|
|
71
90
|
rescue SystemCallError
|
|
72
91
|
nil
|
|
73
|
-
ensure
|
|
74
|
-
waiter.join(1)
|
|
75
92
|
end
|
|
76
93
|
end
|
|
77
94
|
|
|
@@ -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
|
|
@@ -1477,6 +1483,36 @@ 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:}, 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.
|
|
1495
|
+
def handle_working_set(_req, id)
|
|
1496
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1497
|
+
live = bridge_get_json(id, "snapshot")
|
|
1498
|
+
messages = live_or_saved(live && live["snapshot"], "messages") { session.messages }
|
|
1499
|
+
# A snapshot's entries have string keys (paired_runs reads symbols).
|
|
1500
|
+
messages = Array(messages).map { |message| message.is_a?(Hash) ? message.transform_keys(&:to_sym) : message }
|
|
1501
|
+
llm_context = SessionSetting::LLM_CONTEXT.snapshot_value(live && live["session_state_snapshot"]) do
|
|
1502
|
+
SessionSetting::LLM_CONTEXT.saved_summary(session, registry: host_registry)
|
|
1503
|
+
end
|
|
1504
|
+
snapshot = WorkingSet.build(messages, cwd: session.working_directory, strategy: working_set_strategy(llm_context))
|
|
1505
|
+
json_response(200, snapshot.to_h)
|
|
1506
|
+
end
|
|
1507
|
+
|
|
1508
|
+
# The strategy an llm ctx chip names ("none", "stale,forget"), as
|
|
1509
|
+
# LLMContextView takes it; none when there is no chip.
|
|
1510
|
+
def working_set_strategy(llm_context)
|
|
1511
|
+
named = llm_context.is_a?(Hash) ? (llm_context[:strategy] || llm_context["strategy"]).to_s : ""
|
|
1512
|
+
layers = named.split(",").map(&:strip).reject { |layer| layer.empty? || layer == "none" }
|
|
1513
|
+
layers.empty? ? LLMContextView::NONE : layers.map(&:to_sym)
|
|
1514
|
+
end
|
|
1515
|
+
|
|
1480
1516
|
# ── Attached context (ContextSources): the session bar's chips ───────
|
|
1481
1517
|
# A source's command isn't shown (the page may be on a phone over the
|
|
1482
1518
|
# LAN), and the web adds a URL only, through an installed bundle's
|
|
@@ -2126,6 +2162,8 @@ module Samagotchi
|
|
|
2126
2162
|
# the one serving now (an upgraded chi web after a reconnect).
|
|
2127
2163
|
attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd), "version" => Samagotchi::VERSION }
|
|
2128
2164
|
attrs["view"] = view_name(req.params["view"])
|
|
2165
|
+
# Only when on: the page reads its presence (the turn view ignores it).
|
|
2166
|
+
attrs["working-set"] = "1" if working_set?(req.params["working_set"])
|
|
2129
2167
|
# Always there: an empty one means no presets, not the default.
|
|
2130
2168
|
attrs["annotate-presets"] = @annotate_presets
|
|
2131
2169
|
dir, = scope_dir(req.params["dir"])
|
|
@@ -2154,6 +2192,16 @@ module Samagotchi
|
|
|
2154
2192
|
VIEWS.include?(param) ? param : @view
|
|
2155
2193
|
end
|
|
2156
2194
|
|
|
2195
|
+
# The working set for this page load: ?working_set=1|0 wins, anything
|
|
2196
|
+
# else leaves the config's choice (web.working_set).
|
|
2197
|
+
def working_set?(param)
|
|
2198
|
+
case param
|
|
2199
|
+
when "1" then true
|
|
2200
|
+
when "0" then false
|
|
2201
|
+
else @working_set
|
|
2202
|
+
end
|
|
2203
|
+
end
|
|
2204
|
+
|
|
2157
2205
|
def json_response(status, payload)
|
|
2158
2206
|
body = JSON.generate(payload)
|
|
2159
2207
|
[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,8 +38,10 @@ 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
|
-
import {
|
|
44
|
+
import { openerFill, openTitle, restoredComposer } from "./chains.js";
|
|
42
45
|
import { createTaskStopper } from "./task_stop.js";
|
|
43
46
|
import { appendQuote } from "./annotations.js";
|
|
44
47
|
import { parsePresets } from "./annotate_presets.js";
|
|
@@ -67,7 +70,7 @@ import {
|
|
|
67
70
|
appendAboveLiveTiming, mergeTiming, normalizeTiming,
|
|
68
71
|
} from "./timing.js";
|
|
69
72
|
import { createLiveTurn } from "./live_turn.js";
|
|
70
|
-
import { applyFinalAnswer, pairAnswerRefs, setAnswerRefs } from "./answer_refs.js";
|
|
73
|
+
import { answerRefsOf, applyFinalAnswer, pairAnswerRefs, setAnswerRefs } from "./answer_refs.js";
|
|
71
74
|
import { installRefClicks } from "./refs.js";
|
|
72
75
|
import { linkRefs, touchedFiles } from "./ref_scan.js";
|
|
73
76
|
import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
@@ -174,6 +177,15 @@ const infoCancelBtn = $("#infoCancelBtn");
|
|
|
174
177
|
const composeHintEl = $("#composeHint");
|
|
175
178
|
|
|
176
179
|
let selected = null;
|
|
180
|
+
// The session the composer's text was last written in ("" the start page,
|
|
181
|
+
// undefined unknown): the composer is one for every session, and continue →
|
|
182
|
+
// keeps only a draft of its own (fillOpener).
|
|
183
|
+
let draftFrom;
|
|
184
|
+
// Record that the composer's text was written now, in session +id+ (default
|
|
185
|
+
// the open one). Every write of text into the composer comes through here.
|
|
186
|
+
function noteDraft(id) {
|
|
187
|
+
draftFrom = id ?? selected ?? "";
|
|
188
|
+
}
|
|
177
189
|
let streamControl = null;
|
|
178
190
|
// The session list's stream (GET /api/events); null while the page falls
|
|
179
191
|
// back to fetching (a chi web without a hub).
|
|
@@ -213,12 +225,24 @@ const markdownPlace = createMarkdownPlace({
|
|
|
213
225
|
normalize,
|
|
214
226
|
pairAnswerRefs,
|
|
215
227
|
setAnswerRefs,
|
|
228
|
+
answerRefsOf,
|
|
216
229
|
});
|
|
217
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
|
+
})
|
|
240
|
+
: null;
|
|
241
|
+
document.body.classList.toggle("working-set", !!workingSet);
|
|
218
242
|
const turnDom = view === "stage"
|
|
219
243
|
? createStageView({
|
|
220
244
|
...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl,
|
|
221
|
-
renderMarkdown, linkRefs: linkRefsIn,
|
|
245
|
+
renderMarkdown, linkRefs: linkRefsIn, workingSet,
|
|
222
246
|
})
|
|
223
247
|
: createTurnView(viewArgs);
|
|
224
248
|
// The history's bottom reserve and the toast's place follow the composer
|
|
@@ -500,6 +524,7 @@ const sessionActions = createSessionActions({
|
|
|
500
524
|
toast: showToast,
|
|
501
525
|
addCreatedSession,
|
|
502
526
|
fillOpener,
|
|
527
|
+
restoreDraft,
|
|
503
528
|
closeLiveStream: () => streamControl?.close(),
|
|
504
529
|
workerGone,
|
|
505
530
|
});
|
|
@@ -759,6 +784,7 @@ function clearNotice() {
|
|
|
759
784
|
noticeEl.textContent = "";
|
|
760
785
|
}
|
|
761
786
|
promptEl.addEventListener("input", clearNotice);
|
|
787
|
+
promptEl.addEventListener("input", () => noteDraft());
|
|
762
788
|
|
|
763
789
|
async function select(id) {
|
|
764
790
|
clearNotice();
|
|
@@ -850,6 +876,7 @@ function renderSessionView(data, { kept = [] } = {}) {
|
|
|
850
876
|
reasoningHistoryControl.refresh();
|
|
851
877
|
if (data.messages && data.messages.length) renderHistory(data.messages);
|
|
852
878
|
else renderHistory(data.history || []);
|
|
879
|
+
seedWorkingSet(data);
|
|
853
880
|
restoreCommandBubbles(historyEl, kept);
|
|
854
881
|
// A failed turn's prompt went back to the user: shown after the
|
|
855
882
|
// conversation, marked failed, as it was live (prompt_restored).
|
|
@@ -909,13 +936,27 @@ function renderSessionView(data, { kept = [] } = {}) {
|
|
|
909
936
|
updateComposerMode();
|
|
910
937
|
}
|
|
911
938
|
|
|
939
|
+
// The working set's turns from a session's messages, as turnHistoryNodes
|
|
940
|
+
// numbers them; the running turn (the replay draws it in the stage) isn't
|
|
941
|
+
// one of them yet.
|
|
942
|
+
function seedWorkingSet(data) {
|
|
943
|
+
if (!workingSet) return;
|
|
944
|
+
const items = data.messages || [];
|
|
945
|
+
const groups = typeof items[0] === "object" && items[0]?.role ? turnGroups(items, current.timing).filter((g) => g.kind === "turn") : [];
|
|
946
|
+
if (data.current_turn && groups.length) groups.pop();
|
|
947
|
+
workingSet.seed(groups.map((g, i) => {
|
|
948
|
+
const text = String(items[g.user]?.content ?? "").replace(/\s+/g, " ").trim();
|
|
949
|
+
return { items: groupItems(g, i), prompt: text.length > 60 ? `${text.slice(0, 59)}\u2026` : text };
|
|
950
|
+
}));
|
|
951
|
+
}
|
|
952
|
+
|
|
912
953
|
function renderHistory(items) {
|
|
913
954
|
if (!items.length) {
|
|
914
955
|
historyEl.replaceChildren(hintEl("No output yet. Send a message."));
|
|
915
956
|
return;
|
|
916
957
|
}
|
|
917
958
|
if (typeof items[0] === "object" && items[0].role) {
|
|
918
|
-
historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images) }));
|
|
959
|
+
historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images), workingSet: !!workingSet }));
|
|
919
960
|
linkTextRefs();
|
|
920
961
|
} else {
|
|
921
962
|
historyEl.replaceChildren(...items.map((c) => elementOf(outputBubbleTemplate(c))));
|
|
@@ -1076,7 +1117,7 @@ function updateInfoBar() {
|
|
|
1076
1117
|
const { buttons, bar } = sessionBar({
|
|
1077
1118
|
id: selected, state: current, sessions: allSessions, versions, cardTitle: (s) => lists.cardTitle(s),
|
|
1078
1119
|
continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
|
|
1079
|
-
now: Date.now(),
|
|
1120
|
+
now: Date.now(), workingSet: workingSet ? workingSet.chip() : undefined,
|
|
1080
1121
|
});
|
|
1081
1122
|
const stopBtn = document.getElementById("infoStopBtn");
|
|
1082
1123
|
const deleteBtn = document.getElementById("infoDeleteBtn");
|
|
@@ -1257,6 +1298,7 @@ const streamHandlers = createStreamHandlers({
|
|
|
1257
1298
|
llmContextControl,
|
|
1258
1299
|
thinkingControl,
|
|
1259
1300
|
reasoningHistoryControl,
|
|
1301
|
+
workingSet,
|
|
1260
1302
|
sentIds,
|
|
1261
1303
|
earlyRestores,
|
|
1262
1304
|
stream: {
|
|
@@ -1277,6 +1319,7 @@ const streamHandlers = createStreamHandlers({
|
|
|
1277
1319
|
// A failed turn's line back: seen, so the composer opens (this visit).
|
|
1278
1320
|
openForVisit();
|
|
1279
1321
|
promptEl.value = text;
|
|
1322
|
+
noteDraft();
|
|
1280
1323
|
fitPrompt();
|
|
1281
1324
|
},
|
|
1282
1325
|
setChips,
|
|
@@ -1372,6 +1415,7 @@ const sendTurnControl = createSendTurn({
|
|
|
1372
1415
|
clear: clearComposer,
|
|
1373
1416
|
openForVisit: () => openForVisit(),
|
|
1374
1417
|
focus: () => focusPrompt(),
|
|
1418
|
+
noteDraft,
|
|
1375
1419
|
},
|
|
1376
1420
|
historyEl,
|
|
1377
1421
|
turnDom,
|
|
@@ -1525,6 +1569,7 @@ const unqueue = createUnqueue({
|
|
|
1525
1569
|
if (promptEl.value.trim() || chips.length) return false;
|
|
1526
1570
|
openForVisit();
|
|
1527
1571
|
promptEl.value = text;
|
|
1572
|
+
noteDraft();
|
|
1528
1573
|
fitPrompt();
|
|
1529
1574
|
promptEl.focus();
|
|
1530
1575
|
return true;
|
|
@@ -1571,7 +1616,10 @@ const commandList = createCommandList({
|
|
|
1571
1616
|
list: $("#commandList"),
|
|
1572
1617
|
prompt: promptEl,
|
|
1573
1618
|
commands: () => (selected && current.owner !== "tui" ? current.commands : []),
|
|
1574
|
-
afterPick: () =>
|
|
1619
|
+
afterPick: () => {
|
|
1620
|
+
noteDraft();
|
|
1621
|
+
fitPrompt();
|
|
1622
|
+
},
|
|
1575
1623
|
});
|
|
1576
1624
|
|
|
1577
1625
|
// ↑/↓: the prompt history shared with the TUI (prompt_history.js), from a
|
|
@@ -1588,6 +1636,7 @@ function historyKey(e) {
|
|
|
1588
1636
|
if (!recalled) return false;
|
|
1589
1637
|
e.preventDefault();
|
|
1590
1638
|
promptEl.value = recalled.value;
|
|
1639
|
+
noteDraft();
|
|
1591
1640
|
promptEl.setSelectionRange(recalled.caret, recalled.caret);
|
|
1592
1641
|
fitPrompt();
|
|
1593
1642
|
return true;
|
|
@@ -1620,15 +1669,31 @@ $("#infoContinueBtn").addEventListener("click", () => continueSelected());
|
|
|
1620
1669
|
$("#infoArchiveBtn").addEventListener("click", () => sessionActions.toggleArchiveSelected());
|
|
1621
1670
|
|
|
1622
1671
|
// The new link's composer: the opener (openerText: the previous link's own,
|
|
1623
|
-
// else the constant)
|
|
1624
|
-
//
|
|
1625
|
-
|
|
1626
|
-
|
|
1672
|
+
// else the constant). A draft written in this session (or the new link, or
|
|
1673
|
+
// of unknown origin) stays instead; one carried over from another session or
|
|
1674
|
+
// the start page gives way to the opener and is returned, for the toast's
|
|
1675
|
+
// Restore draft (chains.js openerFill). +created+: the continue's 201;
|
|
1676
|
+
// +continuedId+: the session continued.
|
|
1677
|
+
// @return the displaced draft's text, or null
|
|
1678
|
+
function fillOpener(created, continuedId) {
|
|
1679
|
+
const { value, displaced } = openerFill({
|
|
1680
|
+
text: promptEl.value, hasChips: chips.length > 0, draftFrom, continuedId, linkId: created.id, created,
|
|
1681
|
+
});
|
|
1682
|
+
if (value === null) return null;
|
|
1627
1683
|
openForVisit();
|
|
1628
|
-
promptEl.value =
|
|
1684
|
+
promptEl.value = value;
|
|
1685
|
+
noteDraft(created.id);
|
|
1629
1686
|
fitPrompt();
|
|
1630
1687
|
focusPrompt();
|
|
1631
1688
|
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
1689
|
+
return displaced;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// Restore draft (the continue toast's): +draft+ back into the composer,
|
|
1693
|
+
// over the untouched +opener+ or an empty one, else after what is there
|
|
1694
|
+
// (chains.js restoredComposer); in whatever session is open now.
|
|
1695
|
+
function restoreDraft(draft, opener) {
|
|
1696
|
+
fillComposer(restoredComposer({ current: promptEl.value, opener, draft }));
|
|
1632
1697
|
}
|
|
1633
1698
|
|
|
1634
1699
|
const toasts = createToasts({ el: $("#toast"), onChange: placeToast });
|
|
@@ -1642,6 +1707,7 @@ function showToast(text, action = null, options = {}) { toasts.show(text, action
|
|
|
1642
1707
|
function fillComposer(text) {
|
|
1643
1708
|
openForVisit();
|
|
1644
1709
|
promptEl.value = text;
|
|
1710
|
+
noteDraft();
|
|
1645
1711
|
fitPrompt();
|
|
1646
1712
|
focusPrompt();
|
|
1647
1713
|
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
@@ -25,6 +25,30 @@ export function openerText(created) {
|
|
|
25
25
|
return typeof own === "string" && own.trim() ? own : CONTINUE_OPENER;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// What continue → does with the composer as the new link opens (app.js
|
|
29
|
+
// fillOpener): +text+ / +hasChips+ the composer now; +draftFrom+ the session
|
|
30
|
+
// the text was last written in ("" the start page, undefined unknown);
|
|
31
|
+
// +continuedId+ the session continued, +linkId+ the new link. An empty
|
|
32
|
+
// composer gets the opener. A draft written in either session, or of unknown
|
|
33
|
+
// origin, stays (+value+ null). One carried over from another session or the
|
|
34
|
+
// start page gives way to the opener and comes back as +displaced+ (the
|
|
35
|
+
// toast's Restore draft). Its image chips stay either way: only text is
|
|
36
|
+
// replaced.
|
|
37
|
+
export function openerFill({ text, hasChips, draftFrom, continuedId, linkId, created }) {
|
|
38
|
+
const hasText = !!text?.trim();
|
|
39
|
+
if (!hasText && !hasChips) return { value: openerText(created), displaced: null };
|
|
40
|
+
if (draftFrom === undefined || draftFrom === continuedId || draftFrom === linkId) return { value: null, displaced: null };
|
|
41
|
+
return { value: openerText(created), displaced: hasText ? text : null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The composer after Restore draft: +draft+ (a displaced draft) replaces
|
|
45
|
+
// +current+ when that is empty or still exactly +opener+; else it goes after
|
|
46
|
+
// it, past a blank line. Never loses text typed since.
|
|
47
|
+
export function restoredComposer({ current, opener, draft }) {
|
|
48
|
+
if (!current.trim() || current === opener) return draft;
|
|
49
|
+
return `${current.replace(/\s+$/, "")}\n\n${draft}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
28
52
|
// Whether +text+ (a title or a first message) is the opener and nothing
|
|
29
53
|
// else, trimmed: "Continue where we left off. Then fix X" is a title of its own.
|
|
30
54
|
export function isOpener(text) {
|