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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/docs/cli.md +29 -7
  4. data/docs/context.md +2 -1
  5. data/docs/plugins.md +4 -2
  6. data/docs/sessions.md +8 -4
  7. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
  8. data/lib/samagotchi/bridge.rb +22 -10
  9. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  10. data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
  11. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  13. data/lib/samagotchi/commands/registry.rb +54 -9
  14. data/lib/samagotchi/engine.rb +8 -3
  15. data/lib/samagotchi/host_registry.rb +28 -3
  16. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  17. data/lib/samagotchi/model_notes.rb +8 -6
  18. data/lib/samagotchi/model_overlay.rb +3 -2
  19. data/lib/samagotchi/model_profile.rb +71 -2
  20. data/lib/samagotchi/plugin/context.rb +19 -7
  21. data/lib/samagotchi/self_report.rb +31 -2
  22. data/lib/samagotchi/session_commands.rb +24 -7
  23. data/lib/samagotchi/session_manager.rb +19 -1
  24. data/lib/samagotchi/session_metrics.rb +39 -6
  25. data/lib/samagotchi/sessions_command.rb +15 -5
  26. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
  27. data/lib/samagotchi/terminal_ui/formatting.rb +19 -1
  28. data/lib/samagotchi/terminal_ui.rb +84 -12
  29. data/lib/samagotchi/version.rb +1 -1
  30. data/lib/samagotchi/web/app.rb +7 -9
  31. data/lib/samagotchi/web/public/app.js +23 -15
  32. data/lib/samagotchi/web/public/command_complete.js +3 -1
  33. data/lib/samagotchi/web/public/ctx.js +42 -15
  34. data/lib/samagotchi/web/public/index.html +3 -1
  35. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  36. data/lib/samagotchi/web/public/stage_model.js +18 -4
  37. data/lib/samagotchi/web/public/stage_view.js +3 -3
  38. data/lib/samagotchi/web/public/turn_events.js +16 -4
  39. data/lib/samagotchi/web/session_hub.rb +9 -1
  40. data/lib/samagotchi/web/session_summary.rb +19 -2
  41. data/lib/samagotchi/worker.rb +156 -20
  42. data/lib/samagotchi/worker_inbound.rb +6 -5
  43. 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: set it when it ends." : ""}</div>` +
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"${running ? " disabled" : ""}>Set</button></div></form>`;
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, and a plugin's nudge (steer).
202
- // chi's own asking-again row (a notice with a line and no hook) does not:
203
- // the status line tells that already. null: nothing flashes.
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" || !data.hook) return null;
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, a
32
- // plugin's nudge (steer).
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.
@@ -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
- @turn_end_seq = event[:event_seq] if Events::TURN_END.include?(event[:type])
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: ->(line, client_id) { @bridge.queue_command(line, client_id: client_id) })
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: lambda { |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 = next_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
- # A command queued while a turn ran is refused (S1), not run after it:
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
- later = []
567
- while (command = next_command)
568
- if mid_turn || command[:after_seq].to_i < @turn_end_seq
569
- announce_command(command, status: "busy", output: BUSY_OUTPUT, changed: [])
570
- else
571
- later << command
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
- later.each { |command| @command_queue << command }
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
- def anytime_command?(line) = @engine.command_registry.lookup(line)&.anytime == true
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 session
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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.44.0
4
+ version: 0.45.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov