samagotchi 0.71.0 → 0.73.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/docs/architecture.md +15 -9
  4. data/docs/cli.md +12 -5
  5. data/docs/configuration.md +23 -13
  6. data/docs/hooks.md +5 -3
  7. data/docs/internals/anthropic.md +1 -1
  8. data/docs/internals/background-tasks.md +2 -2
  9. data/docs/internals/engine.md +38 -13
  10. data/docs/internals/llm-context-live.md +1 -1
  11. data/docs/internals/prompt-caching.md +6 -0
  12. data/docs/plugins.md +18 -7
  13. data/docs/sessions.md +5 -3
  14. data/docs/testing.md +10 -1
  15. data/lib/samagotchi/bootstrap/config_writer.rb +2 -3
  16. data/lib/samagotchi/bridge.rb +5 -2
  17. data/lib/samagotchi/broadcast/scope_card.rb +2 -2
  18. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -7
  19. data/lib/samagotchi/bundles/claude-peer/plugin.rb +2 -2
  20. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -6
  21. data/lib/samagotchi/bundles/codex-peer/plugin.rb +2 -2
  22. data/lib/samagotchi/bundles/coordinator/manifest.yml +8 -6
  23. data/lib/samagotchi/bundles/coordinator/plugin.rb +10 -0
  24. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +2 -1
  25. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  27. data/lib/samagotchi/config.rb +7 -25
  28. data/lib/samagotchi/context_note.rb +2 -6
  29. data/lib/samagotchi/effective_model.rb +1 -1
  30. data/lib/samagotchi/engine.rb +91 -213
  31. data/lib/samagotchi/engine_peers.rb +2 -0
  32. data/lib/samagotchi/event_routes.rb +83 -0
  33. data/lib/samagotchi/hook_routes.rb +16 -37
  34. data/lib/samagotchi/host_config.rb +1 -1
  35. data/lib/samagotchi/host_keys.rb +0 -2
  36. data/lib/samagotchi/paths.rb +13 -0
  37. data/lib/samagotchi/plugin/api.rb +11 -5
  38. data/lib/samagotchi/plugin/peer.rb +5 -8
  39. data/lib/samagotchi/prompt_files.rb +2 -4
  40. data/lib/samagotchi/session.rb +7 -1
  41. data/lib/samagotchi/session_chain.rb +24 -0
  42. data/lib/samagotchi/session_continue.rb +3 -1
  43. data/lib/samagotchi/session_manager.rb +6 -16
  44. data/lib/samagotchi/session_model_settings.rb +227 -0
  45. data/lib/samagotchi/session_setup.rb +2 -1
  46. data/lib/samagotchi/system_prompt.rb +18 -13
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -14
  48. data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
  49. data/lib/samagotchi/tool_activity.rb +1 -0
  50. data/lib/samagotchi/tool_declarations.rb +4 -0
  51. data/lib/samagotchi/toolkit.rb +55 -24
  52. data/lib/samagotchi/toolkit_binding.rb +7 -5
  53. data/lib/samagotchi/toolkit_command.rb +28 -5
  54. data/lib/samagotchi/tools/builtins.rb +2 -1
  55. data/lib/samagotchi/tools/delegate.rb +32 -6
  56. data/lib/samagotchi/tools/execute.rb +1 -15
  57. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  58. data/lib/samagotchi/tools/peers.rb +5 -2
  59. data/lib/samagotchi/tools/register_reminder.rb +1 -0
  60. data/lib/samagotchi/tools/registry.rb +1 -1
  61. data/lib/samagotchi/tools/task_runtime.rb +69 -6
  62. data/lib/samagotchi/tools/tool_index.rb +15 -4
  63. data/lib/samagotchi/turn_note.rb +4 -1
  64. data/lib/samagotchi/version.rb +1 -1
  65. data/lib/samagotchi/web/app.rb +40 -13
  66. data/lib/samagotchi/web/public/app.js +53 -28
  67. data/lib/samagotchi/web/public/bubbles.js +5 -3
  68. data/lib/samagotchi/web/public/cards_place.js +6 -10
  69. data/lib/samagotchi/web/public/chains.js +22 -2
  70. data/lib/samagotchi/web/public/chip_popover.js +3 -1
  71. data/lib/samagotchi/web/public/composer.js +13 -6
  72. data/lib/samagotchi/web/public/context_chips.js +25 -12
  73. data/lib/samagotchi/web/public/data.js +14 -1
  74. data/lib/samagotchi/web/public/format.js +5 -0
  75. data/lib/samagotchi/web/public/index.html +39 -2
  76. data/lib/samagotchi/web/public/notice_links.js +55 -0
  77. data/lib/samagotchi/web/public/question_cards.js +33 -1
  78. data/lib/samagotchi/web/public/sessions_list.js +2 -3
  79. data/lib/samagotchi/web/public/stage_frame.js +1 -1
  80. data/lib/samagotchi/web/public/stage_slots.js +35 -3
  81. data/lib/samagotchi/web/public/stage_view.js +17 -6
  82. data/lib/samagotchi/web/public/stream_handlers.js +6 -3
  83. data/lib/samagotchi/web/public/timing.js +1 -1
  84. data/lib/samagotchi/web/public/toolkit_chip.js +3 -1
  85. data/lib/samagotchi/web/public/turn_view.js +3 -2
  86. data/lib/samagotchi/web/session_hub.rb +8 -0
  87. metadata +4 -1
