samagotchi 0.72.0 → 0.74.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +9 -3
  5. data/docs/desktop.md +2 -1
  6. data/docs/guardrails.md +2 -1
  7. data/docs/hooks.md +4 -2
  8. data/docs/internals/background-tasks.md +4 -3
  9. data/docs/plugins.md +9 -2
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +41 -1
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/desktop/macos.rb +5 -0
  15. data/lib/samagotchi/paths.rb +8 -6
  16. data/lib/samagotchi/session.rb +5 -0
  17. data/lib/samagotchi/session_chain.rb +24 -0
  18. data/lib/samagotchi/session_continue.rb +3 -1
  19. data/lib/samagotchi/session_manager.rb +7 -0
  20. data/lib/samagotchi/tool_output_cut.rb +2 -1
  21. data/lib/samagotchi/tools/builtins.rb +17 -4
  22. data/lib/samagotchi/tools/execute.rb +1 -15
  23. data/lib/samagotchi/tools/task_runtime.rb +75 -6
  24. data/lib/samagotchi/version.rb +1 -1
  25. data/lib/samagotchi/web/app.rb +27 -3
  26. data/lib/samagotchi/web/public/app.js +33 -10
  27. data/lib/samagotchi/web/public/bubbles.js +4 -2
  28. data/lib/samagotchi/web/public/chains.js +21 -1
  29. data/lib/samagotchi/web/public/data.js +9 -0
  30. data/lib/samagotchi/web/public/index.html +39 -2
  31. data/lib/samagotchi/web/public/notice_links.js +55 -0
  32. data/lib/samagotchi/web/public/plain_line.js +55 -0
  33. data/lib/samagotchi/web/public/question_cards.js +37 -3
  34. data/lib/samagotchi/web/public/stage_model.js +3 -33
  35. data/lib/samagotchi/web/public/stage_slots.js +35 -3
  36. data/lib/samagotchi/web/public/stage_view.js +9 -4
  37. data/lib/samagotchi/web/public/turn_html.js +3 -2
  38. data/lib/samagotchi/web/public/turn_model.js +7 -4
  39. data/lib/samagotchi/web/public/turn_view.js +3 -2
  40. metadata +3 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "registry"
4
4
  require_relative "../tool_declarations"
5
+ require_relative "../paths"
5
6
  require_relative "execute"
6
7
  require_relative "read"
7
8
  require_relative "write"
@@ -127,6 +128,8 @@ module Samagotchi
127
128
  # level (SAMAGOTCHI_SESSION_REASONING_HISTORY, the same way). A
128
129
  # worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
129
130
  # is unset: it was that run's, not the default of a chi run here.
131
+ # A set XDG_STATE_HOME / XDG_CONFIG_HOME goes in absolute (Paths), so
132
+ # a chi run in another cwd (chi send) reads this chi's dirs.
130
133
  def parent_env(kctx, env: ENV)
131
134
  peers = kctx.peers
132
135
  id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
@@ -136,7 +139,16 @@ module Samagotchi
136
139
  { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
137
140
  SESSION_MODEL_ENV => model.empty? ? nil : model,
138
141
  SESSION_THINKING_ENV => thinking,
139
- SESSION_REASONING_HISTORY_ENV => history }.merge(cli_model_unset(env))
142
+ SESSION_REASONING_HISTORY_ENV => history }.merge(cli_model_unset(env)).merge(xdg_absolute(env))
143
+ end
144
+
145
+ # The XDG dirs set in +env+, made absolute (nothing for an unset one).
146
+ def xdg_absolute(env = ENV)
147
+ set = ->(key) { !env.fetch(key, "").to_s.strip.empty? }
148
+ dirs = {}
149
+ dirs["XDG_STATE_HOME"] = Paths.state_home(env: env) if set.call("XDG_STATE_HOME")
150
+ dirs["XDG_CONFIG_HOME"] = Paths.config_home(env: env) if set.call("XDG_CONFIG_HOME")
151
+ dirs
140
152
  end
141
153
 
142
154
  # The unsets that keep a `chi --model X` worker's default from passing
