samagotchi 0.13.0 → 0.15.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/bin/chi +4 -7
  4. data/docs/configuration.md +50 -7
  5. data/docs/guardrails.md +2 -1
  6. data/docs/plugins.md +28 -13
  7. data/docs/sessions.md +1 -1
  8. data/docs/sub-agent.md +1 -1
  9. data/lib/samagotchi/bridge/card_store.rb +94 -6
  10. data/lib/samagotchi/bridge.rb +3 -1
  11. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
  13. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/config.rb +31 -6
  17. data/lib/samagotchi/context_window.rb +30 -5
  18. data/lib/samagotchi/engine.rb +21 -7
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +5 -2
  20. data/lib/samagotchi/host_registry.rb +31 -6
  21. data/lib/samagotchi/kernel_loop.rb +1 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  23. data/lib/samagotchi/llm/turn_settings.rb +7 -5
  24. data/lib/samagotchi/plugin_session_state.rb +30 -0
  25. data/lib/samagotchi/self_report.rb +24 -4
  26. data/lib/samagotchi/session_manager.rb +8 -0
  27. data/lib/samagotchi/session_metrics.rb +1 -1
  28. data/lib/samagotchi/session_retention.rb +6 -1
  29. data/lib/samagotchi/system_prompt.rb +1 -1
  30. data/lib/samagotchi/terminal_ui/attached_loop.rb +65 -13
  31. data/lib/samagotchi/terminal_ui.rb +27 -3
  32. data/lib/samagotchi/tools/delegate_wait.rb +17 -10
  33. data/lib/samagotchi/tools/memory.rb +3 -3
  34. data/lib/samagotchi/utf8_default.rb +29 -0
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/app.rb +22 -7
  37. data/lib/samagotchi/web/public/activity.js +6 -1
  38. data/lib/samagotchi/web/public/app.js +46 -10
  39. data/lib/samagotchi/web/public/index.html +10 -1
  40. data/lib/samagotchi/web/public/notify.js +16 -3
  41. data/lib/samagotchi/web/public/question_cards.js +3 -1
  42. data/lib/samagotchi/web/public/sessions_list.js +45 -0
  43. data/lib/samagotchi/web/public/strip.js +8 -1
  44. data/lib/samagotchi/web/public/timing.js +11 -2
  45. data/lib/samagotchi/web/public/turn_view.js +10 -10
  46. metadata +3 -1
@@ -12,6 +12,9 @@ export const ROW_STATUS = Object.freeze({
12
12
  ERROR: "error",
13
13
  // A task_wait the user's Stop ended (the task itself runs on).
14
14
  STOPPED: "stopped",
15
+ // A call a guardrail denied (a hook, the guardrails bundle, a parent's
16
+ // or the user's Deny): it never ran, and it isn't a failed call either.
17
+ BLOCKED: "blocked",
15
18
  });
16
19
 
17
20
  // What a row shows for its tool: a plugin tool's label ("chrome:
@@ -101,9 +104,11 @@ export function addCompleted(model, event = {}) {
101
104
  return row;
102
105
  }
103
106
 
104
- // A completed call's row status from the server's: error, stopped, else ok.
107
+ // A completed call's row status from the server's: error, stopped,
108
+ // blocked, else ok.
105
109
  export function completedStatus(status) {
106
110
  if (status === "error") return ROW_STATUS.ERROR;
107
111
  if (status === "stopped") return ROW_STATUS.STOPPED;
112
+ if (status === "blocked") return ROW_STATUS.BLOCKED;
108
113
  return ROW_STATUS.OK;
109
114
  }
@@ -38,7 +38,7 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
38
38
  import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
39
39
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
40
40
  import { stripColumns, stripShowParts } from "./strip.js";
