samagotchi 0.5.0 → 0.6.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +4 -1
  7. data/docs/desktop.md +3 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +2 -2
  22. data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
  23. data/lib/samagotchi/desktop/macos/Panel.swift +31 -3
  24. data/lib/samagotchi/engine.rb +138 -618
  25. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  26. data/lib/samagotchi/hooks/registry.rb +11 -3
  27. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  28. data/lib/samagotchi/hooks.rb +3 -0
  29. data/lib/samagotchi/kernel_loop.rb +65 -6
  30. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  31. data/lib/samagotchi/llm/openai_chat.rb +16 -4
  32. data/lib/samagotchi/log_subscriber.rb +10 -4
  33. data/lib/samagotchi/plugin/api.rb +8 -0
  34. data/lib/samagotchi/plugin/context.rb +12 -3
  35. data/lib/samagotchi/plugin_tasks.rb +229 -0
  36. data/lib/samagotchi/question_desk.rb +280 -0
  37. data/lib/samagotchi/recap_setup.rb +128 -0
  38. data/lib/samagotchi/reply_wait.rb +9 -0
  39. data/lib/samagotchi/send_command.rb +3 -6
  40. data/lib/samagotchi/session.rb +9 -1
  41. data/lib/samagotchi/sessions_command.rb +255 -0
  42. data/lib/samagotchi/steer.rb +19 -2
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  44. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  45. data/lib/samagotchi/tool_declarations.rb +2 -2
  46. data/lib/samagotchi/tools/delegate.rb +6 -2
  47. data/lib/samagotchi/tools/delegate_wait.rb +26 -8
  48. data/lib/samagotchi/turn_note.rb +20 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +28 -10
  51. data/lib/samagotchi/web/public/app.js +94 -52
  52. data/lib/samagotchi/web/public/card.js +8 -1
  53. data/lib/samagotchi/web/public/format.js +1 -1
  54. data/lib/samagotchi/web/public/index.html +2 -5
  55. data/lib/samagotchi/web/public/stage_model.js +44 -0
  56. data/lib/samagotchi/web/public/stage_view.js +26 -17
  57. data/lib/samagotchi/web/public/timing.js +9 -3
  58. data/lib/samagotchi/web/public/turn_events.js +18 -5
  59. data/lib/samagotchi/web/public/turn_view.js +80 -16
  60. data/lib/samagotchi/web/server.rb +4 -3
  61. metadata +9 -2
  62. data/lib/samagotchi/web/public/chat_view.js +0 -376
@@ -9,10 +9,12 @@ module Samagotchi
9
9
  module Tools
10
10
  # Waiting for a delegated session's next reply, shared by delegate and
11
11
  # delegate_result: ReplyWait in the tools' words, with a cursor per
12
- # child (the newest reply this parent was already given). The cursors
13
- # live in this process (a worker runs one session), keyed by parent and
14
- # child; a worker respawn loses them, which only repeats the newest reply
15
- # once.
12
+ # child (the newest reply this parent was already given) and the child
13
+ # as it was before the message went in (ReplyWait's baseline), so a turn
14
+ # that ends before the wait's first look (a fast failure) still ends it.
15
+ # Both live in this process (a worker runs one session), keyed by parent
16
+ # and child; a worker respawn loses them, which only repeats the newest
17
+ # reply once or waits for a later turn.
16
18
  module DelegateWait
17
19
  TIMEOUT_DEFAULT = 600
18
20
  POLL_INTERVAL = 0.5
@@ -28,6 +30,11 @@ module Samagotchi
28
30
  @seen ||= {}
29
31
  end
30
32
 
33
+ # @return [Hash{Array(String, String) => Hash}] [parent id, child id] → ReplyWait baseline taken before sending
34
+ def baselines
35
+ @baselines ||= {}
36
+ end
37
+
31
38
  # @param child_id [String] the child's full id
32
39
  # @param peers [Peers] the parent (session_id, state_dir, cancelled?)
33
40
  # @param timeout [Integer] seconds
@@ -38,7 +45,9 @@ module Samagotchi
38
45
  key = [peers.session_id, child_id]
39
46
  cancelled = -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
40
47
  wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
41
- poll_interval: poll_interval, cancelled: cancelled)
48
+ poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key])
49
+ # The turn sent to is handed over: a later wait looks for a later one.
50
+ baselines.delete(key) if %i[done no_reply].include?(wait.status)
42
51
  case wait.status
43
52
  when :done
44
53
  seen[key] = wait.file
@@ -61,9 +70,18 @@ module Samagotchi
61
70
  end
62
71
 
63
72
  # Point the cursor at the newest reply now, so only a later one counts