@@ -151,11 +163,12 @@ module Samagotchi
151
163
  SESSION_THINKING_ENV = "SAMAGOTCHI_SESSION_THINKING"
152
164
  SESSION_REASONING_HISTORY_ENV = "SAMAGOTCHI_SESSION_REASONING_HISTORY"
153
165
 
154
- # task_create's parent_env, less the unsets of a variable the model
155
- # set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
166
+ # task_create's parent_env, less what the model set in its env (a
167
+ # SAMAGOTCHI_DEFAULT_MODEL or an XDG dir it asked for stays; chi's own
168
+ # markers are reserved, the model's env can't set them).
156
169
  def task_marker_env(kctx, env)
157
170
  given = env.is_a?(Hash) ? env.keys.map(&:to_s) : []
158
- parent_env(kctx).reject { |key, value| value.nil? && given.include?(key) }
171
+ parent_env(kctx).reject { |key, _| given.include?(key) }
159
172
  end
160
173
 
161
174
  # Stop flips the turn's controller, seen through the Engine's PeerView
@@ -350,7 +350,7 @@ module Samagotchi
350
350
  return nil if error
351
351
 
352
352
  File.write(File.join(dir, BACK_POINTER), File.expand_path(TaskRuntime.metadata_path(record.id)))
353
- write_missing_exit_code(waiter, record.exit_code_path)
353
+ TaskRuntime.record_exit_status(waiter, record.exit_code_path, task_id: record.id)
354
354
  # Ended between the deadline and the adopt: the record says how.
355
355
  TaskRuntime.get_record(record.id) unless waiter.alive?
356
356
  lines = [handed_over_line(timeout_sec), "task_id: #{record.id}",
@@ -363,20 +363,6 @@ module Samagotchi
363
363
  end
364
364
  private_class_method :hand_over
365
365
 
366
- # A command that set its own EXIT trap replaced the wrapper's, so it
367
- # writes no exit_code: while this chi lives, the waiter's status fills
368
- # it in (a refresh in between may still read "no exit code").
369
- def self.write_missing_exit_code(waiter, path)
370
- Thread.new do
371
- Thread.current.report_on_exception = false
372
- code = waiter.value&.exitstatus
373
- File.write(path, code.to_s) if code && !File.exist?(path) && Dir.exist?(File.dirname(path))
374
- rescue StandardError => e
375
- Log.debug(:turn, "execute_exit_code_failed", error: e.class.name, msg: e.message)
376
- end
377
- end
378
- private_class_method :write_missing_exit_code
379
-
380
366
  # <state dir>/execute: every command's dir (LOGS_DIRNAME).
381
367
  def self.logs_root = File.join(Paths.state_dir, LOGS_DIRNAME)
382
368
 
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "shellwords"
7
7
  require "time"
8
8
  require_relative "../atomic_file"
9
+ require_relative "../log"
9
10
  require_relative "../process_group"
10
11
  require_relative "shell"
11
12
  require_relative "task_record"
@@ -55,7 +56,9 @@ module Samagotchi
55
56
  task_id = generate_task_id
56
57
  task_dir = task_dir_for(task_id)
57
58
  output_path = File.join(task_dir, OUTPUT_FILENAME)
58
- exit_code_path = File.join(task_dir, EXIT_CODE_FILENAME)
59
+ # Absolute: the trap writes it wherever the shell is when it exits
60
+ # (a +cwd+, or a cd in the command). Ruby opens output_path here.
61
+ exit_code_path = File.expand_path(File.join(task_dir, EXIT_CODE_FILENAME))
59
62
 
60
63
  FileUtils.mkdir_p(task_dir)
61
64
 
@@ -72,7 +75,7 @@ module Samagotchi
72
75
  out: output_io,
73
76
  err: output_io
74
77
  )
75
- Process.detach(pid)
78
+ waiter = Process.detach(pid)
76
79
  # Read now, compared before each signal: a pid reused later is not this task.
77
80
  pid_started = ProcessGroup.start_time(pid)
78
81
 
@@ -81,6 +84,7 @@ module Samagotchi
81
84
  output_path: output_path, exit_code_path: exit_code_path)
82
85
 
83
86
  write_record(record)
