samagotchi 0.124.0 → 0.126.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 +53 -1
- data/docs/architecture.md +4 -2
- data/docs/cli.md +20 -8
- data/docs/guardrails.md +22 -0
- data/docs/internals/participants.md +96 -3
- data/docs/internals/remote-sessions.md +2 -0
- data/docs/internals/web-push.md +10 -6
- data/docs/sessions.md +5 -2
- data/lib/samagotchi/bridge.rb +4 -3
- data/lib/samagotchi/bridge_client.rb +8 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +3 -0
- data/lib/samagotchi/children_status.rb +6 -1
- data/lib/samagotchi/engine.rb +43 -7
- data/lib/samagotchi/guardrail_wiring.rb +10 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -0
- 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.rb +40 -3
- data/lib/samagotchi/session_inbox.rb +14 -8
- data/lib/samagotchi/session_manager.rb +39 -8
- data/lib/samagotchi/session_participants.rb +10 -2
- 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/verdict.rb +20 -0
- data/lib/samagotchi/verify_anchor.rb +28 -0
- data/lib/samagotchi/verify_fresh.rb +275 -0
- data/lib/samagotchi/verify_fresh_origin.rb +71 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +10 -3
- data/lib/samagotchi/web/participant_access.rb +15 -4
- data/lib/samagotchi/web/participant_review.rb +7 -1
- data/lib/samagotchi/web/participant_view.rb +46 -5
- 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 +43 -7
- data/lib/samagotchi/web/public/app.js +134 -8
- data/lib/samagotchi/web/public/claim_link.js +347 -0
- data/lib/samagotchi/web/public/data.js +2 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/notify.js +13 -2
- data/lib/samagotchi/web/public/participant.html +3 -1
- data/lib/samagotchi/web/public/participant.js +113 -9
- data/lib/samagotchi/web/public/participants_panel.js +15 -0
- data/lib/samagotchi/web/public/route.js +3 -2
- data/lib/samagotchi/web/public/send_turn.js +6 -1
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +3 -0
- data/lib/samagotchi/web/public/tokens.css +2 -1
- data/lib/samagotchi/web/public/verdict_card.js +65 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
- data/lib/samagotchi/web/public/verify_card.css +17 -0
- data/lib/samagotchi/web/public/verify_preset.js +18 -0
- data/lib/samagotchi/web/push/attention.rb +25 -5
- data/lib/samagotchi/web/push/notifier.rb +4 -3
- data/lib/samagotchi/web/session_control.rb +2 -0
- data/lib/samagotchi/web/session_summary.rb +25 -1
- data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
- data/lib/samagotchi/worker.rb +125 -12
- data/lib/samagotchi/worker_idle_exit.rb +7 -6
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +19 -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
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A parsed verdict line from a Verify turn's final answer.
|
|
5
|
+
Verdict = Data.define(:verdict, :reason) do
|
|
6
|
+
LINE = /\AVerdict:\s*(confirmed|refuted|unclear)\b\s*(?:—|–|-)\s*(.+?)\s*\z/i
|
|
7
|
+
MARKDOWN = /(?:\*\*|__|`)/
|
|
8
|
+
|
|
9
|
+
def self.parse(text)
|
|
10
|
+
line = text.to_s.lines.reverse_each.find { |candidate| !candidate.strip.empty? }
|
|
11
|
+
return unless line
|
|
12
|
+
|
|
13
|
+
normalized = line.strip.gsub(MARKDOWN, "")
|
|
14
|
+
match = LINE.match(normalized)
|
|
15
|
+
return unless match
|
|
16
|
+
|
|
17
|
+
new(verdict: match[1].downcase, reason: match[2].strip)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A claim selected in an earlier turn, carried only by a Verify turn.
|
|
5
|
+
VerifyAnchor = Data.define(:turn_id, :quote) do
|
|
6
|
+
UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
7
|
+
QUOTE_MAX = 200
|
|
8
|
+
|
|
9
|
+
def self.from(value)
|
|
10
|
+
return value if value.is_a?(self)
|
|
11
|
+
return nil unless value.is_a?(Hash)
|
|
12
|
+
|
|
13
|
+
fields = value.to_h { |key, entry| [key.to_s, entry] }
|
|
14
|
+
return nil unless fields.keys.sort == %w[quote turn_id]
|
|
15
|
+
|
|
16
|
+
turn_id = fields["turn_id"]
|
|
17
|
+
quote = fields["quote"]
|
|
18
|
+
return nil unless turn_id.is_a?(String) && UUID.match?(turn_id)
|
|
19
|
+
return nil unless quote.is_a?(String) && quote.length.between?(1, QUOTE_MAX) && !quote.strip.empty?
|
|
20
|
+
|
|
21
|
+
new(turn_id:, quote:)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h = { turn_id:, quote: }
|
|
25
|
+
|
|
26
|
+
def to_file = { "turn_id" => turn_id, "quote" => quote }
|
|
27
|
+
end
|
|
28
|
+
end
|