samagotchi 0.124.0 → 0.125.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 +29 -1
- data/docs/architecture.md +4 -2
- data/docs/cli.md +20 -8
- data/docs/internals/participants.md +26 -1
- data/docs/internals/remote-sessions.md +2 -0
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/process_group.rb +10 -1
- data/lib/samagotchi/reminder_carry.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +49 -0
- data/lib/samagotchi/session_manager.rb +28 -4
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
- data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
- data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +7 -3
- data/lib/samagotchi/web/participants_admin.rb +15 -4
- data/lib/samagotchi/web/public/annotate_bar.css +8 -4
- data/lib/samagotchi/web/public/annotate_bar.js +34 -6
- data/lib/samagotchi/web/public/app.js +66 -5
- data/lib/samagotchi/web/public/claim_link.js +347 -0
- data/lib/samagotchi/web/public/participant.html +1 -0
- data/lib/samagotchi/web/public/participant.js +46 -4
- data/lib/samagotchi/web/public/participants_panel.js +15 -0
- data/lib/samagotchi/web/public/route.js +3 -2
- data/lib/samagotchi/web/public/tokens.css +2 -1
- data/lib/samagotchi/web/public/verify_preset.js +18 -0
- data/lib/samagotchi/worker.rb +57 -3
- data/lib/samagotchi/worker_idle_exit.rb +7 -6
- metadata +11 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../tools/task_record"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class TerminalUI
|
|
7
|
+
# The /exit ask and its reply lines: pure text from a reply, holds, and
|
|
8
|
+
# the invocation used to spell commands.
|
|
9
|
+
module ExitLines
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Why a request the Bridge read after its deadline didn't go through.
|
|
13
|
+
LATE = "worker not answering in time"
|
|
14
|
+
# An exit the worker agreed to in an empty session. Said before it has
|
|
15
|
+
# left: it checks again once it lets go, and a note or prompt arriving
|
|
16
|
+
# first keeps the session (SessionManager.discard_left_session).
|
|
17
|
+
EMPTY_WORDS = "the session is empty: it will be discarded if nothing arrives before its worker stops."
|
|
18
|
+
EMPTY_EXIT = "Detached; #{EMPTY_WORDS}"
|
|
19
|
+
# Why the worker stayed up after /exit, by the reason it gave.
|
|
20
|
+
HELD_REASONS = {
|
|
21
|
+
"turn_running" => "a turn is running", "input_queued" => "prompts are queued",
|
|
22
|
+
"continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
|
|
23
|
+
"reminders" => "reminders are set", "starting" => "the worker is still starting",
|
|
24
|
+
"background_tasks" => "background tasks it started are still running",
|
|
25
|
+
"delegates" => "delegates of this session are still running"
|
|
26
|
+
}.freeze
|
|
27
|
+
# How to end a background task that holds the worker up (an older
|
|
28
|
+
# worker's 409 has no hint of its own).
|
|
29
|
+
TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
|
|
30
|
+
# The /exit ask, when every hold is one the user can stop: what still
|
|
31
|
+
# runs, then the three answers. Each kind, singular and plural.
|
|
32
|
+
EXIT_HOLD_WORDS = {
|
|
33
|
+
"reminders" => %w[reminder reminders],
|
|
34
|
+
"background_tasks" => ["background task", "background tasks"],
|
|
35
|
+
"delegates" => %w[delegate delegates]
|
|
36
|
+
}.freeze
|
|
37
|
+
# The 200's +stopped+ keys, by the kind the ask lists them under.
|
|
38
|
+
STOPPED_KEYS = { "reminders" => "reminders", "tasks" => "background_tasks", "delegates" => "delegates" }.freeze
|
|
39
|
+
EXIT_ASK_OPTIONS = ["Keep running in the background (detach)", "Stop them and exit", "Back to the session"].freeze
|
|
40
|
+
# How the ask reads an answer.
|
|
41
|
+
EXIT_ANSWERS = {
|
|
42
|
+
"1" => :keep, "keep" => :keep, "detach" => :keep, "background" => :keep,
|
|
43
|
+
"2" => :stop, "stop" => :stop, "exit" => :stop,
|
|
44
|
+
"3" => :back, "back" => :back, "stay" => :back
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# The ask: what still runs, then the three answers.
|
|
48
|
+
def ask_lines(holds)
|
|
49
|
+
lines = holds.map do |hold|
|
|
50
|
+
items = Array(hold["items"])
|
|
51
|
+
singular, plural = hold_words(hold["kind"])
|
|
52
|
+
" • #{items.size} #{items.size == 1 ? singular : plural}: #{items.map { |item| item_word(item, hold["kind"]) }.join(", ")}"
|
|
53
|
+
end
|
|
54
|
+
lines += EXIT_ASK_OPTIONS.each_with_index.map { |option, index| " #{index + 1}. #{option}" }
|
|
55
|
+
(["This session still has:"] + lines).join("\n")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# One entry of a hold: the command or name, with its detail in
|
|
59
|
+
# brackets; a delegate leads with its short id and its title in quotes.
|
|
60
|
+
def item_word(item, kind)
|
|
61
|
+
label = item["label"].to_s
|
|
62
|
+
label = "#{item["id"].to_s[0, 8]} \"#{label}\"" if kind == "delegates"
|
|
63
|
+
detail = item["detail"].to_s
|
|
64
|
+
detail.empty? ? label : "#{label} (#{detail})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# 1: detach, everything left running.
|
|
68
|
+
def kept_line(holds, via:, session_id:)
|
|
69
|
+
"Detached; the session keeps running (#{hold_words_list(holds)}). " \
|
|
70
|
+
"Re-attach with: #{via.attach(session_id)}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# "2 reminders, 1 background task, 1 delegate" from the ask's holds.
|
|
74
|
+
def hold_words_list(holds)
|
|
75
|
+
holds.map do |hold|
|
|
76
|
+
items = Array(hold["items"])
|
|
77
|
+
singular, plural = hold_words(hold["kind"])
|
|
78
|
+
"#{items.size} #{items.size == 1 ? singular : plural}"
|
|
79
|
+
end.join(", ")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def hold_words(kind) = EXIT_HOLD_WORDS.fetch(kind.to_s, [kind.to_s, kind.to_s])
|
|
83
|
+
|
|
84
|
+
# What a stop-all exit stopped: "stopped 2 reminders (api_health, git
|
|
85
|
+
# push), 1 background task (rails s)"; nil when it stopped nothing.
|
|
86
|
+
def stopped_words(stopped)
|
|
87
|
+
return nil unless stopped.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
parts = STOPPED_KEYS.filter_map do |key, kind|
|
|
90
|
+
names = Array(stopped[key])
|
|
91
|
+
next if names.empty?
|
|
92
|
+
|
|
93
|
+
singular, plural = hold_words(kind)
|
|
94
|
+
"#{names.size} #{names.size == 1 ? singular : plural} (#{names.join(", ")})"
|
|
95
|
+
end
|
|
96
|
+
parts.empty? ? nil : "stopped #{parts.join(", ")}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def exit_line(reply, via:, session_id:)
|
|
100
|
+
error = reply.json&.fetch("error", nil)
|
|
101
|
+
case reply.status
|
|
102
|
+
when 200
|
|
103
|
+
stopped = stopped_words(reply.json&.fetch("stopped", nil))
|
|
104
|
+
return EMPTY_EXIT if discards?(reply) && stopped.nil?
|
|
105
|
+
return "Detached; #{stopped}. #{EMPTY_WORDS.capitalize}" if discards?(reply)
|
|
106
|
+
|
|
107
|
+
"Detached; #{stopped || "the session's worker is stopping"}. Resume with: #{via.resume(session_id)}"
|
|
108
|
+
when 409
|
|
109
|
+
reason = reply.json&.fetch("reason", nil).to_s
|
|
110
|
+
"Detached; the session keeps running (#{held_words(reason, reply.json&.fetch("tasks", nil))}). " \
|
|
111
|
+
"Re-attach with: #{via.attach(session_id)}"
|
|
112
|
+
when 0 then exit_failed_line("no reply", via: via, session_id: session_id)
|
|
113
|
+
when 408 then exit_failed_line(LATE, via: via, session_id: session_id)
|
|
114
|
+
else
|
|
115
|
+
# A worker from before the route answers the Bridge's plain 404.
|
|
116
|
+
if error == "not_found"
|
|
117
|
+
return "Detached; this worker runs an older chi and can't be stopped from here: #{via.command("sessions stop #{session_id}")}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
exit_failed_line([reply.status, reply.json&.fetch("detail", nil) || error].compact.join(" "),
|
|
121
|
+
via: via, session_id: session_id)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Why the worker stayed up; the background tasks it waits for by
|
|
126
|
+
# their short id and command (a finish wake's, D3), and how to stop one.
|
|
127
|
+
def held_words(reason, tasks)
|
|
128
|
+
words = HELD_REASONS.fetch(reason, reason)
|
|
129
|
+
listed = Array(tasks).filter_map { |t| "#{Tools::TaskRecord.short_id(t["id"])} (#{t["command"]})" if t.is_a?(Hash) }
|
|
130
|
+
listed.empty? ? words : "#{words}: #{listed.join(", ")}; #{TASK_STOP_HINT}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# An older worker leaves the field out.
|
|
134
|
+
def discards?(reply) = reply.json&.fetch("discard", nil) == true
|
|
135
|
+
|
|
136
|
+
def exit_failed_line(why, via:, session_id:)
|
|
137
|
+
"Detached (could not ask the worker to stop: #{why}). Re-attach with: #{via.attach(session_id)}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../context_note"
|
|
4
|
+
require_relative "../image_store"
|
|
5
|
+
require_relative "../log"
|
|
6
|
+
require_relative "../message_parts"
|
|
7
|
+
require_relative "../output_formatter"
|
|
8
|
+
require_relative "../steer"
|
|
9
|
+
require_relative "../tool_activity"
|
|
10
|
+
require_relative "../tool_view"
|
|
11
|
+
require_relative "../turn_note"
|
|
12
|
+
require_relative "event_renderer"
|
|
13
|
+
require_relative "formatting"
|
|
14
|
+
|
|
15
|
+
module Samagotchi
|
|
16
|
+
class TerminalUI
|
|
17
|
+
# Draws the saved part of a worker snapshot when an attached TUI joins or
|
|
18
|
+
# resyncs. The live loop owns the collaborators; this owns replay only.
|
|
19
|
+
class JoinReplay
|
|
20
|
+
include Formatting
|
|
21
|
+
|
|
22
|
+
# How much of the last answer a join shows.
|
|
23
|
+
JOIN_ANSWER_LINES = 12
|
|
24
|
+
JOIN_ANSWER_CHARS = 1200
|
|
25
|
+
|
|
26
|
+
def initialize(screen:, view:, link:, gaps:, renderer:)
|
|
27
|
+
@screen = screen
|
|
28
|
+
@view = view
|
|
29
|
+
@link = link
|
|
30
|
+
@gaps = gaps
|
|
31
|
+
@renderer = renderer
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render_join(snapshot, saved)
|
|
35
|
+
render_join_header(Array(snapshot[:messages]))
|
|
36
|
+
render_join_gap(snapshot, saved)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The snapshot's cards and between-turns notices (Bridge CardStore):
|
|
40
|
+
# on join the ones since the last turn (and the running turn's), as
|
|
41
|
+
# the join header shows the last exchange; on a resync only cards not
|
|
42
|
+
# shown here yet, and not the ones an earlier worker saved (a resync
|
|
43
|
+
# onto a new worker: earlier turns, not news). A turn's own rows (notices, retries) are left out:
|
|
44
|
+
# the running turn's come with its parts, and the join shows no older
|
|
45
|
+
# turn's steps.
|
|
46
|
+
def render_snapshot_cards(entries, joining:)
|
|
47
|
+
entries.each do |entry|
|
|
48
|
+
case entry[:type].to_s
|
|
49
|
+
when "hook_notice"
|
|
50
|
+
next if entry[:in_turn]
|
|
51
|
+
|
|
52
|
+
@screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
|
|
53
|
+
when "card"
|
|
54
|
+
next unless joining ? entry[:turns_since].to_i.zero? : !entry[:earlier] && !@renderer.card_shown?(entry[:id])
|
|
55
|
+
|
|
56
|
+
@renderer.render_card(entry, updated: entry[:updated] == true)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Where the session's last activity was: its newest stamped entry,
|
|
64
|
+
# else its saved recap's time. A gap ago (and no turn running): one
|
|
65
|
+
# line after the join block says when.
|
|
66
|
+
def render_join_gap(snapshot, saved)
|
|
67
|
+
@gaps.seed(snapshot[:messages], fallback: saved&.dig(:created_at))
|
|
68
|
+
return if snapshot[:current_turn]
|
|
69
|
+
|
|
70
|
+
line = @gaps.join_line
|
|
71
|
+
@screen.commit(gap_divider(line)) if line
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render_join_header(messages)
|
|
75
|
+
exchange = messages.select { |m| %w[user model assistant].include?(m[:role].to_s) }
|
|
76
|
+
# The id stays findable: the detach line and chi sessions list show it.
|
|
77
|
+
Log.session_id = @link.client.session_id
|
|
78
|
+
Log.info(:attached, "joined", session: @link.client.session_id, messages: exchange.size,
|
|
79
|
+
notes: messages.count { |m| ContextNote.note?(m) })
|
|
80
|
+
last_user = exchange.rindex { |m| Steer.prompt?(m) }
|
|
81
|
+
render_join_notes(messages)
|
|
82
|
+
return unless last_user
|
|
83
|
+
|
|
84
|
+
@screen.commit(join_prompt_line(exchange[last_user]))
|
|
85
|
+
# An image-only turn's text names its images already.
|
|
86
|
+
unless ImageRef.placeholder_only?(exchange[last_user][:content])
|
|
87
|
+
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
88
|
+
end
|
|
89
|
+
from = messages.index { |m| m.equal?(exchange[last_user]) }
|
|
90
|
+
render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
|
|
91
|
+
record = @link.saved_turn_record(exchange[last_user][:turn_id] || exchange[last_user]["turn_id"])
|
|
92
|
+
failed = join_failed_record(record)
|
|
93
|
+
# A turn that ended with no answer: its notice, as live.
|
|
94
|
+
empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
|
|
95
|
+
if empty
|
|
96
|
+
@screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"], empty[:refusal] || empty["refusal"]))
|
|
97
|
+
elsif failed
|
|
98
|
+
# A failed turn whose steps stayed: the line the live view left,
|
|
99
|
+
# and no step text as if it were the answer (the web keeps a
|
|
100
|
+
# failed turn's texts as steps too).
|
|
101
|
+
failure = failed["failure"]
|
|
102
|
+
@screen.commit(@view.turn_failed_line(failure["summary"] || failure["message"], failed["duration_ms"]))
|
|
103
|
+
@screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) if failure["kept_steps"]
|
|
104
|
+
elsif record && record["status"] == "canceled"
|
|
105
|
+
# A canceled turn (a Stop, Ctrl-C): its line, as live, and no
|
|
106
|
+
# text it streamed before the cancel as if it were the answer.
|
|
107
|
+
@screen.commit(@view.turn_canceled_line(record["cancellation_reason"], record["duration_ms"], by: record["cancelled_by"]))
|
|
108
|
+
@screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) unless record["continue"]
|
|
109
|
+
else
|
|
110
|
+
# The saved answer is raw: the latest keeps its thinking, and one may
|
|
111
|
+
# be only a tool call.
|
|
112
|
+
answer = exchange[(last_user + 1)..].reverse_each
|
|
113
|
+
.map { |m| OutputFormatter.strip_markup(m[:content]) }
|
|
114
|
+
.find { |text| !text.empty? }
|
|
115
|
+
@screen.commit(last_lines(answer)) if answer
|
|
116
|
+
end
|
|
117
|
+
render_join_notes(messages, after_exchange: true)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# What the last exchange did, as the live turn drew it: each saved
|
|
121
|
+
# call's tool row (action, params, status from its result, and its
|
|
122
|
+
# duration from +durations+, the turn's saved tool records) and a
|
|
123
|
+
# plugin's steer where it came. The step texts stay out, as live (the
|
|
124
|
+
# ticker's).
|
|
125
|
+
def render_join_steps(messages, from, durations: [])
|
|
126
|
+
records = durations.dup
|
|
127
|
+
messages.each_with_index.drop(from + 1).each do |m, i|
|
|
128
|
+
if Steer.steer?(m)
|
|
129
|
+
@screen.commit(format_steer_line(source: m[:source], text: m[:content]))
|
|
130
|
+
elsif %w[model assistant].include?(m[:role].to_s)
|
|
131
|
+
responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
|
|
132
|
+
Array(MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
|
|
133
|
+
record = join_record(records, part)
|
|
134
|
+
duration = record&.dig("duration_ms")
|
|
135
|
+
@screen.commit(join_tool_line(part, duration_ms: duration.is_a?(Numeric) ? duration : nil,
|
|
136
|
+
saved_status: record&.dig("status"),
|
|
137
|
+
auto_allowed: record&.dig("guardrail")))
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The prompt's turn's saved tool records (AttachLink: SessionMetrics,
|
|
144
|
+
# in this machine's state dir, as #attach_images): none for a prompt
|
|
145
|
+
# with no turn id (an older session) or a session with no analytics.
|
|
146
|
+
def join_tool_records(prompt)
|
|
147
|
+
@link.saved_tool_records(prompt[:turn_id] || prompt["turn_id"])
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The turn's saved +record+ when it failed with the fields its line is
|
|
151
|
+
# made of (SessionMetrics#failure_fields), else nil: a completed or
|
|
152
|
+
# older turn — or one saved before the failure field — draws its
|
|
153
|
+
# answer as before (a canceled one its line).
|
|
154
|
+
def join_failed_record(record)
|
|
155
|
+
return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
|
|
156
|
+
|
|
157
|
+
failure = record["failure"]
|
|
158
|
+
return nil if failure["summary"].to_s.empty? && failure["message"].to_s.empty?
|
|
159
|
+
|
|
160
|
+
record
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The saved record of +part+'s call (its duration and status): the
|
|
164
|
+
# next record of its tool, in call order, consumed with the ones it
|
|
165
|
+
# skipped. By tool and order, not by iteration: an empty retry spends
|
|
166
|
+
# an iteration that left no message (as the web's timing.js
|
|
167
|
+
# takeRecord). nil for a call that left none: it never finished, or a
|
|
168
|
+
# worker before turn ids saved it.
|
|
169
|
+
def join_record(records, part)
|
|
170
|
+
return nil if part[:output].nil?
|
|
171
|
+
|
|
172
|
+
at = records.index { |r| r["tool"].to_s == part[:tool].to_s }
|
|
173
|
+
return nil unless at
|
|
174
|
+
|
|
175
|
+
records.shift(at + 1).last
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A saved call's row; "no result" for a call the turn never answered
|
|
179
|
+
# (it ended first). Its status is the saved record's, as live and the
|
|
180
|
+
# web's reload (a guardrail's "blocked" is no error), else read from
|
|
181
|
+
# its result (a guardrail's deny text reads blocked too). A record that
|
|
182
|
+
# System 1 allowed once (+auto_allowed+, its saved {rule, p}) says so,
|
|
183
|
+
# as the live row does.
|
|
184
|
+
def join_tool_line(part, duration_ms: nil, saved_status: nil, auto_allowed: nil)
|
|
185
|
+
output = part[:output]
|
|
186
|
+
command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
|
|
187
|
+
text = ToolActivity.saved_result_text(output) unless output.nil?
|
|
188
|
+
status = if output.nil?
|
|
189
|
+
"no result"
|
|
190
|
+
elsif !saved_status.to_s.empty?
|
|
191
|
+
saved_status.to_s
|
|
192
|
+
elsif ToolActivity.guardrail_denied?(text)
|
|
193
|
+
"blocked"
|
|
194
|
+
else
|
|
195
|
+
ToolActivity.tool_activity_status(text, part[:tool], command: command)
|
|
196
|
+
end
|
|
197
|
+
activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
|
|
198
|
+
params: part[:params], status: status,
|
|
199
|
+
description: ToolView.description_title(part[:view] || {}) }
|
|
200
|
+
"#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
|
|
201
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}" \
|
|
202
|
+
"#{auto_allowed_suffix(auto_allowed)}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The live row's System 1 suffix for a saved record's {rule, p}.
|
|
206
|
+
def auto_allowed_suffix(saved)
|
|
207
|
+
return "" unless saved.is_a?(Hash)
|
|
208
|
+
|
|
209
|
+
format_tool_guardrail_suffix({ decided_by: "system one", p: saved["p"] || saved[:p] })
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The context notes that came since the last prompt (all of them in a
|
|
213
|
+
# session with none), after the exchange the header shows.
|
|
214
|
+
def render_join_notes(messages, after_exchange: false)
|
|
215
|
+
last_user = messages.rindex { |m| Steer.prompt?(m) }
|
|
216
|
+
return if after_exchange != !last_user.nil?
|
|
217
|
+
|
|
218
|
+
messages[(last_user ? last_user + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
|
|
219
|
+
@screen.commit(context_note_line(ContextNote.label_of(m), ContextNote.text_of(m)))
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The end of a long answer; the whole of it is in the session.
|
|
224
|
+
def last_lines(text)
|
|
225
|
+
lines = text.split("\n", -1)
|
|
226
|
+
if lines.size > JOIN_ANSWER_LINES
|
|
227
|
+
text = "(… #{lines.size - JOIN_ANSWER_LINES} earlier lines)\n#{lines.last(JOIN_ANSWER_LINES).join("\n")}"
|
|
228
|
+
end
|
|
229
|
+
return text if text.length <= JOIN_ANSWER_CHARS
|
|
230
|
+
|
|
231
|
+
"(… earlier text)\n…#{text[-JOIN_ANSWER_CHARS..]}"
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1878,9 +1878,10 @@ module Samagotchi
|
|
|
1878
1878
|
def home_label(dir) = Paths.tilde(dir)
|
|
1879
1879
|
|
|
1880
1880
|
# <body> data for the page: where sessions are stored, where an
|
|
1881
|
-
# all-view new chat starts (the start folder),
|
|
1882
|
-
# folder in a repo, that project's name and root. A
|
|
1883
|
-
# nothing here: the list call answers 400 and the page
|
|
1881
|
+
# all-view new chat starts (the start folder), the home folder, and,
|
|
1882
|
+
# when ?dir names a folder in a repo, that project's name and root. A
|
|
1883
|
+
# bad dir adds nothing here: the list call answers 400 and the page
|
|
1884
|
+
# says so.
|
|
1884
1885
|
# Without ?dir (and without ?scope=all) the page is the start folder's
|
|
1885
1886
|
# project view when that folder is in a repo: data-scope-dir names it
|
|
1886
1887
|
# for the page (the URL stays bare, so a token's 303, a link code's
|
|
@@ -1890,6 +1891,9 @@ module Samagotchi
|
|
|
1890
1891
|
# version: the chi that served the page; the events snapshot names
|
|
1891
1892
|
# the one serving now (an upgraded chi web after a reconnect).
|
|
1892
1893
|
attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(start_dir), "version" => Samagotchi::VERSION }
|
|
1894
|
+
# The owner's home: a claim link's quote reads it "~", as the
|
|
1895
|
+
# participant page shows it (claim_link.js).
|
|
1896
|
+
attrs["home"] = Dir.home
|
|
1893
1897
|
attrs["view"] = view_name(req.params["view"])
|
|
1894
1898
|
# Only when on: the page reads its presence (the turn view ignores it).
|
|
1895
1899
|
attrs["working-set"] = "1" if working_set?(req.params["working_set"])
|
|
@@ -26,15 +26,17 @@ module Samagotchi
|
|
|
26
26
|
# +identity_header+: the header this chi web's auth proxy names the
|
|
27
27
|
# signed-in user in (null without one), for the Invite's email field;
|
|
28
28
|
# +mode+: what a participant invited now gets, "pair" or "review"
|
|
29
|
-
# (ReviewStart.share_terms), for the panel's words
|
|
30
|
-
|
|
29
|
+
# (ReviewStart.share_terms), for the panel's words; +link_base+: where
|
|
30
|
+
# a participant's link opens (participant_base), for a claim link
|
|
31
|
+
# (claim_link.js) the participant's browser opens with its cookie.
|
|
32
|
+
def handle_participants_list(req, id = nil)
|
|
31
33
|
now = @grants.now
|
|
32
34
|
grants = @grants.list(session_id: id).map { |grant| grant.public_h(now) }
|
|
33
35
|
return json_response(200, { participants: grants }) unless id
|
|
34
36
|
|
|
35
37
|
terms = ReviewStart.share_terms(Session.load(id, state_dir: default_state_dir), state_dir: default_state_dir)
|
|
36
38
|
json_response(200, { participants: with_spend(id, grants), warning: terms.warning, mode: terms.mode.to_s,
|
|
37
|
-
identity_header: @public_url&.identity_header })
|
|
39
|
+
identity_header: @public_url&.identity_header, link_base: participant_base(req) })
|
|
38
40
|
rescue Grants::Unwritable => e
|
|
39
41
|
grants_unwritable(e)
|
|
40
42
|
end
|
|
@@ -149,10 +151,19 @@ module Samagotchi
|
|
|
149
151
|
return @public_url.link(@link_codes.issue(kind: :grant, token: token, ttl: ttl), "s=#{id}")
|
|
150
152
|
end
|
|
151
153
|
|
|
154
|
+
"#{participant_base(req)}?token=#{token}#s=#{id}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Where a participant's link opens, no token: web.public_url's origin,
|
|
158
|
+
# else this chi web's LAN address, else the Host asked
|
|
159
|
+
# ("http://192.168.1.5:4567/").
|
|
160
|
+
def participant_base(req)
|
|
161
|
+
return "#{@public_url.origin}/" if @public_url
|
|
162
|
+
|
|
152
163
|
host = req.get_header("HTTP_HOST").to_s
|
|
153
164
|
port = host[/:(\d+)\z/, 1] || req.port.to_s
|
|
154
165
|
name = @lan ? @lan[:ip] : host.sub(/:\d*\z/, "")
|
|
155
|
-
"http://#{name}:#{port}
|
|
166
|
+
"http://#{name}:#{port}/"
|
|
156
167
|
end
|
|
157
168
|
end
|
|
158
169
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/* The selection bubble (annotate_bar.js): Annotate + the presets (web.annotate_presets),
|
|
2
|
-
wrap on a narrow page). Both pages link it: the owner's (app.js) and the
|
|
3
|
-
are tokens.css's. */
|
|
1
|
+
/* The selection bubble (annotate_bar.js): Annotate + the presets (web.annotate_presets), Copy link last where the page
|
|
2
|
+
has one, small pills with a gap (they wrap on a narrow page). Both pages link it: the owner's (app.js) and the
|
|
3
|
+
participant page (participant.js). Colours are tokens.css's. */
|
|
4
4
|
#annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
|
|
5
5
|
#annotateBar[hidden]{display:none}
|
|
6
6
|
#annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px var(--shadow-strong)}
|
|
@@ -15,4 +15,8 @@
|
|
|
15
15
|
#annotateBar.docked{right:8px;justify-content:center}
|
|
16
16
|
@media (pointer:coarse){#annotateBar button{font-size:14px;padding:8px 14px}}
|
|
17
17
|
#annotateBar .annotate-more-list[hidden]{display:none}
|
|
18
|
-
#annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
18
|
+
#annotateBar .annotate-preset,#annotateBar .annotate-link{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
19
|
+
/* A claim link's target (claim_link.js): its bubble (or its turn's opening) ringed, the quote itself highlighted. One
|
|
20
|
+
name for both pages; --claim-mark/--claim-ring follow the palette (warn-bright and warn are set per palette). */
|
|
21
|
+
.claim-hit{outline:2px solid var(--claim-ring);outline-offset:3px;border-radius:12px}
|
|
22
|
+
::highlight(chi-claim){background-color:var(--claim-mark);color:inherit}
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
//
|
|
10
10
|
// A bundle's pills (chi.annotation) come after the presets: each prepares
|
|
11
11
|
// its command line with the selection (annotate_actions.js), at most 3
|
|
12
|
-
// inline, the rest behind "⋯".
|
|
12
|
+
// inline, the rest behind "⋯". Last, "Copy link" where the page has a
|
|
13
|
+
// link for the selection (a claim link, claim_link.js).
|
|
13
14
|
//
|
|
14
15
|
// On a touch screen the bar docks above the composer instead: the OS's own
|
|
15
16
|
// selection menu (Copy, Look Up…) and the handles sit next to the selection,
|
|
@@ -21,7 +22,8 @@ import { annotateBarLeft, annotationSource, quoteBlock } from "./annotations.js"
|
|
|
21
22
|
import { presetNote } from "./annotate_presets.js";
|
|
22
23
|
import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline } from "./annotate_actions.js";
|
|
23
24
|
|
|
24
|
-
// @param presets the
|
|
25
|
+
// @param presets the presets: each a text (annotate_presets.js
|
|
26
|
+
// parsePresets) or {label, text} (verify_preset.js)
|
|
25
27
|
// @param canAnnotate () => whether the open session takes annotations (one
|
|
26
28
|
// is open and the composer can send)
|
|
27
29
|
// @param roots () => the elements a selection may start in (the history,
|
|
@@ -45,12 +47,17 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
|
|
|
45
47
|
// passes its own, participant_annotate.js)
|
|
46
48
|
// @param quote (text, source) => the quote block for the selected +text+
|
|
47
49
|
// (annotations.js quoteBlock by default), or null for no text
|
|
50
|
+
// @param link (source, range) => the selection's link, {url, title}, or
|
|
51
|
+
// null for no "Copy link" (the default); +range+ the selection clipped
|
|
52
|
+
// to +source+'s root
|
|
53
|
+
// @param onLink (link) runs on "Copy link"; the selection is cleared and
|
|
54
|
+
// the bar hidden first
|
|
48
55
|
// @param doc, win the page's document and window
|
|
49
56
|
// @return {schedule(), hide(), el}; schedule() re-reads the selection on the
|
|
50
57
|
// next frame (call it on scroll)
|
|
51
58
|
export function createAnnotateBar({
|
|
52
59
|
presets, canAnnotate, roots, isLive, onQuote, actions = () => [], running = () => false, onAction = () => {},
|
|
53
|
-
dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock,
|
|
60
|
+
dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock, link = () => null, onLink = () => {},
|
|
54
61
|
doc = globalThis.document, win = globalThis.window,
|
|
55
62
|
}) {
|
|
56
63
|
const bar = doc.createElement("div");
|
|
@@ -66,8 +73,9 @@ export function createAnnotateBar({
|
|
|
66
73
|
return btn;
|
|
67
74
|
}
|
|
68
75
|
bar.appendChild(button("Annotate", "annotate-main"));
|
|
69
|
-
presets.forEach((
|
|
70
|
-
const
|
|
76
|
+
presets.forEach((preset, i) => {
|
|
77
|
+
const { label, text } = presetParts(preset);
|
|
78
|
+
const btn = button(label, "annotate-preset");
|
|
71
79
|
btn.dataset.index = String(i);
|
|
72
80
|
btn.title = text;
|
|
73
81
|
bar.appendChild(btn);
|
|
@@ -78,10 +86,14 @@ export function createAnnotateBar({
|
|
|
78
86
|
group.className = "annotate-actions";
|
|
79
87
|
group.hidden = true;
|
|
80
88
|
bar.appendChild(group);
|
|
89
|
+
const linkBtn = button("Copy link", "annotate-link");
|
|
90
|
+
linkBtn.hidden = true;
|
|
91
|
+
bar.appendChild(linkBtn);
|
|
81
92
|
doc.body.appendChild(bar);
|
|
82
93
|
const touch = win.matchMedia?.("(pointer: coarse)");
|
|
83
94
|
let pendingQuote = null;
|
|
84
95
|
let pendingFields = null;
|
|
96
|
+
let pendingLink = null;
|
|
85
97
|
let drawn = [];
|
|
86
98
|
let moreList = null;
|
|
87
99
|
|
|
@@ -129,6 +141,7 @@ export function createAnnotateBar({
|
|
|
129
141
|
function hide() {
|
|
130
142
|
pendingQuote = null;
|
|
131
143
|
pendingFields = null;
|
|
144
|
+
pendingLink = null;
|
|
132
145
|
if (moreList) moreList.hidden = true;
|
|
133
146
|
bar.hidden = true;
|
|
134
147
|
}
|
|
@@ -151,6 +164,9 @@ export function createAnnotateBar({
|
|
|
151
164
|
if (!block) return hide();
|
|
152
165
|
pendingQuote = block;
|
|
153
166
|
pendingFields = selectionFields(raw, source);
|
|
167
|
+
pendingLink = link(source, clipped) || null;
|
|
168
|
+
linkBtn.hidden = !pendingLink;
|
|
169
|
+
linkBtn.title = pendingLink?.title || "";
|
|
154
170
|
const list = actions() || [];
|
|
155
171
|
if (!sameActions(list, drawn)) drawActions(list);
|
|
156
172
|
markActions();
|
|
@@ -209,6 +225,13 @@ export function createAnnotateBar({
|
|
|
209
225
|
moreList.hidden = !moreList.hidden;
|
|
210
226
|
return;
|
|
211
227
|
}
|
|
228
|
+
if (btn === linkBtn) {
|
|
229
|
+
const target = pendingLink;
|
|
230
|
+
win.getSelection()?.removeAllRanges();
|
|
231
|
+
hide();
|
|
232
|
+
if (target) onLink(target);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
212
235
|
if (btn.dataset.action !== undefined) {
|
|
213
236
|
const action = drawn[Number(btn.dataset.action)];
|
|
214
237
|
if (!action || btn.disabled) return;
|
|
@@ -219,7 +242,7 @@ export function createAnnotateBar({
|
|
|
219
242
|
return;
|
|
220
243
|
}
|
|
221
244
|
const preset = btn.dataset.index === undefined ? null : presets[Number(btn.dataset.index)];
|
|
222
|
-
const text = preset ? presetNote(pendingQuote, preset) : pendingQuote;
|
|
245
|
+
const text = preset ? presetNote(pendingQuote, presetParts(preset).text) : pendingQuote;
|
|
223
246
|
win.getSelection()?.removeAllRanges();
|
|
224
247
|
hide();
|
|
225
248
|
onQuote(text);
|
|
@@ -227,3 +250,8 @@ export function createAnnotateBar({
|
|
|
227
250
|
|
|
228
251
|
return { schedule, hide, el: bar };
|
|
229
252
|
}
|
|
253
|
+
|
|
254
|
+
// A preset's button label and note: a text is both.
|
|
255
|
+
function presetParts(preset) {
|
|
256
|
+
return typeof preset === "string" ? { label: preset, text: preset } : { label: preset.label, text: preset.text };
|
|
257
|
+
}
|