samagotchi 0.48.0 → 0.50.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/bin/chi +2 -2
  4. data/docs/cli.md +12 -1
  5. data/docs/configuration.md +1 -1
  6. data/docs/context.md +9 -2
  7. data/docs/hooks.md +9 -1
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/plugins.md +4 -2
  10. data/docs/sessions.md +5 -2
  11. data/docs/testing.md +2 -1
  12. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/github-pr/plugin.rb +39 -6
  14. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/loop-guard/plugin.rb +13 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/child_move.rb +22 -8
  18. data/lib/samagotchi/engine.rb +3 -2
  19. data/lib/samagotchi/hooks/stream_watch.rb +9 -2
  20. data/lib/samagotchi/host_registry.rb +1 -1
  21. data/lib/samagotchi/model_profile.rb +11 -4
  22. data/lib/samagotchi/plugin/context.rb +13 -1
  23. data/lib/samagotchi/prompt.rb +26 -11
  24. data/lib/samagotchi/prompt_literal_guard.rb +4 -1
  25. data/lib/samagotchi/send_command.rb +61 -0
  26. data/lib/samagotchi/session_commands.rb +1 -2
  27. data/lib/samagotchi/session_metrics.rb +7 -0
  28. data/lib/samagotchi/sessions_command.rb +84 -7
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  30. data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
  31. data/lib/samagotchi/terminal_ui/formatting.rb +17 -2
  32. data/lib/samagotchi/terminal_ui/question_prompt.rb +7 -0
  33. data/lib/samagotchi/terminal_ui.rb +1 -1
  34. data/lib/samagotchi/tools/task_runtime.rb +4 -3
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/vision_support.rb +3 -3
  37. data/lib/samagotchi/web/public/app.js +12 -3
  38. data/lib/samagotchi/web/public/live_timing.js +32 -5
  39. data/lib/samagotchi/web/public/stage_model.js +15 -0
  40. data/lib/samagotchi/web/public/stage_view.js +10 -4
  41. data/lib/samagotchi/web/public/timing.js +11 -0
  42. data/lib/samagotchi/web/public/turn_events.js +11 -0
  43. data/lib/samagotchi/web/public/turn_html.js +21 -0
  44. data/lib/samagotchi/web/public/turn_view.js +9 -3
  45. data/lib/samagotchi/web/session_hub.rb +11 -3
  46. metadata +1 -1
@@ -4,15 +4,24 @@
4
4
  // on (a provider retry, plugins' setup) and how many turns this page saw
5
5
  // end. The DOM, the clock and the interval come in, so `node --test` drives
6
6
  // it with a fake element.
7
- import { elapsedSince, turnTimingText } from "./timing.js";
7
+ import { elapsedSince, liveDurationText, turnTimingText } from "./timing.js";
8
8
 
9
9
  // The live line's text, or null when the turn's start is unknown. +inStage+:
10
10
  // the stage's status row already says the turn runs, so the line drops its
11
- // own "running" there.
11
+ // own "running" there. The duration is the live one (never "0ms": under a
12
+ // second it shows tenths).
12
13
  export function liveLineText({ number, startedAt, note = "", inStage = false }, now = Date.now()) {
13
14
  const duration = elapsedSince(startedAt, now);
14
15
  if (duration === null) return null;
15
- return turnTimingText(number, duration, { running: !inStage }) + (note ? ` · ${note}` : "");
16
+ const turn = Number.isInteger(number) && number > 0 ? `turn ${number}` : "turn";
17
+ return `${turn}${!inStage ? " running" : ""} · ${liveDurationText(duration)}${note ? ` · ${note}` : ""}`;
18
+ }
19
+
20
+ // The live ticker's interval at +elapsedMs+ into the turn: 200 ms during the
21
+ // first ten seconds (the live text counts up from the first second), then
22
+ // the one-second tick.
23
+ export function liveTickMs(elapsedMs) {
24
+ return Number(elapsedMs) < 10000 ? 200 : 1000;
16
25
  }
17
26
 
18
27
  // The record a turn-end read finishes its line with: the one with the
