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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -1
- data/docs/architecture.md +6 -0
- data/docs/cli.md +14 -3
- data/docs/configuration.md +4 -1
- data/docs/guardrails.md +92 -14
- data/docs/plugins.md +5 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bundle_command.rb +22 -4
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +16 -2
- data/lib/samagotchi/guardrail_wiring.rb +2 -1
- data/lib/samagotchi/guardrails/approval.rb +4 -3
- data/lib/samagotchi/guardrails/approvals.rb +11 -5
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/idle_recap.rb +7 -2
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +1 -5
- data/lib/samagotchi/memory_bundle/installer.rb +46 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +12 -4
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_metrics.rb +224 -19
- data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -0
- data/lib/samagotchi/web/public/app.js +34 -15
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/format.js +11 -0
- data/lib/samagotchi/web/public/index.html +26 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/session_summary.rb +6 -2
- metadata +5 -1
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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="
|
|
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 (
|
|
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
|
-
|
|
1203
|
-
setTimeout(() =>
|
|
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 "${escapeHtml(attachCmd)}" 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
|
-
|
|
1653
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|