41
- import { applySessionEvent, listedSessions, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
41
+ import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
42
42
  import { createNotifications } from "./notifications.js";
43
43
  import {
44
44
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
@@ -262,12 +262,15 @@ function filteredSessions(list) {
262
262
  (s.first_preview || "").toLowerCase().includes(q) ||
263
263
  s.short_id.toLowerCase().includes(q) ||
264
264
  s.id.toLowerCase().includes(q) ||
265
- s.status.toLowerCase().includes(q),
265
+ s.status.toLowerCase().includes(q) ||
266
+ waitingSearchText(s).includes(q),
266
267
  );
267
268
  }
268
269
 
269
270
  // A card: the first message as its title, then status, how long ago, owner
270
- // and memories. The id is in the tooltip (and the search) only.
271
+ // and memories. The id is in the tooltip (and the search) only. A session
272
+ // that waits on the user (a question, an approval, a card with actions)
273
+ // wears the warning colour and says what for.
271
274
  // deletable: the all-sessions view's cards get a delete button.
272
275
  function cardHtml(s, { deletable = false } = {}) {
273
276
  const ts = s.updated_at || s.created_at || "";
@@ -281,7 +284,10 @@ function cardHtml(s, { deletable = false } = {}) {
281
284
  const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
282
285
  const ctx = cardCtxText(s.ctx_pct);
283
286
  const ctxHtml = ctx ? `<span class="ctx" title="Context used after the last turn">${escapeHtml(ctx)}</span>` : "";
284
- return `<div class="card${s.archived ? " archived" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}" aria-label="${escapeHtml(s.status)}"></span>${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
287
+ const waiting = waitingBadge(s);
288
+ const waitingHtml = waiting ? `<span class="attn ${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
289
+ const dotWord = waiting ? waiting.title : s.status;
290
+ return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
285
291
  }
286
292
 
287
293
  // A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
@@ -304,9 +310,30 @@ function fitStripColumns() {
304
310
  topStripEl.style.setProperty("--strip-cols", stripCols);
305
311
  }
306
312
 
313
+ // The order a list draws its cards in: the sessions that wait on the user
314
+ // first (waitingFirst), back to their place once answered. Never under the
315
+ // pointer, though: while it is over the list the order last drawn holds,
316
+ // and its leaving draws the list again.
317
+ const drawnOrder = new Map(); // list element → ids in the order last drawn
318
+ const heldLists = new Set(); // lists drawn in a held order
319
+ const canHover = globalThis.matchMedia?.("(hover: hover)").matches ?? false;
320
+ function listOrder(el, list) {
321
+ const next = waitingFirst(list);
322
+ const held = canHover && el.matches(":hover") ? drawnOrder.get(el) : null;
323
+ const ordered = held ? heldOrder(held, next) : next;
324
+ if (held && ordered.some((s, i) => s !== next[i])) heldLists.add(el);
325
+ else heldLists.delete(el);
326
+ drawnOrder.set(el, ordered.map((s) => s.id));
327
+ return ordered;
328
+ }
329
+
330
+ function stripSessions() {
331
+ return listOrder(topStripEl, shownSessions()).slice(0, stripCols);
332
+ }
333
+
307
334
  function render() {
308
335
  fitStripColumns();
309
- renderTop(shownSessions().slice(0, stripCols));
336
+ renderTop(stripSessions());
310
337
  if (allViewOpen()) renderAll();
311
338
  }
312
339
 
@@ -316,7 +343,7 @@ window.addEventListener("resize", () => {
316
343
  stripResizeTimer = setTimeout(() => {
317
344
  const before = stripCols;
318
345
  fitStripColumns();
319
- if (stripCols !== before && allSessions.length) renderTop(shownSessions().slice(0, stripCols));
346
+ if (stripCols !== before && allSessions.length) renderTop(stripSessions());
320
347
  }, 150);
321
348
  });
322
349
 
@@ -359,18 +386,20 @@ function renderTop(sessions) {
359
386
  $("#stripHide").addEventListener("click", () => setStripHidden(true));
360
387
  }
361
388
 
362
- // The hidden strip's pill: "▾ 5 sessions · ● 1 live", following the list.
389
+ // The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting",
390
+ // following the list.
363
391
  function renderStripShow() {
364
- const { count, live } = stripShowParts(shownSessions());
392
+ const { count, live, waiting } = stripShowParts(shownSessions());
365
393
  const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
366
- $("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}`;
394
+ const waitingHtml = waiting ? ` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${escapeHtml(waiting)}</span>` : "";
395
+ $("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}${waitingHtml}`;
367
396
  }
368
397
 
369
398
  // A delegated session sits right after its parent: applied on every
370
399
  // render, since a hub snapshot hands the list back in the server's order.
371
400
  function renderAll() {
372
401
  const listed = allViewSessions();
373
- const sessions = withChildrenAfterParents(filteredSessions(listed));
402
+ const sessions = listOrder(allListEl, withChildrenAfterParents(filteredSessions(listed)));
374
403
  $("#count").textContent = sessions.length === listed.length
375
404
  ? `${listed.length}`
376
405
  : `${sessions.length} / ${listed.length}`;
@@ -2208,6 +2237,13 @@ setStripHidden(stripHiddenSaved());
2208
2237
  $("#stripShow").addEventListener("click", () => setStripHidden(false));
2209
2238
 
2210
2239
  $("#filter").addEventListener("input", renderAll);
2240
+ // A list whose order was held under the pointer (listOrder) catches up.
2241
+ topStripEl.addEventListener("pointerleave", () => {
2242
+ if (heldLists.has(topStripEl)) renderTop(stripSessions());
2243
+ });
2244
+ allListEl.addEventListener("pointerleave", () => {
2245
+ if (heldLists.has(allListEl) && allViewOpen()) renderAll();
2246
+ });
2211
2247
  // Esc leaves the all-sessions view.
2212
2248
  document.addEventListener("keydown", (e) => {
2213
2249
  if (e.key === "Escape" && allViewOpen()) closeAllView();
@@ -14,6 +14,9 @@ body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetic
14
14
  #dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,.bubble.question,.bubble.plugin-card{background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
15
15
  #topStrip .card:hover,#allList .card:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);border-color:rgba(255,255,255,.14)}
16
16
  #topStrip .card.active,#allList .card.active{background:color-mix(in srgb,var(--accent-bg) 75%,transparent);border-color:color-mix(in srgb,var(--accent) 35%,transparent)}
17
+ /* A session that waits on the user (a question, an approval, a card with actions): the warning colour of its question card, also when active or hovered. */
18
+ #topStrip .card.waiting,#allList .card.waiting{border-color:color-mix(in srgb,var(--warn) 75%,transparent)}
19
+ #topStrip .card.waiting:hover,#allList .card.waiting:hover{border-color:var(--warn)}
17
20
  @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){#dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel)}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel))}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel))}}
18
21
  @media (prefers-reduced-transparency:reduce){#dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}}
19
22
  body.no-glass #dock,body.no-glass .turn-stage,body.no-glass .dock-card,body.no-glass #topStrip .card,body.no-glass #allList .card,body.no-glass #allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
@@ -111,6 +114,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
111
114
  .activity-status.ok{color:var(--ok)}
112
115
  .activity-status.error{color:var(--danger)}
113
116
  .activity-status.stopped{color:var(--warn-text);opacity:.8}
117
+ .activity-status.blocked{color:var(--danger-text)}
114
118
  .activity-tool{color:var(--accent);font-weight:600}
115
119
  .activity-params{color:var(--text-muted);word-break:break-all}
116
120
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
@@ -632,6 +636,8 @@ body.has-session #scope .scope-where{display:none}
632
636
  #stripShow{display:none;white-space:nowrap;flex-shrink:0}
633
637
  #stripShow .live{color:var(--ok-text);white-space:nowrap}
634
638
  #stripShow .live-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px;vertical-align:1px}
639
+ #stripShow .waiting{color:var(--warn-text);white-space:nowrap}
640
+ #stripShow .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
635
641
  #stripHide{align-self:flex-end;border:0;padding:0 2px;border-radius:4px}
636
642
  #stripHide:hover{text-decoration:underline;color:var(--text-2)}
637
643
  body.strip-hidden #stripShow{display:block}
@@ -687,7 +693,10 @@ body.all-view #center, body.all-view #topStrip{display:none}
687
693
  .card .dot.completed{background:var(--accent)}
688
694
  .card .dot.error{background:var(--danger)}
689
695
  @keyframes dot-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--ok) 0%,transparent)}}
690
- @media (prefers-reduced-motion:reduce){.card .dot.running{animation:none}}
696
+ .card .dot.waiting{background:var(--warn);animation:attn-pulse 1.6s ease-in-out infinite}
697
+ @keyframes attn-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--warn) 60%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--warn) 0%,transparent)}}
698
+ @media (prefers-reduced-motion:reduce){.card .dot.running,.card .dot.waiting{animation:none}}
699
+ .card .attn{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn-bg);color:var(--warn-text);border:1px solid var(--warn-border)}
691
700
  .status.running{background:var(--ok-bg);color:var(--ok-text)}