87
+ record_exit_status(waiter, exit_code_path, task_id: task_id)
84
88
  [record, nil]
85
89
  rescue StandardError => e
86
90
  [nil, "Error: #{e.message}"]
@@ -88,6 +92,66 @@ module Samagotchi
88
92
  output_io&.close
89
93
  end
90
94
 
95
+ # The shell's status from chi's waiter (+waiter+, a Process.detach
96
+ # thread), for a shell whose EXIT trap wrote nothing: one killed by a
97
+ # signal (zsh's sh mode skips the trap on TERM, INT and KILL) gets
98
+ # 128 + the signal, one whose command replaced the trap gets its exit
99
+ # status. Only while this chi runs. A record a refresh finished in
100
+ # between (failed, no exit code) is corrected; a stopped task (its
101
+ # stop flag on disk) is left as task_stop records it.
102
+ # The thread is named EXIT_STATUS_THREAD (a spec joins them before it
103
+ # removes the tasks' folder).
104
+ # @return [Thread]
105
+ def record_exit_status(waiter, path, task_id:)
106
+ # Absolute now: safe against a later chdir.
107
+ record_path = File.expand_path(metadata_path(task_id))
108
+ path = File.expand_path(path)
109
+ Thread.new do
110
+ Thread.current.report_on_exception = false
111
+ Thread.current.name = EXIT_STATUS_THREAD
112
+ code = status_code(waiter.value)
113
+ next if code.nil? || record_at(record_path)&.stop_requested_by
114
+
115
+ File.write(path, code.to_s) if !File.exist?(path) && Dir.exist?(File.dirname(path))
116
+ correct_finished_record(record_path, read_exit_code(path))
117
+ rescue StandardError => e
118
+ Log.debug(:turn, "task_exit_code_failed", id: task_id, error: e.class.name, msg: e.message)
119
+ end
120
+ end
121
+
122
+ # A Process::Status as a shell reports one: its exit status, or
123
+ # 128 + the signal that killed it.
124
+ def status_code(status)
125
+ return nil unless status
126
+
127
+ status.exitstatus || (status.termsig && (128 + status.termsig))
128
+ end
129
+
130
+ # record_exit_status's threads' name.
131
+ EXIT_STATUS_THREAD = "task_exit_status"
132
+
133
+ # Reasons a refresh finishes a record with when it read no exit code.
134
+ MISSING_CODE_REASONS = ["process_ended_without_exit_code", NOT_CHIS_PROCESS].freeze
135
+
136
+ # A record a refresh finished before the exit code was there (failed,
137
+ # exit_code nil, no stop) gets +code+; finished_at is kept.
138
+ def correct_finished_record(record_path, code)
139
+ record = record_at(record_path)
140
+ return unless code && record && !record.running? && record.exit_code.nil? && !record.stop_requested_by
141
+ return unless MISSING_CODE_REASONS.include?(record.stop_reason)
142
+
143
+ status = code.zero? ? "completed" : "failed"
144
+ AtomicFile.write(record_path, record_json(record.with(status: status, exit_code: code, stop_reason: nil)))
145
+ end
146
+
147
+ def record_at(path)
148
+ return nil unless File.file?(path)
149
+
150
+ TaskRecord.from_file(JSON.parse(File.read(path)))
151
+ rescue JSON::ParserError
152
+ nil
153
+ end
154
+
91
155
  # A record for a command chi already runs: an execute still running at
92
156
  # its limit, handed over as a task (Execute::HANDED_OVER), given as a
93
157
  # TaskRecord.running whose +started_at+ is the spawn's. Its logs and
@@ -321,9 +385,11 @@ module Samagotchi
321
385
  FileUtils.mkdir_p(dir)
322
386
 
323
387
  path = File.join(dir, METADATA_FILENAME)
324
- AtomicFile.write(path, JSON.pretty_generate(record.to_file) + "\n")
388
+ AtomicFile.write(path, record_json(record))
325
389
  end
326
390
 
391
+ def record_json(record) = JSON.pretty_generate(record.to_file) + "\n"
392
+
327
393
  def metadata_path(task_id)
328
394
  File.join(task_dir_for(task_id), METADATA_FILENAME)
