samagotchi 0.49.0 → 0.51.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/bin/chi +2 -2
  4. data/docs/cli.md +33 -8
  5. data/docs/configuration.md +11 -9
  6. data/docs/hooks.md +12 -3
  7. data/docs/internals/engine.md +4 -2
  8. data/docs/sessions.md +8 -4
  9. data/docs/testing.md +2 -1
  10. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  11. data/lib/samagotchi/bridge.rb +24 -9
  12. data/lib/samagotchi/bridge_client.rb +3 -1
  13. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/loop-guard/plugin.rb +13 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/delivery.rb +33 -0
  17. data/lib/samagotchi/engine.rb +1 -1
  18. data/lib/samagotchi/hooks/stream_watch.rb +9 -2
  19. data/lib/samagotchi/host_registry.rb +17 -4
  20. data/lib/samagotchi/kernel_loop.rb +28 -2
  21. data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -2
  22. data/lib/samagotchi/send_command.rb +124 -10
  23. data/lib/samagotchi/session_commands.rb +10 -2
  24. data/lib/samagotchi/session_inbox.rb +27 -7
  25. data/lib/samagotchi/session_manager.rb +13 -3
  26. data/lib/samagotchi/session_metrics.rb +32 -6
  27. data/lib/samagotchi/sessions_command.rb +84 -7
  28. data/lib/samagotchi/steer_cut.rb +12 -8
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +50 -3
  30. data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
  31. data/lib/samagotchi/terminal_ui/formatting.rb +68 -6
  32. data/lib/samagotchi/terminal_ui.rb +48 -5
  33. data/lib/samagotchi/tool_activity.rb +3 -0
  34. data/lib/samagotchi/tool_runner.rb +2 -0
  35. data/lib/samagotchi/tools/builtin_calls.rb +33 -0
  36. data/lib/samagotchi/version.rb +1 -1
  37. data/lib/samagotchi/web/app.rb +6 -2
  38. data/lib/samagotchi/web/message_parts.rb +3 -0
  39. data/lib/samagotchi/web/public/activity.js +6 -0
  40. data/lib/samagotchi/web/public/app.js +71 -29
  41. data/lib/samagotchi/web/public/index.html +17 -15
  42. data/lib/samagotchi/web/public/live_timing.js +32 -5
  43. data/lib/samagotchi/web/public/session_title.js +26 -0
  44. data/lib/samagotchi/web/public/stage_model.js +15 -0
  45. data/lib/samagotchi/web/public/stage_view.js +10 -4
  46. data/lib/samagotchi/web/public/strip.js +4 -6
  47. data/lib/samagotchi/web/public/timing.js +11 -0
  48. data/lib/samagotchi/web/public/turn_events.js +14 -2
  49. data/lib/samagotchi/web/public/turn_html.js +24 -1
  50. data/lib/samagotchi/web/public/turn_view.js +9 -3
  51. data/lib/samagotchi/web/session_hub.rb +11 -3
  52. data/lib/samagotchi/worker.rb +1 -1
  53. data/lib/samagotchi/worker_inbound.rb +8 -5
  54. data/lib/samagotchi.rb +1 -0
  55. metadata +3 -1
@@ -370,6 +370,17 @@ export function commandView(event, myId) {
370
370
  };
371
371
  }
372
372
 
373
+ // Whether a command_ran that re-read the conversation (`resync`) needs its
374
+ // own bubble: the redrawn history ends with a command bubble for its line
375
+ // (a `!cmd` appends its "!(<command>)\n<output>" message, which renders as
376
+ // one), so a second bubble would show the output twice. +historyBubbles+:
377
+ // the `.bubble.command` elements in document order; +line+: the event's
378
+ // line. Only the last one counts: it is the redrawn history's end.
379
+ export function historyHasCommandBubble(historyBubbles, line) {
380
+ const last = historyBubbles[historyBubbles.length - 1];
381
+ return !!last && last.querySelector(":scope > .command-line")?.textContent === line;
382
+ }
383
+
373
384
  // What a prompt_restored (the worker rolled a failed turn back and handed its
374
385
  // prompt back) does in this tab: `refill` is the text for an empty composer,
375
386
  // only for a prompt this page sent (`sentIds`, its acks' enqueued_ids), so a
@@ -547,8 +558,9 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
547
558
  const call = { iteration: part.iteration, call_index: part.call_index, tool: part.tool };
548
559
  if (part.label) call.label = part.label;
549
560
  const title = part.title ? { title: part.title } : {};
561
+ const alias = part.called_as ? { called_as: part.called_as } : {};
550
562
  const view = part.view ? { view: part.view } : {};