692
701
  .status.idle{background:var(--bg-raised)}
693
702
  .status.completed{background:var(--accent-bg);color:var(--accent)}
@@ -15,6 +15,21 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
15
15
  // A finished turn shorter than this was watched: no "done".
16
16
  export const DONE_MIN_SECONDS = 10;
17
17
 
18
+ const questionReason = (q) => (q.kind === "approval" ? "approval" : "question");
19
+
20
+ // What a session waits on the user for right now, from one summary: an open
21
+ // question or approval, or a running turn's card with actions (check-in's
22
+ // Nudge / Keep going / Stop asks as a question does). The session cards
23
+ // mark and lift these; attentionFor notifies when one opens.
24
+ // @return null | {reason: "question"|"approval"|"card", id}
25
+ export function waitingOn(summary) {
26
+ const q = summary?.pending_question;
27
+ if (q && q.id) return { reason: questionReason(q), id: q.id };
28
+ const card = summary?.pending_card;
29
+ if (card && card.id) return { reason: "card", id: card.id };
30
+ return null;
31
+ }
32
+
18
33
  // What changed between two summaries of one session that needs the user.
19
34
  // @param prev the summary held before (null for a session not seen yet)
20
35
  // @param next the new summary
@@ -24,10 +39,8 @@ export function attentionFor(prev, next) {
24
39
  if (!next || !next.id) return null;
25
40
  const q = next.pending_question;
26
41
  if (q && q.id && q.id !== prev?.pending_question?.id) {
27
- return { reason: q.kind === "approval" ? "approval" : "question", sessionId: next.id, key: `${next.id}:${q.id}` };
42
+ return { reason: questionReason(q), sessionId: next.id, key: `${next.id}:${q.id}` };
28
43
  }
29
- // A running turn's card with actions (check-in's Nudge / Keep going /
30
- // Stop) asks the user as a question does.
31
44
  const card = next.pending_card;
32
45
  if (card && card.id && card.id !== prev?.pending_card?.id) {
33
46
  return { reason: "card", sessionId: next.id, key: cardKey(next.id, card.id) };
@@ -158,9 +158,11 @@ export function createQuestionCards({
158
158
  });
159
159
  card.appendChild(dismiss);
160
160
 
161
+ // Read before the card makes the history taller.
162
+ const follow = isNearBottom();
161
163
  place(card);
162
164
  questionEl = card;
163
- if (isNearBottom()) scrollToEnd();
165
+ if (follow) scrollToEnd();
164
166
  }
165
167
 
166
168
  // The tool, the command (or paths, or args) as code, where and why; no
@@ -1,5 +1,6 @@
1
1
  // The page's session list as a projection of the hub's events
2
2
  // (GET /api/events): a pure reducer over the list the page holds.
3
+ import { waitingOn } from "./notify.js";
3
4
 
4
5
  // @param list the current list (never mutated)
5
6
  // @param type "snapshot" | "session" | "session_gone"
@@ -65,3 +66,47 @@ export function withChildrenAfterParents(list) {
65
66
  const ordered = heads.slice().sort((a, b) => familyMs(b) - familyMs(a));
66
67
  return ordered.flatMap((s) => [s, ...(children.get(s.id) || [])]);
67
68
  }
69
+
70
+ const WAITING_BADGES = {
71
+ question: { text: "question", title: "Waiting for your answer" },
72
+ approval: { text: "approval", title: "Waiting for your approval" },
73
+ card: { text: "needs you", title: "A card in the running turn waits for you" },
74
+ };
75
+
76
+ // A card's "needs you" badge: what the session waits on the user for
77
+ // (notify.js waitingOn), or null.
78
+ // @return null | {kind, text, title}
79
+ export function waitingBadge(summary) {
80
+ const waiting = waitingOn(summary);
81
+ return waiting ? { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] } : null;
82
+ }
83
+
84
+ // What the all view's search matches for a session that waits on the user:
85
+ // "waiting" and its badge's word ("question", "approval", "needs you");
86
+ // "" for one that doesn't.
87
+ export function waitingSearchText(summary) {
88
+ const badge = waitingBadge(summary);
89
+ return badge ? `waiting ${badge.text}` : "";
90
+ }
91
+
92
+ // The sessions that wait on the user (waitingOn) first, the rest after,
93
+ // each part in the list's order: an answered one goes back to its place.
94
+ // A delegated session moves with its parent when both are listed (a
95
+ // family waits when any of it does), so withChildrenAfterParents' groups
96
+ // stay together.
97
+ export function waitingFirst(list) {
98
+ const ids = new Set(list.map((s) => s.id));
99
+ const family = (s) => (s.parent_id && ids.has(s.parent_id) ? s.parent_id : s.id);
100
+ const waiting = new Set(list.filter((s) => waitingOn(s)).map(family));
101
+ if (!waiting.size) return list;
102
+ return [...list.filter((s) => waiting.has(family(s))), ...list.filter((s) => !waiting.has(family(s)))];
103
+ }
104
+
105
+ // The list in an order shown before (+ids+): what the pointer is over
106
+ // doesn't move. Sessions new since then go after, in the list's order;
107
+ // gone ones drop out.
108
+ export function heldOrder(ids, list) {
109
+ const at = new Map(ids.map((id, i) => [id, i]));
110
+ const known = list.filter((s) => at.has(s.id)).sort((a, b) => at.get(a.id) - at.get(b.id));
111
+ return [...known, ...list.filter((s) => !at.has(s.id))];
112
+ }
@@ -1,3 +1,5 @@
1
+ import { waitingOn } from "./notify.js";
2
+
1
3
  // How many session cards the top strip shows for its width in px: a card