@@ -52,18 +61,36 @@ export function createLiveTiming({
52
61
  // the next one, so a turn queued behind another reads its own number while
53
62
  // the other's re-read is still out.
54
63
  let ended = 0;
64
+ // The ticker's current interval, so a tick that crossed the 10 s boundary
65
+ // re-arms it at the slower one.
66
+ let tickerMs = null;
55
67
 
56
68
  function stopTicker() {
57
69
  if (ticker !== null) {
58
70
  cancel(ticker);
59
71
  ticker = null;
72
+ tickerMs = null;
60
73
  }
61
74
  }
62
75
 
76
+ function armTicker(ms) {
77
+ stopTicker();
78
+ ticker = every(tick, ms);
79
+ tickerMs = ms;
80
+ }
81
+
63
82
  function tick() {
64
- const text = liveLineText({ number, startedAt: timing().activeTurn?.started_at, note, inStage }, now());
83
+ const startedAt = timing().activeTurn?.started_at;
84
+ const text = liveLineText({ number, startedAt, note, inStage }, now());
65
85
  if (el && text !== null) el.textContent = text;
66
86
  onTick();
87
+ // The interval follows the elapsed time: 200 ms while the line counts
88
+ // up from the first second, one a second once it is on whole seconds.
89
+ if (ticker !== null) {
90
+ const elapsed = elapsedSince(startedAt, now());
91
+ const ms = elapsed === null ? tickerMs : liveTickMs(elapsed);
92
+ if (ms !== tickerMs) armTicker(ms);
93
+ }
67
94
  }
68
95
 
69
96
  return {
@@ -87,7 +114,7 @@ export function createLiveTiming({
87
114
  el.className = "turn-timing live";
88
115
  inStage = !!place(el);
89
116
  tick();
90
- ticker = every(tick, 1000);
117
+ armTicker(liveTickMs(0));
91
118
  },
92
119
 
93
120
  tick,
@@ -108,6 +108,21 @@ function withTask(slot, row) {
108
108
  return row.tool === "task_wait" && row.view?.task_id ? { ...slot, taskId: row.view.task_id, key: row.key } : slot;
109
109
  }
110
110
 
111
+ // A trail item's text and its hover: the text the (CSS-truncated) item shows,
112
+ // and the full text it hovers. A call hovers its full command when it has
113
+ // one, else its whole "<mark> <name> <title>" text; a flash (a hook notice)
114
+ // hovers its whole "<mark> <text>" line.
115
+ export function trailItemText(item) {
116
+ const text = `${trailMark(item.status)} ${item.name}${item.title ? ` ${item.title}` : ""}`;
117
+ return { text, hover: item.command || text };
118
+ }
119
+
120
+ // A trail flash's text and hover: the whole "<mark> <text>" line.
121
+ export function trailFlashText(flash, mark) {
122
+ const text = `${mark} ${flash.text}`;
123
+ return { text, hover: text };
124
+ }
125
+
111
126
  // While the first step has no thinking and no tool rows the turn is plain:
112
127
  // its text streams in the stage's answer slot, as the turn view's
113
128
  // chrome-less block streams like a bubble.
@@ -11,7 +11,7 @@
11
11
  // reload. Slots are patched in place, never rebuilt from HTML: a selection
12
12
  // and annotate survive. The rules are pure in stage_model.js.
13
13
  import { createTurnView } from "./turn_view.js";
14
- import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailMark, trailTone } from "./stage_model.js";
14
+ import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
15
15
  import { formatDuration } from "./timing.js";
16
16
  import { taskCommand, taskStopButton } from "./task_stop.js";
17
17
 
@@ -265,12 +265,18 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
265
265
  if (shown.trail === key) return;
266
266
  shown.trail = key;
267
267
  trail.replaceChildren();
268
- if (flashing) node("span", `ts-trail-item ${flashing.kind}`, trail).textContent = `${FLASH_MARKS[flashing.kind]} ${flashing.text}`;
268
+ if (flashing) {
269
+ const flash = trailFlashText(flashing, FLASH_MARKS[flashing.kind]);
270
+ const el = node("span", `ts-trail-item ${flashing.kind}`, trail);
271
+ el.textContent = flash.text;
272
+ el.title = flash.hover;
273
+ }
269
274
  for (const item of items) {
270
275
  const tone = trailTone(item.status);
271
276
  const el = node("span", `ts-trail-item${tone ? ` ${tone}` : ""}`, trail);
272
- el.textContent = `${trailMark(item.status)} ${item.name} ${item.title}`.trim();
273
- if (item.command) el.title = item.command;
277
+ const { text, hover } = trailItemText(item);
278
+ el.textContent = text;
279
+ el.title = hover;
274
280
  }
275
281
  if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
276
282
  }
@@ -20,6 +20,17 @@ export function turnTimingText(number, durationMs, { running = false, canceled =
20
20
  return `${turn}${running ? " running" : ""} · ${formatDuration(durationMs)}${canceled ? " · canceled" : ""}`;
21
21
  }
22
22
 
23
+ // The LIVE line's duration: never milliseconds — under a second it shows
24
+ // tenths ("0.0s", "0.2s"), then what formatDuration prints from 1 s on
25
+ // (its 0.1 s precision holds to 10 s). A finished turn's line keeps
26
+ // formatDuration exactly as it is.
27
+ export function liveDurationText(durationMs) {
28
+ const ms = Number(durationMs);
29
+ if (!Number.isFinite(ms) || ms < 0) return "";
30
+ if (ms < 1000) return `${(ms / 1000).toFixed(1)}s`;
31
+ return formatDuration(ms);
32
+ }
33
+
23
34
  // A cancel's reason as the terminal UIs name it too (Formatting::
24
35
  // CANCEL_REASONS); anything else as it is. Both sides: spec/shared/labels_matrix.json.
25
36
  const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
@@ -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
@@ -99,6 +99,27 @@ export function stateBadgeHtml(state) {
99
99
  return BADGES[state] ? `<span class="state-badge">${escapeHtml(BADGES[state])}</span>` : "";
100
100
  }
101
101
 
102
+ // A `!cmd`'s saved user message (the worker appends "!(<command>)\n<output>"
103
+ // for the next turn): its command line and its output, or null when the
104
+ // content is any other message. The command sits on the first line inside
105
+ // `!(` `)`; the output is everything after that first newline. A message
106
+ // that merely starts with "!" or "!(" without the newline shape is a plain
107
+ // message.
108
+ export function shellCommandMessage(content) {
109
+ const text = String(content ?? "");
110
+ const match = text.match(/^!\((.*)\)\n([\s\S]*)$/);
111
+ if (!match) return null;
112
+ return { line: `!${match[1]}`, text: match[2] };
113
+ }
114
+
115
+ // A `!cmd`'s history bubble: the same markup the live page draws for a
116
+ // command (app.js's drawCommandBubble): the line, the output in a
117
+ // pre.command-output.
118
+ export function commandBubbleHtml({ line, text }) {
119
+ const body = text ? `<pre class="command-output">${escapeHtml(text)}</pre>` : "";
120
+ return `<div class="bubble command"><div class="command-line">${escapeHtml(line)}</div>${body}</div>`;
121
+ }
122
+
102
123
  // A user's line: who sent it (+label+, a delegate report's or another
103
124
  // client's), its body (`>` lines as quotes) and +thumbs+ (its images'
104
125
  // 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)
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.48.0
4
+ version: 0.50.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov