samagotchi 0.44.0 → 0.45.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 +70 -1
- data/docs/cli.md +29 -7
- data/docs/context.md +2 -1
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +8 -4
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
- data/lib/samagotchi/bridge.rb +22 -10
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/commands/registry.rb +54 -9
- data/lib/samagotchi/engine.rb +8 -3
- data/lib/samagotchi/host_registry.rb +28 -3
- data/lib/samagotchi/llm_context_strategy.rb +37 -0
- data/lib/samagotchi/model_notes.rb +8 -6
- data/lib/samagotchi/model_overlay.rb +3 -2
- data/lib/samagotchi/model_profile.rb +71 -2
- data/lib/samagotchi/plugin/context.rb +19 -7
- data/lib/samagotchi/self_report.rb +31 -2
- data/lib/samagotchi/session_commands.rb +24 -7
- data/lib/samagotchi/session_manager.rb +19 -1
- data/lib/samagotchi/session_metrics.rb +39 -6
- data/lib/samagotchi/sessions_command.rb +15 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +19 -1
- data/lib/samagotchi/terminal_ui.rb +84 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +7 -9
- data/lib/samagotchi/web/public/app.js +23 -15
- data/lib/samagotchi/web/public/command_complete.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +42 -15
- data/lib/samagotchi/web/public/index.html +3 -1
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
- data/lib/samagotchi/web/public/stage_model.js +18 -4
- data/lib/samagotchi/web/public/stage_view.js +3 -3
- data/lib/samagotchi/web/public/turn_events.js +16 -4
- data/lib/samagotchi/web/session_hub.rb +9 -1
- data/lib/samagotchi/web/session_summary.rb +19 -2
- data/lib/samagotchi/worker.rb +156 -20
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +1 -1
|
@@ -302,6 +302,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
302
302
|
.note-text{white-space:pre-wrap;max-height:8em;overflow:auto}
|
|
303
303
|
.bubble.command.busy{border-left-color:var(--warn)}
|
|
304
304
|
.bubble.command.failed{border-left-color:var(--danger)}
|
|
305
|
+
.bubble.command.queued{border-left-style:dashed}
|
|
306
|
+
.bubble.command.queued .command-output{color:var(--text-faint)}
|
|
305
307
|
.command-line{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-3)}
|
|
306
308
|
.command-output{margin:6px 0 0;white-space:pre-wrap;font-size:12px;color:var(--text-2)}
|
|
307
309
|
.bubble.continue{border-left:3px solid var(--accent);background:var(--bg-panel);padding:10px 12px}
|
|
@@ -612,7 +614,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
612
614
|
.ts-trail-item:is(.error,.blocked){color:var(--danger-text)}
|
|
613
615
|
.ts-trail-item.warn{color:var(--warn-text)}
|
|
614
616
|
.ts-trail-item.stopped{color:var(--warn-text);opacity:.8}
|
|
615
|
-
.ts-trail-item.info,.ts-trail-item.steer{color:var(--accent)}
|
|
617
|
+
.ts-trail-item.info,.ts-trail-item.steer,.ts-trail-item.edit{color:var(--accent)}
|
|
616
618
|
.ts-trail-item.none{font-style:italic}
|
|
617
619
|
/* A card waits: the tool row and trail give it their room. */
|
|
618
620
|
.turn-stage.waiting .ts-now,.turn-stage.waiting .ts-trail{display:none}
|
|
@@ -179,9 +179,9 @@ export function formHtml(summary, escapeHtml, { running = false, kind = SESSION,
|
|
|
179
179
|
`<label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off" ` +
|
|
180
180
|
`placeholder="follow the model (64000, 64k or off)" value="${escapeHtml(own.budget)}"></label>` +
|
|
181
181
|
`<div class="ctx-pop-error llmctx-error" hidden></div>` +
|
|
182
|
-
`<div class="ctx-pop-meta">${when}${running ? " A turn is running:
|
|
182
|
+
`<div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div>` +
|
|
183
183
|
`<div class="ctx-pop-actions"><button type="button" class="ctx-pop-detach" data-act="reset">follow the model</button>` +
|
|
184
|
-
`<button type="submit" class="llmctx-set"
|
|
184
|
+
`<button type="submit" class="llmctx-set">Set</button></div></form>`;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
const EDGE = 16;
|
|
@@ -263,8 +263,6 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
|
|
|
263
263
|
if (!data || session() !== openFor) return close();
|
|
264
264
|
const now = pop.querySelector(".llmctx-now");
|
|
265
265
|
if (now) now.textContent = nowText(data);
|
|
266
|
-
const set = pop.querySelector(".llmctx-set");
|
|
267
|
-
if (set) set.disabled = running();
|
|
268
266
|
}
|
|
269
267
|
|
|
270
268
|
el.addEventListener("click", (e) => {
|
|
@@ -198,15 +198,29 @@ export function handOffOrder({ prompt = null, block = null, extras = [], answer
|
|
|
198
198
|
// history (it is idle-only).
|
|
199
199
|
// What flashes in the trail for a few seconds while the turn runs (the
|
|
200
200
|
// rows themselves go into their step inside the closed cloud): a hook's
|
|
201
|
-
// notice, warn or info, as its row reads,
|
|
202
|
-
//
|
|
203
|
-
//
|
|
201
|
+
// notice, warn or info, as its row reads, the ✂ row of an applied LLM
|
|
202
|
+
// context edit (kind "edit"), a steer-cut row (a message cut in on a
|
|
203
|
+
// thinking-only generation; kind "steer"), and a plugin's nudge (steer).
|
|
204
|
+
// The row's leading mark ("✂ ", "↪ ") goes: FLASH_MARKS's stands in.
|
|
205
|
+
// chi's own asking-again row (a notice with a line and no hook or cls)
|
|
206
|
+
// does not: the status line tells that already. null: nothing flashes.
|
|
207
|
+
// A hookless notice's cls names its flash: [cls, trail kind, the leading
|
|
208
|
+
// mark the line loses].
|
|
209
|
+
const HOOKLESS_FLASH = [
|
|
210
|
+
["llm-context", "edit", /^✂ /],
|
|
211
|
+
["steer-cut", "steer", /^↪ /],
|
|
212
|
+
];
|
|
204
213
|
export function flashOf(what, data = {}) {
|
|
205
214
|
if (what === "steer") {
|
|
206
215
|
const sender = steerSender(data.source);
|
|
207
216
|
return { kind: "steer", text: sender ? `${sender} nudged the model` : "nudged the model" };
|
|
208
217
|
}
|
|
209
|
-
if (what !== "notice"
|
|
218
|
+
if (what !== "notice") return null;
|
|
219
|
+
if (!data.hook) {
|
|
220
|
+
const flash = HOOKLESS_FLASH.find(([cls]) => cls === data.cls);
|
|
221
|
+
if (!flash || !data.line) return null;
|
|
222
|
+
return { kind: flash[1], text: data.line.replace(flash[2], "") };
|
|
223
|
+
}
|
|
210
224
|
return { kind: data.level === "warn" ? "warn" : "info", text: data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}` };
|
|
211
225
|
}
|
|
212
226
|
|
|
@@ -28,9 +28,9 @@ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.contin
|
|
|
28
28
|
// and never holds the hand-off (a turn cut short can leave it open).
|
|
29
29
|
const ASKS = ".plugin-card[data-in-turn] .card-action";
|
|
30
30
|
const MARKS = { answered: "✓", "no answer": "∅", canceled: "■", failed: "✕" };
|
|
31
|
-
// The mark of a flash in the trail: a warn notice, a hook's info notice,
|
|
32
|
-
// plugin's nudge (
|
|
33
|
-
const FLASH_MARKS = { warn: "⚠", info: "ℹ", steer: "↪" };
|
|
31
|
+
// The mark of a flash in the trail: a warn notice, a hook's info notice,
|
|
32
|
+
// an applied LLM context edit (✂), a steer-cut row and a plugin's nudge (↪).
|
|
33
|
+
const FLASH_MARKS = { warn: "⚠", info: "ℹ", edit: "✂", steer: "↪" };
|
|
34
34
|
|
|
35
35
|
function node(tag, cls, parent = null) {
|
|
36
36
|
const el = document.createElement(tag);
|
|
@@ -339,18 +339,26 @@ export function continueLine(choice, reason = "") {
|
|
|
339
339
|
// tab's own), `resync` says the conversation changed (!rollback, !cmd, a
|
|
340
340
|
// continue answered no) and must be read again. An anytime command's
|
|
341
341
|
// (`anytime`, D8) bubble is drawn at its command_queued, before the cards
|
|
342
|
-
// it shows; its command_ran fills that bubble in (`commandId`).
|
|
342
|
+
// it shows; its command_ran fills that bubble in (`commandId`). So is a
|
|
343
|
+
// command queued for after the running turn (`queued`): its command_queued
|
|
344
|
+
// (waits: "turn_end") draws it `waiting`, saying so, until it ran or was
|
|
345
|
+
// dropped (styled as failed).
|
|
346
|
+
export const QUEUED_TEXT = "queued: runs after this turn";
|
|
347
|
+
|
|
343
348
|
export function commandView(event, myId) {
|
|
344
349
|
const own = isOwn(event.client_id, myId);
|
|
350
|
+
const waiting = event.waits === "turn_end";
|
|
345
351
|
return {
|
|
346
352
|
label: own ? null : clientLabel(event.client_id),
|
|
347
353
|
line: event.line || "",
|
|
348
|
-
text: event.output || "",
|
|
354
|
+
text: event.output || (waiting ? QUEUED_TEXT : ""),
|
|
349
355
|
busy: event.status === "busy",
|
|
350
|
-
failed: event.status === "error",
|
|
356
|
+
failed: event.status === "error" || event.status === "dropped",
|
|
351
357
|
resync: (event.changed || []).includes("messages"),
|
|
352
358
|
modelName: event.model_name || null,
|
|
353
359
|
anytime: event.anytime === true,
|
|
360
|
+
queued: waiting || event.queued === true,
|
|
361
|
+
waiting,
|
|
354
362
|
commandId: event.command_id || null,
|
|
355
363
|
// A card's action (Nudge…): no bubble unless it says something back.
|
|
356
364
|
hidden: event.card === true && !event.output && event.status !== "error",
|
|
@@ -498,7 +506,7 @@ export function replaysDrawnTurn(event, turnRecords = []) {
|
|
|
498
506
|
// A `notice` part carries its event as it came (TurnNotice). The TUI replays
|
|
499
507
|
// a turn the same way (TurnAccumulator.replay_events); spec/shared/
|
|
500
508
|
// turn_snapshot.json pins both.
|
|
501
|
-
export function snapshotEvents({ current_turn: turn = null, queued = [], started_at = undefined } = {}) {
|
|
509
|
+
export function snapshotEvents({ current_turn: turn = null, queued = [], queued_commands: commands = [], started_at = undefined } = {}) {
|
|
502
510
|
const events = [];
|
|
503
511
|
if (turn) {
|
|
504
512
|
events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at }, turn.images));
|
|
@@ -576,6 +584,10 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
|
|
|
576
584
|
for (const entry of queued || []) {
|
|
577
585
|
events.push(withImages({ type: "turn_enqueued", enqueued_id: entry.enqueued_id, client_id: entry.client_id, prompt: entry.prompt }, entry.images));
|
|
578
586
|
}
|
|
587
|
+
// Commands waiting for the turn's end: their bubbles, as live.
|
|
588
|
+
for (const entry of commands || []) {
|
|
589
|
+
events.push({ type: "command_queued", command_id: entry.command_id, client_id: entry.client_id, line: entry.line, waits: "turn_end" });
|
|
590
|
+
}
|
|
579
591
|
return events;
|
|
580
592
|
}
|
|
581
593
|
|
|
@@ -297,7 +297,7 @@ module Samagotchi
|
|
|
297
297
|
|
|
298
298
|
entry.owner = owner
|
|
299
299
|
entry.live = live
|
|
300
|
-
summary = SessionSummary.build(entry.session, owner: owner, session_dir: dir)
|
|
300
|
+
summary = SessionSummary.build(entry.session, owner: owner, session_dir: dir, registry: hub_registry)
|
|
301
301
|
# A new worker (pid) or a new Bridge for the same session is an
|
|
302
302
|
# event for the page even when the summary reads the same: it
|
|
303
303
|
# reconnects its stream on it.
|
|
@@ -307,6 +307,14 @@ module Samagotchi
|
|
|
307
307
|
emit("session", session: summary)
|
|
308
308
|
end
|
|
309
309
|
|
|
310
|
+
# One HostRegistry for the hub's lifetime: every session's ctx_pct may
|
|
311
|
+
# need its llm_context budget resolved, and the registry's config read
|
|
312
|
+
# is what would otherwise repeat per scan and per session. Built on
|
|
313
|
+
# the first summary that needs it.
|
|
314
|
+
def hub_registry
|
|
315
|
+
@hub_registry ||= HostRegistry.new
|
|
316
|
+
end
|
|
317
|
+
|
|
310
318
|
def drop(id)
|
|
311
319
|
entry = @sessions.delete(id)
|
|
312
320
|
emit("session_gone", id: id) if entry&.summary
|
|
@@ -4,6 +4,8 @@ require_relative "../session"
|
|
|
4
4
|
require_relative "../recap_store"
|
|
5
5
|
require_relative "../archive_store"
|
|
6
6
|
require_relative "../session_metrics"
|
|
7
|
+
require_relative "../llm_context_strategy"
|
|
8
|
+
require_relative "../host_registry"
|
|
7
9
|
require_relative "../bridge_client"
|
|
8
10
|
require_relative "../worker_sidecar"
|
|
9
11
|
require_relative "../bridge/pending_card"
|
|
@@ -26,12 +28,18 @@ module Samagotchi
|
|
|
26
28
|
# @param root_cache [Hash, nil] ProjectScope.root_for's cache, shared
|
|
27
29
|
# across one listing (a session saved before project_root existed
|
|
28
30
|
# looks its project up)
|
|
31
|
+
# @param registry [HostRegistry, nil] the hosts to resolve the
|
|
32
|
+
# session's llm_context budget against (its ctx_pct counts against
|
|
33
|
+
# the smaller of that budget and the window, as the live meter
|
|
34
|
+
# does). nil: one is built here; a caller that builds many summaries
|
|
35
|
+
# (the session hub) keeps one and passes it.
|
|
29
36
|
# @return [Hash] symbol keys
|
|
30
|
-
def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil
|
|
37
|
+
def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil,
|
|
38
|
+
registry: nil)
|
|
31
39
|
used = Array(session.used_memory_names)
|
|
32
40
|
up = bridge_up?(session_dir, owner)
|
|
33
41
|
sidecar = up ? WorkerSidecar.read(session_dir) : nil
|
|
34
|
-
saved = SessionMetrics.saved_summary(session_dir)
|
|
42
|
+
saved = SessionMetrics.saved_summary(session_dir, budget_tokens: budget_resolver(session, registry))
|
|
35
43
|
{
|
|
36
44
|
id: session.id,
|
|
37
45
|
status: status,
|
|
@@ -86,6 +94,15 @@ module Samagotchi
|
|
|
86
94
|
}
|
|
87
95
|
end
|
|
88
96
|
|
|
97
|
+
# The budget resolver SessionMetrics.saved_summary takes: a callable,
|
|
98
|
+
# so the session's llm_context budget (config + the registry's index,
|
|
99
|
+
# no network) is resolved only for a session that has a saved context
|
|
100
|
+
# to count. One registry per caller (the hub) or one built here.
|
|
101
|
+
def budget_resolver(session, registry)
|
|
102
|
+
-> { LLMContextStrategy.session_budget(session, registry: registry ||= HostRegistry.new) }
|
|
103
|
+
end
|
|
104
|
+
private_class_method :budget_resolver
|
|
105
|
+
|
|
89
106
|
# status is turn state (idle/running). The live worker's snapshot is the
|
|
90
107
|
# truth; on disk, a "running" with no live owner was left by a worker
|
|
91
108
|
# that died mid-turn.
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -59,6 +59,8 @@ module Samagotchi
|
|
|
59
59
|
# How much of a command's output goes into its :command_ran.
|
|
60
60
|
COMMAND_OUTPUT_LIMIT = 4096
|
|
61
61
|
BUSY_OUTPUT = "busy: wait for the turn to end"
|
|
62
|
+
# !rollback mid-turn would mean "the turn I'm in".
|
|
63
|
+
ROLLBACK_BUSY_OUTPUT = "busy: Ctrl-C the turn first, then !rollback"
|
|
62
64
|
|
|
63
65
|
# Wakes the worker loop. Whoever queues work writes it first and wakes
|
|
64
66
|
# after, and #wait drains every wake before the loop looks for work: a
|
|
@@ -184,7 +186,10 @@ module Samagotchi
|
|
|
184
186
|
# queued while that turn ran.
|
|
185
187
|
@turn_end_seq = 0
|
|
186
188
|
@engine.subscribe(observer: lambda { |event|
|
|
187
|
-
|
|
189
|
+
if Events::TURN_END.include?(event[:type])
|
|
190
|
+
@turn_end_seq = event[:event_seq]
|
|
191
|
+
@turn_end_type = event[:type]
|
|
192
|
+
end
|
|
188
193
|
@turn_id = event[:turn_id] if event[:type] == :turn_started
|
|
189
194
|
ring_question(event) if event[:type] == :question_requested
|
|
190
195
|
})
|
|
@@ -198,7 +203,9 @@ module Samagotchi
|
|
|
198
203
|
context_absorber: @context_absorber, wakes: @wakes,
|
|
199
204
|
awaiting_continue: -> { @turn_flow.awaiting_continue? },
|
|
200
205
|
stopped: -> { stopped_on_disk? },
|
|
201
|
-
queue_command:
|
|
206
|
+
queue_command: lambda { |line, client_id, after_file|
|
|
207
|
+
@bridge.queue_command(line, client_id: client_id, after_file: after_file)
|
|
208
|
+
})
|
|
202
209
|
# /model's default is the config's, as in the REPL; the Engine started
|
|
203
210
|
# on the session's model.
|
|
204
211
|
@default_model = ModelProfile.required_model_name(nil)
|
|
@@ -215,14 +222,7 @@ module Samagotchi
|
|
|
215
222
|
def serve
|
|
216
223
|
@bridge = SessionManager.start_bridge(engine: @engine, state_dir: @state_dir, session_id: @session_id,
|
|
217
224
|
on_input: -> { @waker.wake },
|
|
218
|
-
on_command:
|
|
219
|
-
next start_anytime_command(command) if anytime_command?(command[:line])
|
|
220
|
-
|
|
221
|
-
# Called with the event log held: the
|
|
222
|
-
# count says which turn ends came before it.
|
|
223
|
-
@command_queue << command.merge(after_seq: @engine.event_count)
|
|
224
|
-
@waker.wake
|
|
225
|
-
},
|
|
225
|
+
on_command: method(:on_command),
|
|
226
226
|
on_exit_request: method(:exit_request),
|
|
227
227
|
# Decided again as it leaves (a note may still come in).
|
|
228
228
|
exit_discards: method(:empty_session?))
|
|
@@ -307,6 +307,9 @@ module Samagotchi
|
|
|
307
307
|
# A stop between two queued turns leaves the rest queued.
|
|
308
308
|
break if stopped_on_disk?
|
|
309
309
|
|
|
310
|
+
# Commands that came before this file (D1: a queued /model X sent
|
|
311
|
+
# before this prompt, a command an earlier file carried) run first.
|
|
312
|
+
run_queued_commands
|
|
310
313
|
@inbound.absorb_notes
|
|
311
314
|
run_input_file(input_file)
|
|
312
315
|
end
|
|
@@ -334,6 +337,7 @@ module Samagotchi
|
|
|
334
337
|
# group) before the Engine does.
|
|
335
338
|
def teardown
|
|
336
339
|
@context_poller&.stop
|
|
340
|
+
drop_queued_commands
|
|
337
341
|
@engine&.shutdown
|
|
338
342
|
@bridge&.stop
|
|
339
343
|
# The process exits next: a parent wake it rang for (a crash's
|
|
@@ -549,29 +553,122 @@ module Samagotchi
|
|
|
549
553
|
|
|
550
554
|
def max_iterations(no_interrupt) = IterationLimit.for(no_interrupt: no_interrupt)
|
|
551
555
|
|
|
556
|
+
# The queued commands that may run now, in order, up to one whose
|
|
557
|
+
# after_file (D1) still waits in the inbox: it and those behind it stay
|
|
558
|
+
# queued.
|
|
552
559
|
# @return [Boolean] whether any command ran
|
|
553
560
|
def run_queued_commands
|
|
554
561
|
ran = false
|
|
555
|
-
while (command =
|
|
562
|
+
while (command = next_ready_command)
|
|
556
563
|
run_command(command)
|
|
557
564
|
ran = true
|
|
558
565
|
end
|
|
559
566
|
ran
|
|
560
567
|
end
|
|
561
568
|
|
|
562
|
-
#
|
|
569
|
+
# Pops the first queued command if it may run now (its after_file is
|
|
570
|
+
# taken), else none: one behind it waits too, in arrival order (an idle
|
|
571
|
+
# /model Y that came just after the turn ended stays behind a /model X
|
|
572
|
+
# still waiting for a prompt sent before it). With the event log held,
|
|
573
|
+
# as the Bridge queues.
|
|
574
|
+
def next_ready_command
|
|
575
|
+
return nil if @command_queue.empty?
|
|
576
|
+
|
|
577
|
+
@engine.synchronize_events do
|
|
578
|
+
commands = drain_commands
|
|
579
|
+
first = commands.first
|
|
580
|
+
ready = first && ready_command?(first) ? commands.shift : nil
|
|
581
|
+
commands.each { |command| @command_queue << command }
|
|
582
|
+
ready
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def ready_command?(command)
|
|
587
|
+
return true if command[:after_file].nil?
|
|
588
|
+
|
|
589
|
+
SessionInbox.find_new_input_files(@session_dir).none? { |path| File.basename(path) <= command[:after_file] }
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def drain_commands
|
|
593
|
+
commands = []
|
|
594
|
+
while (command = next_command)
|
|
595
|
+
commands << command
|
|
596
|
+
end
|
|
597
|
+
commands
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# A command a turn refuses (:refuse while it ran, S1) is answered busy:
|
|
563
601
|
# at the turn's iteration boundaries (mid_turn: all of them), and when
|
|
564
602
|
# it ends (the ones queued before its end event; later ones run next).
|
|
603
|
+
# A queued one (:queue) stays for after the turn, but a !cmd after a
|
|
604
|
+
# canceled or failed turn (D4): running it would close the rollback
|
|
605
|
+
# window the turn left, so it is dropped. With the event log held, so
|
|
606
|
+
# a command the Bridge queues meanwhile stays behind the ones kept, in
|
|
607
|
+
# arrival order. One that comes after the empty look is the next
|
|
608
|
+
# boundary's (or end's), as it would be.
|
|
565
609
|
def refuse_queued_commands(mid_turn: false)
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
610
|
+
return if @command_queue.empty?
|
|
611
|
+
|
|
612
|
+
@engine.synchronize_events do
|
|
613
|
+
drain_commands.each do |command|
|
|
614
|
+
during = mid_turn || command[:after_seq].to_i < @turn_end_seq
|
|
615
|
+
case (during ? turn_answer(command, ended: !mid_turn) : :keep)
|
|
616
|
+
when :refuse then announce_command(command, status: "busy", output: busy_output(command), changed: [])
|
|
617
|
+
when :drop then drop_command(waiting_for_turn_end(command), "turn #{@turn_end_type == :turn_failed ? "failed" : "canceled"}: " \
|
|
618
|
+
"#{command[:line]} not run; send it again")
|
|
619
|
+
else @command_queue << (during ? waiting_for_turn_end(command) : command)
|
|
620
|
+
end
|
|
572
621
|
end
|
|
573
622
|
end
|
|
574
|
-
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# What a turn does with +command+, queued while it ran: :refuse, :drop
|
|
626
|
+
# (D4, once it +ended+) or :keep. One queued idle just as the turn
|
|
627
|
+
# began (:loop) goes by its line's policy (a show form waits too).
|
|
628
|
+
def turn_answer(command, ended:)
|
|
629
|
+
policy = command[:mid_turn]
|
|
630
|
+
policy = @engine.command_registry.mid_turn(command[:line]) if policy == :loop
|
|
631
|
+
return :refuse if policy == :refuse
|
|
632
|
+
return :drop if ended && policy == :queue && shell_command?(command) &&
|
|
633
|
+
%i[turn_canceled turn_failed].include?(@turn_end_type)
|
|
634
|
+
|
|
635
|
+
:keep
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# +command+, kept for after the turn. One queued idle just as the turn
|
|
639
|
+
# began (:loop) whose line waits for a turn's end (/model X, !cmd) is
|
|
640
|
+
# marked as one queued mid-turn: its command_queued (waits) tells the
|
|
641
|
+
# UIs now, and its command_ran says queued.
|
|
642
|
+
def waiting_for_turn_end(command)
|
|
643
|
+
return command unless command[:mid_turn] == :loop && @engine.command_registry.mid_turn(command[:line]) == :queue
|
|
644
|
+
|
|
645
|
+
event = { type: :command_queued, **command.slice(:command_id, :client_id, :line), waits: "turn_end" }
|
|
646
|
+
event[:card] = true if command[:card]
|
|
647
|
+
@engine.announce(event)
|
|
648
|
+
command.merge(mid_turn: :queue)
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def shell_command?(command) = @engine.command_registry.lookup(command[:line])&.id == :shell
|
|
652
|
+
|
|
653
|
+
def busy_output(command)
|
|
654
|
+
@engine.command_registry.lookup(command[:line])&.id == :rollback ? ROLLBACK_BUSY_OUTPUT : BUSY_OUTPUT
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# +command+ won't run: its sender (a UI's queued bubble) is told.
|
|
658
|
+
def drop_command(command, output)
|
|
659
|
+
announce_command(command, status: "dropped", output: output, changed: [])
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# The worker leaves with commands still queued (a stop, a crash): each
|
|
663
|
+
# one's sender is told it won't run, so no UI shows it waiting for good.
|
|
664
|
+
def drop_queued_commands
|
|
665
|
+
return if @command_queue.nil? || @command_queue.empty? || @engine.nil?
|
|
666
|
+
|
|
667
|
+
@engine.synchronize_events do
|
|
668
|
+
drain_commands.each { |command| drop_command(command, "dropped: the session's worker stopped before it ran") }
|
|
669
|
+
end
|
|
670
|
+
rescue StandardError => e
|
|
671
|
+
Log.warn(:worker, "drop_commands_failed", error: e.class.name, msg: e.message)
|
|
575
672
|
end
|
|
576
673
|
|
|
577
674
|
def next_command
|
|
@@ -607,7 +704,44 @@ module Samagotchi
|
|
|
607
704
|
[result || SessionCommands::Result.new(status: :error, output: "not a session command", changed: []), shown]
|
|
608
705
|
end
|
|
609
706
|
|
|
610
|
-
|
|
707
|
+
# A session command from the Bridge, called with the event log held:
|
|
708
|
+
# an anytime one starts now, any other is queued for the loop (and
|
|
709
|
+
# refused if a turn runs: #refuse_queued_commands). While a turn runs
|
|
710
|
+
# the line's policy decides (a show form like /model runs now); idle,
|
|
711
|
+
# only an always-anytime command (/help) runs beside the loop, so an
|
|
712
|
+
# idle /model X then /model run in order. A turn starting or ending
|
|
713
|
+
# right now may see the other side: the line is only shown early or
|
|
714
|
+
# run in order.
|
|
715
|
+
#
|
|
716
|
+
# A queued one (:queue) keeps arrival order with the prompts (D1): it
|
|
717
|
+
# runs once every input file written before it is taken (after_file,
|
|
718
|
+
# the newest one now; names compare, not clocks), so a prompt sent
|
|
719
|
+
# before it runs first and one sent after it runs after. One that came
|
|
720
|
+
# in an input file runs once that file is taken (+after_file+).
|
|
721
|
+
# @return [Symbol] the policy it applied (the Bridge marks an anytime
|
|
722
|
+
# one's command_queued, and a queued one's): :anytime, :queue or
|
|
723
|
+
# :refuse while a turn runs, :loop idle
|
|
724
|
+
def on_command(command, after_file: nil)
|
|
725
|
+
registry = @engine.command_registry
|
|
726
|
+
policy = if @engine.turn_running? then registry.mid_turn(command[:line])
|
|
727
|
+
else registry.anytime?(command[:line]) ? :anytime : :loop
|
|
728
|
+
end
|
|
729
|
+
if policy == :anytime
|
|
730
|
+
start_anytime_command(command)
|
|
731
|
+
return policy
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
after_file ||= newest_input_file if policy == :queue
|
|
735
|
+
# The count says which turn ends came before it.
|
|
736
|
+
@command_queue << command.merge(after_seq: @engine.event_count, mid_turn: policy, after_file: after_file).compact
|
|
737
|
+
@waker.wake
|
|
738
|
+
policy
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# @return [String, nil] the newest input file waiting (its basename)
|
|
742
|
+
def newest_input_file
|
|
743
|
+
SessionInbox.find_new_input_files(@session_dir).map { |path| File.basename(path) }.max
|
|
744
|
+
end
|
|
611
745
|
|
|
612
746
|
# An anytime command (/help, a plugin's /btw; D8) runs now, on its own
|
|
613
747
|
# thread, never queued behind a turn: it is never busy. The Bridge calls
|
|
@@ -640,6 +774,8 @@ module Samagotchi
|
|
|
640
774
|
status: status, output: text[0, COMMAND_OUTPUT_LIMIT], changed: changed.map(&:to_s),
|
|
641
775
|
model_name: @engine.effective_model_name }
|
|
642
776
|
event[:anytime] = true if anytime
|
|
777
|
+
# Its line was shown at its command_queued (waits: turn_end).
|
|
778
|
+
event[:queued] = true if command[:mid_turn] == :queue
|
|
643
779
|
event[:card] = true if command[:card]
|
|
644
780
|
# /llm-context or /model: what the next turn runs under now (the web's chip).
|
|
645
781
|
event[:llm_context] = @engine.llm_context_summary if changed.intersect?(%i[llm_context model])
|
|
@@ -61,8 +61,9 @@ module Samagotchi
|
|
|
61
61
|
# @param wakes [WorkerWakes] the wake budget a context wake asks
|
|
62
62
|
# @param awaiting_continue [#call] whether a continue offer waits
|
|
63
63
|
# @param stopped [#call] whether the session was stopped on disk
|
|
64
|
-
# @param queue_command [#call] (line, client_id) queues a
|
|
65
|
-
# command for the loop's next pass
|
|
64
|
+
# @param queue_command [#call] (line, client_id, after_file) queues a
|
|
65
|
+
# session command for the loop's next pass; after_file is the input
|
|
66
|
+
# file it came in, or nil
|
|
66
67
|
def initialize(session:, state_dir:, session_dir:, engine:, context_absorber:, wakes:, awaiting_continue:,
|
|
67
68
|
stopped:, queue_command:)
|
|
68
69
|
@session = session
|
|
@@ -182,7 +183,7 @@ module Samagotchi
|
|
|
182
183
|
begin
|
|
183
184
|
message, origin, no_interrupt, images = SessionInbox.read_input(claimed_file)
|
|
184
185
|
return if message.to_s.strip.empty?
|
|
185
|
-
return if Array(images).empty? && queue_as_command(message, origin)
|
|
186
|
+
return if Array(images).empty? && queue_as_command(message, origin, File.basename(input_file))
|
|
186
187
|
|
|
187
188
|
yield Prompt.new(text: message, origin: origin, no_interrupt: !!no_interrupt, images: images || [])
|
|
188
189
|
ensure
|
|
@@ -211,10 +212,10 @@ module Samagotchi
|
|
|
211
212
|
# POST /turn; an unknown /word stays a prompt. Queued: the loop's next
|
|
212
213
|
# pass runs it.
|
|
213
214
|
# @return [Boolean] whether +text+ was one
|
|
214
|
-
def queue_as_command(text, origin)
|
|
215
|
+
def queue_as_command(text, origin, after_file = nil)
|
|
215
216
|
return false unless @engine.command_registry.command?(text.to_s)
|
|
216
217
|
|
|
217
|
-
@queue_command.call(text, origin&.dig(:client_id))
|
|
218
|
+
@queue_command.call(text, origin&.dig(:client_id), after_file)
|
|
218
219
|
true
|
|
219
220
|
end
|
|
220
221
|
|