64
- # (a follow-up sent to a child that already answered).
65
- def mark_seen(parent_id, child_id, state_dir:)
66
- seen[[parent_id, child_id]] = ReplyWait.newest_reply(child_id, state_dir: state_dir)
73
+ # (a follow-up sent to a child that already answered), and take the
74
+ # child's baseline before the follow-up goes in.
75
+ # @param child [Session] the child as loaded before sending
76
+ def mark_seen(parent_id, child, state_dir:)
77
+ seen[[parent_id, child.id]] = ReplyWait.newest_reply(child.id, state_dir: state_dir)
78
+ baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child)
79
+ end
80
+
81
+ # A new child's baseline: as spawned, before its first turn ran.
82
+ # @param child [Session] the session spawn_session returned
83
+ def mark_started(parent_id, child)
84
+ baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child, question_id: nil)
67
85
  end
68
86
 
69
87
  def reply_result(child_id, text)
@@ -17,6 +17,7 @@ module Samagotchi
17
17
  RESTORED = "The message went back to the user, who may send it again."
18
18
  TASKS_LISTED = 5
19
19
  TASK_COMMAND_CHARS = 60
20
+ RETRY_NUDGE = :retry_nudge
20
21
 
21
22
  module_function
22
23
 
@@ -64,7 +65,25 @@ module Samagotchi
64
65
  # The hidden nudge before a retry of an empty answer (EmptyAnswerRetry):
65
66
  # the model never sees its empty generation, so it is told what happened.
66
67
  def empty_retry
67
- message("your last reply had no visible answer. Answer the user's last message now, briefly.")
68
+ retry_nudge(message("your last reply had no visible answer. Answer the user's last message now, briefly."))
69
+ end
70
+
71
+ # The hidden nudge before a retry of a generation a plugin cut
72
+ # (stop_generation): +by+ is the bundle, +reason+ what it said.
73
+ def cut_retry(by, reason)
74
+ why = reason.to_s.strip.empty? ? "" : ": #{one_line(reason)}"
75
+ retry_nudge(message("your last reply was cut off by #{by || "a plugin"}#{why}. Don't start the same reasoning " \
76
+ "again; answer the user's last message now, briefly."))
77
+ end
78
+
79
+ # A retry nudge is marked, so the spent one is found by the mark
80
+ # whatever its words (KernelLoop drops it when the retry fails too).
81
+ def retry_nudge(note)
82
+ note.merge(RETRY_NUDGE => true)
83
+ end
84
+
85
+ def retry_nudge?(entry)
86
+ entry.respond_to?(:[]) && (entry[RETRY_NUDGE] || entry[RETRY_NUDGE.to_s]) == true
68
87
  end
69
88
 
70
89
  def note?(entry)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cgi/escape" # CGI.escapeHTML; Ruby 4.0 ships only cgi/escape
4
+ require "digest"
4
5
  require "fileutils"
5
6
  require "json"
6
7
  require "time"
@@ -70,8 +71,8 @@ module Samagotchi
70
71
 
71
72
  # @param bridge_wait_timeout [Float] bounded seconds to wait for a
72
73
  # freshly-spawned worker's bridge before answering POST /api/sessions.
73
- # @param view ["turn", "stage", "chat"] the page's view of a turn
74
- # (web.view); ?view=turn|stage|chat overrides it for one page load
74
+ # @param view ["stage", "turn"] the page's view of a turn (web.view);
75
+ # ?view=stage|turn overrides it for one page load
75
76
  # @param annotate_presets [String, Array] the quick replies next to
76
77
  # Annotate (web.annotate_presets, "|"-separated); "" shows none. The
77
78
  # page parses them (annotate_presets.js).
@@ -86,7 +87,7 @@ module Samagotchi
86
87
  # String, or a Web::Token::Source that follows the file); nil:
87
88
  # loopback only, no token
88
89
  def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
89
- bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: "turn", hub: nil,
90
+ bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
90
91
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
91
92
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
92
93
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
@@ -1161,13 +1162,26 @@ module Samagotchi
1161
1162
  full = File.join(@public_dir, rel)
1162
1163
  if File.file?(full) && full.start_with?(@public_dir)
1163
1164
  body = File.binread(full)
1164
- ctype = mime_type(full)
1165
- [200, { "Content-Type" => ctype, "Content-Length" => body.bytesize.to_s, "Cache-Control" => "public, max-age=3600" }, [body]]
1165
+ # no-cache + a content ETag: the browser revalidates every asset
1166
+ # (the ES-module imports too) and gets a cheap 304 while it is
1167
+ # unchanged, so a gem upgrade + chi web restart takes effect on the
1168
+ # next load instead of after max-age runs out.
1169
+ etag = %("#{Digest::SHA256.hexdigest(body)[0, 32]}")
1170
+ headers = { "Cache-Control" => "no-cache", "ETag" => etag }
1171
+ return [304, headers, []] if etag_match?(req.get_header("HTTP_IF_NONE_MATCH"), etag)
1172
+
1173
+ [200, headers.merge("Content-Type" => mime_type(full), "Content-Length" => body.bytesize.to_s), [body]]
1166
1174
  else
