samagotchi 0.63.0 → 0.65.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 +86 -1
- data/docs/architecture.md +32 -5
- data/docs/cli.md +41 -9
- data/docs/configuration.md +143 -11
- data/docs/internals/engine.md +25 -6
- data/docs/plugins.md +7 -0
- data/docs/sessions.md +14 -10
- data/docs/sub-agent.md +21 -4
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +29 -5
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_reports.rb +7 -2
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/composer_route.rb +90 -0
- data/lib/samagotchi/config.rb +11 -7
- data/lib/samagotchi/continue_offer.rb +17 -3
- data/lib/samagotchi/delivery.rb +13 -8
- data/lib/samagotchi/engine.rb +41 -7
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +33 -12
- data/lib/samagotchi/host_model.rb +2 -2
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/interrupted_turn.rb +7 -3
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/pending_input_queue.rb +10 -3
- data/lib/samagotchi/plugin/sessions.rb +27 -5
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +171 -6
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/send_command.rb +63 -19
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +63 -7
- data/lib/samagotchi/session_manager.rb +49 -20
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +72 -57
- data/lib/samagotchi/tool_activity.rb +5 -5
- data/lib/samagotchi/tools/delegate.rb +28 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +11 -4
- data/lib/samagotchi/turn_checkpoint.rb +9 -3
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +96 -13
- data/lib/samagotchi/web/public/commands.js +8 -8
- data/lib/samagotchi/web/public/composer.js +3 -3
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +29 -0
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/timing.js +2 -1
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +29 -12
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/worker.rb +7 -5
- metadata +9 -1
|
@@ -72,7 +72,8 @@ module Samagotchi
|
|
|
72
72
|
started = monotonic
|
|
73
73
|
wait = ReplyWait.call(child_id, state_dir: sd, cursor: cursor.reply_file, timeout: [left, 0].max,
|
|
74
74
|
poll_interval: poll_interval, cancelled: cancelled, baseline: baseline,
|
|
75
|
-
owner_grace: owner_grace, interject: others && -> { others.poll }
|
|
75
|
+
owner_grace: owner_grace, interject: others && -> { others.poll },
|
|
76
|
+
turn: cursor.turn)
|
|
76
77
|
left -= monotonic - started
|
|
77
78
|
unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
|
|
78
79
|
outcomes.concat(relayed_outcomes.delete(key) || [])
|
|
@@ -143,7 +144,9 @@ module Samagotchi
|
|
|
143
144
|
# gave, and the child as that same look loaded it (a fresh load could
|
|
144
145
|
# swallow a turn that ended in between), so a later wait looks for a
|
|
145
146
|
# later turn and a question already reported isn't reported again.
|
|
146
|
-
#
|
|
147
|
+
# The follow-up's turn (DelegateCursor#turn) is done with once its end
|
|
148
|
+
# was reported; a question keeps it. Nothing moves on a timeout or a
|
|
149
|
+
# cancel.
|
|
147
150
|
# @param wait [ReplyWait::Result]
|
|
148
151
|
# @return [DelegateCursor, nil] the new cursor
|
|
149
152
|
def advance(parent_id, child_id, wait, state_dir:)
|
|
@@ -151,6 +154,7 @@ module Samagotchi
|
|
|
151
154
|
|
|
152
155
|
DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |cursor|
|
|
153
156
|
cursor = cursor.with(reply_file: wait.file) if wait.status == :done
|
|
157
|
+
cursor = cursor.with(turn: nil) unless wait.status == :waiting_for_answer
|
|
154
158
|
cursor.with_baseline(ReplyWait.baseline_of(wait.session))
|
|
155
159
|
end
|
|
156
160
|
end
|
|
@@ -187,20 +191,28 @@ module Samagotchi
|
|
|
187
191
|
|
|
188
192
|
# Point the cursor at the newest reply now, so only a later one counts
|
|
189
193
|
# (a follow-up sent to a child that already answered), and take the
|
|
190
|
-
# child's baseline before the follow-up goes in.
|
|
194
|
+
# child's baseline before the follow-up goes in. No turn yet
|
|
195
|
+
# (#mark_turn, once it went in).
|
|
191
196
|
# @param child [Session] the child as loaded before sending
|
|
192
197
|
def mark_seen(parent_id, child, state_dir:)
|
|
193
198
|
newest = ReplyWait.newest_reply(child.id, state_dir: state_dir)
|
|
194
199
|
DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
|
|
195
|
-
cursor.with(reply_file: newest).with_baseline(ReplyWait.baseline_of(child))
|
|
200
|
+
cursor.with(reply_file: newest, turn: nil).with_baseline(ReplyWait.baseline_of(child))
|
|
196
201
|
end
|
|
197
202
|
end
|
|
198
203
|
|
|
204
|
+
# The follow-up went in: the child's turn it joins, whose reply the
|
|
205
|
+
# wait takes (DelegateCursor#turn).
|
|
206
|
+
# @param turn [String, nil]
|
|
207
|
+
def mark_turn(parent_id, child_id, turn, state_dir:)
|
|
208
|
+
DelegateCursors.update(parent_id, child_id, state_dir: state_dir) { |cursor| cursor.with(turn: turn) }
|
|
209
|
+
end
|
|
210
|
+
|
|
199
211
|
# A new child's baseline: as spawned, before its first turn ran.
|
|
200
212
|
# @param child [Session] the session spawn_session returned
|
|
201
213
|
def mark_started(parent_id, child, state_dir:)
|
|
202
214
|
DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
|
|
203
|
-
cursor.with_baseline(ReplyWait.baseline_of(child, question_id: nil))
|
|
215
|
+
cursor.with(turn: nil).with_baseline(ReplyWait.baseline_of(child, question_id: nil))
|
|
204
216
|
end
|
|
205
217
|
end
|
|
206
218
|
|
|
@@ -32,6 +32,10 @@ module Samagotchi
|
|
|
32
32
|
# stopped the turn: its cancel reason is :signal.
|
|
33
33
|
NOT_RUN_ON_SIGNAL = "Error: not run, a signal to chi stopped the turn"
|
|
34
34
|
STOPPED_BY_SIGNAL = "Error: command stopped by a signal to chi"
|
|
35
|
+
# The same two when a session stop (chi sessions stop, the web's stop,
|
|
36
|
+
# a parent stopping its delegate) canceled the turn: :session_stopped.
|
|
37
|
+
NOT_RUN_ON_SESSION_STOP = "Error: not run, the session was stopped"
|
|
38
|
+
STOPPED_BY_SESSION_STOP = "Error: command stopped as the session was stopped"
|
|
35
39
|
# The first words of a command killed at the limit in kill mode (hooks
|
|
36
40
|
# match on it).
|
|
37
41
|
TIMEOUT_PREFIX = "Error: command timed out after"
|
|
@@ -79,7 +83,8 @@ module Samagotchi
|
|
|
79
83
|
# @param cancelled [#call] true once the user stopped the turn: the
|
|
80
84
|
# command is killed (or not started) and its output so far returned
|
|
81
85
|
# @param stop_reason [#call] the stop's reason (:signal: a signal to
|
|
82
|
-
# chi, which the result then names
|
|
86
|
+
# chi, :session_stopped: a session stop, which the result then names
|
|
87
|
+
# instead of the user)
|
|
83
88
|
# @param env [Hash, nil] added to the command's environment
|
|
84
89
|
# @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
|
|
85
90
|
# nil reads execute.on_timeout
|
|
@@ -116,16 +121,18 @@ module Samagotchi
|
|
|
116
121
|
# a long command, and return whatever it printed before it was killed.
|
|
117
122
|
stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
|
|
118
123
|
rescue CommandCancelled => e
|
|
119
|
-
first =
|
|
124
|
+
first = STOPPED_BY.fetch(stop_reason.call, STOPPED_BY_USER)
|
|
120
125
|
stopped_result("#{first} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
|
|
121
126
|
rescue StandardError => e
|
|
122
127
|
"Error: #{e.message}"
|
|
123
128
|
end
|
|
124
129
|
|
|
125
130
|
# What a command the turn's stop kept from starting returns.
|
|
126
|
-
def self.not_run(stop_reason) =
|
|
131
|
+
def self.not_run(stop_reason) = NOT_RUN.fetch(stop_reason.call, NOT_RUN_ON_STOP)
|
|
127
132
|
|
|
128
|
-
|
|
133
|
+
# The texts above by the stop's reason; any other is the user's Stop.
|
|
134
|
+
NOT_RUN = { signal: NOT_RUN_ON_SIGNAL, session_stopped: NOT_RUN_ON_SESSION_STOP }.freeze
|
|
135
|
+
STOPPED_BY = { signal: STOPPED_BY_SIGNAL, session_stopped: STOPPED_BY_SESSION_STOP }.freeze
|
|
129
136
|
|
|
130
137
|
# execute.on_timeout, or the caller's: :task (the default) or :kill.
|
|
131
138
|
def self.on_timeout_mode(value)
|
|
@@ -67,6 +67,7 @@ module Samagotchi
|
|
|
67
67
|
@boundaries = 0
|
|
68
68
|
@written_at = nil
|
|
69
69
|
@started_at = nil
|
|
70
|
+
@enqueued_id = nil
|
|
70
71
|
end
|
|
71
72
|
|
|
72
73
|
# A turn begins (before Engine#run_turn and the worker's running save):
|
|
@@ -74,14 +75,18 @@ module Samagotchi
|
|
|
74
75
|
# images, no turn id), marked at iteration 0, so a worker killed before
|
|
75
76
|
# the loop's first boundary leaves the prompt and a mark the next one
|
|
76
77
|
# settles. A continue turn (no prompt) is the history, marked.
|
|
78
|
+
# Every mark of the turn carries +enqueued_id+ (InterruptedTurn's).
|
|
77
79
|
# @param prompt [String, nil]
|
|
78
|
-
|
|
80
|
+
# @param enqueued_id [String, nil] the input the turn runs
|
|
81
|
+
def begin_turn(prompt: nil, enqueued_id: nil)
|
|
79
82
|
@boundaries = 0
|
|
83
|
+
@enqueued_id = enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
|
|
80
84
|
@written_at = nil
|
|
81
85
|
@started_at = Time.now.iso8601(3)
|
|
82
86
|
messages = Array(@session.messages).dup
|
|
83
87
|
messages << { role: "user", content: prompt } unless prompt.to_s.empty?
|
|
84
|
-
@session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0
|
|
88
|
+
@session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0,
|
|
89
|
+
enqueued_id: @enqueued_id) }
|
|
85
90
|
end
|
|
86
91
|
|
|
87
92
|
# The turn ended (or never began): the session saves its own messages.
|
|
@@ -104,7 +109,8 @@ module Samagotchi
|
|
|
104
109
|
# One level deep: the loop may edit an entry in place later (a
|
|
105
110
|
# forget stub) while a save on another thread serializes this one.
|
|
106
111
|
messages = conversation.map { |message| message.is_a?(Hash) ? message.dup : message } + merged
|
|
107
|
-
marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries
|
|
112
|
+
marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries,
|
|
113
|
+
enqueued_id: @enqueued_id)
|
|
108
114
|
@session.turn_checkpoint = { messages: messages, marker: marker }
|
|
109
115
|
return false unless due? || !merged.empty?
|
|
110
116
|
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -136,13 +136,27 @@ module Samagotchi
|
|
|
136
136
|
# text cut to its last STEP_CHARS. Never
|
|
137
137
|
# sent: the payload builders send a system message's content, and the
|
|
138
138
|
# copies for hooks, plugins and the recap drop it (AnswerDisplay.strip).
|
|
139
|
-
|
|
139
|
+
# +refusal+: the provider refused the answer (ModelResult#refusal:
|
|
140
|
+
# {category:} or {}), kept on the marker and said in the note.
|
|
141
|
+
def empty(retries: 0, steps: [], refusal: nil)
|
|
140
142
|
marker = { retries: retries.to_i }
|
|
141
143
|
marker[:steps] = steps.map { |step| capped_step(step) } unless steps.empty?
|
|
142
|
-
|
|
144
|
+
marker[:refusal] = refusal if refusal
|
|
145
|
+
what = if refusal
|
|
146
|
+
"the previous turn's answer was refused by the provider#{refusal_category(refusal)}"
|
|
147
|
+
else
|
|
148
|
+
"the previous turn ended with no visible answer (thinking only, or nothing)"
|
|
149
|
+
end
|
|
150
|
+
message("#{what}. The user's last message is still unanswered.")
|
|
143
151
|
.merge(EMPTY_ANSWER => marker)
|
|
144
152
|
end
|
|
145
153
|
|
|
154
|
+
# " (cyber)" for a refusal that names its category (either key type), else "".
|
|
155
|
+
def refusal_category(refusal)
|
|
156
|
+
category = (refusal[:category] || refusal["category"] if refusal.is_a?(Hash)).to_s.strip
|
|
157
|
+
category.empty? ? "" : " (#{one_line(category)})"
|
|
158
|
+
end
|
|
159
|
+
|
|
146
160
|
def capped_step(step)
|
|
147
161
|
step.slice(:role, :content, :thinking).to_h do |key, value|
|
|
148
162
|
[key, key == :role || !value.is_a?(String) ? value : tail_of(value)]
|
|
@@ -167,9 +181,12 @@ module Samagotchi
|
|
|
167
181
|
end
|
|
168
182
|
|
|
169
183
|
# The notice every UI shows for a turn that ended with no answer:
|
|
170
|
-
# "no answer: the model returned nothing (after 1 retry)"
|
|
171
|
-
#
|
|
172
|
-
|
|
184
|
+
# "no answer: the model returned nothing (after 1 retry)", or for a
|
|
185
|
+
# +refusal+ (the marker's) "no answer: refused by the provider (cyber)"
|
|
186
|
+
# (turn_events.js emptyAnswerLine words it the same).
|
|
187
|
+
def empty_answer_line(retries, refusal: nil)
|
|
188
|
+
return "no answer: refused by the provider#{refusal_category(refusal)}" if refusal
|
|
189
|
+
|
|
173
190
|
count = retries.to_i
|
|
174
191
|
after = if count.positive?
|
|
175
192
|
" (after #{count} #{count == 1 ? "retry" : "retries"})"
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1944,7 +1944,8 @@ module Samagotchi
|
|
|
1944
1944
|
did = step.is_a?(Hash) ? MessageParts.for_message(step, [], cwd: cwd) : nil
|
|
1945
1945
|
{ role: "assistant", content: "", parts: did } if did
|
|
1946
1946
|
end
|
|
1947
|
-
|
|
1947
|
+
refusal = marker[:refusal] || marker["refusal"]
|
|
1948
|
+
items << { role: "empty_answer", content: "", retries: (marker[:retries] || marker["retries"]).to_i, refusal: refusal }.compact
|
|
1948
1949
|
end
|
|
1949
1950
|
|
|
1950
1951
|
# The last turn failed and its prompt went back to the user: the
|
|
@@ -103,8 +103,11 @@ export function addStarted(model, event = {}, now = null) {
|
|
|
103
103
|
return { row, created: true };
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// A replayed running call a snapshot timed (its elapsed_ms so far) started
|
|
107
|
+
// that long before +now+; any other replayed start is no clock.
|
|
106
108
|
function markStart(row, event, now) {
|
|
107
109
|
if (now !== null && !event.replayed) row.startedAt = now;
|
|
110
|
+
else if (now !== null && event.elapsed_ms != null) row.startedAt = now - Number(event.elapsed_ms);
|
|
108
111
|
else delete row.startedAt;
|
|
109
112
|
}
|
|
110
113
|
|
|
@@ -76,6 +76,10 @@ import { createComposerCommands, routeComposerLine, routeSessionLine } from "./c
|
|
|
76
76
|
import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
|
|
77
77
|
import { createStreamHandlers } from "./stream_handlers.js";
|
|
78
78
|
import { createListsView } from "./lists_view.js";
|
|
79
|
+
import { createDeliveryControl, QUEUE_NEEDS_WORKER } from "./delivery_line.js";
|
|
80
|
+
import { liveSlots } from "./stage_model.js";
|
|
81
|
+
import { currentGen } from "./turn_model.js";
|
|
82
|
+
import { toolName } from "./activity.js";
|
|
79
83
|
|
|
80
84
|
const $ = (s) => document.querySelector(s);
|
|
81
85
|
const topStripEl = $("#topStrip");
|
|
@@ -251,6 +255,28 @@ const liveTurn = createLiveTurn({
|
|
|
251
255
|
onTick: () => updateInfoBar(),
|
|
252
256
|
});
|
|
253
257
|
|
|
258
|
+
// The composer's delivery line while the open session's turn runs
|
|
259
|
+
// (delivery_line.js): where a message typed now goes in, and the cut and
|
|
260
|
+
// queue links.
|
|
261
|
+
const deliveryControl = createDeliveryControl({
|
|
262
|
+
el: $("#deliveryLine"),
|
|
263
|
+
read: () => {
|
|
264
|
+
const turn = turnDom.current?.().turn || null;
|
|
265
|
+
const running = turn ? turn.gens.flatMap((g) => g.tools).reverse().find((r) => r.status === "running") : null;
|
|
266
|
+
return {
|
|
267
|
+
phase: turnDom.phase?.() || liveSlots(turn).phase,
|
|
268
|
+
gen: turn ? currentGen(turn) : null,
|
|
269
|
+
tool: running ? { name: toolName(running), startedAt: running.startedAt ?? null } : null,
|
|
270
|
+
cutAfter: liveTurn.cutAfter,
|
|
271
|
+
serverGen: liveTurn.serverGen,
|
|
272
|
+
canQueue: current.workerFeatures.includes("delivery"),
|
|
273
|
+
model: current.model,
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
composerEmpty: () => !promptEl.value.trim() && !chips.length,
|
|
277
|
+
send: (delivery) => handleSendTurn({ delivery }),
|
|
278
|
+
});
|
|
279
|
+
|
|
254
280
|
// This tab's id in the session's events: tells its own prompts from the
|
|
255
281
|
// ones other clients (an attached TUI, another tab, reminders) sent.
|
|
256
282
|
const clientId = newClientId();
|
|
@@ -552,8 +578,9 @@ function updateComposerMode() {
|
|
|
552
578
|
}
|
|
553
579
|
const running = liveTurn.running && !!selected;
|
|
554
580
|
cancelBtn.style.display = running ? "" : "none";
|
|
581
|
+
syncDeliveryLine();
|
|
555
582
|
if (running) {
|
|
556
|
-
setPlaceholder("Running… a message
|
|
583
|
+
setPlaceholder("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…");
|
|
557
584
|
composeHintEl.textContent = SEND_HINT;
|
|
558
585
|
actionBtn.textContent = "Send";
|
|
559
586
|
emptyEl.classList.add("hidden");
|
|
@@ -574,7 +601,8 @@ function updateComposerMode() {
|
|
|
574
601
|
actionBtn.disabled = true;
|
|
575
602
|
emptyEl.classList.add("hidden");
|
|
576
603
|
} else {
|
|
577
|
-
|
|
604
|
+
// The keys are in the delivery line's row above.
|
|
605
|
+
setPlaceholder("Message chi…", "Message chi…");
|
|
578
606
|
composeHintEl.textContent = SEND_HINT;
|
|
579
607
|
actionBtn.textContent = "Send";
|
|
580
608
|
actionBtn.disabled = false;
|
|
@@ -582,6 +610,14 @@ function updateComposerMode() {
|
|
|
582
610
|
}
|
|
583
611
|
}
|
|
584
612
|
|
|
613
|
+
// The delivery line's row in a session (delivery_line.js): the key hint
|
|
614
|
+
// while idle, where a message goes in while the turn runs.
|
|
615
|
+
function syncDeliveryLine() {
|
|
616
|
+
// A terminal owns the session: the composer is off, so is its row.
|
|
617
|
+
if (!selected || current.owner === "tui") deliveryControl.show("off");
|
|
618
|
+
else deliveryControl.show(liveTurn.running && current.owner !== "tui" ? "running" : "idle");
|
|
619
|
+
}
|
|
620
|
+
|
|
585
621
|
function setTurnRunning(running) {
|
|
586
622
|
liveTurn.running = !!running;
|
|
587
623
|
updateComposerMode();
|
|
@@ -790,6 +826,9 @@ function renderSessionView(data, { kept = [] } = {}) {
|
|
|
790
826
|
});
|
|
791
827
|
// Marked replayed: a call's duration isn't timed from the replay's clock.
|
|
792
828
|
replay.forEach((event) => streamHandlers[event.type]?.({ ...event, replayed: true }));
|
|
829
|
+
// A join mid-generation: the worker's word on it clocks the delivery line.
|
|
830
|
+
const gen = data.current_turn?.generation;
|
|
831
|
+
liveTurn.serverGen = gen ? { ...gen, at: Date.now() } : null;
|
|
793
832
|
if (!data.current_turn) {
|
|
794
833
|
setTurnRunning(current.status === "running");
|
|
795
834
|
if (liveTurn.running && current.timing.activeTurn?.started_at) liveTurn.timing.start(current.timing.activeTurn.started_at, activeTurnId);
|
|
@@ -997,11 +1036,13 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
|
|
|
997
1036
|
// prompts are echoed at send (data-own) and tagged by their turn_enqueued.
|
|
998
1037
|
|
|
999
1038
|
// The bubble a reload draws (turn_html.js), with this page's own fields.
|
|
1000
|
-
|
|
1039
|
+
// +delivery+: how it went into a running turn (next_step, cut, queue): its
|
|
1040
|
+
// queued badge says what it waits for.
|
|
1041
|
+
function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null } = {}) {
|
|
1001
1042
|
const hint = historyEl.querySelector(".hint");
|
|
1002
1043
|
if (hint) hint.remove();
|
|
1003
1044
|
const div = elementOf(userBubbleTemplate({ content: prompt }, {
|
|
1004
|
-
label, state, own, enqueuedId, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
|
|
1045
|
+
label, state, own, enqueuedId, delivery, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
|
|
1005
1046
|
}));
|
|
1006
1047
|
const follow = isNearBottom();
|
|
1007
1048
|
appendToHistory(div);
|
|
@@ -1018,7 +1059,17 @@ function setBubbleState(bubble, state) {
|
|
|
1018
1059
|
}
|
|
1019
1060
|
bubble.classList.add(state);
|
|
1020
1061
|
bubble.dataset.userState = state;
|
|
1021
|
-
bubble.append(elementOf(stateBadgeTemplate(state)));
|
|
1062
|
+
bubble.append(elementOf(stateBadgeTemplate(state, bubble.dataset.delivery || null)));
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// A bubble's delivery changed (the ack's cut: off goes in at the next
|
|
1066
|
+
// step; a queue to a turn that ended meanwhile is just the next turn): its
|
|
1067
|
+
// badge follows.
|
|
1068
|
+
function setBubbleDelivery(bubble, delivery) {
|
|
1069
|
+
if ((bubble.dataset.delivery || null) === (delivery || null)) return;
|
|
1070
|
+
if (delivery) bubble.dataset.delivery = delivery;
|
|
1071
|
+
else delete bubble.dataset.delivery;
|
|
1072
|
+
if (bubble.dataset.userState) setBubbleState(bubble, bubble.dataset.userState);
|
|
1022
1073
|
}
|
|
1023
1074
|
|
|
1024
1075
|
function bubbleById(enqueuedId) {
|
|
@@ -1047,15 +1098,18 @@ function applyPromptOps(event) {
|
|
|
1047
1098
|
if (op.op === "report") {
|
|
1048
1099
|
liveTurn.unmatchedMerge = "report";
|
|
1049
1100
|
} else if (op.op === "add") {
|
|
1050
|
-
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
|
|
1101
|
+
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
|
|
1051
1102
|
if (op.state === "started") started = bubble;
|
|
1052
1103
|
if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
|
|
1053
1104
|
} else if (op.op === "tag") {
|
|
1054
1105
|
if (bubbleById(op.enqueuedId)) continue;
|
|
1055
1106
|
const echo = untaggedOwnBubble(op.prompt);
|
|
1056
|
-
if (echo)
|
|
1107
|
+
if (echo) {
|
|
1108
|
+
echo.dataset.enqueuedId = op.enqueuedId;
|
|
1109
|
+
if (op.delivery) setBubbleDelivery(echo, op.delivery);
|
|
1110
|
+
}
|
|
1057
1111
|
// Re-rendered from a snapshot: the echo is gone, the prompt still queued.
|
|
1058
|
-
else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images });
|
|
1112
|
+
else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
|
|
1059
1113
|
} else if (op.op === "start") {
|
|
1060
1114
|
const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
|
|
1061
1115
|
// A resync (a new worker woken by this page's send) re-rendered the
|
|
@@ -1683,10 +1737,14 @@ function handleCreate() {
|
|
|
1683
1737
|
return handleSendTurn();
|
|
1684
1738
|
}
|
|
1685
1739
|
|
|
1686
|
-
|
|
1740
|
+
// +delivery+: the composer's choice for a running turn (Alt+Enter: queue;
|
|
1741
|
+
// a delivery-line link: cut or queue); else the armed one; a typed /cut or
|
|
1742
|
+
// /queue wins over both.
|
|
1743
|
+
async function handleSendTurn({ delivery: chosen = null } = {}) {
|
|
1687
1744
|
if (current.owner === "tui") return;
|
|
1688
1745
|
let prompt = turnText(promptEl.value, chips);
|
|
1689
1746
|
if (!prompt) return;
|
|
1747
|
+
const armed = deliveryControl.take();
|
|
1690
1748
|
// An image-only send's text is its placeholders: not for ↑.
|
|
1691
1749
|
const typed = promptEl.value.trim() !== "";
|
|
1692
1750
|
// The composer holds the line as typed until the ack (composerAfterAck).
|
|
@@ -1727,20 +1785,25 @@ async function handleSendTurn() {
|
|
|
1727
1785
|
}
|
|
1728
1786
|
if (route.command) return composerCommands.send(route.command);
|
|
1729
1787
|
prompt = route.send;
|
|
1730
|
-
const
|
|
1788
|
+
const running = liveTurn.running && !!selected;
|
|
1789
|
+
const delivery = route.delivery || (running ? chosen || armed : null);
|
|
1731
1790
|
const hint = historyEl.querySelector(".hint");
|
|
1732
1791
|
if (hint) hint.remove();
|
|
1733
1792
|
// Sending moves a finished turn out of the stage at once (else the echo
|
|
1734
1793
|
// would land among its rows); into a running one it is a queued row there.
|
|
1735
1794
|
turnDom.handOff?.({ immediate: true });
|
|
1736
1795
|
const sending = chips.slice();
|
|
1737
|
-
|
|
1796
|
+
// Into a running turn the echo says what it waits for (its delivery); a
|
|
1797
|
+
// line with images always waits for the turn's end (the worker queues it).
|
|
1798
|
+
const shownDelivery = running ? (sending.length ? "queue" : delivery || "next_step") : null;
|
|
1799
|
+
const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending, delivery: shownDelivery });
|
|
1738
1800
|
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1739
1801
|
if (!current.firstPreview || current.firstPreview==="—") {
|
|
1740
1802
|
current.firstPreview = firstPreview(prompt);
|
|
1741
1803
|
updateInfoBar();
|
|
1742
1804
|
}
|
|
1743
1805
|
actionBtn.disabled = true;
|
|
1806
|
+
const sentTo = selected;
|
|
1744
1807
|
try {
|
|
1745
1808
|
for (const chip of sending) {
|
|
1746
1809
|
if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
|
|
@@ -1755,6 +1818,11 @@ async function handleSendTurn() {
|
|
|
1755
1818
|
return;
|
|
1756
1819
|
}
|
|
1757
1820
|
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
1821
|
+
// A cut the worker didn't take (no thinking-only step: cut off) goes in
|
|
1822
|
+
// at the next step; a queue whose turn ended meanwhile is the next turn.
|
|
1823
|
+
if (ack?.cut === "off" || (ack?.delivery === "next_step" && echo.dataset.delivery === "queue")) {
|
|
1824
|
+
setBubbleDelivery(echo, ack.cut === "off" ? "next_step" : null);
|
|
1825
|
+
}
|
|
1758
1826
|
refreshHistory();
|
|
1759
1827
|
// A turn that failed fast was restored before this ack: its prompt
|
|
1760
1828
|
// (and images) go back into the composer, without replacing text typed
|
|
@@ -1787,6 +1855,11 @@ async function handleSendTurn() {
|
|
|
1787
1855
|
updateComposerMode();
|
|
1788
1856
|
return;
|
|
1789
1857
|
}
|
|
1858
|
+
// A stale tab sent into a session deleted meanwhile: the start page
|
|
1859
|
+
// says so, and the line stays in the composer.
|
|
1860
|
+
if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
|
|
1861
|
+
// A /queue to a worker that can't queue (it would steer): its words, the text kept.
|
|
1862
|
+
if (e.code === "delivery_unavailable") return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
|
|
1790
1863
|
alert(e.message);
|
|
1791
1864
|
} finally {
|
|
1792
1865
|
actionBtn.disabled = current.owner === "tui";
|
|
@@ -2112,10 +2185,20 @@ function historyKey(e) {
|
|
|
2112
2185
|
promptEl.addEventListener("keydown", (e) => {
|
|
2113
2186
|
if (commandList.handleKey(e)) return;
|
|
2114
2187
|
if (historyKey(e)) return;
|
|
2115
|
-
|
|
2188
|
+
// Esc lets go of a choice armed on the delivery line.
|
|
2189
|
+
if (e.key === "Escape" && deliveryControl.disarm()) {
|
|
2190
|
+
e.preventDefault();
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
// An IME's Enter picks the composition, it doesn't send.
|
|
2194
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
2116
2195
|
e.preventDefault();
|
|
2117
2196
|
if (!selected) handleCreate();
|
|
2118
|
-
|
|
2197
|
+
// Alt/Option+Enter: queued for after the running turn (D6), only to a
|
|
2198
|
+
// worker that queues (else it would steer): the text stays, a note says why.
|
|
2199
|
+
else if (e.altKey && liveTurn.running && !current.workerFeatures.includes("delivery")) {
|
|
2200
|
+
addCommandBubble({ label: null, line: promptEl.value.trim(), text: QUEUE_NEEDS_WORKER, failed: true });
|
|
2201
|
+
} else handleSendTurn(e.altKey ? { delivery: "queue" } : {});
|
|
2119
2202
|
}
|
|
2120
2203
|
});
|
|
2121
2204
|
$("#infoStopBtn").addEventListener("click", async () => {
|
|
@@ -23,16 +23,17 @@ export function sessionCommandLine(text, commands) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
// /cut TEXT and /queue TEXT: the UIs' own words for a delivery (listed
|
|
26
|
-
// for Tab and /help, never run as commands;
|
|
27
|
-
//
|
|
26
|
+
// for Tab and /help, never run as commands; ComposerRoute::STEER_CHOICE
|
|
27
|
+
// (spec/fixtures/composer_routes.json): the word, then any whitespace and the text). During a
|
|
28
28
|
// turn /cut TEXT is the text sent as a cut (POST /turn, delivery "cut"),
|
|
29
|
-
// as chi send --cut does
|
|
30
|
-
//
|
|
31
|
-
//
|
|
29
|
+
// as chi send --cut does, and /queue TEXT the text queued to run as a turn
|
|
30
|
+
// of its own after this one (delivery "queue"; a worker that can't queue
|
|
31
|
+
// refuses it, and the page shows the refusal's detail). With no running
|
|
32
|
+
// turn both are the text as a normal turn. The bare word gets the usage
|
|
33
|
+
// line.
|
|
32
34
|
// @return {{send: string, delivery?: string} | {reply: string} | null}
|
|
33
35
|
// null: not a delivery line
|
|
34
36
|
export const STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)";
|
|
35
|
-
export const QUEUE_UNAVAILABLE_WEB = "(/queue isn't available in the web yet; a plain message goes in at the running turn's next step, and /cut TEXT cuts in)";
|
|
36
37
|
const STEER_CHOICE = /^\/(cut|queue)(?:\s+([\s\S]*))?$/;
|
|
37
38
|
|
|
38
39
|
export function steerChoiceRoute(text, { running = false } = {}) {
|
|
@@ -41,8 +42,7 @@ export function steerChoiceRoute(text, { running = false } = {}) {
|
|
|
41
42
|
const message = (match[2] || "").trim();
|
|
42
43
|
if (!message) return { reply: STEER_CHOICE_USAGE };
|
|
43
44
|
if (!running) return { send: message };
|
|
44
|
-
|
|
45
|
-
return { send: message, delivery: "cut" };
|
|
45
|
+
return { send: message, delivery: match[1] };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// A line that is one word: a slash and a name, no spaces, no second slash
|
|
@@ -7,8 +7,8 @@ import { sessionCommandLine, startPageReplyOrHint, steerChoiceRoute, unknownComm
|
|
|
7
7
|
|
|
8
8
|
// Where a composer line goes, in the order the page reads it:
|
|
9
9
|
// 1. /cut TEXT, /queue TEXT (steerChoiceRoute): during a turn in a session
|
|
10
|
-
// the text as a cut
|
|
11
|
-
// as the line.
|
|
10
|
+
// the text as a cut or queued for after the turn; else the text goes
|
|
11
|
+
// on as the line.
|
|
12
12
|
// 2. On the start page (+session+ false): the page's own reply or a typo
|
|
13
13
|
// hint (startPageReplyOrHint) needs no session; anything else makes one.
|
|
14
14
|
// 3. In a session (routeSessionLine): a session command, the page's own
|
|
@@ -21,7 +21,7 @@ import { sessionCommandLine, startPageReplyOrHint, steerChoiceRoute, unknownComm
|
|
|
21
21
|
// +send+ again in it (routeSessionLine, with its
|
|
22
22
|
// commands)
|
|
23
23
|
// {command} POST /command
|
|
24
|
-
// {send, delivery} POST /turn (+delivery+ "cut" or null)
|
|
24
|
+
// {send, delivery} POST /turn (+delivery+ "cut", "queue" or null)
|
|
25
25
|
export function routeComposerLine(text, { session = true, running = false, commands = [], images = 0 } = {}) {
|
|
26
26
|
const choice = steerChoiceRoute(text, { running: running && session });
|
|
27
27
|
if (choice?.reply) return { reply: choice.reply, line: text };
|