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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -1
  3. data/bin/chi +1 -0
  4. data/docs/architecture.md +10 -1
  5. data/docs/cli.md +55 -0
  6. data/docs/configuration.md +1 -0
  7. data/docs/hooks.md +2 -0
  8. data/docs/internals/engine.md +1 -0
  9. data/docs/internals/working-set.md +130 -0
  10. data/docs/sessions.md +1 -1
  11. data/docs/testing.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/work-cap/plugin.rb +26 -16
  15. data/lib/samagotchi/config.rb +3 -0
  16. data/lib/samagotchi/engine.rb +30 -0
  17. data/lib/samagotchi/hooks/loader.rb +17 -2
  18. data/lib/samagotchi/host_keys.rb +35 -18
  19. data/lib/samagotchi/testing/fake_executable.rb +6 -16
  20. data/lib/samagotchi/testing/fake_executable_launcher +6 -0
  21. data/lib/samagotchi/tool_response.rb +10 -6
  22. data/lib/samagotchi/tool_runner.rb +25 -6
  23. data/lib/samagotchi/version.rb +1 -1
  24. data/lib/samagotchi/web/app.rb +49 -1
  25. data/lib/samagotchi/web/public/app.js +77 -11
  26. data/lib/samagotchi/web/public/chains.js +24 -0
  27. data/lib/samagotchi/web/public/chip_popover.js +56 -17
  28. data/lib/samagotchi/web/public/data.js +6 -0
  29. data/lib/samagotchi/web/public/index.html +134 -2
  30. data/lib/samagotchi/web/public/info_bar.js +16 -4
  31. data/lib/samagotchi/web/public/info_bar_model.js +7 -3
  32. data/lib/samagotchi/web/public/markdown_place.js +47 -1
  33. data/lib/samagotchi/web/public/refs.js +10 -1
  34. data/lib/samagotchi/web/public/send_turn.js +6 -2
  35. data/lib/samagotchi/web/public/session_actions.js +10 -5
  36. data/lib/samagotchi/web/public/stage_model.js +6 -5
  37. data/lib/samagotchi/web/public/stage_view.js +34 -2
  38. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  39. data/lib/samagotchi/web/public/turn_view.js +37 -11
  40. data/lib/samagotchi/web/public/working_set.js +520 -0
  41. data/lib/samagotchi/web/public/working_set_rail.js +278 -0
  42. data/lib/samagotchi/web/public/working_set_view.js +426 -0
  43. data/lib/samagotchi/web/server.rb +6 -4
  44. data/lib/samagotchi/working_set.rb +200 -0
  45. metadata +7 -1
@@ -21,16 +21,22 @@ module Samagotchi
21
21
  # Reads one generic password through /usr/bin/security (by absolute
22
22
  # path: the helper's PATH isn't the shell's) in its own process group,
23
23
  # killed at the deadline: a locked keychain or an access dialog must not
24
- # hang a turn. nil on a miss, a non-zero exit, a timeout, no binary, or
25
- # off macOS.
24
+ # hang a turn. The deadline covers the spawn too (macOS can hold a first
25
+ # exec for minutes while it scans the binary): the run is a thread the
26
+ # caller gives up on, and a pid that turns up late is killed and reaped
27
+ # there. nil on a miss, a non-zero exit, a timeout, no binary, or off
28
+ # macOS.
26
29
  class Keychain
27
30
  SECURITY = "/usr/bin/security"
28
31
  TIMEOUT = 5
29
32
 
30
- def initialize(security: SECURITY, timeout: TIMEOUT, host_os: RbConfig::CONFIG["host_os"])
33
+ # +spawner+: how the run starts (Process.spawn's arguments; specs stall it).
34
+ def initialize(security: SECURITY, timeout: TIMEOUT, host_os: RbConfig::CONFIG["host_os"],
35
+ spawner: Process.method(:spawn))
31
36
  @security = security
32
37
  @timeout = timeout
33
38
  @macos = host_os.to_s.include?("darwin")
39
+ @spawner = spawner
34
40
  end
35
41
 
36
42
  # @return [String, nil]
@@ -45,33 +51,44 @@ module Samagotchi
45
51
  private
46
52
 
47
53
  def run(name)
48
- reader, writer = IO.pipe
49
- pid = Process.spawn(@security, "find-generic-password", "-s", SERVICE, "-a", name, "-w",
50
- in: File::NULL, out: writer, err: File::NULL, pgroup: true)
51
- writer.close
52
- waiter = Process.detach(pid)
53
- unless waiter.join(@timeout)
54
- stop(pid, waiter)
55
- Log.warn(:config, "keychain_timeout", name: name, seconds: @timeout)
56
- return nil
54
+ lock = Mutex.new
55
+ pid = nil
56
+ late = false
57
+ worker = Thread.new do
58
+ Thread.current.report_on_exception = false
59
+ security(name) { |spawned| lock.synchronize { late ? stop(spawned) : pid = spawned } }
57
60
  end
58
- return nil unless waiter.value.success?
61
+ return worker.value if worker.join(@timeout)
59
62
 
60
- key = reader.read.to_s.chomp
61
- key.strip.empty? ? nil : key
63
+ lock.synchronize do
64
+ late = true
65
+ stop(pid) if pid
66
+ end
67
+ Log.warn(:config, "keychain_timeout", name: name, seconds: @timeout)
68
+ nil
62
69
  rescue SystemCallError
63
70
  nil
71
+ end
72
+
73
+ # One run, start to reap; yields the pid as soon as there is one.
74
+ def security(name)
75
+ reader, writer = IO.pipe
76
+ pid = @spawner.call(@security, "find-generic-password", "-s", SERVICE, "-a", name, "-w",
77
+ in: File::NULL, out: writer, err: File::NULL, pgroup: true)
78
+ writer.close
79
+ yield pid
80
+ key = reader.read.to_s.chomp
81
+ _, status = Process.wait2(pid)
82
+ status.success? && !key.strip.empty? ? key : nil
64
83
  ensure
65
84
  writer&.close unless writer&.closed?
66
85
  reader&.close
67
86
  end
68
87
 
69
- def stop(pid, waiter)
88
+ def stop(pid)
70
89
  Process.kill(:KILL, -pid)
71
90
  rescue SystemCallError
72
91
  nil
73
- ensure
74
- waiter.join(1)
75
92
  end
76
93
  end
77
94
 
@@ -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.0"
4
+ VERSION = "0.101.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
@@ -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 { openerText, openTitle } from "./chains.js";
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: () => fitPrompt(),
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), unless a draft is there (it stays). +created+: the
1624
- // continue's 201.
1625
- function fillOpener(created) {
1626
- if (promptEl.value.trim() || chips.length) return;
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 = openerText(created);
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) {