1167
1175
  not_found(path: req.path_info)
1168
1176
  end
1169
1177
  end
1170
1178
 
1179
+ def etag_match?(if_none_match, etag)
1180
+ return false if if_none_match.nil?
1181
+
1182
+ if_none_match.split(",").map { |t| t.strip.delete_prefix("W/") }.any? { |t| t == "*" || t == etag }
1183
+ end
1184
+
1171
1185
  def mime_type(path)
1172
1186
  case File.extname(path).downcase
1173
1187
  when ".html" then "text/html; charset=utf-8"
@@ -1268,16 +1282,17 @@ module Samagotchi
1268
1282
  return nil unless saved
1269
1283
 
1270
1284
  messages = Array(session.messages)
1271
- since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.prompt?(m) }
1285
+ since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_prompt?(m) }
1272
1286
  { text: saved[:text], turns_since: since }
1273
1287
  rescue StandardError
1274
1288
  nil
1275
1289
  end
1276
1290
 
1277
- # The step (iteration) that answered the steer at +index+: one past
1278
- # the model messages between it and the prompt before it.
1291
+ # The step (iteration) that answered the steer or merged input at
1292
+ # +index+: one past the model messages between it and the turn's
1293
+ # prompt.
1279
1294
  def steer_step(list, index)
1280
- list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.prompt?(m) }
1295
+ list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.turn_prompt?(m) }
1281
1296
  .count { |m| %w[model assistant].include?((m[:role] || m["role"]).to_s) } + 1
1282
1297
  end
1283
1298
 
@@ -1326,6 +1341,9 @@ module Samagotchi
1326
1341
  next unless %w[user assistant].include?(norm_role)
1327
1342
 
1328
1343
  message = { role: norm_role, content: stripped }
1344
+ # Merged into the running turn (Steer::INPUT_KIND): part of it,
1345
+ # read by step +step+.
1346
+ message.merge!(merged: true, step: steer_step(list, index)) if norm_role == "user" && Samagotchi::Steer.input?(m)
1329
1347
  images = m[:images] || m["images"]
1330
1348
  message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
1331
1349
  if norm_role == "assistant"
@@ -1440,7 +1458,7 @@ module Samagotchi
1440
1458
 
1441
1459
  VIEWS = Config::BY_KEY["web.view"].enum_values
1442
1460
 
1443
- # The view for this page load: ?view=turn|stage|chat wins, anything
1461
+ # The view for this page load: ?view=stage|turn wins, anything
1444
1462
  # else leaves the config's choice (web.view).
1445
1463
  def view_name(param)
1446
1464
  VIEWS.include?(param) ? param : @view
@@ -21,7 +21,6 @@ import { MODEL_KEY } from "./model_pick.js";
21
21
  import { createModelPicker } from "./model_picker.js";
22
22
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
23
23
  import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, firstMessageOf, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
24
- import { chatHistoryHtml, createChatView } from "./chat_view.js";
25
24
  import { createTurnView, turnHistoryHtml } from "./turn_view.js";
26
25
  import { createStageView } from "./stage_view.js";
27
26
  import { annotateBarLeft, annotationSource, appendQuote, quoteBlock } from "./annotations.js";
@@ -31,7 +30,7 @@ import { routeChunk } from "./chunk_router.js";
31
30
  import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
32
31
  import { diffHtml } from "./diff_view.js";
33
32
  import { approvalAllowed, approvalView, isApproval, nextCardAction, resultText, summaryText } from "./question_card.js";
34
- import { cardClass, cardInnerHtml, cardPlace, splitSnapshotCards, turnNoticePlace } from "./card.js";
33
+ import { cardClass, cardInnerHtml, cardPlace, isCard, splitSnapshotCards, turnNoticePlace } from "./card.js";
35
34
  import { gripFloor, promptHeight } from "./composer_size.js";
36
35
  import { commandMatches, commandRow, moveSelection, pickCommand } from "./command_complete.js";
37
36
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
@@ -44,7 +43,7 @@ import {
44
43
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
45
44
  } from "./timing.js";
46
45
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
47
- import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
46
+ import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
48
47
 
49
48
  const $ = (s) => document.querySelector(s);
50
49
  const topStripEl = $("#topStrip");
@@ -73,14 +72,12 @@ let resyncsInFlight = 0;
73
72
  let resyncAgain = false;
