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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/docs/architecture.md +9 -1
  4. data/docs/cli.md +9 -1
  5. data/docs/configuration.md +4 -0
  6. data/docs/guardrails.md +26 -3
  7. data/docs/plugins.md +9 -3
  8. data/docs/sessions.md +9 -1
  9. data/docs/sub-agent.md +19 -8
  10. data/docs/testing.md +19 -0
  11. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  12. data/lib/samagotchi/bootstrap_command.rb +1 -1
  13. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
  14. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  15. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/cli/parent_wait.rb +9 -3
  19. data/lib/samagotchi/engine.rb +31 -5
  20. data/lib/samagotchi/guardrails/data_readers.rb +214 -0
  21. data/lib/samagotchi/guardrails/rm_targets.rb +4 -2
  22. data/lib/samagotchi/guardrails/rules.rb +4 -2
  23. data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
  24. data/lib/samagotchi/guardrails/shell_paths.rb +5 -3
  25. data/lib/samagotchi/guardrails/targets.rb +14 -0
  26. data/lib/samagotchi/host_config.rb +8 -8
  27. data/lib/samagotchi/idle_client.rb +36 -11
  28. data/lib/samagotchi/idle_recap.rb +6 -1
  29. data/lib/samagotchi/idle_target.rb +12 -4
  30. data/lib/samagotchi/llm/http.rb +7 -2
  31. data/lib/samagotchi/llm/usage.rb +9 -3
  32. data/lib/samagotchi/model_price.rb +16 -0
  33. data/lib/samagotchi/parent_report.rb +7 -3
  34. data/lib/samagotchi/plugin/context.rb +10 -3
  35. data/lib/samagotchi/plugin/service.rb +24 -1
  36. data/lib/samagotchi/recap_setup.rb +2 -1
  37. data/lib/samagotchi/reply_wait.rb +68 -2
  38. data/lib/samagotchi/send_command.rb +47 -8
  39. data/lib/samagotchi/session_metrics/side_totals.rb +117 -0
  40. data/lib/samagotchi/session_metrics.rb +49 -12
  41. data/lib/samagotchi/session_observer.rb +3 -0
  42. data/lib/samagotchi/side_usage.rb +38 -0
  43. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  44. data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
  45. data/lib/samagotchi/testing/boundary.rb +15 -0
  46. data/lib/samagotchi/testing/bundle.rb +35 -9
  47. data/lib/samagotchi/testing/host.rb +2 -2
  48. data/lib/samagotchi/testing/recorder.rb +7 -4
  49. data/lib/samagotchi/testing/sandbox.rb +5 -3
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/app.rb +4 -1
  52. data/lib/samagotchi/web/public/app.js +3 -1
  53. data/lib/samagotchi/web/public/ctx.js +32 -6
  54. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  55. data/lib/samagotchi/web/public/lists_view.js +1 -1
  56. data/lib/samagotchi/web/public/question_cards.js +10 -5
  57. data/lib/samagotchi/web/public/stream_handlers.js +7 -0
  58. data/lib/samagotchi/web/public/timing.js +6 -1
  59. data/lib/samagotchi/web/session_summary.rb +2 -0
  60. 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 state homes
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"),
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.80.0"
4
+ VERSION = "0.82.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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
- export function ctxBarTip(tokens, memoryIndex, window, segments = null) {
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)) return "No request in this session yet";
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
- function focusWhenIdle(card, id) {
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.80.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