samagotchi 0.80.0 → 0.82.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 +57 -1
- data/docs/architecture.md +9 -1
- data/docs/cli.md +9 -1
- data/docs/configuration.md +4 -0
- data/docs/guardrails.md +26 -3
- data/docs/plugins.md +9 -3
- data/docs/sessions.md +9 -1
- data/docs/sub-agent.md +19 -8
- data/docs/testing.md +19 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +1 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cli/parent_wait.rb +9 -3
- data/lib/samagotchi/engine.rb +31 -5
- data/lib/samagotchi/guardrails/data_readers.rb +214 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +4 -2
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
- data/lib/samagotchi/guardrails/shell_paths.rb +5 -3
- data/lib/samagotchi/guardrails/targets.rb +14 -0
- data/lib/samagotchi/host_config.rb +8 -8
- data/lib/samagotchi/idle_client.rb +36 -11
- data/lib/samagotchi/idle_recap.rb +6 -1
- data/lib/samagotchi/idle_target.rb +12 -4
- data/lib/samagotchi/llm/http.rb +7 -2
- data/lib/samagotchi/llm/usage.rb +9 -3
- data/lib/samagotchi/model_price.rb +16 -0
- data/lib/samagotchi/parent_report.rb +7 -3
- data/lib/samagotchi/plugin/context.rb +10 -3
- data/lib/samagotchi/plugin/service.rb +24 -1
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +68 -2
- data/lib/samagotchi/send_command.rb +47 -8
- data/lib/samagotchi/session_metrics/side_totals.rb +117 -0
- data/lib/samagotchi/session_metrics.rb +49 -12
- data/lib/samagotchi/session_observer.rb +3 -0
- data/lib/samagotchi/side_usage.rb +38 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
- data/lib/samagotchi/testing/boundary.rb +15 -0
- data/lib/samagotchi/testing/bundle.rb +35 -9
- data/lib/samagotchi/testing/host.rb +2 -2
- data/lib/samagotchi/testing/recorder.rb +7 -4
- data/lib/samagotchi/testing/sandbox.rb +5 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -1
- data/lib/samagotchi/web/public/app.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +32 -6
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/lists_view.js +1 -1
- data/lib/samagotchi/web/public/question_cards.js +10 -5
- data/lib/samagotchi/web/public/stream_handlers.js +7 -0
- data/lib/samagotchi/web/public/timing.js +6 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- metadata +4 -1
|
@@ -9,8 +9,8 @@ require_relative "fake_executable"
|
|
|
9
9
|
|
|
10
10
|
module Samagotchi
|
|
11
11
|
module Testing
|
|
12
|
-
# The environment a bundle test runs in: temp config and
|
|
13
|
-
# (config.yml, memories, ctx.data_dir, the debug log), HOME and the peer
|
|
12
|
+
# The environment a bundle test runs in: temp config, state, data and cache
|
|
13
|
+
# homes (config.yml, memories, ctx.data_dir, the debug log), HOME and the peer
|
|
14
14
|
# CLIs' folders, git without the user's config, no Keychain, none of
|
|
15
15
|
# the user's SAMAGOTCHI_* settings, a bin/ first on PATH for fake
|
|
16
16
|
# commands. ENV is put back on #leave.
|
|
@@ -59,12 +59,14 @@ module Samagotchi
|
|
|
59
59
|
@saved = ENV.to_h
|
|
60
60
|
@outer = Sandbox.current
|
|
61
61
|
ENV.each_key.select { |key| key.start_with?("SAMAGOTCHI_") && !KEPT.include?(key) }.each { |key| ENV.delete(key) }
|
|
62
|
-
%w[config state home work bin].each { |dir| FileUtils.mkdir_p(File.join(@root, dir)) }
|
|
62
|
+
%w[config state data cache home work bin].each { |dir| FileUtils.mkdir_p(File.join(@root, dir)) }
|
|
63
63
|
File.write(File.join(@root, "gitconfig"), "")
|
|
64
64
|
ENV.update(
|
|
65
65
|
"HOME" => File.join(@root, "home"),
|
|
66
66
|
"XDG_CONFIG_HOME" => File.join(@root, "config"),
|
|
67
67
|
"XDG_STATE_HOME" => File.join(@root, "state"),
|
|
68
|
+
"XDG_DATA_HOME" => File.join(@root, "data"),
|
|
69
|
+
"XDG_CACHE_HOME" => File.join(@root, "cache"),
|
|
68
70
|
"CLAUDE_CONFIG_DIR" => File.join(@root, "home", ".claude"),
|
|
69
71
|
"CODEX_HOME" => File.join(@root, "home", ".codex"),
|
|
70
72
|
"GIT_CONFIG_GLOBAL" => File.join(@root, "gitconfig"),
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1798,7 +1798,10 @@ module Samagotchi
|
|
|
1798
1798
|
# model_segments; none with one model, or from an older file).
|
|
1799
1799
|
model_segments: Array(source["model_segments"]),
|
|
1800
1800
|
# The memory indexes the session's prompt holds (the ctx tooltip).
|
|
1801
|
-
memory_index: source["memory_index"]
|
|
1801
|
+
memory_index: source["memory_index"],
|
|
1802
|
+
# The side requests (recap, /btw, ctx.ask_model), apart from the
|
|
1803
|
+
# tokens: the ctx tooltip's own line (nil without any).
|
|
1804
|
+
side: source["side"]
|
|
1802
1805
|
}
|
|
1803
1806
|
end
|
|
1804
1807
|
|
|
@@ -1143,10 +1143,12 @@ function updateSessionTitle() {
|
|
|
1143
1143
|
}
|
|
1144
1144
|
|
|
1145
1145
|
// The stage takes a running turn's rows (its extras); else the history.
|
|
1146
|
+
// @return true when the stage took +el+ (it scrolls it into its view itself)
|
|
1146
1147
|
function appendToHistory(el) {
|
|
1147
1148
|
// (The stage's place first moves a turn still sliding away into the history.)
|
|
1148
|
-
if (turnDom.place?.(el)) return;
|
|
1149
|
+
if (turnDom.place?.(el)) return true;
|
|
1149
1150
|
appendAboveLiveTiming(historyEl, el, liveTurn.timing.el);
|
|
1151
|
+
return false;
|
|
1150
1152
|
}
|
|
1151
1153
|
|
|
1152
1154
|
// A turn-end re-read's timing (?tail=1&recent=1: the newest turn's records
|
|
@@ -142,14 +142,19 @@ export function ctxBarText(pct, windowSource = null) {
|
|
|
142
142
|
// bar's; a /model whose new window didn't resolve, no request since:
|
|
143
143
|
// Engine#announce_switched_context! sent usage null) adds "until the next
|
|
144
144
|
// request", which asks for it again.
|
|
145
|
-
|
|
145
|
+
// +side+ (the timing's side block) adds the side requests' line, in the
|
|
146
|
+
// "No request in this session yet" answer too (a /btw before any turn).
|
|
147
|
+
export function ctxBarTip(tokens, memoryIndex, window, segments = null, side = null) {
|
|
146
148
|
if (num(window?.pct) === null) {
|
|
147
|
-
if (!num(tokens?.prompt_sum) && !num(tokens?.completion_sum))
|
|
149
|
+
if (!num(tokens?.prompt_sum) && !num(tokens?.completion_sum)) {
|
|
150
|
+
const sideText = sideRequestsText(side);
|
|
151
|
+
return `No request in this session yet${sideText ? `\nside requests: ${sideText}` : ""}`;
|
|
152
|
+
}
|
|
148
153
|
const why = window?.afterSwitch ? "Context window unknown until the next request" : "Context window unknown";
|
|
149
|
-
return tokensTipText(tokens, why, memoryIndex, null, segments);
|
|
154
|
+
return tokensTipText(tokens, why, memoryIndex, null, segments, side);
|
|
150
155
|
}
|
|
151
156
|
|
|
152
|
-
return tokensTipText(tokens, undefined, memoryIndex, window, segments);
|
|
157
|
+
return tokensTipText(tokens, undefined, memoryIndex, window, segments, side);
|
|
153
158
|
}
|
|
154
159
|
|
|
155
160
|
// The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
|
|
@@ -198,6 +203,24 @@ export function modelSegmentCostText(row) {
|
|
|
198
203
|
return [costText(row?.cost_sum), costText(row?.cost_estimate_sum, { estimate: true })].filter(Boolean).join(" + ");
|
|
199
204
|
}
|
|
200
205
|
|
|
206
|
+
// "3 (recap 2, btw 1) · 14.2k in / 380 out · $0.0012 + ~$0.0009": the
|
|
207
|
+
// session's side requests (the timing's side block: the idle recap, /btw and
|
|
208
|
+
// other ctx.ask_model calls, apart from its turns' totals), the kinds in
|
|
209
|
+
// first-seen order (one kind: its name alone), then the model rows' tokens
|
|
210
|
+
// and cost words on the block; "" without any (the line is hidden). As
|
|
211
|
+
// Formatting#side_requests_text (spec/shared/labels_matrix.json side_requests).
|
|
212
|
+
export function sideRequestsText(side) {
|
|
213
|
+
if (!side || typeof side !== "object") return "";
|
|
214
|
+
const requests = num(side.requests) || 0;
|
|
215
|
+
if (requests <= 0) return "";
|
|
216
|
+
const kinds = side.by_kind && typeof side.by_kind === "object" ? Object.entries(side.by_kind) : [];
|
|
217
|
+
const names = kinds.length === 1
|
|
218
|
+
? kinds[0][0]
|
|
219
|
+
: kinds.map(([kind, row]) => `${kind} ${Math.round(num(row?.requests) || 0)}`).join(", ");
|
|
220
|
+
const head = names ? `${Math.round(requests)} (${names})` : String(Math.round(requests));
|
|
221
|
+
return [head, modelSegmentTokensText(side), modelSegmentCostText(side)].filter(Boolean).join(" · ");
|
|
222
|
+
}
|
|
223
|
+
|
|
201
224
|
// "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
|
|
202
225
|
// row's model prefilled (its cost an estimate from the configured price,
|
|
203
226
|
// left out without one); "" for none.
|
|
@@ -246,14 +269,16 @@ function modelSegmentLines(segments) {
|
|
|
246
269
|
// totals. A delegated session keeps its own metrics, so these are this
|
|
247
270
|
// session's only.
|
|
248
271
|
export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null, window = null,
|
|
249
|
-
segments = null) {
|
|
272
|
+
segments = null, side = null) {
|
|
250
273
|
const memory = memoryIndexText(memoryIndex);
|
|
251
274
|
const memoryLine = memory ? `\nmemory index: ${memory}` : "";
|
|
275
|
+
const sideText = sideRequestsText(side);
|
|
276
|
+
const sideLine = sideText ? `side requests: ${sideText}` : "";
|
|
252
277
|
const windowText = ctxWindowText(window);
|
|
253
278
|
if (windowText) title = `${title}\n${windowText}`;
|
|
254
279
|
const prompt = num(tokens?.prompt_sum) || 0;
|
|
255
280
|
const completion = num(tokens?.completion_sum) || 0;
|
|
256
|
-
if (!prompt && !completion) return `${title}${memoryLine}`;
|
|
281
|
+
if (!prompt && !completion) return `${title}${sideLine ? `\n${sideLine}` : ""}${memoryLine}`;
|
|
257
282
|
const cached = num(tokens.cached_sum) || 0;
|
|
258
283
|
const reasoning = num(tokens.reasoning_sum) || 0;
|
|
259
284
|
const parts = [`in ${count(prompt)}`];
|
|
@@ -262,6 +287,7 @@ export function tokensTipText(tokens, title = "Context used after the last turn"
|
|
|
262
287
|
const lines = [title, ...modelSegmentLines(segments), `tokens: ${parts.join(" · ")}`];
|
|
263
288
|
const cost = costLine(tokens);
|
|
264
289
|
if (cost) lines.push(cost);
|
|
290
|
+
if (sideLine) lines.push(sideLine);
|
|
265
291
|
lines.push("this session only, all requests");
|
|
266
292
|
return `${lines.join("\n")}${memoryLine}`;
|
|
267
293
|
}
|
|
@@ -68,7 +68,7 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
|
|
|
68
68
|
const ctxTip = ctxBarTip(tokens, state.timing.memoryIndex,
|
|
69
69
|
{ tokens: state.ctxWindow, source: state.ctxWindowSource, pct: state.ctxPct,
|
|
70
70
|
budget: state.llmContext?.budget_tokens, afterSwitch: state.ctxUnknownAfterSwitch },
|
|
71
|
-
state.timing.modelSegments);
|
|
71
|
+
state.timing.modelSegments, state.timing.side);
|
|
72
72
|
const speedTextNow = lastSpeedText(tokens);
|
|
73
73
|
const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
|
|
74
74
|
const sessionDuration = sessionDurationNow(state.timing, running);
|
|
@@ -145,7 +145,7 @@ export function cardTemplate(s, { deletable = false, archivable = false, selecta
|
|
|
145
145
|
const folderT = folder ? html`<span class="folder" title=${s.working_directory}>${folder}</span>` : nothing;
|
|
146
146
|
const archived = s.archived ? html`<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : nothing;
|
|
147
147
|
const ctx = cardCtxText(s.ctx_pct);
|
|
148
|
-
const ctxT = ctx ? html`<span class="ctx" title=${ctxBarTip(s.tokens, s.memory_index, { pct: s.ctx_pct })}>${ctx}</span>` : nothing;
|
|
148
|
+
const ctxT = ctx ? html`<span class="ctx" title=${ctxBarTip(s.tokens, s.memory_index, { pct: s.ctx_pct }, null, s.side)}>${ctx}</span>` : nothing;
|
|
149
149
|
const waiting = waitingBadge(s);
|
|
150
150
|
const waitingT = waiting ? html`<span class=${`attn attn-${waiting.kind}`} title=${waiting.title}>${waiting.text}</span>` : nothing;
|
|
151
151
|
// A turn a hook stopped wears its own badge, unless the session waits on
|
|
@@ -26,7 +26,8 @@ import { plainText } from "./plain_line.js";
|
|
|
26
26
|
|
|
27
27
|
// @param sessionId () => the open session's id (answers go there)
|
|
28
28
|
// @param clientId this tab's id in the session's events
|
|
29
|
-
// @param place (card) puts a new card at the end of the history
|
|
29
|
+
// @param place (card) puts a new card at the end of the history; true: the
|
|
30
|
+
// stage took it (it brings the card into its view itself, eased)
|
|
30
31
|
// @param isNearBottom () => whether the history follows its end
|
|
31
32
|
// @param scrollToEnd () scrolls the history to its end
|
|
32
33
|
// @param removeHintIfEmpty () drops the history's placeholder hint
|
|
@@ -200,10 +201,10 @@ export function createQuestionCards({
|
|
|
200
201
|
|
|
201
202
|
// Read before the card makes the history taller.
|
|
202
203
|
const follow = isNearBottom();
|
|
203
|
-
place(card);
|
|
204
|
+
const staged = place(card) === true;
|
|
204
205
|
questionEl = card;
|
|
205
206
|
if (follow) scrollToEnd();
|
|
206
|
-
focusWhenIdle(card, pq.id);
|
|
207
|
+
focusWhenIdle(card, pq.id, { staged });
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
function isPlainEnter(e) {
|
|
@@ -213,7 +214,10 @@ export function createQuestionCards({
|
|
|
213
214
|
// A new card takes the focus while nobody is typing: the focus is on
|
|
214
215
|
// the page or an empty composer. Checked after the caller is done, so a
|
|
215
216
|
// card drawn already answered (the history replay) never takes it.
|
|
216
|
-
|
|
217
|
+
// +staged+: the stage holds the card and scrolls to it once it has grown
|
|
218
|
+
// for it (eased): the focus doesn't scroll, or the stage would snap to the
|
|
219
|
+
// card before its room eases in.
|
|
220
|
+
function focusWhenIdle(card, id, { staged = false } = {}) {
|
|
217
221
|
queueMicrotask(() => {
|
|
218
222
|
if (questionEl !== card || String(pending?.id) !== String(id)) return;
|
|
219
223
|
const prompt = doc.getElementById?.("prompt");
|
|
@@ -221,7 +225,8 @@ export function createQuestionCards({
|
|
|
221
225
|
const idle = !active || active === doc.body || (active === prompt && !prompt.value);
|
|
222
226
|
if (!idle || (prompt && prompt.value)) return;
|
|
223
227
|
const target = card.querySelector(".question-option input") || card.querySelector(".question-freeform");
|
|
224
|
-
target?.focus();
|
|
228
|
+
if (staged) target?.focus({ preventScroll: true });
|
|
229
|
+
else target?.focus();
|
|
225
230
|
});
|
|
226
231
|
}
|
|
227
232
|
|
|
@@ -339,6 +339,13 @@ export function createStreamHandlers(deps) {
|
|
|
339
339
|
question_cancelled: (data) => {
|
|
340
340
|
questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
|
|
341
341
|
},
|
|
342
|
+
// A side request (the recap, /btw, a plugin's ctx.ask_model) counted:
|
|
343
|
+
// the server's whole side block, for the ctx tooltip's line at once.
|
|
344
|
+
side_usage: (data) => {
|
|
345
|
+
if (!data?.side || typeof data.side !== "object") return;
|
|
346
|
+
state().timing.side = data.side;
|
|
347
|
+
updateInfoBar();
|
|
348
|
+
},
|
|
342
349
|
// One collected just as a turn started describes the chat before it.
|
|
343
350
|
recap_ready: (data) => {
|
|
344
351
|
// After the turn it covers, never above it.
|
|
@@ -85,6 +85,10 @@ export function normalizeTiming(data = {}) {
|
|
|
85
85
|
// (SessionMetrics' model_segments; [] with one model), for the ctx
|
|
86
86
|
// tooltip.
|
|
87
87
|
modelSegments: Array.isArray(data.model_segments) ? data.model_segments.filter(validRecord) : [],
|
|
88
|
+
// The session's side requests (SessionMetrics' side block: recap,
|
|
89
|
+
// /btw, ctx.ask_model, apart from tokens), for the ctx tooltip; null
|
|
90
|
+
// without any.
|
|
91
|
+
side: validRecord(data.side) ? data.side : null,
|
|
88
92
|
};
|
|
89
93
|
}
|
|
90
94
|
|
|
@@ -121,11 +125,12 @@ export function mergeTiming(timing, data = {}) {
|
|
|
121
125
|
});
|
|
122
126
|
const tokens = fresh.tokens || timing?.tokens || null;
|
|
123
127
|
const memoryIndex = fresh.memoryIndex || timing?.memoryIndex || null;
|
|
128
|
+
const side = fresh.side || timing?.side || null;
|
|
124
129
|
// A reply that has the rows is the answer, an empty list too (one model
|
|
125
130
|
// now): a stale list never survives it; one without them (an older
|
|
126
131
|
// server) keeps the page's.
|
|
127
132
|
const modelSegments = data && "model_segments" in data ? fresh.modelSegments : (timing?.modelSegments || []);
|
|
128
|
-
return { timing: { ...fresh, turnRecords, toolRecords, tokens, memoryIndex, modelSegments },
|
|
133
|
+
return { timing: { ...fresh, turnRecords, toolRecords, tokens, memoryIndex, modelSegments, side },
|
|
129
134
|
short: turnRecords.length < count };
|
|
130
135
|
}
|
|
131
136
|
|
|
@@ -77,6 +77,8 @@ module Samagotchi
|
|
|
77
77
|
tokens: saved&.tokens,
|
|
78
78
|
# The memory indexes its prompt held (the card's ctx tooltip).
|
|
79
79
|
memory_index: saved&.memory_index,
|
|
80
|
+
# Its side requests, apart from the tokens (the tooltip's own line).
|
|
81
|
+
side: saved&.side,
|
|
80
82
|
# Hidden from the strip and the list unless "include archived".
|
|
81
83
|
archived: ArchiveStore.archived?(session_dir),
|
|
82
84
|
project_root: session.project_root(cache: root_cache),
|
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.
|
|
4
|
+
version: 0.82.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -292,6 +292,7 @@ files:
|
|
|
292
292
|
- lib/samagotchi/guardrails/approvals.rb
|
|
293
293
|
- lib/samagotchi/guardrails/child_boundary.rb
|
|
294
294
|
- lib/samagotchi/guardrails/context.rb
|
|
295
|
+
- lib/samagotchi/guardrails/data_readers.rb
|
|
295
296
|
- lib/samagotchi/guardrails/gate.rb
|
|
296
297
|
- lib/samagotchi/guardrails/load_failures.rb
|
|
297
298
|
- lib/samagotchi/guardrails/model_size.rb
|
|
@@ -469,6 +470,7 @@ files:
|
|
|
469
470
|
- lib/samagotchi/session_manager.rb
|
|
470
471
|
- lib/samagotchi/session_metrics.rb
|
|
471
472
|
- lib/samagotchi/session_metrics/model_segment.rb
|
|
473
|
+
- lib/samagotchi/session_metrics/side_totals.rb
|
|
472
474
|
- lib/samagotchi/session_metrics/totals.rb
|
|
473
475
|
- lib/samagotchi/session_model_settings.rb
|
|
474
476
|
- lib/samagotchi/session_observer.rb
|
|
@@ -477,6 +479,7 @@ files:
|
|
|
477
479
|
- lib/samagotchi/session_setup.rb
|
|
478
480
|
- lib/samagotchi/sessions_command.rb
|
|
479
481
|
- lib/samagotchi/setting_explained.rb
|
|
482
|
+
- lib/samagotchi/side_usage.rb
|
|
480
483
|
- lib/samagotchi/steer.rb
|
|
481
484
|
- lib/samagotchi/steer_cut.rb
|
|
482
485
|
- lib/samagotchi/steer_cut_after.rb
|