samagotchi 0.19.0 → 0.20.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/docs/architecture.md +6 -0
  4. data/docs/cli.md +14 -3
  5. data/docs/configuration.md +4 -1
  6. data/docs/guardrails.md +92 -14
  7. data/docs/plugins.md +5 -0
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  10. data/lib/samagotchi/bootstrap_command.rb +26 -5
  11. data/lib/samagotchi/bundle_command.rb +22 -4
  12. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  14. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  15. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  16. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  18. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  20. data/lib/samagotchi/commands/registry.rb +4 -3
  21. data/lib/samagotchi/config.rb +3 -0
  22. data/lib/samagotchi/context_usage.rb +3 -3
  23. data/lib/samagotchi/engine.rb +16 -2
  24. data/lib/samagotchi/guardrail_wiring.rb +2 -1
  25. data/lib/samagotchi/guardrails/approval.rb +4 -3
  26. data/lib/samagotchi/guardrails/approvals.rb +11 -5
  27. data/lib/samagotchi/guardrails/context.rb +16 -9
  28. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  29. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  30. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  31. data/lib/samagotchi/guardrails/rules.rb +69 -5
  32. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  33. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  34. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  35. data/lib/samagotchi/guardrails/targets.rb +65 -4
  36. data/lib/samagotchi/guardrails.rb +2 -0
  37. data/lib/samagotchi/hooks/registry.rb +6 -2
  38. data/lib/samagotchi/idle_recap.rb +7 -2
  39. data/lib/samagotchi/llm/usage.rb +1 -1
  40. data/lib/samagotchi/memory_bundle/builder.rb +1 -5
  41. data/lib/samagotchi/memory_bundle/installer.rb +46 -13
  42. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  43. data/lib/samagotchi/memory_bundle/status.rb +12 -4
  44. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
  45. data/lib/samagotchi/memory_paths.rb +6 -0
  46. data/lib/samagotchi/self_report.rb +38 -2
  47. data/lib/samagotchi/session.rb +35 -6
  48. data/lib/samagotchi/session_commands.rb +13 -2
  49. data/lib/samagotchi/session_metrics.rb +224 -19
  50. data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
  51. data/lib/samagotchi/token_usage.rb +57 -41
  52. data/lib/samagotchi/tool_runner.rb +21 -2
  53. data/lib/samagotchi/tools/execute.rb +9 -8
  54. data/lib/samagotchi/version.rb +1 -1
  55. data/lib/samagotchi/web/app.rb +19 -0
  56. data/lib/samagotchi/web/public/app.js +34 -15
  57. data/lib/samagotchi/web/public/card.js +34 -2
  58. data/lib/samagotchi/web/public/ctx.js +59 -0
  59. data/lib/samagotchi/web/public/format.js +11 -0
  60. data/lib/samagotchi/web/public/index.html +26 -2
  61. data/lib/samagotchi/web/public/model_pick.js +6 -2
  62. data/lib/samagotchi/web/public/model_picker.js +2 -1
  63. data/lib/samagotchi/web/public/question_card.js +2 -1
  64. data/lib/samagotchi/web/public/timing.js +6 -2
  65. data/lib/samagotchi/web/public/turn_events.js +1 -1
  66. data/lib/samagotchi/web/session_summary.rb +6 -2
  67. metadata +5 -1
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.19.0"
4
+ VERSION = "0.20.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -21,6 +21,7 @@ require_relative "../steer"
21
21
  require_relative "../host_registry"
22
22
  require_relative "../model_catalog"
23
23
  require_relative "../model_profile"
24
+ require_relative "../sampling_settings"
24
25
  require_relative "../project_scope"
25
26
  require_relative "../prompt_history"
26
27
  require_relative "../version"
@@ -440,11 +441,29 @@ module Samagotchi
440
441
  if default && models.none? { |m| m[:name].casecmp?(default) }
441
442
  models.unshift({ name: default, host: nil, id: default })
442
443
  end
444
+ add_sampling(models, registry)
443
445
  payload = { default: default, models: models }
444
446
  payload[:warning] = warnings.join("; ") unless warnings.empty?
445
447
  json_response(200, payload)
