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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +9 -3
- data/docs/desktop.md +2 -1
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +4 -2
- data/docs/internals/background-tasks.md +4 -3
- data/docs/plugins.md +9 -2
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +41 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +5 -0
- data/lib/samagotchi/paths.rb +8 -6
- data/lib/samagotchi/session.rb +5 -0
- data/lib/samagotchi/session_chain.rb +24 -0
- data/lib/samagotchi/session_continue.rb +3 -1
- data/lib/samagotchi/session_manager.rb +7 -0
- data/lib/samagotchi/tool_output_cut.rb +2 -1
- data/lib/samagotchi/tools/builtins.rb +17 -4
- data/lib/samagotchi/tools/execute.rb +1 -15
- data/lib/samagotchi/tools/task_runtime.rb +75 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +27 -3
- data/lib/samagotchi/web/public/app.js +33 -10
- data/lib/samagotchi/web/public/bubbles.js +4 -2
- data/lib/samagotchi/web/public/chains.js +21 -1
- data/lib/samagotchi/web/public/data.js +9 -0
- data/lib/samagotchi/web/public/index.html +39 -2
- data/lib/samagotchi/web/public/notice_links.js +55 -0
- data/lib/samagotchi/web/public/plain_line.js +55 -0
- data/lib/samagotchi/web/public/question_cards.js +37 -3
- data/lib/samagotchi/web/public/stage_model.js +3 -33
- data/lib/samagotchi/web/public/stage_slots.js +35 -3
- data/lib/samagotchi/web/public/stage_view.js +9 -4
- data/lib/samagotchi/web/public/turn_html.js +3 -2
- data/lib/samagotchi/web/public/turn_model.js +7 -4
- data/lib/samagotchi/web/public/turn_view.js +3 -2
- 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
|
|
155
|
-
#
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
356
|
-
#
|
|
357
|
-
#
|
|
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"
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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.
|
|
683
|
-
#
|
|
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 {
|
|
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({
|
|
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
|
|
1111
|
-
//
|
|
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
|
|
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
|
|
1936
|
-
|
|
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 =
|
|
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)
|
|
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
|
|
712
|
-
|
|
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
|
+
}
|