74
73
  let allSessions = [];
75
74
  let seenContent = new Set();
76
- // The page's view of a turn (web.view, or ?view=turn|stage|chat): one block
77
- // per turn with its steps (turn_view.js), the running turn pinned above the
78
- // composer (the stage), or a row of bubbles (chat_view.js).
79
- const view = ["turn", "stage", "chat"].includes(document.body.dataset.view) ? document.body.dataset.view : "turn";
80
- // The turn and stage views draw a turn as steps (a reload asks for parts).
81
- const turnView = view !== "chat";
82
- // The running turn's DOM: the streaming bubbles, the thinking block and the
83
- // activity panel, the turn block, or the stage (the turn block in its cloud).
75
+ // The page's view of a turn (web.view, or ?view=stage|turn): the running
76
+ // turn pinned above the composer (the stage, stage_view.js), or one block
77
+ // per turn with its steps in the history (turn_view.js).
78
+ const view = ["stage", "turn"].includes(document.body.dataset.view) ? document.body.dataset.view : "stage";
79
+ // The running turn's DOM: the turn block, or the stage (the turn block in
80
+ // its cloud).
84
81
  const viewArgs = {
85
82
  historyEl,
86
83
  appendToHistory: (el) => appendToHistory(el),
@@ -90,7 +87,7 @@ const viewArgs = {
90
87
  };
91
88
  const turnDom = view === "stage"
92
89
  ? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
93
- : (turnView ? createTurnView : createChatView)(viewArgs);
90
+ : createTurnView(viewArgs);
94
91
  // Where a running turn's rows can be: the history, and the stage while it
95
92
  // holds a turn (lookups by id, copy, annotate, card actions).
96
93
  function turnRoots() {
@@ -683,7 +680,7 @@ async function resync({ endText = null } = {}) {
683
680
  let data;
684
681
  try {
685
682
  try {
686
- data = await getSession(id, { parts: turnView });
683
+ data = await getSession(id, { parts: true });
687
684
  } catch (e) {
688
685
  if (e.status === 404) return leaveGoneSession(id, "not_found");
689
686
  if (id === selected) historyEl.innerHTML = `<div class="hint">Error: ${escapeHtml(e.message)}</div>`;
@@ -749,9 +746,11 @@ function renderSessionView(data) {
749
746
  seedSeenFromDom();
750
747
  seedInitTasks(initTasks, data.init_tasks);
751
748
  renderInitRow();
752
- // A guardrail file that failed to load, announced before this page joined.
753
- showGuardrailWarning(data.guardrail_warning);
754
- showGuardrailWarning(data.plugin_warning, "plugins");
749
+ // A guardrail file that failed to load, announced before this page
750
+ // joined: where it came (the snapshot's cards), else at the end.
751
+ const loadWarned = (label) => (data.cards || []).some((c) => c?.type === "guardrail_warning" && (c.label || "guardrails") === label);
752
+ if (!loadWarned("guardrails")) showGuardrailWarning(data.guardrail_warning);
753
+ if (!loadWarned("plugins")) showGuardrailWarning(data.plugin_warning, "plugins");
755
754
  // The recap saved with the session (also a stopped one's), else a live
756
755
  // worker's from before saved_recap existed.
757
756
  if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since);
@@ -789,10 +788,8 @@ function renderHistory(items) {
789
788
  historyEl.innerHTML = `<div class="hint">No output yet. Send a message.</div>`;
790
789
  return;
791
790
  }
792
- if (typeof items[0] === "object" && items[0].role && turnView) {
791
+ if (typeof items[0] === "object" && items[0].role) {
793
792
  historyEl.innerHTML = turnHistoryHtml(items, currentTiming, { thumbs: (images) => thumbsHtml(selected, images) });
794
- } else if (typeof items[0] === "object" && items[0].role) {
795
- historyEl.innerHTML = chatHistoryHtml(items, currentTiming, { thumbs: (images) => thumbsHtml(selected, images) });
796
793
  } else {
797
794
  historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
798
795
  }
@@ -905,7 +902,7 @@ function addEndBubble(text) {
905
902
  function endTurnView(kind, endText = null) {
906
903
  setTurnRunning(false);
907
904
  const kept = turnDom.turnEnded({ kind }) || [];
908
- finishLiveTurnTiming();
905
+ finishLiveTurnTiming(null, { canceled: kind === "canceled" });
909
906
  if (endText) addEndBubble(endText);
910
907
  if (kept.length) {
911
908
  kept.forEach((el) => appendToHistory(el));
@@ -1034,7 +1031,7 @@ function showRecap(text, turnsSince = 0) {
1034
1031
  details.appendChild(body);
1035
1032
  // A stale one sits where it was written, above the turns it doesn't cover.
1036
1033
  // Never in the stage: a recap is between turns.
1037
- const before = recapPlace([...historyEl.querySelectorAll(":scope > .bubble.user")], turnsSince);
1034
+ const before = recapPlace(turnPromptBubbles(), turnsSince);
1038
1035
  keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurnTimingEl)));
1039
1036
  }
1040
1037
 
@@ -1049,21 +1046,23 @@ function markRecapStale() {
1049
1046
 
1050
1047
  // A guardrail hook or rule file that failed to load (once per worker), or
1051
1048
  // a bundle's plugin (+label+ "plugins").
1052
- function showGuardrailWarning(text, label = "guardrails") {
1049
+ // +before+: the element it goes above (a snapshot's), else the end.
1050
+ function showGuardrailWarning(text, label = "guardrails", { before = null } = {}) {
1053
1051
  if (!text) return;
1054
1052
  const el = document.createElement("div");
1055
1053
  el.className = "bubble guardrail-warning";
1056
1054
  el.textContent = `${label}: ${text}`;
1057
1055
  const follow = isNearBottom();
1058
- appendToHistory(el);
1056
+ if (before) historyEl.insertBefore(el, before);
1057
+ else appendToHistory(el);
1059
1058
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1060
1059
  }
1061
1060
 
1062
1061
  // A hook's line to the user (event[:notify]): "<bundle>: text", in the
1063
1062
  // warning colour for level warn.
1064
1063
  // +before+: the element it goes above (a snapshot's notice), else the end.
1065
- // A plugin's steers (pending_input_merged's steers): in the turn view a row
1066
- // of the running turn's step, else a bubble.
1064
+ // A plugin's steers (pending_input_merged's steers): a row of the running
1065
+ // turn's step, else (no turn runs) a bubble.
1067
1066
  function showSteers(steers) {
1068
1067
  for (const steer of steers || []) {
1069
1068
  if (turnDom.steer(steer)) continue;
@@ -1080,7 +1079,7 @@ function showHookNotice(data, { before = null } = {}) {
1080
1079
  removeHintIfEmpty();
1081
1080
  const el = document.createElement("div");
1082
1081
  el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "");
1083
- el.textContent = `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
1082
+ el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
1084
1083
  const follow = isNearBottom();
1085
1084
  if (before) historyEl.insertBefore(el, before);
1086
1085
  else appendToHistory(el);
@@ -1101,6 +1100,9 @@ function cardElement(id) {
1101
1100
  function drawCard(el, card) {
1102
1101
  el.className = cardClass(card);
1103
1102
  el.dataset.cardId = card.id;
1103
+ // A running turn's own card (the stage's status waits on one that asks).
1104
+ if (card.in_turn) el.dataset.inTurn = "1";
1105
+ else delete el.dataset.inTurn;
1104
1106
  if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
1105
1107
  // The same body again from the stream: keep the rendered one.
1106
1108
  const body = el.querySelector(".card-body").innerHTML;
@@ -1139,41 +1141,35 @@ function upsertCard(card, { before = null } = {}) {
1139
1141
  // A snapshot's cards and notices, each above the first turn after it; a
1140
1142
  // turn's own notice where it showed live (placeTurnNotice).
1141
1143
  function placeSnapshotCards(entries) {
1142
- const users = [...historyEl.querySelectorAll(":scope > .bubble.user")];
1144
+ const users = turnPromptBubbles();
1143
1145
  for (const entry of entries) {
1144
1146
  const before = cardPlace(users, entry);
1145
- if (entry.type === "hook_notice" && entry.in_turn) placeTurnNotice(entry, before);
1147
+ if (isCard(entry)) upsertCard(entry, { before });
1148
+ else if (entry.type === "question") placeResolvedQuestion(entry, before);
1149
+ else if (entry.type === "guardrail_warning") showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
1150
+ else if (entry.in_turn) placeTurnNotice(entry, before);
1146
1151
  else if (entry.type === "hook_notice") showHookNotice(entry, { before });
1147
- else upsertCard(entry, { before });
1148
1152
  }
1149
1153
  }
1150
1154
 
1151
- // A finished turn's hook notice (+before+: the next turn's prompt, or null
1152
- // for the last turn): in the turn view a row of its step, above the row of
1153
- // the call it came before, as live; else (the chat view, a plain answer, a
1154
- // step the history doesn't have) a bubble before the turn's answer, or
1155
- // before its timing line when it has none.
1155
+ // A finished turn's row, a hook's notice or the loop's asking again
1156
+ // (+before+: the next turn's prompt, or null for the last turn): a row of
1157
+ // its step, above the row of the call it came before, as live; else (a
1158
+ // plain answer, a step the history doesn't have) a bubble before the
1159
+ // turn's answer, or before its timing line when it has none.
1156
1160
  function placeTurnNotice(entry, before) {
1157
- const turnEls = [];
1158
- for (let el = before ? before.previousElementSibling : historyEl.lastElementChild; el; el = el.previousElementSibling) {
1159
- turnEls.unshift(el);
1160
- if (el.matches(".bubble.user")) break;
1161
- }
1161
+ const line = noticeLine(entry);
1162
+ const turnEls = reloadedTurnEls(before);
1162
1163
  const place = turnNoticePlace(entry);
1163
1164
  const block = turnEls.find((el) => el.matches(".turn-work"));
1164
1165
  const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
1165
1166
  if (!step) {
1166
- // The chat view's canceled turn has no answer: its text bubbles are
1167
- // partial narration the notice came after.
1168
- const canceled = !turnView && turnEls.some((el) => el.matches(".bubble.cancel"));
1169
- const answer = canceled ? null : turnEls.filter((el) => el.matches(".bubble.output")).pop();
1170
- const timing = turnEls.find((el) => el.matches(".turn-timing"));
1171
- showHookNotice(entry, { before: answer || timing || before });
1167
+ showHookNotice({ ...entry, line }, { before: turnAnswerEl(turnEls) || before });
1172
1168
  return;
1173
1169
  }
1174
1170
  const el = document.createElement("div");
1175
1171
  el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "");
1176
- el.textContent = `${hookNoticeLabel(entry.hook)}: ${entry.text || ""}`;
1172
+ el.textContent = line;
1177
1173
  let body = step.querySelector(":scope > .activity-body");
1178
1174
  if (!body) {
1179
1175
  body = document.createElement("div");
@@ -1184,6 +1180,49 @@ function placeTurnNotice(entry, before) {
1184
1180
  body.insertBefore(el, row || null);
1185
1181
  }
1186
1182
 
1183
+ // A prompt that started a turn: not a line steered into a running one.
1184
+ const TURN_PROMPT = ".bubble.user:not(.steered)";
1185
+
1186
+ // The history's turn prompts, oldest first (what turns_since counts).
1187
+ function turnPromptBubbles() {
1188
+ return [...historyEl.querySelectorAll(`:scope > ${TURN_PROMPT}`)];
1189
+ }
1190
+
1191
+ // The elements of the reloaded turn that ends before +before+ (the next
1192
+ // turn's prompt, or null for the last turn), its prompt first.
1193
+ function reloadedTurnEls(before) {
1194
+ const turnEls = [];
1195
+ for (let el = before ? before.previousElementSibling : historyEl.lastElementChild; el; el = el.previousElementSibling) {
1196
+ turnEls.unshift(el);
1197
+ if (el.matches(TURN_PROMPT)) break;
1198
+ }
1199
+ return turnEls;
1200
+ }
1201
+
1202
+ // What goes after a reloaded turn's own rows and cards: its answer, else
1203
+ // its timing line; null when it has neither.
1204
+ function turnAnswerEl(turnEls) {
1205
+ return turnEls.filter((el) => el.matches(".bubble.output")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
1206
+ }
1207
+
1208
+ // A finished turn's question (the snapshot's cards keep it with its
1209
+ // answer): the card as it was left live, resolved, before the lines the
1210
+ // user sent into the turn after it (a steered bubble's data-step: the step
1211
+ // that read it) and the turn's answer. A pending one is the running turn's
1212
+ // (pending_question draws it).
1213
+ function placeResolvedQuestion(entry, before) {
1214
+ const pq = entry.pending_question;
1215
+ if (!pq?.id || !(entry.answer || entry.cancelled)) return;
1216
+ renderQuestionCard(pq);
1217
+ const card = questionCardEl;
1218
+ if (!card || card.dataset.qid !== String(pq.id)) return;
1219
+ resolveQuestionCard(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
1220
+ const turnEls = reloadedTurnEls(before);
1221
+ const asked = Number(entry.iteration) || 1;
1222
+ const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
1223
+ historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
1224
+ }
1225
+
1187
1226
  // A live card's body comes as text; the worker's snapshot (through the
1188
1227
  // server) has it rendered. One re-read for a burst of cards.
1189
1228
  let cardBodiesTimer = null;
@@ -1337,7 +1376,9 @@ function setLiveTurnNote(text) {
1337
1376
  if (currentTiming.activeTurn) updateLiveTiming();
1338
1377
  }
1339
1378
 
1340
- function finishLiveTurnTiming(record = null) {
1379
+ // A canceled turn's line says so (+canceled+, or its record's status), as
1380
+ // a reload's does.
1381
+ function finishLiveTurnTiming(record = null, { canceled = false } = {}) {
1341
1382
  const duration = Number.isFinite(Number(record?.duration_ms))
1342
1383
  ? Number(record.duration_ms)
1343
1384
  : elapsedSince(currentTiming.activeTurn?.started_at);
@@ -1345,7 +1386,8 @@ function finishLiveTurnTiming(record = null) {
1345
1386
  liveTurnNote = "";
1346
1387
  liveTurnTimingEl.classList.remove("live");
1347
1388
  const at = record ? currentTiming.turnRecords.indexOf(record) : -1;
1348
- liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration);
1389
+ liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration,
1390
+ { canceled: canceled || record?.status === "canceled" });
1349
1391
  }
1350
1392
  currentTiming.activeTurn = null;
1351
1393
  clearTimingInterval();
@@ -1496,7 +1538,7 @@ const streamHandlers = {
1496
1538
  // that the rendered markdown is in it (or the re-read failed: then the
1497
1539
  // plain text pops), and the display if one was coming, so the links
1498
1540
  // never swap in after the pop. The hold reveals by itself after
1499
- // HOLD_MS whatever comes. The chat view has no hold.
1541
+ // HOLD_MS whatever comes. The stage has no hold.
1500
1542
  if (display) await display;
1501
1543
  turnDom.answerReady?.();
1502
1544
  },
@@ -1611,10 +1653,10 @@ const streamHandlers = {
1611
1653
  if (!turnRunning) showRecap(data.recap);
1612
1654
  },
1613
1655
  guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
1614
- // In the turn view, a notice during a turn is a row of its current step.
1656
+ // A notice during a turn is a row of its current step.
1615
1657
  hook_notice: (data) => { if (!turnDom.notice(data)) showHookNotice(data); },
1616
- // The loop asks again after an empty answer: a row of the empty step (the
1617
- // chat view shows nothing; after a reload neither does the turn view).
1658
+ // The loop asks again after an empty answer: a row of the empty step
1659
+ // (a reload puts it back from the snapshot's cards).
1618
1660
  empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
1619
1661
  // The provider is asked again after an error, or the turn waits for
1620
1662
  // plugins' setup: a note on the live timing line until it streams.
@@ -53,15 +53,22 @@ export function cardPlace(userBubbles, card) {
53
53
  // (a row of its current step once the turn is drawn), and the ones that
54
54
  // came during the running turn but aren't its own (an anytime command's,
55
55
  // such as /btw: after the running turn once it is drawn, as they came).
56
+ // The running turn's own rows (a hook's notice, a retry) are left out:
57
+ // they come with the turn's parts (snapshotEvents).
56
58
  export function splitSnapshotCards(cards) {
57
59
  const list = Array.isArray(cards) ? cards : [];
58
60
  return {
59
61
  placed: list.filter((c) => !c.current && !c.during),
60
- current: list.filter((c) => c.current),
62
+ current: list.filter((c) => c.current && isCard(c)),
61
63
  during: list.filter((c) => !c.current && c.during),
62
64
  };
63
65
  }
64
66
 
67
+ // An entry of the snapshot's cards that is a card (not a notice or a row).
68
+ export function isCard(entry) {
69
+ return (entry?.type ?? "card") === "card";
70
+ }
71
+
65
72
  // Where a snapshot's turn notice (a hook's notice during a turn, in_turn)
66
73
  // goes in its reloaded turn's block: the step of its iteration (the
67
74
  // iteration-th .gen, 0-based index here) and the key of the tool row it
@@ -25,7 +25,7 @@ export function noteHtml(note) {
25
25
 
26
26
  // A plugin's steer (a nudge put into the running turn): one line that
27
27
  // names who nudged the model, its text collapsed under it. +bubble+: on its
28
- // own between bubbles (the chat view), not a row of a step.
28
+ // own between bubbles (no step holds it), not a row of a step.
29
29
  export function steerRowHtml({ source, text } = {}, { bubble = false } = {}) {
30
30
  const who = source ? `${source} nudged the model` : "nudged the model";
31
31
  return `<details class="${bubble ? "bubble " : ""}steer-row"><summary>${escapeHtml(who)}</summary>` +
@@ -104,11 +104,6 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
104
104
  .bubble.recap summary{cursor:pointer;color:var(--think-text);font-size:12px}
105
105
  .bubble.recap .recap-body{white-space:pre-wrap;margin-top:6px}
106
106
  .bubble.recap.stale{opacity:.7;border-left-style:dashed}
107
- .bubble.activity{border-left:3px solid var(--accent);background:var(--bg-panel);padding:8px 12px}
108
- .bubble.activity>summary{cursor:pointer;color:var(--text-muted);font-size:13px;font-weight:600;user-select:none;list-style:none}
109
- .bubble.activity>summary::-webkit-details-marker{display:none}
110
- .bubble.activity>summary::before{content:"▸ ";color:var(--accent)}
111
- .bubble.activity[open]>summary::before{content:"▾ "}
112
107
  .activity-body{margin-top:6px}
113
108
  .activity-row{display:flex;gap:8px;align-items:baseline;font-size:13px;color:var(--text-2);padding:5px 2px;border-top:1px solid var(--border)}
114
109
  .activity-row:first-child{border-top:none}
@@ -561,6 +556,8 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
561
556
  #composeHint{display:none}
562
557
  #infoBar{flex-wrap:nowrap;align-items:center;gap:6px;padding:6px 10px 8px}
563
558
  #infoBar .preview-line{display:none}
559
+ /* No terminal on a phone to attach from. */
560
+ #infoBar .meta .copy-attach{display:none}
564
561
  #infoBar .meta{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;white-space:nowrap}
565
562
  #infoBar .meta::-webkit-scrollbar{display:none}
566
563
  #infoBar .meta>*{flex-shrink:0}
@@ -3,6 +3,7 @@
3
3
  // history, and in what order its pieces go there. No DOM, so `node --test`
4
4
  // covers the rules; stage_view.js draws them.
5
5
  import { toolName } from "./activity.js";
6
+ import { splitSentences } from "./sentences.js";
6
7
 
7
8
  const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
8
9
  const EDITS = new Set(["edit", "write", "memory_write"]);
@@ -14,6 +15,49 @@ export const QUIET_MS = 1500;
14
15
  // A touch, wheel or scroll counts as use this long (phones have no hover).
15
16
  export const TOUCH_MS = 4000;
16
17
 
18
+ // One headline sentence as plain text: the markdown a streaming answer
19
+ // carries (headings, quotes, bullets, table pipes, emphasis, code ticks,
20
+ // links) goes, and a line that is only syntax (a code fence, a table's
21
+ // separator row, a rule) is empty. A small pass, no renderer: a live line
22
+ // only needs to read well, and an unclosed "**" mid-stream goes too.
23
+ export function plainHeadline(line) {
24
+ let s = String(line ?? "").trim();
25
+ if (/^(```|~~~)/.test(s)) return "";
26
+ if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?$/.test(s)) return "";
27
+ if (/^([-*_])(\s*\1){2,}$/.test(s)) return "";
28
+ s = s.replace(/^(>\s*)+/, "").replace(/^#{1,6}\s+/, "").replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, "");
29
+ // A table row: a pipe at either end (a pipe mid-prose, "a || b", stays).
30
+ if (/^\||\|$/.test(s)) {
31
+ s = s.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()).filter(Boolean).join(" · ");
32
+ }
33
+ // Code spans keep their text as is; an unclosed tick just goes.
34
+ const code = [];
35
+ s = s.replace(/`+([^`]+)`+/g, (_, c) => `\u0000${code.push(c) - 1}\u0000`);
36
+ s = s
37
+ .replace(/!\[([^\]]*)\]\([^)]*\)?/g, "$1")
38
+ .replace(/\[([^\]]*)\]\([^)]*\)?/g, "$1")
39
+ .replace(/<(https?:\/\/[^>\s]+)>/g, "$1")
40
+ .replace(/`+/g, "")
41
+ .replace(/(^|[^\w])__(?=\S)(.*?\S)__(?=$|[^\w])/g, "$1$2")
42
+ .replace(/(\*\*|~~)/g, "")
43
+ .replace(/(^|[^\w*])\*(?=\S)([^*]*?\S)\*(?=$|[^\w*])/g, "$1$2")
44
+ .replace(/(^|[^\w])_(?=\S)([^_]*?\S)_(?=$|[^\w])/g, "$1$2")
45
+ .replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
46
+ return s.replace(/\s+/g, " ").trim();
47
+ }
48
+
49
+ // The step's newest narration sentence, as plain text: the end of a
50
+ // finished text is a boundary (its last line may have no period), a live
51
+ // one's not yet. A sentence that is only markdown syntax is skipped.
52
+ export function headlineOf(text, done) {
53
+ const { sentences, rest } = splitSentences(text, { atEnd: true });
54
+ const fragment = plainHeadline(rest);
55
+ if (done && fragment) return fragment;
56
+ const plain = sentences.map(plainHeadline).filter(Boolean);
57
+ if (plain.length) return plain[plain.length - 1];
58
+ return fragment.length > 160 ? fragment : "";
59
+ }
60
+
17
61
  // A tool's tick colour: read-like, edit, exec or other.
18
62
  export function toolKind(name) {
19
63
  if (READ_LIKE.has(name)) return "read";