446
448
  end
447
449
 
450
+ # Each model's configured sampling as /model words it ("temperature=0.6
451
+ # (hosts.work)"), on the models that have some: the picker's tooltip.
452
+ # A config that can't be read leaves the list as it is.
453
+ def add_sampling(models, registry)
454
+ return if registry.nil? || models.empty?
455
+
456
+ settings = ConfigFile.model_settings
457
+ models.each do |model|
458
+ target = registry.resolve(model[:name])
459
+ summary = SamplingSettings.summary(target, names: registry.lookup_names(model[:name], target: target),
460
+ models: settings)
461
+ model[:sampling] = summary if summary
462
+ end
463
+ rescue StandardError
464
+ nil
465
+ end
466
+
448
467
  # Built again when config.yml's hosts: changed (compared as read: the
449
468
  # YAML read is mtime-cached), so the model picker lists a host added
450
469
  # while chi web runs. An injected registry (specs) stays.
@@ -20,7 +20,7 @@ import { MODEL_KEY } from "./model_pick.js";
20
20
  import { emptySessionState } from "./session_state.js";
21
21
  import { createModelPicker } from "./model_picker.js";
22
22
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
23
- import { escapeHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
23
+ import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
24
24
  import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
25
25
  import { createStageView } from "./stage_view.js";
26
26
  import { appendQuote } from "./annotations.js";
@@ -28,9 +28,9 @@ import { parsePresets } from "./annotate_presets.js";
28
28
  import { createAnnotateBar } from "./annotate_bar.js";
29
29
  import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
30
30
  import { routeChunk } from "./chunk_router.js";
31
- import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
31
+ import { extractCtxPct, savedCtxPct, cardCtxText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
32
32
  import { createQuestionCards } from "./question_cards.js";
33
- import { cardClass, cardInnerHtml, cardPlace, isCard, splitSnapshotCards, turnNoticePlace } from "./card.js";
33
+ import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
34
34
  import { gripFloor, promptHeight } from "./composer_size.js";
35
35
  import { createCommandList } from "./command_list.js";
36
36
  import { createPromptHistory } from "./prompt_history.js";
@@ -317,7 +317,7 @@ function cardHtml(s, { deletable = false } = {}) {
317
317
  const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
318
318
  const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
319
319
  const ctx = cardCtxText(s.ctx_pct);
320
- const ctxHtml = ctx ? `<span class="ctx" title="Context used after the last turn">${escapeHtml(ctx)}</span>` : "";
320
+ const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens))}">${escapeHtml(ctx)}</span>` : "";
321
321
  const waiting = waitingBadge(s);
322
322
  const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
323
323
  const dotWord = waiting ? waiting.title : s.status;
@@ -1173,13 +1173,26 @@ function cardElement(id) {
1173
1173
  return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
1174
1174
  }
1175
1175
 
1176
+ // A notice is a <details> (a one-line row that opens), a card a <div>: a
1177
+ // card that turns into the other kind (btw's "thinking…" → its answer) is
1178
+ // drawn on a new element in its place. Returns the element drawn.
1176
1179
  function drawCard(el, card) {
1180
+ const notice = isNoticeCard(card);
1181
+ if (el.tagName !== (notice ? "DETAILS" : "DIV")) {
1182
+ const fresh = document.createElement(notice ? "details" : "div");
1183
+ el.replaceWith(fresh);
1184
+ el = fresh;
1185
+ }
1177
1186
  el.className = cardClass(card);
1178
1187
  el.dataset.cardId = card.id;
1179
1188
  // A running turn's own card (the stage's status waits on one that asks).
1180
1189
  if (card.in_turn) el.dataset.inTurn = "1";
1181
1190
  else delete el.dataset.inTurn;
1182
- if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
1191
+ if (notice) {
1192
+ const open = el.open;
1193
+ el.innerHTML = noticeInnerHtml(card);
1194
+ el.open = open;
1195
+ } else if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
1183
1196
  // The same body again from the stream: keep the rendered one.
1184
1197
  const body = el.querySelector(".card-body").innerHTML;
1185
1198
  el.innerHTML = cardInnerHtml({ ...card, body_html: body });
@@ -1187,6 +1200,7 @@ function drawCard(el, card) {
1187
1200
  el.innerHTML = cardInnerHtml(card);
1188
1201
  }
1189
1202
  el.dataset.body = String(card.body ?? "");
1203
+ return el;
1190
1204
  }