329
395
  end
@@ -352,9 +418,12 @@ module Samagotchi
352
418
  # exit status to +exit_code_path+. A prefix, not lines after the
353
419
  # command: a trailing "\\" or "|" in the command would swallow those,
354
420
  # and an `exit N` or a `set -e` failure would skip them. A command that
355
- # sets its own EXIT trap replaces this one: no exit_code file then
356
- # (the task ends failed with process_ended_without_exit_code, unless
357
- # its starter's waiter writes the code: Execute's hand-over).
421
+ # sets its own EXIT trap replaces this one, and a shell killed by a
422
+ # signal skips it (zsh's sh mode, on TERM, INT and KILL): no exit_code
423
+ # file from the shell then. The waiter of the chi that started it
424
+ # writes the code instead (record_exit_status: the status, or
425
+ # 128 + the signal); with that chi gone too, the task ends failed with
426
+ # process_ended_without_exit_code.
358
427
  def wrapped_shell_command(command, exit_code_path)
359
428
  write = %(printf "%s" "$?" > #{Shellwords.escape(exit_code_path)})
360
429
  "trap #{Shellwords.escape(write)} EXIT\n#{command}\n"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.72.0"
4
+ VERSION = "0.74.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -66,6 +66,9 @@ module Samagotchi
66
66
  BRIDGE_WAIT_TIMEOUT = 10.0
67
67
  # An uploaded image may be this big before it is downscaled.
68
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
69
72
  SESSION_ID_RE = /\A[0-9A-Za-z-]{1,64}\z/
70
73
  IMAGE_NAME_RE = /\A[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z/
71
74
  IMAGE_TYPES = { "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif",
@@ -158,6 +161,7 @@ module Samagotchi
158
161
  ["GET", %r{\A/api/models\z}, :handle_models],
159
162
  ["GET", %r{\A/api/history\z}, :handle_history],
160
163
  ["GET", %r{\A/api/events\z}, :handle_events],
164
+ ["POST", %r{\A/api/render\z}, :handle_render],
161
165
  ["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
162
166
  ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
163
167
  ["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
@@ -679,8 +683,9 @@ module Samagotchi
679
683
  # refused beside it (a create takes no reasoning_history). It runs on
680
684
  # model (or model_name) when given, else on the previous link's: one
681
685
  # whose host is gone falls back to the default, and the 201's
682
- # model_warning (null when none) says so. A model it can't start on is
683
- # 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.
684
689
  # 409 continued (with next_id, the link that continues it already: the
685
690
  # page opens that one), open_children (ids), folder_gone, busy, scratch
686
691
  # or owned_by_tui.
@@ -707,7 +712,8 @@ module Samagotchi
707
712
  [session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
708
713
  record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
709
714
  json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved,
710
- model_warning: session.model_warning))
715
+ model_warning: session.model_warning,
716
+ opener: session.opener))
711
717
  end
712
718
 
713
719
  # SessionManager.continue_session, its refusals as responses: [session,
@@ -1006,6 +1012,24 @@ module Samagotchi
1006
1012
  html || "<pre>#{CGI.escapeHTML(body)}</pre>"
1007
1013
  end
1008
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
+
1009
1033
  def handle_question_answer(req, id)
1010
1034
  session = @session_class.load(id, state_dir: default_state_dir) rescue nil
1011
1035
  return error_response(404, "not_found", "Session not found: #{id}") unless session
@@ -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 {
@@ -45,7 +46,7 @@ import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "
45
46
  import { createCardsPlace } from "./cards_place.js";
46
47
  import { createStageView, FOLD_MS } from "./stage_view.js";
47
48
  import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
48
- import { CONTINUE_OPENER, chainNeighbours, isOpener, continueRefusal, continuedText, freshLink, openChildrenTemplate } from "./chains.js";
49
+ import { chainNeighbours, continueRefusal, continuedText, freshLink, openChildrenTemplate, openerText, openTitle } from "./chains.js";
49
50
  import { createTaskStopper } from "./task_stop.js";
50
51
  import { appendQuote } from "./annotations.js";
51
52
  import { parsePresets } from "./annotate_presets.js";
@@ -231,8 +232,25 @@ const viewArgs = {
231
232
  taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
232
233
  };
233
234
  const turnDom = view === "stage"
234
- ? 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
+ })
235
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
+ }
236
254
  // Where a running turn's rows can be: the history, and the stage while it