2
4
  // needs about 360 px plus a 12 px gap next to the 150 px "All sessions" tile;
3
5
  // never fewer than 3 (narrow screens scroll them), never more than 6.
@@ -13,13 +15,18 @@ export function stripColumns(width) {
13
15
  // The hidden strip's "▾ sessions" pill carries the list's count, and how
14
16
  // many of those sessions a worker runs (the cards' green "live" badge), so a
15
17
  // user who hid the strip still sees there is a live session to go back to.
16
- // Plain text parts; live is "" when none. Zero sessions keep the bare label.
18
+ // And how many wait on the user (notify.js waitingOn: a question, an
19
+ // approval, a card), so a hidden strip still says one needs an answer.
20
+ // Plain text parts; live and waiting are "" when none. Zero sessions keep
21
+ // the bare label.
17
22
  export function stripShowParts(sessions) {
18
23
  const list = sessions || [];
19
24
  const n = list.length;
20
25
  const liveN = list.filter((s) => s && s.owner === "worker").length;
26
+ const waitingN = list.filter((s) => waitingOn(s)).length;
21
27
  return {
22
28
  count: n ? `${n} ${n === 1 ? "session" : "sessions"}` : "sessions",
23
29
  live: liveN ? `${liveN} live` : "",
30
+ waiting: waitingN ? `${waitingN} waiting` : "",
24
31
  };
25
32
  }
@@ -105,7 +105,7 @@ export function turnGroups(items, timing) {
105
105
  turn.merged.push(i);
106
106
  } else if (item?.role === "user") {
107
107
  turnIndex += 1;
108
- turn = { kind: "turn", turnIndex, user: i, texts: [], steers: [], merged: [] };
108
+ turn = { kind: "turn", turnIndex, turnId: item.turn_id, user: i, texts: [], steers: [], merged: [] };
109
109
  groups.push(turn);
110
110
  } else if (item?.role === "assistant") {
111
111
  if (turn) turn.texts.push(i);
@@ -138,8 +138,17 @@ function withSteers(group, steerIndexes, items) {
138
138
  return { ...group, steps, steers: left };
139
139
  }
140
140
 
141
+ // A prompt's turn record: the one with its turn id (the engine saves it
142
+ // on the prompt), so a turn whose prompt left the conversation (a failed
143
+ // one's went back to the user, !rollback) shifts no other; a prompt saved
144
+ // before turn ids pairs by its place among the prompts.
145
+ function promptRecord(timing, g) {
146
+ if (typeof g.turnId !== "string" || !g.turnId) return turnRecordAt(timing, g.turnIndex);
147
+ return (timing?.turnRecords || []).find((r) => r.id === g.turnId) || null;
148
+ }
149
+
141
150
  function turnGroup(g, items, timing) {
142
- const record = turnRecordAt(timing, g.turnIndex);
151
+ const record = promptRecord(timing, g);
143
152
  const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
144
153
  const stepsOnly = canceledSteps(g, record, tools);
145
154
  if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
@@ -24,19 +24,16 @@ import * as model from "./turn_model.js";
24
24
  import { createHold } from "./hold.js";
25
25
 
26
26
  // A row's status (live, or a saved tool record's on reload): running,
27
- // error, stopped (a task_wait the user's Stop ended), else ok ("done").
27
+ // error, stopped (a task_wait the user's Stop ended), blocked (a guardrail
28
+ // denied it), else ok ("done").
29
+ const NAMED_STATUSES = ["running", "error", "stopped", "blocked"];
30
+
28
31
  function activityStatusClass(status) {
29
- if (status === "running") return "running";
30
- if (status === "error") return "error";
31
- if (status === "stopped") return "stopped";
32
- return "ok";
32
+ return NAMED_STATUSES.includes(status) ? status : "ok";
33
33
  }
34
34
 
35
35
  function activityStatusLabel(status) {
36
- if (status === "running") return "running";
37
- if (status === "error") return "error";
38
- if (status === "stopped") return "stopped";
39
- return "done";
36
+ return NAMED_STATUSES.includes(status) ? status : "done";
40
37
  }
41
38
 
42
39
  // A live tool row filled from its activity row (status, title, output).
@@ -690,8 +687,11 @@ function reloadRowHtml(r, thumbs) {
690
687
  export function turnHistoryHtml(items, timing, { thumbs }) {
691
688
  const timingHtml = (group) => {
692
689
  const record = group.record;
690
+ // Numbered by its place in the turn records, as live (a failed turn
691
+ // whose prompt went back still counts).
692
+ const at = (timing?.turnRecords || []).indexOf(record);
693
693
  return record && formatDuration(record.duration_ms)
694
- ? `<div class="turn-timing">${escapeHtml(turnTimingText(group.turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" }))}</div>`
694
+ ? `<div class="turn-timing">${escapeHtml(turnTimingText(at >= 0 ? at + 1 : group.turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" }))}</div>`
695
695
  : "";
696
696
  };
697
697
  // The timing line after the bubble, not in it: where the live view
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.13.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -301,6 +301,7 @@ files:
301
301
  - lib/samagotchi/plugin/sessions.rb
302
302
  - lib/samagotchi/plugin/side_question.rb
303
303
  - lib/samagotchi/plugin/tool_result.rb
304
+ - lib/samagotchi/plugin_session_state.rb
304
305
  - lib/samagotchi/plugin_tasks.rb
305
306
  - lib/samagotchi/project_scope.rb
306
307
  - lib/samagotchi/prompt.rb
@@ -394,6 +395,7 @@ files:
394
395
  - lib/samagotchi/update_command.rb
395
396
  - lib/samagotchi/update_hint.rb
396
397
  - lib/samagotchi/used_memories.rb
398
+ - lib/samagotchi/utf8_default.rb
397
399
  - lib/samagotchi/version.rb
398
400
  - lib/samagotchi/vision_context.rb
399
401
  - lib/samagotchi/vision_support.rb