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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -1
- data/bin/chi +2 -2
- data/docs/cli.md +12 -1
- data/docs/configuration.md +1 -1
- data/docs/context.md +9 -2
- data/docs/hooks.md +9 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +5 -2
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +39 -6
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +13 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +22 -8
- data/lib/samagotchi/engine.rb +3 -2
- data/lib/samagotchi/hooks/stream_watch.rb +9 -2
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/model_profile.rb +11 -4
- data/lib/samagotchi/plugin/context.rb +13 -1
- data/lib/samagotchi/prompt.rb +26 -11
- data/lib/samagotchi/prompt_literal_guard.rb +4 -1
- data/lib/samagotchi/send_command.rb +61 -0
- data/lib/samagotchi/session_commands.rb +1 -2
- data/lib/samagotchi/session_metrics.rb +7 -0
- data/lib/samagotchi/sessions_command.rb +84 -7
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +17 -2
- data/lib/samagotchi/terminal_ui/question_prompt.rb +7 -0
- data/lib/samagotchi/terminal_ui.rb +1 -1
- data/lib/samagotchi/tools/task_runtime.rb +4 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +3 -3
- data/lib/samagotchi/web/public/app.js +12 -3
- data/lib/samagotchi/web/public/live_timing.js +32 -5
- data/lib/samagotchi/web/public/stage_model.js +15 -0
- data/lib/samagotchi/web/public/stage_view.js +10 -4
- data/lib/samagotchi/web/public/timing.js +11 -0
- data/lib/samagotchi/web/public/turn_events.js +11 -0
- data/lib/samagotchi/web/public/turn_html.js +21 -0
- data/lib/samagotchi/web/public/turn_view.js +9 -3
- data/lib/samagotchi/web/session_hub.rb +11 -3
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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)
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|