237
255
  // holds a turn (lookups by id, copy, annotate, card actions).
238
256
  function turnRoots() {
@@ -293,6 +311,8 @@ const questionCards = createQuestionCards({
293
311
  scrollToEnd: () => { historyEl.scrollTop = historyEl.scrollHeight; },
294
312
  removeHintIfEmpty: () => removeHintIfEmpty(),
295
313
  askingText: () => turnDom.askingText(),
314
+ renderMarkdown,
315
+ linkRefs: linkRefsIn,
296
316
  });
297
317
  // The history's bubbles (bubbles.js): output, end lines, every client's
298
318
  // prompts, the recap, warnings and notices, command and note bubbles.
@@ -1107,12 +1127,12 @@ function updateInfoBar() {
1107
1127
 
1108
1128
  // The open session's title, once on screen (session_title.js): its strip
1109
1129
  // card says it when the strip is shown and has one; else the top bar. The
1110
- // browser tab names it too. It follows the card's: a chain link opened with
1111
- // 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.
1112
1132
  function updateSessionTitle() {
1113
1133
  const shown = selected ? lists.sessionsById().get(selected) : null;
1114
1134
  const first = current.firstPreview || "";
1115
- const raw = !selected ? "" : (shown && isOpener(first) ? lists.cardTitle(shown) : first);
1135
+ const raw = !selected ? "" : openTitle({ shown, first, cardTitle: (s) => lists.cardTitle(s) });
1116
1136
  const title = raw === "\u2014" ? "" : raw; // "—": no first message yet
1117
1137
  const text = topBarTitle({
1118
1138
  title,
@@ -1888,7 +1908,8 @@ $("#infoArchiveBtn").addEventListener("click", () => {
1888
1908
  // ── Continue (session chains) ───────────────────────────────────────────────
1889
1909
  // Continue → starts the next link of the open session's chain (the server
1890
1910
  // waits up to 30 s for its recap, archives it, starts the new one with the
1891
- // 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).
1892
1913
  // A link continued already opens that next link; its open delegates move
1893
1914
  // to the new link (the toast counts them); a refusal for open delegates
1894
1915
  // that can't move names them, each a link.
@@ -1914,7 +1935,7 @@ async function continueSelected() {
1914
1935
  return;
1915
1936
  }
1916
1937
  await select(next);
1917
- if (selected === next) fillOpener();
1938
+ if (selected === next) fillOpener(created);
1918
1939
  showToast(continuedText(id, created), null, { sticky });
1919
1940
  } catch (e) {
1920
1941
  const refusal = continueRefusal(e, allSessions);
@@ -1932,10 +1953,12 @@ async function continueSelected() {
1932
1953
  }
1933
1954
  }
1934
1955
 
1935
- // The new link's composer: the opener, unless a draft is there (it stays).
1936
- 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) {
1937
1960
  if (promptEl.value.trim() || chips.length) return;
1938
- promptEl.value = CONTINUE_OPENER;
1961
+ promptEl.value = openerText(created);
1939
1962
  fitPrompt();
1940
1963
  promptEl.focus();
1941
1964
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
@@ -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".
@@ -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;
@@ -120,6 +120,15 @@ export function sendCommand(id, line, { clientId, card = false, ...opts } = {})
120
120
  });
121
121
  }
122
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
+
123
132
  export function cancelTurn(id, opts = {}) {
124
133
  return api(`/api/sessions/${id}/cancel`, {
125
134
  method: "POST",
@@ -326,6 +326,22 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
326
326
  .bubble.question>summary .question-said{max-width:72ch;margin:2px 0 1px 1.05em;font-size:12.5px;font-weight:400;line-height:1.5;color:var(--text-muted);white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden}
327
327
  .bubble.question[open]>summary .question-said{display:none}
328
328
  .question-said-full{margin:4px 0 6px;font-size:13px;line-height:1.55;color:var(--text-muted);white-space:pre-wrap;overflow-wrap:anywhere}
329
+ /* Its markdown once the card is first opened (question_cards.js renderSaidOnOpen), as compact as the stage's. */
330
+ .question-said-full.markdown{white-space:normal}
331
+ .question-said-full.markdown :is(h1,h2,h3,h4,h5,h6){font-size:13.5px;font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--text-2)}
332
+ .question-said-full.markdown :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
333
+ .question-said-full.markdown>:first-child{margin-top:0}
334
+ .question-said-full.markdown>:last-child{margin-bottom:0}
335
+ .question-said-full.markdown :is(ul,ol){padding-left:1.3em}
336
+ .question-said-full.markdown li>:is(ul,ol){margin:0}
337
+ .question-said-full.markdown hr{border:0;border-top:1px solid var(--border);margin:8px 0}
338
+ .question-said-full.markdown blockquote{border-left:3px solid var(--border-strong);padding-left:10px}
339
+ .question-said-full.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
340
+ .question-said-full.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:0 4px}
341
+ .question-said-full.markdown pre{overflow:auto;padding:6px 8px;border-radius:6px;background:var(--bg-inset);border:1px solid var(--border);font-size:12px;line-height:1.45;white-space:pre}
342
+ .question-said-full.markdown table{border-collapse:collapse;display:block;max-width:100%;overflow:auto}
343
+ .question-said-full.markdown :is(th,td){border:1px solid var(--border-strong);padding:3px 7px;font-size:12.5px}
344
+ .question-said-full.markdown th{background:var(--bg-inset);font-weight:600}
329
345
  .bubble.command{border-left:3px solid var(--text-faint);background:var(--bg-panel);font-size:13px}
330
346
  .bubble.note{border-left:3px dotted var(--text-faint);background:transparent;font-size:12px;color:var(--text-muted);opacity:.85}
331
347
  .note-line{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-faint);margin-bottom:2px}
@@ -438,6 +454,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
438
454
  .gen .activity-body>.hook-notice:first-child{border-top:none}
439
455
  .gen .hook-notice.warn{border-left-color:var(--warn);color:var(--warn-text)}
440
456
  .hook-notice.llm-context{border-left-style:dashed;cursor:help}
457
+ .hook-notice a{color:inherit;text-decoration:underline;text-underline-offset:2px}
458
+ .hook-notice a:hover{text-decoration-thickness:2px}
441
459
  /* A plugin's steer (a nudge into the running turn): a row of its step, or a bubble; its text collapsed. */
442
460
  .steer-row{font-size:13px;color:var(--text-3);border-left:2px solid var(--accent-border)}
443
461
  .steer-row>summary{cursor:pointer;list-style:none;font-style:italic}
@@ -708,9 +726,28 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
708
726
  .ts-headline.thinking{font-size:13.5px;font-style:italic;color:var(--text-muted)}
709
727
  .ts-think-tag{font-style:normal;font-size:12.5px;color:var(--think-text);margin-right:6px}
710
728
  .ts-headline.fresh{animation:ts-fade .3s ease}
711
- /* While a question or approval waits: the whole reply that asked it, raw as a closed step's text, in the headline's
712
- place; capped so the card keeps its room, read from the top (stage_view.js drawAsking). */
729
+ /* While a question or approval waits: the whole reply that asked it, in the headline's place; raw as a closed step's
730
+ text, then its markdown when web.markdown is on (.markdown, stage_slots.js drawAsking); capped so the card keeps
731
+ its room, read from the top. */
713
732
  .ts-asking{max-height:calc(var(--stage-max,60vh) * .35);overflow:auto;scrollbar-width:thin;white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.55;color:var(--narr);border-left:2px solid var(--border-strong);padding-left:10px}
733
+ /* Its markdown, compact: about the raw text's metrics, so the swap barely moves the slot. */
734
+ .ts-asking.markdown{white-space:normal}
735
+ .ts-asking.markdown :is(h1,h2,h3,h4,h5,h6){font-size:14.5px;font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--text-bright)}
736
+ .ts-asking.markdown :is(h4,h5,h6){font-size:14px;color:var(--text-2)}
737
+ .ts-asking.markdown :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
738
+ .ts-asking.markdown>:first-child{margin-top:0}
739
+ .ts-asking.markdown>:last-child{margin-bottom:0}
740
+ .ts-asking.markdown :is(ul,ol){padding-left:1.3em}
741
+ .ts-asking.markdown li>:is(ul,ol){margin:0}
742
+ .ts-asking.markdown strong{color:var(--text-bright)}
743
+ .ts-asking.markdown hr{border:0;border-top:1px solid var(--border);margin:8px 0}
744
+ .ts-asking.markdown blockquote{border-left:3px solid var(--border-strong);padding-left:10px;color:var(--text-muted)}
745
+ .ts-asking.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
746
+ .ts-asking.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:0 4px;color:var(--text)}
747
+ .ts-asking.markdown pre{overflow:auto;padding:6px 8px;border-radius:6px;background:var(--bg-inset);border:1px solid var(--border);font-size:12.5px;line-height:1.45;white-space:pre}
748
+ .ts-asking.markdown table{border-collapse:collapse;display:block;max-width:100%;overflow:auto}
749
+ .ts-asking.markdown :is(th,td){border:1px solid var(--border-strong);padding:3px 7px;font-size:13px}
750
+ .ts-asking.markdown th{background:var(--bg-inset);font-weight:600}
714
751
  .turn-stage.asking .ts-headline{display:none}
715
752
  @keyframes ts-fade{from{opacity:.35}}
716
753
  .ts-now{height:var(--ts-now-h);display:flex;align-items:center;gap:8px;min-width:0;padding:0 9px;border-radius:8px;background:var(--accent-tint);font-size:13px}
@@ -0,0 +1,55 @@
1
+ // A hook notice line's http(s) URLs as links (a source-links `sources:`
2
+ // line with markdown off): the terminals need the line as text, the web can
3
+ // click it. Only http(s) URLs that refs.js httpUrl accepts (no userinfo, not
4
+ // over-long) are linked; everything else stays text, escaped by lit (no
5
+ // unsafeHTML). A link opens in a new tab: refs.js installRefClicks leaves a
6
+ // plain <a> (no data-ref-kind) to the browser.
7
+ import { html } from "./vendor/lit-html.js";
8
+ import { httpUrl } from "./refs.js";
9
+
10
+ // A candidate: the scheme, then anything up to a space or an angle bracket
11
+ // (`<https://x/a>` links https://x/a).
12
+ const CANDIDATE = /https?:\/\/[^\s<>]+/gi;
13
+ const TRAILING = /[.,;:!?'"]+$/;
14
+
15
+ // +url+ cut at its first ) that no ( inside it balances: `(https://x/a)`
16
+ // ends before the ), `…/Foo_(bar)` keeps it (source_links.rb trim_url_end).
17
+ function cutUnbalancedParen(url) {
18
+ let depth = 0;
19
+ for (let i = 0; i < url.length; i++) {
20
+ if (url[i] === "(") depth++;
21
+ else if (url[i] === ")") {
22
+ if (depth === 0) return url.slice(0, i);
23
+ depth--;
24
+ }
25
+ }
26
+ return url;
27
+ }
28
+
29
+ // A candidate without what ends the sentence around it: trailing
30
+ // punctuation (the sources line's ", "), an unbalanced ), a ] no [ opens.
31
+ function trimUrl(candidate) {
32
+ let url = cutUnbalancedParen(candidate.replace(TRAILING, ""));
33
+ if (url.endsWith("]") && !url.includes("[")) url = url.replace(/\]+$/, "");
34
+ return url.replace(TRAILING, "");
35
+ }
36
+
37
+ // +text+ with each http(s) URL a link (a lit template's array of strings
38
+ // and <a> parts); +text+ itself when it links nothing, so a URL-less line
39
+ // renders exactly as before.
40
+ export function linkedLine(text) {
41
+ if (typeof text !== "string" || !text) return text;
42
+ const parts = [];
43
+ let last = 0;
44
+ for (const match of text.matchAll(CANDIDATE)) {
45
+ const shown = trimUrl(match[0]);
46
+ const href = httpUrl(shown);
47
+ if (!href) continue;
48
+ if (match.index > last) parts.push(text.slice(last, match.index));
49
+ parts.push(html`<a href=${href} target="_blank" rel="noopener noreferrer">${shown}</a>`);
50
+ last = match.index + shown.length;
51
+ }
52
+ if (!parts.length) return text;
53
+ if (last < text.length) parts.push(text.slice(last));
54
+ return parts;
55
+ }
@@ -0,0 +1,55 @@
1
+ // Markdown out of the web UI's plain one-line places: the stage's live
2
+ // headline (stage_model.js), a closed step's title (turn_model.js
3
+ // genLabel), an answered question card's kept-reply line (question_cards.js).
4
+ // Pure, no DOM, so `node --test` covers it; the places that render markdown
5
+ // (step bodies, the card's open copy) keep the text as written.
6
+
7
+ // One line as plain text: the markdown a reply carries (headings, quotes,
8
+ // bullets and item numbers, table pipes, emphasis, code ticks, links) goes,
9
+ // and a line that is only syntax (a code fence, a table's separator row, a
10
+ // rule) is empty. A small pass, no renderer: a one-line
11
+ // preview only needs to read well, and an unclosed "**" mid-stream goes too.
12
+ export function plainLine(line) {
13
+ let s = String(line ?? "").trim();
14
+ if (/^(```|~~~)/.test(s)) return "";
15
+ if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?$/.test(s)) return "";
16
+ if (/^([-*_])(\s*\1){2,}$/.test(s)) return "";
17
+ s = s.replace(/^(>\s*)+/, "").replace(/^#{1,6}\s+/, "").replace(/^([-*+]|\d{1,9}[.)])\s+(\[[ xX]\]\s+)?/, "");
18
+ // A table row: a pipe at either end (a pipe mid-prose, "a || b", stays).
19
+ if (/^\||\|$/.test(s)) {
20
+ s = s.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()).filter(Boolean).join(" · ");
21
+ }
22
+ // Code spans keep their text as is; an unclosed tick just goes.
23
+ const code = [];
24
+ s = s.replace(/`+([^`]+)`+/g, (_, c) => `\u0000${code.push(c) - 1}\u0000`);
25
+ s = s
26
+ .replace(/!\[([^\]]*)\]\([^)]*\)?/g, "$1")
27
+ .replace(/\[([^\]]*)\]\([^)]*\)?/g, "$1")
28
+ .replace(/<(https?:\/\/[^>\s]+)>/g, "$1")
29
+ .replace(/`+/g, "")
30
+ .replace(/(^|[^\w])__(?=\S)(.*?\S)__(?=$|[^\w])/g, "$1$2")
31
+ .replace(/(\*\*|~~)/g, "")
32
+ .replace(/(^|[^\w*])\*(?=\S)([^*]*?\S)\*(?=$|[^\w*])/g, "$1$2")
33
+ .replace(/(^|[^\w])_(?=\S)([^_]*?\S)_(?=$|[^\w])/g, "$1$2")
34
+ .replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
35
+ return s.replace(/\s+/g, " ").trim();
36
+ }
37
+
38
+ // A reply as one plain line (each of its lines plainLine's), for a preview
39
+ // that clamps it: blank and syntax-only lines go; the lines inside a code
40
+ // fence stay as written (code's "*" and "_" are not emphasis), the fences
41
+ // themselves go. A line that ends a sentence (or a clause: ":", ",") runs on
42
+ // with a space, one that doesn't (a heading, an item) with " · ", so
43
+ // "## Plan" and the line under it don't read as one sentence.
44
+ export function plainText(text) {
45
+ let out = "";
46
+ let fenced = false;
47
+ for (const raw of String(text ?? "").split("\n")) {
48
+ if (/^\s*(```|~~~)/.test(raw)) { fenced = !fenced; continue; }
49
+ const line = fenced ? raw.trim() : plainLine(raw);
50
+ if (!line) continue;
51
+ if (out) out += /[.!?…:;,]["')\]]*$/.test(out) ? " " : " · ";
52
+ out += line;
53
+ }
54
+ return out;
55
+ }