1191
1205
 
1192
1206
  // Show +card+: update the one with its id where it is, else add it (a
@@ -1198,14 +1212,13 @@ function upsertCard(card, { before = null } = {}) {
1198
1212
  if (!card || !card.id) return;
1199
1213
  const existing = cardElement(card.id);
1200
1214
  if (existing) {
1201
- drawCard(existing, card);
1202
- existing.classList.add("card-updated");
1203
- setTimeout(() => existing.classList.remove("card-updated"), 900);
1215
+ const drawn = drawCard(existing, card);
1216
+ drawn.classList.add("card-updated");
1217
+ setTimeout(() => drawn.classList.remove("card-updated"), 900);
1204
1218
  return;
1205
1219
  }
1206
1220
  removeHintIfEmpty();
1207
- const el = document.createElement("div");
1208
- drawCard(el, card);
1221
+ const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
1209
1222
  const asks = (card.actions || []).some((a) => a && a.command);
1210
1223
  if (card.in_turn && !before && !asks && turnDom.card(el)) return;
1211
1224
  const follow = isNearBottom();
@@ -1416,6 +1429,11 @@ function updateInfoBar() {
1416
1429
  const idShort = selected.slice(0,8);
1417
1430
  const memText = current.usedMemories.length ? current.usedMemories.join(", ") : "—";
1418
1431
  const ctxText = current.ctxPct !== null ? `ctx ${Math.round(current.ctxPct)}%` : "";
1432
+ const tokens = current.timing.tokens;
1433
+ const ctxTip = tokensTipText(tokens);
1434
+ const speedTextNow = lastSpeedText(tokens);
1435
+ const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
1436
+ const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
1419
1437
  const sessionDuration = currentSessionDuration();
1420
1438
  const timingText = sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`;
1421
1439
  const statusClass = current.status ? `status ${escapeHtml(current.status)}` : "status";
@@ -1426,7 +1444,7 @@ function updateInfoBar() {
1426
1444
  const attachCmd = `chi --attach ${selected}`;
1427
1445
  const copied = Date.now() - attachCopiedAt < 2000;
1428
1446
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1429
- const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}<span class="dir" title="${escapeHtml(current.dir||"")}">${escapeHtml((current.dir||"").slice(0,48))}</span>${ctxText ? `<span class="model">${escapeHtml(ctxText)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}${workerBadgeHtml()}</div>`;
1447
+ const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}">${escapeHtml((current.dir||"").slice(0,48))}</span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}${workerBadgeHtml()}</div>`;
1430
1448
  const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
1431
1449
  infoTextEl.innerHTML = metaHtml + previewHtml;
1432
1450
  infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
@@ -1648,10 +1666,10 @@ const streamHandlers = {
1648
1666
  },
1649
1667
  generation_completed: (data) => {
1650
1668
  setLiveTurnNote("");
1651
- if (data?.served_model) {
1652
- current.served = [data.served_model, data.requested_model];
1653
- updateInfoBar();
1654
- }
1669
+ if (data?.served_model) current.served = [data.served_model, data.requested_model];
1670
+ // The generation's speed and the session's token totals, from the server.
1671
+ current.timing.tokens = generationTokens(current.timing.tokens, data);
1672
+ if (data?.served_model || data?.tokens) updateInfoBar();
1655
1673
  turnDom.generationCompleted();
1656
1674
  },
1657
1675
  // Prompts sent while a turn ran merge into it at an iteration boundary:
@@ -2432,6 +2450,7 @@ shortWindow.addEventListener("change", () => {
2432
2450
  applyStripHidden();
2433
2451
  });
2434
2452
  $("#stripShow").addEventListener("click", () => setStripHidden(false));
2453
+ $("#stripHideTop").addEventListener("click", () => setStripHidden(true));
2435
2454
 
2436
2455
  $("#filter").addEventListener("input", renderAll);
2437
2456
  // A list whose order was held under the pointer (listOrder) catches up.
@@ -3,9 +3,41 @@
3
3
  // the element; this module draws it and says where it goes.
4
4
  import { escapeHtml, recapPlace } from "./format.js";
5
5
 
6
- // The card's class: a warn card wears the warning colour.
6
+ // The card's class: a warn card wears the warning colour; a notice is a
7
+ // one-line row.
7
8
  export function cardClass(card) {
8
- return "bubble plugin-card" + (card?.level === "warn" ? " warn" : "");
9
+ return "bubble plugin-card" + (card?.level === "warn" ? " warn" : "") + (isNoticeCard(card) ? " notice" : "");
10
+ }
11
+
12
+ // The longest body a notice row carries; a longer one is a card to read.
13
+ export const NOTICE_CHARS = 160;
14
+
15
+ // Whether a card is a notice (check-in's "Nudged the model at 105 tool
16
+ // calls."): info level, nothing to click, a body of one short line. It
17
+ // shows as a collapsed one-line row, "▸ check-in: Nudged …", as the tool
18
+ // rows and the resolved question cards do; a click opens it. A warn card,
19
+ // one that asks (actions) and one with more to read stay cards.
20
+ export function isNoticeCard(card) {
21
+ if (!card || card.level === "warn") return false;
22
+ if ((card.actions || []).some((a) => a && a.command)) return false;
23
+ const body = String(card.body ?? "").trim();
24
+ return !body.includes("\n") && body.length <= NOTICE_CHARS;
25
+ }
26
+
27
+ // A notice's one line: "title: body" (the title alone for no body).
28
+ export function noticeCardLine(card) {
29
+ const title = String(card?.title ?? "").trim();
30
+ const body = String(card?.body ?? "").trim();
31
+ return title && body ? `${title}: ${body}` : title || body;
32
+ }
33
+
34
+ // A notice's markup inside its <details>: the line as the summary, and
35
+ // where it came from when the row is opened (the line wraps then).
36
+ export function noticeInnerHtml(card) {
37
+ const title = String(card?.title ?? "").trim();
38
+ const source = String(card?.source ?? "").trim();
39
+ const from = source && source !== title ? `<div class="notice-from">from <span class="card-source">${escapeHtml(source)}</span></div>` : "";
40
+ return `<summary>${escapeHtml(noticeCardLine(card))}</summary>${from}`;
9
41
  }
10
42
 
11
43
  // Whether a card shown as a row of a step leaves the turn's block when the
@@ -48,3 +48,62 @@ export function savedCtxPct(context) {
48
48
  export function cardCtxText(pct) {
49
49
  return num(pct) === null ? "" : `${Math.round(pct)}%`;
50
50
  }
51
+
52
+ // A decode speed: "87 tok/s", "1.9k tok/s"; "~64 tok/s" for an estimate (no
53
+ // server timings); "" without one. The TUI's /stats says the same
54
+ // (Formatting#speed_text; spec/shared/labels_matrix.json).
55
+ export function speedText(tps, source) {
56
+ const value = num(tps);
57
+ if (value === null || value <= 0) return "";
58
+ const number = value >= 1000 ? `${(Math.round(value / 100) / 10).toFixed(1)}k` : String(Math.round(value));
59
+ return `${source === "estimate" ? "~" : ""}${number} tok/s`;
60
+ }
61
+
62
+ // A cost in USD: "$0.42"; under a cent, four decimals ("$0.0012"); "" for
63
+ // none or zero. As Formatting#cost_text.
64
+ export function costText(cost) {
65
+ const value = num(cost);
66
+ if (value === null || value <= 0) return "";
67
+ return value >= 0.01 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`;
68
+ }
69
+
70
+ function count(n) {
71
+ return Math.round(n).toLocaleString("en-US");
72
+ }
73
+
74
+ // The ctx tooltip (info bar and card): +title+, then the session's tokens
75
+ // (in summed over every request, cached with its share, out with the
76
+ // reasoning part) and its cost, when it has any. A delegated session keeps
77
+ // its own metrics, so these are this session's only.
78
+ export function tokensTipText(tokens, title = "Context used after the last turn") {
79
+ const prompt = num(tokens?.prompt_sum) || 0;
80
+ const completion = num(tokens?.completion_sum) || 0;
81
+ if (!prompt && !completion) return title;
82
+ const cached = num(tokens.cached_sum) || 0;
83
+ const reasoning = num(tokens.reasoning_sum) || 0;
84
+ const parts = [`in ${count(prompt)}`];
85
+ if (cached > 0 && prompt > 0) parts.push(`cached ${count(cached)} (${Math.round((cached * 100) / prompt)}%)`);
86
+ parts.push(`out ${count(completion)}${reasoning > 0 ? ` (reasoning ${count(reasoning)})` : ""}`);
87
+ const lines = [title, `tokens: ${parts.join(" · ")}`];
88
+ const cost = costText(tokens.cost_sum);
89
+ if (cost) lines.push(`cost: ${cost}`);
90
+ lines.push("this session only, all requests");
91
+ return lines.join("\n");
92
+ }
93
+
94
+ // The info bar's speed: the newest generation's decode speed, "" before one.
95
+ export function lastSpeedText(tokens) {
96
+ return speedText(tokens?.last_decode_tps, tokens?.tps_source);
97
+ }
98
+
99
+ // The session's tokens block after a :generation_completed: the event's
100
+ // running totals (the server closed the generation and put them on it),
101
+ // with its speed; an event without them (a replay, an older worker) keeps
102
+ // +previous+.
103
+ export function generationTokens(previous, data) {
104
+ const tokens = data?.tokens;
105
+ if (!tokens || typeof tokens !== "object") return previous ?? null;
106
+ const speed = data.speed;
107
+ if (!speed || num(speed.decode_tps) === null) return tokens;
108
+ return { ...tokens, last_decode_tps: speed.decode_tps, tps_source: speed.source };
109
+ }
@@ -218,3 +218,14 @@ export function recapLabel(turnsSince, stale = false) {
218
218
  if (n > 1) return `recap · before the last ${n} turns`;
219
219
  return stale ? "recap · earlier" : "recap";
220
220
  }
221
+
222
+ // The info bar's memory chip on a phone (where the "mem: …" line goes),
223
+ // for a session that used memories: "mem 2", the names its tooltip.
224
+ export function memChipText(names) {
225
+ return `mem ${(names || []).length}`;
226
+ }
227
+
228
+ export function memChipTitle(names) {
229
+ const list = names || [];
230
+ return list.length ? `memories: ${list.join(", ")}` : "no memories in this session";
231
+ }
@@ -303,6 +303,17 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
303
303
  .bubble.plugin-card{border-left:3px solid var(--accent);background:color-mix(in srgb,var(--accent) 6%,var(--glass-bg));padding:10px 12px;transition:box-shadow .3s ease}
304
304
  .bubble.plugin-card.warn{border-left-color:var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg))}
305
305
  .bubble.plugin-card.card-updated{box-shadow:inset 0 1px 0 var(--glass-highlight),0 0 0 1px var(--accent-ring),var(--glass-shadow)}
306
+ /* A notice card (card.js isNoticeCard): a one-line row like a resolved question, "▸ check-in: Nudged …"; opened, the line wraps and the card's glass comes back. */
307
+ .bubble.plugin-card.notice:not([open]){padding:3px 10px;background:transparent;border-color:transparent;border-left-color:var(--accent);box-shadow:none;-webkit-backdrop-filter:none;backdrop-filter:none;align-self:flex-start;max-width:100%;border-radius:6px;transition:background .12s ease}
308
+ .bubble.plugin-card.notice:not([open]):hover{background:var(--bg-panel)}
309
+ .bubble.plugin-card.notice>summary{cursor:pointer;color:var(--text-muted);font-size:12px;font-weight:500;user-select:none;list-style:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
310
+ .bubble.plugin-card.notice>summary::-webkit-details-marker{display:none}
311
+ .bubble.plugin-card.notice>summary::before{content:"▸ ";color:var(--accent)}
312
+ .bubble.plugin-card.notice[open]{padding:6px 10px}
313
+ .bubble.plugin-card.notice[open]>summary{white-space:pre-wrap;font-size:13px;color:var(--text-2)}
314
+ .bubble.plugin-card.notice[open]>summary::before{content:"▾ "}
315
+ .notice-from{margin-top:4px;font-size:11px;color:var(--text-faint)}
316
+ .gen .bubble.plugin-card.notice:not([open]){margin:2px 0;padding:2px 8px}
306
317
  .card-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px}
307
318
  .card-title{font-size:14px;font-weight:600;color:var(--text-bright)}
308
319
  .card-source{font-size:11px;color:var(--text-faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
@@ -557,7 +568,10 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
557
568
  #infoBar .meta .copy-attach:hover{color:#fff;border-color:var(--border-strong)}
558
569
  #infoBar .meta .status{font-size:11px;padding:2px 6px;border-radius:999px}
559
570
  #infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);word-break:break-all}
560
- #infoBar .meta .model{font-size:11px;padding:2px 6px;border-radius:6px;background:var(--bg-hover);color:var(--text-3)}
571
+ /* The footer sits on the card's glass: its chips are tinted, not solid, so the glass reads through them. */
572
+ #infoBar .meta .model{font-size:11px;padding:2px 6px;border-radius:6px;background:color-mix(in srgb,var(--bg-hover) 55%,transparent);color:var(--text-3)}
573
+ /* The memory chip ("mem 2", when the session used some): phones only, where the "mem: …" line is hidden. */
574
+ #infoBar .meta .mem-chip{display:none}
561
575
  #infoBar .meta .model.served-mismatch{background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border)}
562
576
  /* The open session's worker runs an older chi: its version, and Restart when it can. */
563
577
  #infoBar .meta .worker-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:2px 2px 2px 6px;border-radius:6px;background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border);white-space:nowrap}
@@ -588,10 +602,12 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
588
602
  #infoBar .meta>*{flex-shrink:0}
589
603
  #infoBar .meta .id{order:-2}
590
604
  #infoBar .meta .status{order:-1}
605
+ /* After the status (same order, later in the DOM): the meta scrolls, and only its start is in sight. */
606
+ #infoBar .meta .mem-chip{display:inline;order:-1}
591
607
  #infoBar .meta .dir{word-break:normal}
592
608
  #infoBar button{padding:4px 8px}
593
609
  }
594
- #empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--text-muted);padding:24px;text-align:center;background:var(--bg);z-index:1}
610
+ #empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--text-muted);padding:24px;text-align:center;background:color-mix(in srgb,var(--bg) 35%,transparent);z-index:1}
595
611
  #empty.hidden{display:none !important}
596
612
  /* Start page (no session): the composer alone, centered; no info bar. */
597
613
  body:not(.has-session) #center{justify-content:safe center;background:transparent}
@@ -661,6 +677,13 @@ body.has-session #scope .scope-where{display:none}
661
677
  #stripShow .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
662
678
  #stripHide{align-self:flex-end;border:0;padding:0 2px;border-radius:4px}
663
679
  #stripHide:hover{text-decoration:underline;color:var(--text-2)}
680
+ /* A narrow window's strip scrolls sideways, which puts its own "hide ▴" out of sight: the top bar has it instead, where the pill comes back, with a finger-sized target. */
681
+ #stripHideTop{display:none;white-space:nowrap;flex-shrink:0}
682
+ @media(max-width:900px){
683
+ body:not(.strip-hidden):not(.all-view) #stripHideTop{display:block}
684
+ #stripHide{display:none}
685
+ #stripHideTop,#stripShow{min-height:30px;padding:4px 12px;font-size:12px}
686
+ }
664
687
  body.strip-hidden #stripShow{display:block}
665
688
  body.strip-hidden #topStrip, body.all-view #stripShow{display:none}
666
689
  #allTile{flex:1;flex-direction:column;justify-content:center;align-items:flex-start;text-align:left;color:var(--text-2);font-size:13px;font-weight:600;width:100%}
@@ -740,6 +763,7 @@ a{color:var(--accent)}
740
763
  <button type="button" id="newBtn" class="brand" title="New chat"><span class="brand-name">chi</span><span class="brand-new">+<span class="brand-new-word"> new chat</span></span></button>
741
764
  <div id="scope" hidden></div>
742
765
  <button type="button" class="strip-toggle" id="stripShow" title="Show the latest sessions"><span class="pill-arrow" aria-hidden="true">▾</span> sessions</button>
766
+ <button type="button" class="strip-toggle" id="stripHideTop" title="Hide the latest sessions"><span class="pill-arrow" aria-hidden="true">▴</span> hide</button>
743
767
  <button type="button" id="notifyBtn" aria-label="Notifications" aria-pressed="false" hidden><svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path class="bell" d="M8 1.75a4 4 0 0 0-4 4v2.6L2.75 10.6v.9h10.5v-.9L12 8.35v-2.6a4 4 0 0 0-4-4Z"/><path class="clapper" d="M6.5 13a1.5 1.5 0 0 0 3 0"/><path class="slash" d="M2.5 2.5l11 11"/></svg></button>
744
768
  </div>
745
769
  <section id="topStrip" aria-label="Latest sessions" class="loading"><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div></section>
@@ -17,7 +17,9 @@ export function pickModel(names, defaultName, stored) {
17
17
 
18
18
  export const RECENT_KEY = "chi_model_recent";
19
19
 
20
- // The payload's models as rows: {name, host, id, isDefaultHost}. The
20
+ // The payload's models as rows: {name, host, id, isDefaultHost}, and
21
+ // sampling (the configured request parameters as /model words them) when
22
+ // the model has some. The
21
23
  // default host's names are bare (name == id); the server's own default,
22
24
  // unshifted when no host lists it, has no host and goes with them, shown
23
25
  // by its name.
@@ -29,7 +31,9 @@ export function modelRows(payload) {
29
31
  const name = String(m.name);
30
32
  const host = m.host ? String(m.host) : "";
31
33
  const id = host ? String(m.id || name) : name;
32
- return { name, host, id, isDefaultHost: !host || name === id };
34
+ const row = { name, host, id, isDefaultHost: !host || name === id };
35
+ if (m.sampling) row.sampling = String(m.sampling);
36
+ return row;
33
37
  });
34
38
  }
35
39
 
@@ -59,7 +59,8 @@ export function createModelPicker({ button, panel, input, list, composer, center
59
59
  const host = showHost ? `<span class="model-host">${markedHtml(row.host, hostMarks)}</span><span class="model-dot"> · </span>` : "";
60
60
  const note = row.name === defaultName ? `<span class="model-note">default</span>` : "";
61
61
  const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}`;
62
- return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}">` +
62
+ const title = row.sampling ? ` title="${escapeHtml(`sampling: ${row.sampling}`)}"` : "";
63
+ return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}"${title}>` +
63
64
  `<span class="model-id">${host}${markedHtml(row.id, idMarks)}</span>${note}</div>`;
64
65
  }
65
66
 
@@ -52,7 +52,8 @@ export function approvalView(pq) {
52
52
  const command = a.command || null;
53
53
  let where = a.cwd || "";
54
54
  if (a.repo_root) {
55
- const repo = String(a.repo_root).split("/").filter(Boolean).pop() || a.repo_root;
55
+ // The repository's name (a worktree's main checkout), else the root's folder.
56
+ const repo = a.repo_name || String(a.repo_root).split("/").filter(Boolean).pop() || a.repo_root;
56
57
  where += ` (repo ${repo}${a.branch ? `, branch ${a.branch}` : ""})`;
57
58
  } else if (where) {
58
59
  where += " (not in a repo)";
@@ -52,6 +52,8 @@ export function normalizeTiming(data = {}) {
52
52
  turnRecords: Array.isArray(data.turn_records) ? data.turn_records.filter(validRecord) : [],
53
53
  toolRecords: Array.isArray(data.tool_records) ? data.tool_records.filter(validRecord) : [],
54
54
  activeTurn: validRecord(data.active_turn) ? data.active_turn : null,
55
+ // The session's token sums and speeds (SessionMetrics' tokens block).
56
+ tokens: validRecord(data.tokens) ? data.tokens : null,
55
57
  };
56
58
  }
57
59
 
@@ -61,7 +63,8 @@ export function normalizeTiming(data = {}) {
61
63
  // place; an unknown turn goes in started_at order (ties and unparsable
62
64
  // dates keep arrival order), an unknown tool record at the end (they come
63
65
  // grouped by turn, in call order). startedAt, sessionDurationMs and
64
- // activeTurn are the reply's. `short`: the reply's turn_count says the page
66
+ // activeTurn are the reply's, and so are its tokens (a reply without them
67
+ // keeps the page's). `short`: the reply's turn_count says the page
65
68
  // misses a turn (it then reads the whole timing). A reply without
66
69
  // turn_count (an older server: the whole timing) replaces, as before; more
67
70
  // records than the count (a worker died before saving a turn the page saw)
@@ -85,7 +88,8 @@ export function mergeTiming(timing, data = {}) {
85
88
  fresh.toolRecords.forEach((record) => {
86
89
  if (!replaceById(toolRecords, record)) toolRecords.push(record);
87
90
  });
88
- return { timing: { ...fresh, turnRecords, toolRecords }, short: turnRecords.length < count };
91
+ const tokens = fresh.tokens || timing?.tokens || null;
92
+ return { timing: { ...fresh, turnRecords, toolRecords, tokens }, short: turnRecords.length < count };
89
93
  }
90
94
 
91
95
  function replaceById(records, record) {
@@ -234,7 +234,7 @@ const WEB_LOCAL_REPLIES = {
234
234
  "/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
235
235
  "/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
236
236
  "/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
237
- "/stats": "/stats: not in the web yet; each turn shows its time and the context use, and /stats in a terminal (chi --attach) has the totals",
237
+ "/stats": "/stats: not in the web yet; each turn shows its time, the session bar the context use and speed (hover ctx for tokens and cost), and /stats in a terminal (chi --attach) has the rest",
238
238
  };
239
239
 
240
240
  export function webLocalReply(text) {
@@ -31,6 +31,7 @@ module Samagotchi
31
31
  used = Array(session.used_memory_names)
32
32
  up = bridge_up?(session_dir, owner)
33
33
  sidecar = up ? WorkerSidecar.read(session_dir) : nil
34
+ saved = SessionMetrics.saved_summary(session_dir)
34
35
  {
35
36
  id: session.id,
36
37
  status: status,
@@ -52,8 +53,11 @@ module Samagotchi
52
53
  owner: owner&.kind,
53
54
  # The saved recap's first sentence, for the session card.
54
55
  recap: RecapStore.preview(session_dir),
55
- # How full the context was after the last turn (%), or nil.
56
- ctx_pct: SessionMetrics.saved_context_pct(session_dir)&.round(1),
56
+ # How full the context was after the last turn (%), or nil, and
57
+ # the session's token sums and cost (the card's ctx tooltip), from
58
+ # one read of analytics.json.
59
+ ctx_pct: saved&.ctx_pct&.round(1),
60
+ tokens: saved&.tokens,
57
61
  # Hidden from the strip and the list unless "include archived".
58
62
  archived: ArchiveStore.archived?(session_dir),
59
63
  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.19.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -235,9 +235,13 @@ files:
235
235
  - lib/samagotchi/guardrails/parent_approvals.rb
236
236
  - lib/samagotchi/guardrails/parent_continue.rb
237
237
  - lib/samagotchi/guardrails/protected_paths.rb
238
+ - lib/samagotchi/guardrails/read_only_shell.rb
239
+ - lib/samagotchi/guardrails/rm_targets.rb
238
240
  - lib/samagotchi/guardrails/rules.rb
239
241
  - lib/samagotchi/guardrails/scratch_writes.rb
240
242
  - lib/samagotchi/guardrails/shell_git_dirs.rb
243
+ - lib/samagotchi/guardrails/shell_lex.rb
244
+ - lib/samagotchi/guardrails/shell_paths.rb
241
245
  - lib/samagotchi/guardrails/targets.rb
242
246
  - lib/samagotchi/guardrails/verdict.rb
243
247
  - lib/samagotchi/hooks.rb