551
- events.push({ type: "tool_call_started", ...call, params: part.params, ...title, ...view });
563
+ events.push({ type: "tool_call_started", ...call, params: part.params, ...title, ...alias, ...view });
552
564
  if (part.status !== "running") {
553
565
  // The activity as the live event had it; action and duration_ms
554
566
  // only in snapshots from a worker that keeps them (the TUI's
@@ -556,7 +568,7 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
556
568
  const action = part.action ? { action: part.action } : {};
557
569
  const completed = {
558
570
  type: "tool_call_completed", ...call, output: part.output, output_truncated: !!part.output_truncated,
559
- activity: { ...action, tool: part.tool, status: part.status, params: part.params, ...title },
571
+ activity: { ...action, tool: part.tool, status: part.status, params: part.params, ...title, ...alias },
560
572
  };
561
573
  if (part.duration_ms != null) completed.duration_ms = part.duration_ms;
562
574
  if (part.diff) completed.diff = part.diff;
@@ -78,13 +78,15 @@ export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
78
78
  const shown = row.title || row.params;
79
79
  const hover = rowHover(row) ? ` title="${escapeHtml(rowHover(row))}"` : "";
80
80
  const params = shown ? `<span class="activity-params"${hover}>${escapeHtml(shown)}</span>` : "";
81
+ // The name the model called the tool by when it was an alias's, dim.
82
+ const alias = row.called_as ? `<span class="activity-alias">(called as ${escapeHtml(row.called_as)})</span>` : "";
81
83
  const duration = formatDuration(row.duration_ms);
82
84
  const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
83
85
  const output = full
84
86
  ? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
85
87
  : "";
86
88
  const status = statusParts(row.status);
87
- return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${statusWordHtml(status)}` +
89
+ return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${alias}${statusWordHtml(status)}` +
88
90
  `${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
89
91
  `${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}` +
90
92
  `${row.images?.length ? thumbs(row.images) : ""}${row.diff ? activityDiffHtml(row.diff) : ""}`;
@@ -99,6 +101,27 @@ export function stateBadgeHtml(state) {
99
101
  return BADGES[state] ? `<span class="state-badge">${escapeHtml(BADGES[state])}</span>` : "";
100
102
  }
101
103
 
104
+ // A `!cmd`'s saved user message (the worker appends "!(<command>)\n<output>"
105
+ // for the next turn): its command line and its output, or null when the
106
+ // content is any other message. The command sits on the first line inside
107
+ // `!(` `)`; the output is everything after that first newline. A message
108
+ // that merely starts with "!" or "!(" without the newline shape is a plain
109
+ // message.
110
+ export function shellCommandMessage(content) {
111
+ const text = String(content ?? "");
112
+ const match = text.match(/^!\((.*)\)\n([\s\S]*)$/);
113
+ if (!match) return null;
114
+ return { line: `!${match[1]}`, text: match[2] };
115
+ }
116
+
117
+ // A `!cmd`'s history bubble: the same markup the live page draws for a
118
+ // command (app.js's drawCommandBubble): the line, the output in a
119
+ // pre.command-output.
120
+ export function commandBubbleHtml({ line, text }) {
121
+ const body = text ? `<pre class="command-output">${escapeHtml(text)}</pre>` : "";
122
+ return `<div class="bubble command"><div class="command-line">${escapeHtml(line)}</div>${body}</div>`;
123
+ }
124
+
102
125
  // A user's line: who sent it (+label+, a delegate report's or another
103
126
  // client's), its body (`>` lines as quotes) and +thumbs+ (its images'
104
127
  // markup), its state's badge. +message+: { content, display } (the copy
@@ -15,7 +15,7 @@ import { escapeHtml, failedTurnText, messageBodyHtml, noteHtml, normalize, sourc
15
15
  import { thumbsHtml } from "./images.js";
16
16
  import { shouldFollowScroll } from "./scroll.js";
17
17
  import { hookNoticeLabel } from "./turn_events.js";
18
- import { emptyAnswerHtml, htmlElement, toolRowHtml, toolRowInnerHtml, userBubbleHtml } from "./turn_html.js";
18
+ import { commandBubbleHtml, emptyAnswerHtml, htmlElement, shellCommandMessage, toolRowHtml, toolRowInnerHtml, userBubbleHtml } from "./turn_html.js";
19
19
  import { cancelLineHtml, formatDuration, turnGroups, turnTimingText } from "./timing.js";
20
20
  import { createSentenceFeed } from "./sentences.js";
21
21
  import { createTicker } from "./thinking_ticker.js";
@@ -755,8 +755,14 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
755
755
  const { body, renderedMarkdown } = messageBodyHtml(m);
756
756
  return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
757
757
  };
758
- // A user's line, labelled when chi brought it in (a delegate report).
759
- const userHtml = (m, opts = {}) => userBubbleHtml(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), ...opts });
758
+ // A user's line, labelled when chi brought it in (a delegate report). A
759
+ // `!cmd`'s saved message (the worker's "!(<command>)\n<output>") renders
760
+ // as its command bubble, as live does — not as a user bubble.
761
+ const userHtml = (m, opts = {}) => {
762
+ const shell = shellCommandMessage(m?.content);
763
+ if (shell) return commandBubbleHtml(shell);
764
+ return userBubbleHtml(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), ...opts });
765
+ };
760
766
  // The first text after a delegate report merged mid-turn, unless it is
761
767
  // the turn's answer (shown anyway).
762
768
  const reportReplyHtml = (i, group) => {
@@ -307,12 +307,20 @@ 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
310
+ # One HostRegistry kept across scans: every session's ctx_pct may
311
311
  # need its llm_context budget resolved, and the registry's config read
312
312
  # is what would otherwise repeat per scan and per session. Built on
313
- # the first summary that needs it.
313
+ # the first summary that needs it, and built again when config.yml's
314
+ # hosts: changed (compared as read, like App#host_registry: the YAML
315
+ # read is mtime-cached), so a host added or a window changed while
316
+ # chi web runs is picked up on the next refresh of a card.
314
317
  def hub_registry
315
- @hub_registry ||= HostRegistry.new
318
+ hosts = ConfigFile.hosts_config
319
+ if @hub_registry.nil? || hosts != @hub_registry_hosts
320
+ @hub_registry = HostRegistry.new(hosts_config: hosts)
321
+ @hub_registry_hosts = hosts
322
+ end
323
+ @hub_registry
316
324
  end
317
325
 
318
326
  def drop(id)
@@ -953,7 +953,7 @@ module Samagotchi
953
953
  # A line with images runs as its own next turn (steering merges text
954
954
  # only), and so does anything queued after it, to keep the order.
955
955
  files = SessionInbox.find_new_input_files(@session_dir).sort
956
- .take_while { |input_file| !SessionInbox.input_has_images?(input_file) }
956
+ .take_while { |input_file| !SessionInbox.waits_for_turn_end?(input_file) }
957
957
  merged = files.filter_map do |input_file|
958
958
  claimed_file = SessionInbox.claim_input_file(input_file)
959
959
  next unless claimed_file
@@ -31,9 +31,11 @@ module Samagotchi
31
31
 
32
32
  # A prompt to run a turn for: an input file's, or the first prompt.
33
33
  # +no_interrupt+: an offer its turn makes keeps it for its continue
34
- # turn; +images+: its image refs ({file:, name:}).
35
- Prompt = Data.define(:text, :origin, :no_interrupt, :images) do
36
- def initialize(text:, origin: nil, no_interrupt: false, images: []) = super
34
+ # turn; +images+: its image refs ({file:, name:}); +delivery+: the wire
35
+ # value the sender asked for ("queue", or nil for the default
36
+ # next_step).
37
+ Prompt = Data.define(:text, :origin, :no_interrupt, :images, :delivery) do
38
+ def initialize(text:, origin: nil, no_interrupt: false, images: [], delivery: nil) = super
37
39
  end
38
40
 
39
41
  # A context wake turn to run: the ContextAbsorber::Delivery that woke
@@ -181,11 +183,12 @@ module Samagotchi
181
183
  return unless claimed_file
182
184
 
183
185
  begin
184
- message, origin, no_interrupt, images = SessionInbox.read_input(claimed_file)
186
+ message, origin, no_interrupt, images, delivery = SessionInbox.read_input(claimed_file)
185
187
  return if message.to_s.strip.empty?
186
188
  return if Array(images).empty? && queue_as_command(message, origin, File.basename(input_file))
187
189
 
188
- yield Prompt.new(text: message, origin: origin, no_interrupt: !!no_interrupt, images: images || [])
190
+ yield Prompt.new(text: message, origin: origin, no_interrupt: !!no_interrupt, images: images || [],
191
+ delivery: delivery)
189
192
  ensure
190
193
  FileUtils.rm_f(claimed_file)
191
194
  end
data/lib/samagotchi.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "samagotchi/model_profile"
6
6
  require_relative "samagotchi/prompt"
7
7
  require_relative "samagotchi/client"
8
8
  require_relative "samagotchi/debug_log"
9
+ require_relative "samagotchi/delivery"
9
10
  require_relative "samagotchi/pending_input_queue"
10
11
  require_relative "samagotchi/kernel_loop"
11
12
  require_relative "samagotchi/session"
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.49.0
4
+ version: 0.51.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -238,6 +238,7 @@ files:
238
238
  - lib/samagotchi/continue_offer.rb
239
239
  - lib/samagotchi/cut_policy.rb
240
240
  - lib/samagotchi/debug_log.rb
241
+ - lib/samagotchi/delivery.rb
241
242
  - lib/samagotchi/desktop.rb
242
243
  - lib/samagotchi/desktop/macos.rb
243
244
  - lib/samagotchi/desktop/macos/App.swift
@@ -533,6 +534,7 @@ files:
533
534
  - lib/samagotchi/web/public/scroll.js
534
535
  - lib/samagotchi/web/public/sentences.js
535
536
  - lib/samagotchi/web/public/session_state.js
537
+ - lib/samagotchi/web/public/session_title.js
536
538
  - lib/samagotchi/web/public/sessions_list.js
537
539
  - lib/samagotchi/web/public/shown_texts.js
538
540
  - lib/samagotchi/web/public/stable_html.js