@@ -198,8 +198,11 @@ module Samagotchi
198
198
 
199
199
  # The hidden nudge before a retry of an empty answer (EmptyAnswerRetry):
200
200
  # the model never sees its empty generation, so it is told what happened.
201
+ # It asks to go on with the task, like cut_retry: "answer now" made a
202
+ # mid-task model stop and report before its last steps.
201
203
  def empty_retry
202
- retry_nudge(message("your last reply had no visible answer. Answer the user's last message now, briefly."))
204
+ retry_nudge(message("your last reply had no visible answer. Continue the task with your next tool call, " \
205
+ "or answer if it is done."))
203
206
  end
204
207
 
205
208
  # The hidden nudge before a retry of a generation a plugin cut
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.71.0"
4
+ VERSION = "0.73.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -46,6 +46,7 @@ require_relative "editor"
46
46
  require_relative "message_parts"
47
47
  require_relative "session_summary"
48
48
  require_relative "../log"
49
+ require_relative "../paths"
49
50
  require_relative "../redacted_inspect"
50
51
 
51
52
  module Samagotchi
@@ -65,6 +66,9 @@ module Samagotchi
65
66
  BRIDGE_WAIT_TIMEOUT = 10.0
66
67
  # An uploaded image may be this big before it is downscaled.
67
68
  MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024
69
+ # A POST /api/render body may be this big: a decision message is a few
70
+ # KB, and the cap bounds the work a page can ask for.
71
+ MAX_RENDER_BYTES = 64 * 1024
68
72
  SESSION_ID_RE = /\A[0-9A-Za-z-]{1,64}\z/
69
73
  IMAGE_NAME_RE = /\A[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z/
70
74
  IMAGE_TYPES = { "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif",
@@ -157,6 +161,7 @@ module Samagotchi
157
161
  ["GET", %r{\A/api/models\z}, :handle_models],
158
162
  ["GET", %r{\A/api/history\z}, :handle_history],
159
163
  ["GET", %r{\A/api/events\z}, :handle_events],
164
+ ["POST", %r{\A/api/render\z}, :handle_render],
160
165
  ["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
161
166
  ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
162
167
  ["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
@@ -678,8 +683,9 @@ module Samagotchi
678
683
  # refused beside it (a create takes no reasoning_history). It runs on
679
684
  # model (or model_name) when given, else on the previous link's: one
680
685
  # whose host is gone falls back to the default, and the 201's
681
- # model_warning (null when none) says so. A model it can't start on is
682
- # 400 invalid_model, nothing archived.
686
+ # model_warning (null when none) says so. Its opener is the text the
687
+ # page prefills (SessionChain.opener_for; null: the page's constant).
688
+ # A model it can't start on is 400 invalid_model, nothing archived.
683
689
  # 409 continued (with next_id, the link that continues it already: the
684
690
  # page opens that one), open_children (ids), folder_gone, busy, scratch
685
691
  # or owned_by_tui.
@@ -706,7 +712,8 @@ module Samagotchi
706
712
  [session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
707
713
  record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
708
714
  json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved,
709
- model_warning: session.model_warning))
715
+ model_warning: session.model_warning,
716
+ opener: session.opener))
710
717
  end
711
718
 
712
719
  # SessionManager.continue_session, its refusals as responses: [session,
@@ -909,7 +916,14 @@ module Samagotchi
909
916
  state = nil unless state.is_a?(Hash)
910
917
  if state
911
918
  status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
912
- messages = last_assistant_for_display([tail["answer"]].compact)
919
+ # No session file read here: the folder the answer's file refs must
920
+ # stay under is the worker's (its tail's working_directory), else the
921
+ # hub's session list entry's (a worker from before the field, or read
922
+ # through its snapshot); nil then without a hub or before it saw the
923
+ # session: refs aren't checked.
924
+ cwd = tail["working_directory"]
925
+ cwd = @hub&.working_directory(id) unless cwd.is_a?(String) && !cwd.empty?
926
+ messages = last_assistant_for_display([tail["answer"]].compact, cwd: cwd)
913
927
  else
914
928
  session = @session_class.load(id, state_dir: default_state_dir)
915
929
  status = displayed_status(session)
@@ -949,9 +963,9 @@ module Samagotchi
949
963
  end
950
964
 
951
965
  # The worker's light read (Bridge GET tail): {"session_state_snapshot",
952
- # "answer", "cards", "event_seq", "event_id"}. A worker from before the
953
- # route answers 404 not_found: its GET snapshot, in the same shape (the
954
- # answer by AnswerTail). Anything else (no worker, a refused connect, a
966
+ # "working_directory", "answer", "cards", "event_seq", "event_id"}. A
967
+ # worker from before the route answers 404 not_found: its GET snapshot,
968
+ # in the same shape (the answer by AnswerTail; no working_directory). Anything else (no worker, a refused connect, a
955
969
  # 500, a timeout) is nil: the caller reads the disk, with no second
956
970
  # try at a worker that failed once.
957
971
  # @return [Hash, nil]
@@ -998,6 +1012,24 @@ module Samagotchi
998
1012
  html || "<pre>#{CGI.escapeHTML(body)}</pre>"
999
1013
  end
1000
1014
 
1015
+ # POST /api/render {text}: the page's markdown read for a text it
1016
+ # already shows (the stage's asking text, a resolved question card's
1017
+ # reply), session-free. {html: nil} when markdown is off or
1018
+ # commonmarker is missing: the page keeps the raw text.
1019
+ def handle_render(req)
1020
+ too_large = error_response(413, "too_large", "a text to render may be up to #{MAX_RENDER_BYTES / 1024} KB")
1021
+ return too_large if req.content_length.to_i > MAX_RENDER_BYTES
1022
+
1023
+ raw = request_body(req, MAX_RENDER_BYTES + 1).to_s
1024
+ return too_large if raw.bytesize > MAX_RENDER_BYTES
1025
+
1026
+ body = parse_json(raw)
1027
+ return error_response(400, "invalid_json", "give {text}") unless body.is_a?(Hash) && body["text"].is_a?(String)
1028
+
1029
+ html = @markdown_renderer.render(body["text"]) if @markdown_renderer.available?
1030
+ json_response(200, { html: html })
1031
+ end
1032
+
1001
1033
  def handle_question_answer(req, id)
1002
1034
  session = @session_class.load(id, state_dir: default_state_dir) rescue nil
1003
1035
  return error_response(404, "not_found", "Session not found: #{id}") unless session
@@ -1998,12 +2030,7 @@ module Samagotchi
1998
2030
  home_label(File.expand_path(default_state_dir))
1999
2031
  end
2000
2032
 
2001
- def home_label(dir)
2002
- home = Dir.home
2003
- return "~" if dir == home
2004
-
2005
- dir.start_with?("#{home}/") ? "~#{dir.delete_prefix(home)}" : dir
2006
- end
2033
+ def home_label(dir) = Paths.tilde(dir)
2007
2034
 
2008
2035
  # <body> data for the page: where sessions are stored, where an
2009
2036
  # all-view new chat starts (the server's cwd), and, when ?dir names a
@@ -20,6 +20,7 @@ import {
20
20
  getContext,
21
21
  detachContext,
22
22
  addContext,
23
+ renderText,
23
24
  } from "./data.js";
24
25
  import { createContextChips } from "./context_chips.js";
25
26
  import {
@@ -38,13 +39,14 @@ import { MODEL_KEY } from "./model_pick.js";
38
39
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
39
40
  import { createModelPicker } from "./model_picker.js";
40
41
  import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
41
- import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, withLiveStatus, withoutWorker } from "./format.js";
42
+ import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
42
43
  import { createTurnView, turnHistoryNodes } from "./turn_view.js";
43
44
  import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
44
45
  import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
45
46
  import { createCardsPlace } from "./cards_place.js";
46
47
  import { createStageView, FOLD_MS } from "./stage_view.js";
47
- import { CONTINUE_OPENER, chainNeighbours, isOpener, continueRefusal, continuedText, freshLink, openChildrenTemplate } from "./chains.js";
48
+ import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
49
+ import { chainNeighbours, continueRefusal, continuedText, freshLink, openChildrenTemplate, openerText, openTitle } from "./chains.js";
48
50
  import { createTaskStopper } from "./task_stop.js";
49
51
  import { appendQuote } from "./annotations.js";
50
52
  import { parsePresets } from "./annotate_presets.js";
@@ -230,8 +232,25 @@ const viewArgs = {
230
232
  taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
231
233
  };
232
234
  const turnDom = view === "stage"
233
- ? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
235
+ ? createStageView({
236
+ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl,
237
+ renderMarkdown, linkRefs: linkRefsIn,
238
+ })
234
239
  : createTurnView(viewArgs);
240
+ // A text the page already shows as its markdown HTML (POST /api/render,
241
+ // sanitised there), or null: markdown off here (no request), or a failed
242
+ // read (the raw text stays).
243
+ function renderMarkdown(text) {
244
+ if (pageCapabilities.display !== true) return Promise.resolve(null);
245
+ return renderText(text)
246
+ .then((d) => (typeof d?.html === "string" && d.html ? d.html : null))
247
+ .catch(() => null);
248
+ }
249
+ // The `path:line` refs in one rendered text (the stage's asking slot, a
250
+ // kept reply), as linkTextRefs links the answers'.
251
+ function linkRefsIn(el) {
252
+ linkRefs(el, touchedFiles(turnRoots()), current.dir);
253
+ }
235
254
  // Where a running turn's rows can be: the history, and the stage while it
236
255
  // holds a turn (lookups by id, copy, annotate, card actions).
237
256
  function turnRoots() {
@@ -292,6 +311,8 @@ const questionCards = createQuestionCards({
292
311
  scrollToEnd: () => { historyEl.scrollTop = historyEl.scrollHeight; },
293
312
  removeHintIfEmpty: () => removeHintIfEmpty(),
294
313
  askingText: () => turnDom.askingText(),
314
+ renderMarkdown,
315
+ linkRefs: linkRefsIn,
295
316
  });
296
317
  // The history's bubbles (bubbles.js): output, end lines, every client's
297
318
  // prompts, the recap, warnings and notices, command and note bubbles.
@@ -313,8 +334,6 @@ const {
313
334
  // The plugin cards and a snapshot's notices in the history (cards_place.js).
314
335
  const cards = createCardsPlace({
315
336
  historyEl,
316
- appendToHistory: (el) => appendToHistory(el),
317
- isNearBottom: () => isNearBottom(),
318
337
  turnDom,
319
338
  findInTurnRoots: (selector) => findInTurnRoots(selector),
320
339
  selected: () => selected,
@@ -1063,7 +1082,7 @@ async function restartWorker() {
1063
1082
  const result = await restartSession(id);
1064
1083
  showToast(result.version ? `Restarted on chi ${result.version}` : "Restarting: the new worker is starting");
1065
1084
  } catch (e) {
1066
- showToast(`Can't restart: ${String(e.message).replace(/ \(\d+\)$/, "")}`, null, { sticky: true });
1085
+ showToast(cantText("restart", e), null, { sticky: true });
1067
1086
  } finally {
1068
1087
  restartingIds.delete(id);
1069
1088
  updateInfoBar();
@@ -1108,12 +1127,12 @@ function updateInfoBar() {
1108
1127
 
1109
1128
  // The open session's title, once on screen (session_title.js): its strip
1110
1129
  // card says it when the strip is shown and has one; else the top bar. The
1111
- // browser tab names it too. It follows the card's: a chain link opened with
1112
- // the opener reads its chain's first link's title (cardTitle).
1130
+ // browser tab names it too. It follows the card's (openTitle): a chain link
1131
+ // reads its card's title (cardTitle), whatever opener it was sent.
1113
1132
  function updateSessionTitle() {
1114
1133
  const shown = selected ? lists.sessionsById().get(selected) : null;
1115
1134
  const first = current.firstPreview || "";
1116
- const raw = !selected ? "" : (shown && isOpener(first) ? lists.cardTitle(shown) : first);
1135
+ const raw = !selected ? "" : openTitle({ shown, first, cardTitle: (s) => lists.cardTitle(s) });
1117
1136
  const title = raw === "\u2014" ? "" : raw; // "—": no first message yet
1118
1137
  const text = topBarTitle({
1119
1138
  title,
@@ -1482,6 +1501,8 @@ const composerCommands = createComposerCommands({
1482
1501
  followWokenWorker: () => followWokenWorker(),
1483
1502
  refreshHistory: () => refreshHistory(),
1484
1503
  failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
1504
+ // As handleSendTurn says a turn's failure after a switch.
1505
+ failedElsewhere: (line, text) => alert(`${line}: ${text}`),
1485
1506
  ownedByTui: () => {
1486
1507
  current.owner = "tui";
1487
1508
  updateComposerMode();
@@ -1742,7 +1763,6 @@ const centerEl = $("#center");
1742
1763
  // A leaving stage (the hand-off's slide) no longer counts: the reserve eases
1743
1764
  // down over the slide (index.html, #center.reserve-easing).
1744
1765
  const stageEl = view === "stage" ? turnDom.root : null;
1745
- const STAGE_GAP = 10;
1746
1766
  function dockReserve() {
1747
1767
  const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
1748
1768
  return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
@@ -1760,7 +1780,7 @@ function updateReserve({ fromStage = false } = {}) {
1760
1780
  const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
1761
1781
  if (changed) centerEl.style.setProperty("--dock-h", reserve);
1762
1782
  if (changed && follow) {
1763
- if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: matchMedia("(prefers-reduced-motion: reduce)").matches });
1783
+ if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: reducedMotion() });
1764
1784
  else if (historyEl.dataset.following === undefined) historyEl.scrollTop = historyEl.scrollHeight;
1765
1785
  }
1766
1786
  placeToast();
@@ -1864,12 +1884,11 @@ $("#infoStopBtn").addEventListener("click", async () => {
1864
1884
  stoppingId = id;
1865
1885
  try {
1866
1886
  await stopSession(id);
1867
- if (id === selected) {
1868
- // The worker is gone: don't wait for its stream to notice.
1869
- streamControl?.close();
1870
- current.status = "stopped";
1871
- workerGone(id);
1872
- }
1887
+ if (id === selected) stoppedWorker(id);
1888
+ } catch (e) {
1889
+ // A refusal (a terminal holds it) or a worker that won't stop: said, as
1890
+ // a failed restart is.
1891
+ showToast(cantText(`stop ${id.slice(0, 8)}`, e), null, { sticky: true });
1873
1892
  } finally {
1874
1893
  stoppingId = null;
1875
1894
  }
@@ -1889,7 +1908,8 @@ $("#infoArchiveBtn").addEventListener("click", () => {
1889
1908
  // ── Continue (session chains) ───────────────────────────────────────────────
1890
1909
  // Continue → starts the next link of the open session's chain (the server
1891
1910
  // waits up to 30 s for its recap, archives it, starts the new one with the
1892
- // recap as a note) and opens it with the opener in the composer, not sent.
1911
+ // recap as a note) and opens it with the opener in the composer, not sent
1912
+ // (fillOpener: the previous link's own first message when it continues one).
1893
1913
  // A link continued already opens that next link; its open delegates move
1894
1914
  // to the new link (the toast counts them); a refusal for open delegates
1895
1915
  // that can't move names them, each a link.
@@ -1915,7 +1935,7 @@ async function continueSelected() {
1915
1935
  return;
1916
1936
  }
1917
1937
  await select(next);
1918
- if (selected === next) fillOpener();
1938
+ if (selected === next) fillOpener(created);
1919
1939
  showToast(continuedText(id, created), null, { sticky });
1920
1940
  } catch (e) {
1921
1941
  const refusal = continueRefusal(e, allSessions);
@@ -1933,10 +1953,12 @@ async function continueSelected() {
1933
1953
  }
1934
1954
  }
1935
1955
 
1936
- // The new link's composer: the opener, unless a draft is there (it stays).
1937
- function fillOpener() {
1956
+ // The new link's composer: the opener (openerText: the previous link's own,
1957
+ // else the constant), unless a draft is there (it stays). +created+: the
1958
+ // continue's 201.
1959
+ function fillOpener(created) {
1938
1960
  if (promptEl.value.trim() || chips.length) return;
1939
- promptEl.value = CONTINUE_OPENER;
1961
+ promptEl.value = openerText(created);
1940
1962
  fitPrompt();
1941
1963
  promptEl.focus();
1942
1964
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
@@ -1963,6 +1985,14 @@ function markArchived(ids, archived) {
1963
1985
  // and a batch share. The ids in the answer flip in the list at once. When
1964
1986
  // the open session's worker was stopped (it, or a delegate of the archived
1965
1987
  // one), its stream ending is a stop, not a lost worker.
1988
+ // The open session's worker was stopped from here (the stop button, an
1989
+ // archive): don't wait for its stream to notice.
1990
+ function stoppedWorker(id) {
1991
+ streamControl?.close();
1992
+ current.status = "stopped";
1993
+ workerGone(id);
1994
+ }
1995
+
1966
1996
  // @return {ok: true, result} | {ok: false, error}
1967
1997
  async function archiveOne(id, archive) {
1968
1998
  // A live worker in the tree is stopped by the archive: the open
@@ -1972,12 +2002,7 @@ async function archiveOne(id, archive) {
1972
2002
  try {
1973
2003
  const result = archive ? await archiveSession(id) : await unarchiveSession(id);
1974
2004
  markArchived(archive ? result.archived : result.unarchived, archive);
1975
- if (mine && mine === selected && (result.stopped || []).includes(mine)) {
1976
- // Its worker is gone: don't wait for its stream to notice.
1977
- streamControl?.close();
1978
- current.status = "stopped";
1979
- workerGone(mine);
1980
- }
2005
+ if (mine && mine === selected && (result.stopped || []).includes(mine)) stoppedWorker(mine);
1981
2006
  return { ok: true, result };
1982
2007
  } catch (error) {
1983
2008
  return { ok: false, error };
@@ -9,6 +9,7 @@ import { BADGES, elementOf, noteBubbleTemplate, stateBadgeTemplate, steerRowTemp
9
9
  import { thumbsTemplate } from "./images.js";
10
10
  import { appendAboveLiveTiming, STOPPED_LINE_CLASS } from "./timing.js";
11
11
  import { keepFollowing } from "./scroll.js";
12
+ import { linkedLine } from "./notice_links.js";
12
13
  import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
13
14
  import { drawCommandBubble } from "./commands.js";
14
15
 
@@ -53,10 +54,11 @@ export function guardrailWarningTemplate(text, label = "guardrails") {
53
54
 
54
55
  // A hook's line to the user (event[:notify]), in the warning colour for
55
56
  // level warn; +cls+ adds a kind's class (llm_context_edited's). +bubble+:
56
- // one of the history's own, else a row of a turn's step.
57
+ // one of the history's own, else a row of a turn's step. Its http(s) URLs
58
+ // are links (notice_links.js).
57
59
  export function hookNoticeTemplate({ line, level = null, cls = null, title = null }, { bubble = true } = {}) {
58
60
  const klass = (bubble ? "bubble hook-notice" : "hook-notice") + (level === "warn" ? " warn" : "") + (cls ? ` ${cls}` : "");
59
- return html`<div class=${klass} title=${title || nothing}>${line}</div>`;
61
+ return html`<div class=${klass} title=${title || nothing}>${linkedLine(line)}</div>`;
60
62
  }
61
63
 
62
64
  // A hook notice's line: its own (a snapshot's notice), else "<hook>: text".
@@ -285,7 +287,7 @@ export function createBubbles({
285
287
  }
286
288
 
287
289
  return {
288
- removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
290
+ removeHintIfEmpty, placeFollowing, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
289
291
  markRecapStale, showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
290
292
  };
291
293
  }
@@ -32,22 +32,20 @@ export function turnAnswerEl(turnEls) {
32
32
  }
33
33
 
34
34
  // @param historyEl the history
35
- // @param appendToHistory (el) puts +el+ at the history's end (or the stage's)
36
- // @param isNearBottom () => whether the history follows its end
37
35
  // @param turnDom the running turn's view (its card(el) takes a turn's card)
38
36
  // @param findInTurnRoots (selector) => the matching elements in the history
39
37
  // and the stage
40
38
  // @param selected () => the open session's id
41
39
  // @param clientId this tab's id in the session's events
42
40
  // @param questionCards the history's question cards (question_cards.js)
43
- // @param bubbles {removeHintIfEmpty, showGuardrailWarning, showHookNotice}
41
+ // @param bubbles {removeHintIfEmpty, placeFollowing, showGuardrailWarning, showHookNotice}
44
42
  // (bubbles.js createBubbles)
45
43
  // @param followWokenWorker () follows the worker a card's command woke
46
44
  // @param api {getSession, sendCommand} (data.js)
47
45
  // @return {cardElement(id), upsertCard(card, {before}), placeSnapshotCards(entries),
48
46
  // refreshCardBodies(), onCardAction(event)}
49
47
  export function createCardsPlace({
50
- historyEl, appendToHistory, isNearBottom, turnDom, findInTurnRoots, selected, clientId, questionCards, bubbles,
48
+ historyEl, turnDom, findInTurnRoots, selected, clientId, questionCards, bubbles,
51
49
  followWokenWorker, api = { getSession, sendCommand },
52
50
  }) {
53
51
  function cardElement(id) {
@@ -73,10 +71,7 @@ export function createCardsPlace({
73
71
  const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
74
72
  const asks = (card.actions || []).some((a) => a && a.command);
75
73
  if (card.in_turn && !before && !asks && turnDom.card(el)) return;
76
- const follow = isNearBottom();
77
- if (before) historyEl.insertBefore(el, before);
78
- else appendToHistory(el);
79
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
74
+ bubbles.placeFollowing(el, before);
80
75
  }
81
76
 
82
77
  // A snapshot's cards and notices, each above the first turn after it; a
@@ -170,8 +165,9 @@ export function createCardsPlace({
170
165
  button.disabled = true;
171
166
  api.sendCommand(id, button.dataset.command, { clientId, card: true })
172
167
  .then(() => {
173
- // The command woke the worker: follow its stream if none is open.
174
- followWokenWorker();
168
+ // The command woke the worker: follow its stream if none is open
169
+ // (and the page still shows that session).
170
+ if (selected() === id) followWokenWorker();
175
171
  })
176
172
  .catch((error) => {
177
173
  err.textContent = error.message;
@@ -13,9 +13,18 @@ import { archivedBadge, rowBadgesTemplate, rowDotTemplate } from "./families_vie
13
13
  import { html, nothing } from "./vendor/lit-html.js";
14
14
 
15
15
  // The opener a new link's composer is filled with (never sent: the user
16
- // edits or sends it). The same for every chain.
16
+ // edits or sends it) when the continue brings none of its own.
17
17
  export const CONTINUE_OPENER = "Continue where we left off.";
18
18
 
19
+ // What a new link's composer is filled with: the continue's own opener
20
+ // (+created+, the 201: the previous link's first typed message, when that
21
+ // link continues one), else CONTINUE_OPENER. So whatever the user sent on
22
+ // the last link comes back on the next one.
23
+ export function openerText(created) {
24
+ const own = created?.opener;
25
+ return typeof own === "string" && own.trim() ? own : CONTINUE_OPENER;
26
+ }
27
+
19
28
  // Whether +text+ (a title or a first message) is the opener and nothing
20
29
  // else, trimmed: "Continue where we left off. Then fix X" is a title of its own.
21
30
  export function isOpener(text) {
@@ -41,6 +50,17 @@ export function chainTitle(s, byId) {
41
50
  return isOpener(at.first_preview || at.last_prompt) ? own : previewForCard(at);
42
51
  }
43
52
 
53
+ // The open session's title (app.js updateSessionTitle: the top bar and the
54
+ // tab) from +first+, its first message: it follows its card. A chain link
55
+ // (+shown+, its summary, has continues) reads +cardTitle+(shown) whatever
56
+ // its first message: a web link inherits its chain's title, and its first
57
+ // message is an opener (the constant or the user's own). Else +first+,
58
+ // unless it is the opener. +shown+ null: not listed yet, +first+.
59
+ export function openTitle({ shown, first, cardTitle }) {
60
+ if (shown && (shown.continues || isOpener(first))) return cardTitle(shown);
61
+ return first;
62
+ }
63
+
44
64
  const createdMs = (s) => {
45
65
  const ms = Date.parse(s?.created_at || "");
46
66
  return Number.isNaN(ms) ? 0 : ms;
@@ -206,7 +226,7 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
206
226
  export function continueRefusal(error, sessions = []) {
207
227
  const body = error?.body || {};
208
228
  if (error?.code === "continued" && body.next_id) return { kind: "open", id: body.next_id };
209
- const text = String(error?.message || "refused").replace(/\s*\(\d{3}\)$/, "");
229
+ const text = String(error?.message || "refused");
210
230
  if (error?.code === "open_children" && Array.isArray(body.ids) && body.ids.length) {
211
231
  const byId = new Map(sessions.map((s) => [s.id, s]));
212
232
  const children = body.ids.map((id) => {
@@ -84,10 +84,12 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
84
84
  show(data, false);
85
85
  }
86
86
 
87
+ // The click goes on to the document: another chip's popover (or the
88
+ // context popover) open meanwhile closes there. This one's own closer
89
+ // leaves its chip's click alone.
87
90
  el.addEventListener("click", (e) => {
88
91
  const anchor = e.target.closest(anchorSel);
89
92
  if (!anchor) return;
90
- e.stopPropagation();
91
93
  if (isOpen()) close();
92
94
  else open(anchor);
93
95
  });
@@ -60,20 +60,26 @@ export function routeSessionLine(prompt, { commands = [], images = 0, delivery =
60
60
  // followWokenWorker() the command woke the worker: follow its stream
61
61
  // refreshHistory()
62
62
  // failed(line, text) a failed command's bubble
63
+ // failedElsewhere(line, text) a command to a session no longer open failed
63
64
  // ownedByTui() a plain terminal holds the session
64
65
  // composer: {busy(), clear(), done()} the composer's send button and text
65
66
  export function createComposerCommands(deps) {
66
- const { session, clientId, sendCommand, followWokenWorker, refreshHistory, failed, ownedByTui, composer } = deps;
67
+ const { session, clientId, sendCommand, followWokenWorker, refreshHistory, failed, failedElsewhere, ownedByTui,
68
+ composer } = deps;
67
69
 
68
- // A line typed in the composer (routeComposerLine's {command}).
70
+ // The page may open another session while a command is in flight: what
71
+ // follows is about the session it went to (+id+), so the open one's
72
+ // worker, owner and history are left alone then.
69
73
  async function send(line) {
74
+ const id = session();
70
75
  composer.busy();
71
76
  try {
72
- await sendCommand(session(), line, { clientId });
77
+ await sendCommand(id, line, { clientId });
73
78
  composer.clear();
74
79
  refreshHistory();
75
- followWokenWorker();
80
+ if (session() === id) followWokenWorker();
76
81
  } catch (e) {
82
+ if (session() !== id) return failedElsewhere(line, e.message);
77
83
  if (e.code === "owned_by_tui") {
78
84
  ownedByTui();
79
85
  return;
@@ -93,10 +99,11 @@ export function createComposerCommands(deps) {
93
99
  if (!id) return false;
94
100
  try {
95
101
  await sendCommand(id, line, { clientId });
96
- followWokenWorker();
102
+ if (session() === id) followWokenWorker();
97
103
  return true;
98
104
  } catch (e) {
99
- failed(line, e.message);
105
+ if (session() === id) failed(line, e.message);
106
+ else failedElsewhere(line, e.message);
100
107
  return false;
101
108
  }
102
109
  }
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { html, nothing, render } from "./vendor/lit-html.js";
11
11
  import { keepFocus } from "./keep_focus.js";
12
+ import { popoverPlace } from "./chip_popover.js";
13
+ import { cantText } from "./format.js";
12
14
 
13
15
  // "now", "3m", "2h", "4d" since an ISO time; "" for none.
14
16
  export function ageText(iso, now = Date.now()) {
@@ -177,13 +179,24 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
177
179
  render(popoverTemplate(row, { text }), pop);
178
180
  }
179
181
 
180
- // Above the chip, inside the window with a 16px gutter.
182
+ // At the chip as the session bar's chips place theirs (popoverPlace):
183
+ // above it, or below when the room above is short; capped to the room
184
+ // (it scrolls), inside the window with a 16px gutter.
181
185
  function place(chip) {
182
- const rect = chip.getBoundingClientRect();
183
186
  const width = Math.min(380, win.innerWidth - 32);
184
187
  pop.style.width = `${width}px`;
185
- pop.style.left = `${Math.max(16, Math.min(rect.left, win.innerWidth - width - 16))}px`;
186
- pop.style.bottom = `${Math.max(16, win.innerHeight - rect.top + 8)}px`;
188
+ pop.style.maxHeight = "";
189
+ const at = popoverPlace(chip.getBoundingClientRect(), { width, height: pop.offsetHeight,
190
+ innerWidth: win.innerWidth, innerHeight: win.innerHeight });
191
+ pop.style.left = `${at.left}px`;
192
+ pop.style.top = at.top === undefined ? "" : `${at.top}px`;
193
+ pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
194
+ if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
195
+ }
196
+
197
+ function placeAtOpenChip() {
198
+ const chip = openName && el.querySelector(".ctx-chip.open");
199
+ if (chip) place(chip);
187
200
  }
188
201
 
189
202
  function open(name, chip) {
@@ -231,7 +244,7 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
231
244
  } catch (e) {
232
245
  if (id !== sessionId || openName !== ADD) return;
233
246
  // The same form again: what the user typed stays; the URL gets focus.
234
- drawAdd({ busy: false, error: String(e.message).replace(/ \(\d+\)$/, "") });
247
+ drawAdd({ busy: false, error: e.message });
235
248
  pop.querySelector('input[name="url"]')?.focus();
236
249
  }
237
250
  }
@@ -255,8 +268,10 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
255
268
  if (!row) return;
256
269
  text = data.text || "";
257
270
  fill(row);
271
+ // The text makes it taller: placed again.
272
+ placeAtOpenChip();
258
273
  } catch (e) {
259
- toast(`Can't read ${name}: ${String(e.message).replace(/ \(\d+\)$/, "")}`);
274
+ toast(cantText(`read ${name}`, e));
260
275
  }
261
276
  }
262
277
  async function detach() {
@@ -271,14 +286,15 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
271
286
  close();
272
287
  await refresh();
273
288
  } catch (e) {
274
- toast(`Can't detach ${row.name}: ${String(e.message).replace(/ \(\d+\)$/, "")}`);
289
+ toast(cantText(`detach ${row.name}`, e));
275
290
  }
276
291
  }
277
292
 
278
293
  el.addEventListener("click", (e) => {
279
294
  const chip = e.target.closest(".ctx-chip");
280
295
  if (!chip) return;
281
- e.stopPropagation();
296
+ // On to the document: a session-bar chip's popover closes (the close
297
+ // below leaves clicks in +el+ alone).
282
298
  if (chip.dataset.act === "add") {
283
299
  if (openName === ADD) close();
284
300
  else openAdd(chip);
@@ -301,10 +317,7 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
301
317
  doc.addEventListener("keydown", (e) => {
302
318
  if (e.key === "Escape" && openName) close();
303
319
  });
304
- win.addEventListener("resize", () => {
305
- const chip = openName && el.querySelector(".ctx-chip.open");
306
- if (chip) place(chip);
307
- });
320
+ win.addEventListener("resize", placeAtOpenChip);
308
321
  win.addEventListener("focus", () => refresh());
309
322
  // The ages move on by themselves.
310
323
  win.setInterval(() => { if (rows.length) drawChips(); }, 60_000);
@@ -6,7 +6,11 @@ export function api(path, opts = {}) {
6
6
  }).then(async (r) => {
7
7
  const j = await r.json().catch(() => ({}));
8
8
  if (!r.ok) {
9
- const error = new Error(`${j.detail || j.error || r.statusText} (${r.status})`);
9
+ // The server's words as they are: what the page shows the user. The
10
+ // status goes in only when it sent none (a proxy's bare 502, a crash's
11
+ // 500): then it is all there is to go on.
12
+ const words = j.detail || j.error;
13
+ const error = new Error(words || `${r.statusText || "HTTP error"} (${r.status})`);
10
14
  error.status = r.status;
11
15
  // The server's error code ({error: "owned_by_tui", detail}): what
12
16
  // the page matches on, never the message's words.
@@ -116,6 +120,15 @@ export function sendCommand(id, line, { clientId, card = false, ...opts } = {})
116
120
  });
117
121
  }
118
122
 
123
+ // A text's markdown as sanitised HTML ({ html }; null with markdown off).
124
+ export function renderText(text, opts = {}) {
125
+ return api("/api/render", {
126
+ method: "POST",
127
+ body: JSON.stringify({ text }),
128
+ ...opts,
129
+ });
130
+ }
131
+
119
132
  export function cancelTurn(id, opts = {}) {
120
133
  return api(`/api/sessions/${id}/cancel`, {
121
134
  method: "POST",