samagotchi 0.46.0 → 0.47.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -1
  3. data/docs/cli.md +14 -8
  4. data/docs/configuration.md +3 -1
  5. data/docs/desktop.md +2 -1
  6. data/docs/plugins.md +6 -0
  7. data/docs/sessions.md +24 -6
  8. data/lib/samagotchi/bundles/coordinator/manifest.yml +4 -4
  9. data/lib/samagotchi/bundles/coordinator/plugin.rb +15 -2
  10. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +20 -0
  11. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/github-pr/plugin.rb +26 -5
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/config.rb +5 -1
  15. data/lib/samagotchi/engine.rb +4 -5
  16. data/lib/samagotchi/host_model.rb +14 -7
  17. data/lib/samagotchi/host_registry.rb +6 -1
  18. data/lib/samagotchi/llm/chat_loop.rb +27 -7
  19. data/lib/samagotchi/llm/errors.rb +8 -2
  20. data/lib/samagotchi/llm_context_strategy.rb +1 -0
  21. data/lib/samagotchi/model_catalog.rb +13 -12
  22. data/lib/samagotchi/model_profile.rb +4 -3
  23. data/lib/samagotchi/models_command.rb +12 -5
  24. data/lib/samagotchi/question_desk.rb +29 -4
  25. data/lib/samagotchi/send_command.rb +59 -9
  26. data/lib/samagotchi/served_model.rb +9 -0
  27. data/lib/samagotchi/session.rb +13 -4
  28. data/lib/samagotchi/session_chain.rb +93 -0
  29. data/lib/samagotchi/session_commands.rb +8 -7
  30. data/lib/samagotchi/session_manager.rb +225 -14
  31. data/lib/samagotchi/sessions_command.rb +5 -1
  32. data/lib/samagotchi/terminal_ui/formatting.rb +26 -11
  33. data/lib/samagotchi/terminal_ui.rb +4 -2
  34. data/lib/samagotchi/version.rb +1 -1
  35. data/lib/samagotchi/web/app.rb +86 -4
  36. data/lib/samagotchi/web/public/app.js +200 -18
  37. data/lib/samagotchi/web/public/chains.js +234 -0
  38. data/lib/samagotchi/web/public/data.js +12 -0
  39. data/lib/samagotchi/web/public/families_view.js +3 -2
  40. data/lib/samagotchi/web/public/index.html +32 -6
  41. data/lib/samagotchi/web/public/model_pick.js +22 -15
  42. data/lib/samagotchi/web/public/model_picker.js +6 -3
  43. data/lib/samagotchi/web/public/question_card.js +8 -0
  44. data/lib/samagotchi/web/public/session_state.js +2 -0
  45. data/lib/samagotchi/web/public/sessions_list.js +4 -2
  46. data/lib/samagotchi/web/public/turn_events.js +14 -6
  47. data/lib/samagotchi/web/session_summary.rb +3 -0
  48. data/lib/samagotchi/worker.rb +7 -3
  49. metadata +3 -1
@@ -0,0 +1,234 @@
1
+ // Session chains on the page (docs/sessions.md, Session chains): a session
2
+ // that continues an earlier one (its summary's `continues`), "the next day of
3
+ // the same routine". The chain is derived from the list, never stored: back
4
+ // through `continues`, forward through the session whose `continues` names
5
+ // this one. The lists fold a chain into its latest listed link's card (a
6
+ // `day N` chip, its earlier links in a popover); the session page links the
7
+ // previous and the next link. Pure: app.js places the markup and binds it.
8
+ import { escapeHtml, normalize, previewForCard, relativeTime } from "./format.js";
9
+ import { stoppedBadge, waitingBadge } from "./sessions_list.js";
10
+ import { waitingOn } from "./notify.js";
11
+
12
+ // The opener a new link's composer is filled with (never sent: the user
13
+ // edits or sends it). The same for every chain.
14
+ export const CONTINUE_OPENER = "Continue where we left off.";
15
+
16
+ // Whether +text+ (a title or a first message) is the opener and nothing
17
+ // else, trimmed: "Continue where we left off. Then fix X" is a title of its own.
18
+ export function isOpener(text) {
19
+ return normalize(text) === CONTINUE_OPENER;
20
+ }
21
+
22
+ // A link's card title (format.js previewForCard), display only: a link
23
+ // whose title is the opener, as every link's first message is once sent,
24
+ // reads its chain's first link's title instead, so the days of a chain
25
+ // keep its name. Its own title when the first link isn't in +byId+ (id →
26
+ // session; deleted, or a walk a missing link cut short) or is the opener too.
27
+ export function chainTitle(s, byId) {
28
+ const own = previewForCard(s);
29
+ if (!s?.continues || !isOpener(s.first_preview || s.last_prompt)) return own;
30
+ const seen = new Set([s.id]);
31
+ let at = s;
32
+ while (at.continues) {
33
+ const prev = byId.get(at.continues);
34
+ if (!prev || seen.has(prev.id)) return own;
35
+ seen.add(prev.id);
36
+ at = prev;
37
+ }
38
+ return isOpener(at.first_preview || at.last_prompt) ? own : previewForCard(at);
39
+ }
40
+
41
+ const createdMs = (s) => {
42
+ const ms = Date.parse(s?.created_at || "");
43
+ return Number.isNaN(ms) ? 0 : ms;
44
+ };
45
+
46
+ // session id → the session that continues it: the first created, as the
47
+ // server's next_of picks (a link is continued once; a hand-edited second
48
+ // one doesn't fork the chain).
49
+ function nextsOf(list) {
50
+ const nexts = new Map();
51
+ for (const s of list.slice().sort((a, b) => createdMs(a) - createdMs(b))) {
52
+ if (s.continues && s.continues !== s.id && !nexts.has(s.continues)) nexts.set(s.continues, s);
53
+ }
54
+ return nexts;
55
+ }
56
+
57
+ // The chain +id+ is in, earliest link first, as far as +list+ holds it (a
58
+ // link that isn't listed ends the walk there). [] for an id not listed; a
59
+ // session in no chain is a chain of one. A loop a hand-edited file could
60
+ // make ends where it started. +nexts+ and +byId+ (id → session): a caller
61
+ // walking many chains of one list builds them once (foldChains).
62
+ export function chainLinks(list, id, nexts = nextsOf(list), byId = new Map(list.map((s) => [s.id, s]))) {
63
+ const self = byId.get(id);
64
+ if (!self) return [];
65
+ const links = [self];
66
+ const seen = new Set([id]);
67
+ for (let at = byId.get(self.continues); at && !seen.has(at.id); at = byId.get(at.continues)) {
68
+ seen.add(at.id);
69
+ links.unshift(at);
70
+ }
71
+ for (let at = nexts.get(id); at && !seen.has(at.id); at = nexts.get(at.id)) {
72
+ seen.add(at.id);
73
+ links.push(at);
74
+ }
75
+ return links;
76
+ }
77
+
78
+ // The session page's neighbours of +id+: {prev, next}, each {id, session}
79
+ // or null. +continues+: the open session's own `continues`, when the list
80
+ // may not have caught up with it. A previous link the list doesn't hold
81
+ // (deleted) is null: its link would open a not-found page.
82
+ export function chainNeighbours(list, id, { continues = null } = {}) {
83
+ const self = list.find((s) => s.id === id);
84
+ const prevId = self?.continues || continues || null;
85
+ const prevSession = prevId && prevId !== id ? list.find((s) => s.id === prevId) : null;
86
+ const prev = prevSession ? { id: prevId, session: prevSession } : null;
87
+ const nextSession = nextsOf(list).get(id) || null;
88
+ const next = nextSession ? { id: nextSession.id, session: nextSession } : null;
89
+ return { prev, next };
90
+ }
91
+
92
+ // A link's day as the carry note writes it: the local YYYY-MM-DD of its
93
+ // start; "" when it has none.
94
+ export function chainDate(iso) {
95
+ const d = new Date(iso || "");
96
+ if (Number.isNaN(d.getTime())) return "";
97
+ const pad = (n) => String(n).padStart(2, "0");
98
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
99
+ }
100
+
101
+ // The families (sessions_list.js families) with the chains folded in: a
102
+ // family whose head is an earlier link of a chain whose later link heads a
103
+ // listed family is left out (it is in that card's chain list), whatever
104
+ // its archived state, its delegates with it; a family whose head is in a
105
+ // chain of two or more carries chain: {links, at, folded} (at: the head's
106
+ // index). +all+: every session the page holds, archived ones too (the links
107
+ // the list leaves out). The same array when no chain is in it.
108
+ // @param fold false: every family stays (each link's card carries its
109
+ // chip, "day 1 of 3"; folded false), for a search or the archived ones
110
+ export function foldChains(fams, all, { fold = true } = {}) {
111
+ if (!all.some((s) => s.continues)) return fams;
112
+ const nexts = nextsOf(all);
113
+ const byId = new Map(all.map((s) => [s.id, s]));
114
+ const heads = new Set(fams.map((f) => f.head.id));
115
+ const out = [];
116
+ let changed = false;
117
+ for (const f of fams) {
118
+ const links = chainLinks(all, f.head.id, nexts, byId);
119
+ if (links.length < 2) {
120
+ out.push(f);
121
+ continue;
122
+ }
123
+ changed = true;
124
+ const at = links.findIndex((s) => s.id === f.head.id);
125
+ if (fold && links.slice(at + 1).some((s) => heads.has(s.id))) continue;
126
+ out.push({ ...f, chain: { links, at, folded: fold } });
127
+ }
128
+ return changed ? out : fams;
129
+ }
130
+
131
+ // The all view's families: folded (foldChains) unless a search is typed or
132
+ // the archived ones are included. Then every link is a card of its own
133
+ // (with its chip), so a search finds an earlier link, and an earlier
134
+ // link's delegates fold into its own card as anywhere else.
135
+ export function allViewChains(fams, all, { query = "", includeArchived = false } = {}) {
136
+ return foldChains(fams, all, { fold: !query && !includeArchived });
137
+ }
138
+
139
+ // The links a family's chain list shows: the chain but its head, newest
140
+ // first, each {session, day} (day: its 1-based place in the chain).
141
+ export function chainOthers(f) {
142
+ const links = f?.chain?.links || [];
143
+ return links.map((session, i) => ({ session, day: i + 1 }))
144
+ .filter(({ session }) => session.id !== f.head.id)
145
+ .reverse();
146
+ }
147
+
148
+ // The chip in a chain's card: "day 3" (the head's place; "day 2 of 3" when
149
+ // later links aren't listed), with a live dot / "waiting" when one of the
150
+ // other links has a worker / waits on the user (typing in an archived link
151
+ // brings it back, but it stays folded here). "" for a family in no chain.
152
+ // @param open whether the list is open (aria-expanded)
153
+ export function chainChipHtml(f, { open = false } = {}) {
154
+ if (!f?.chain) return "";
155
+ const { links, at } = f.chain;
156
+ const others = chainOthers(f);
157
+ const live = others.filter(({ session: s }) => s.owner === "worker").length;
158
+ const waiting = others.filter(({ session: s }) => waitingOn(s)).length;
159
+ const day = at + 1;
160
+ const text = day === links.length ? `day ${day}` : `day ${day} of ${links.length}`;
161
+ const earlier = at;
162
+ const later = links.length - 1 - at;
163
+ const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : ""].filter(Boolean);
164
+ const title = `Session chain: day ${day} of ${links.length} (${parts.join(", ")}); open the list`;
165
+ const liveHtml = live ? `<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : "";
166
+ const waitingHtml = waiting ? ` · <span class="cc-waiting">${waiting} waiting</span>` : "";
167
+ return `<button type="button" class="chain-chip${waiting ? " waiting" : ""}" aria-expanded="${open ? "true" : "false"}" title="${escapeHtml(title)}"><span class="cc-arrow" aria-hidden="true">↩</span>${escapeHtml(text)}${liveHtml}${waitingHtml}</button>`;
168
+ }
169
+
170
+ // The chain's list (the chip's popover): one row per other link, newest
171
+ // first: status dot, "day N", its date, its recap's first sentence (else
172
+ // its preview), the waiting / stopped badge, archived. A row's click opens
173
+ // that link (families_view.js's popover binds .family-row[data-id]).
174
+ // @param activeId the open session's id (its row is .active)
175
+ export function chainRowsHtml(f, { activeId = null, now = Date.now() } = {}) {
176
+ const byId = new Map((f?.chain?.links || []).map((s) => [s.id, s]));
177
+ const rows = chainOthers(f).map(({ session: s, day }) => {
178
+ const cls = ["family-row", "chain-row"];
179
+ if (activeId && s.id === activeId) cls.push("active");
180
+ if (s.archived) cls.push("archived");
181
+ const waiting = waitingBadge(s, { inFamily: true });
182
+ const stopped = waiting ? null : stoppedBadge(s);
183
+ const dotWord = waiting ? waiting.title : s.status;
184
+ const date = chainDate(s.created_at);
185
+ const ago = relativeTime(s.updated_at || s.created_at || "", now);
186
+ const parts = [
187
+ `<span class="status dot ${escapeHtml(s.status || "")}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord || "")}" aria-label="${escapeHtml(dotWord || "")}"></span>`,
188
+ `<span class="cr-day">day ${day}</span>`,
189
+ date ? `<span class="cr-date">${escapeHtml(date)}</span>` : "",
190
+ `<span class="fr-preview">${escapeHtml(s.recap || chainTitle(s, byId))}</span>`,
191
+ waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "",
192
+ stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "",
193
+ s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "",
194
+ ].join("");
195
+ const tip = `${s.id} · ${s.status || ""}${ago ? ` · updated ${ago}` : ""}`;
196
+ return `<div class="${cls.join(" ")}" data-id="${escapeHtml(s.id)}" style="--depth:1" role="listitem"><button type="button" class="fr-open" title="${escapeHtml(tip)}">${parts}</button></div>`;
197
+ });
198
+ return `<div class="family-rows chain-rows" role="list">${rows.join("")}</div>`;
199
+ }
200
+
201
+ // What the page does with a refused continue (POST /api/sessions
202
+ // continues, api()'s error with the answer's body): "open" the link that
203
+ // continues it already (409 continued, next_id), "children" naming the
204
+ // delegates still open (409 open_children, ids: {id, short, text} each,
205
+ // the text its preview and why it is open: the server's word from its
206
+ // detail, "<short> (running)", else its state here), or "error" with the
207
+ // server's words.
208
+ export function continueRefusal(error, sessions = []) {
209
+ const body = error?.body || {};
210
+ if (error?.code === "continued" && body.next_id) return { kind: "open", id: body.next_id };
211
+ const text = String(error?.message || "refused").replace(/\s*\(\d{3}\)$/, "");
212
+ if (error?.code === "open_children" && Array.isArray(body.ids) && body.ids.length) {
213
+ const byId = new Map(sessions.map((s) => [s.id, s]));
214
+ const children = body.ids.map((id) => {
215
+ const s = byId.get(id);
216
+ const short = String(id).slice(0, 8);
217
+ const said = text.split(short + " (")[1]?.split(")")[0];
218
+ const why = said || (s ? (waitingOn(s) ? "waiting" : s.status || "") : "");
219
+ const preview = s ? previewForCard(s) : "";
220
+ return { id, short, text: [preview, why ? `(${why})` : ""].filter(Boolean).join(" ") };
221
+ });
222
+ return { kind: "children", children, text };
223
+ }
224
+ return { kind: "error", text };
225
+ }
226
+
227
+ // The open-children refusal as the toast shows it: the delegates named,
228
+ // each a link to its session.
229
+ // @param hrefFor (id) → its page's href
230
+ export function openChildrenHtml(prevId, children, hrefFor) {
231
+ const n = children.length;
232
+ const items = children.map((c) => `<li><a href="${escapeHtml(hrefFor(c.id))}" class="cr-child">${escapeHtml(c.short)}</a>${c.text ? ` ${escapeHtml(c.text)}` : ""}</li>`).join("");
233
+ return `<div class="continue-refused"><div>Can't continue ${escapeHtml(String(prevId).slice(0, 8))}: ${n} ${n === 1 ? "delegate is" : "delegates are"} still open. Wait for ${n === 1 ? "it" : "them"}, stop or archive ${n === 1 ? "it" : "them"}, then continue.</div><ul>${items}</ul></div>`;
234
+ }
@@ -11,6 +11,9 @@ export function api(path, opts = {}) {
11
11
  // The server's error code ({error: "owned_by_tui", detail}): what
12
12
  // the page matches on, never the message's words.
13
13
  error.code = j.error || null;
14
+ // The whole answer, for what an error carries besides its words (a
15
+ // refused continue's next_id or ids).
16
+ error.body = j;
14
17
  throw error;
15
18
  }
16
19
  return j;
@@ -39,6 +42,15 @@ export function createIdleSession({ dir, model, preview, llm_context: llmContext
39
42
  return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
40
43
  }
41
44
 
45
+ // The next link of +id+'s chain (docs/sessions.md, Session chains): a new
46
+ // session in its folder, on its model, starting with its recap, idle (the
47
+ // page fills the composer, the user sends); +id+ is archived by it. A
48
+ // refusal is api()'s error: code "continued" with body.next_id (the link
49
+ // that continues it already), "open_children" with body.ids, …
50
+ export function continueSession(id, opts = {}) {
51
+ return api("/api/sessions", { method: "POST", body: JSON.stringify({ continues: id, idle: true }), ...opts });
52
+ }
53
+
42
54
  // The models a new session can start on: {default, models: [{name, host, id}],
43
55
  // warning?} (a host that failed is in the warning, the list has the rest).
44
56
  export function listModels(opts = {}) {
@@ -69,7 +69,8 @@ export const PHONE_MAX_PX = 600;
69
69
  // @param anchorFor (headId) → its chip in the strip, or null
70
70
  // @param onPick (id) a row's click
71
71
  // @param onClose () after it closed (the strip's held order catches up)
72
- export function createFamilyPop({ doc, win, rowsFor, anchorFor, onPick, onClose = () => {} }) {
72
+ // @param id the element's id (a chain's popover, chains.js, is another one)
73
+ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor, onPick, onClose = () => {} }) {
73
74
  let el = null;
74
75
  let headId = null;
75
76
  let html = "";
@@ -77,7 +78,7 @@ export function createFamilyPop({ doc, win, rowsFor, anchorFor, onPick, onClose
77
78
  const element = () => {
78
79
  if (el) return el;
79
80
  el = doc.createElement("div");
80
- el.id = "familyPop";
81
+ el.id = id;
81
82
  el.className = "family-pop";
82
83
  el.hidden = true;
83
84
  el.addEventListener("click", (e) => {
@@ -494,6 +494,7 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
494
494
  #composer .actions-col .model-pick:hover,#composer .actions-col .model-pick[aria-expanded="true"]{filter:none;background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
495
495
  #composer .actions-col .model-pick[hidden],body.has-session #composer .actions-col .model-pick{display:none}
496
496
  .model-pick-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
497
+ #composer .actions-col .model-pick.unavailable .model-pick-name{color:var(--text-faint)}
497
498
  .model-pick::after{content:"";position:absolute;right:9px;top:50%;width:5px;height:5px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:translateY(-70%) rotate(45deg);pointer-events:none;transition:transform .12s ease}
498
499
  .model-pick[aria-expanded="true"]::after{transform:translateY(-20%) rotate(225deg)}
499
500
  @media(max-width:600px){#composer .actions-col .model-pick{max-width:60%}}
@@ -529,6 +530,8 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
529
530
  #modelList mark{background:none;color:var(--accent);font-weight:600}
530
531
  #modelList .model-option.chosen mark{color:var(--text-bright)}
531
532
  #modelList .model-note{flex:none;font-size:11px;color:var(--text-faint)}
533
+ #modelList .model-option.unavailable:not(.chosen) .model-id,#modelList .model-option.unavailable .model-host{color:var(--text-faint)}
534
+ #modelList .model-option.unavailable .model-note{color:var(--warn-text)}
532
535
  #modelList .model-empty{padding:10px;font-size:13px;color:var(--text-faint)}
533
536
  @media(max-width:600px){#modelPanel{left:6px;width:calc(100% - 12px)}#modelSearch{font-size:16px}#modelList .model-option{padding-top:8px;padding-bottom:8px}}
534
537
  /* The / autocomplete: a list over the composer card's top edge. It wears the glass edge and shadow, but opaque:
@@ -758,6 +761,8 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
758
761
  #infoBar .meta .mem-chip{display:inline;order:-1}
759
762
  /* A parent's children chip too: its waiting part is what to see. */
760
763
  #infoBar .meta .children-chip{order:-1}
764
+ /* A chain's ← → links too: the way to the other days. */
765
+ #infoBar .meta .chain-link{order:-1}
761
766
  /* The chips get a second line of their own (only when there are some), scrolling sideways. */
762
767
  #infoBar:has(#contextChips:not([hidden])){flex-wrap:wrap;row-gap:4px}
763
768
  #contextChips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}
@@ -835,12 +840,32 @@ body.has-session #scope .scope-where{display:none}
835
840
  #infoBar .meta a.parent{text-decoration:none}
836
841
  #infoBar .meta a.parent:hover{color:var(--accent)}
837
842
  /* The strip's family popover (in body, position: fixed: the strip's sideways scroll on a phone would clip it), glass as the cards. */
838
- #familyPop{position:fixed;z-index:40;max-height:50vh;overflow:auto;padding:6px;border-radius:10px;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)}
839
- #familyPop[hidden]{display:none}
840
- #familyPop .family-rows{border-top:0;margin:0;padding-top:0}
841
- @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){#familyPop{background:var(--bg-panel)}}
842
- @media (prefers-reduced-transparency:reduce){#familyPop{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}}
843
- body.no-glass #familyPop{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
843
+ #familyPop,#chainPop{position:fixed;z-index:40;max-height:50vh;overflow:auto;padding:6px;border-radius:10px;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)}
844
+ #familyPop[hidden],#chainPop[hidden]{display:none}
845
+ :is(#familyPop,#chainPop) .family-rows{border-top:0;margin:0;padding-top:0}
846
+ @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){#familyPop,#chainPop{background:var(--bg-panel)}}
847
+ @media (prefers-reduced-transparency:reduce){#familyPop,#chainPop{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}}
848
+ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
849
+ /* A chain's chip (chains.js): the card is the latest link of a session chain; a click lists the other links in a popover. */
850
+ .card .meta:has(.chain-chip){flex-wrap:wrap;row-gap:4px}
851
+ .card .chain-chip{font-family:ui-monospace,monospace;font-size:11px;line-height:1.4;white-space:nowrap;flex-shrink:0;padding:1px 7px;border-radius:999px;border:1px solid var(--accent-border);background:var(--accent-tint);color:var(--accent);cursor:pointer}
852
+ .card .chain-chip:hover:not(:disabled){filter:none;border-color:var(--accent);background:var(--accent-bg)}
853
+ .card .chain-chip[aria-expanded="true"]{background:var(--accent-bg);border-color:var(--accent)}
854
+ .card .chain-chip.waiting{color:var(--warn-text);background:var(--warn-bg);border-color:var(--warn-border)}
855
+ .chain-chip .cc-arrow{display:inline-block;margin-right:4px}
856
+ .chain-chip .live-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ok);margin-right:3px;vertical-align:1px}
857
+ .chain-chip .cc-waiting{font-family:system-ui,sans-serif;font-weight:600}
858
+ .chain-row .cr-day{font-family:ui-monospace,monospace;font-size:11px;color:var(--accent);white-space:nowrap}
859
+ .chain-row .cr-date{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums}
860
+ /* The session page's links to the previous and the next link of its chain. */
861
+ #infoBar .meta a.chain-link{text-decoration:none;font-family:ui-monospace,monospace;white-space:nowrap;color:var(--accent)}
862
+ #infoBar .meta a.chain-link:hover{background:var(--accent-bg)}
863
+ #infoBar #infoContinueBtn{color:var(--accent);border:1px solid var(--accent-border)}
864
+ #infoBar #infoContinueBtn:hover:not(:disabled){background:var(--accent-bg)}
865
+ #infoBar #infoContinueBtn:disabled{opacity:.7;cursor:progress}
866
+ /* A continue refused for open delegates: the toast names them, each a link. */
867
+ #toast .continue-refused ul{margin:4px 0 0;padding-left:18px}
868
+ #toast .continue-refused a{color:var(--accent);font-family:ui-monospace,monospace}
844
869
  /* A parent's family chip: its delegates folded into its card; a click opens or closes their list. */
845
870
  /* A card with a family chip lets its meta row wrap: the chip goes to a line of its own when the card is narrow. */
846
871
  .card .meta:has(.family-chip){flex-wrap:wrap;row-gap:4px}
@@ -1029,6 +1054,7 @@ a{color:var(--accent)}
1029
1054
  <div id="infoBar">
1030
1055
  <div class="info" id="infoText"></div>
1031
1056
  <div id="contextChips" aria-label="Attached context" hidden></div>
1057
+ <button class="ghost" id="infoContinueBtn" title="Continue: start the next link of this session's chain from its recap (this one is archived)" style="display:none">continue →</button>
1032
1058
  <button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
1033
1059
  <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop</button>
1034
1060
  <button class="ghost" id="infoDeleteBtn" title="Delete session (for good)" style="display:none">delete</button>
@@ -2,16 +2,19 @@
2
2
 
3
3
  export const MODEL_KEY = "chi_model";
4
4
 
5
- // The name to preselect: the remembered one when it is still offered (any
6
- // case), else the server's default, else the first offered, else "".
7
- export function pickModel(names, defaultName, stored) {
5
+ // The name to preselect from the rows (modelRows): the remembered one when
6
+ // it is still offered (any case), else the server's default, both even
7
+ // when their host is down; else the first row whose host answered, else
8
+ // the first row, else "".
9
+ export function pickModel(rows, defaultName, stored) {
10
+ const names = rows.map((r) => r.name);
8
11
  const want = String(stored || "").trim().toLowerCase();
9
12
  if (want) {
10
13
  const kept = names.find((n) => n.toLowerCase() === want);
11
14
  if (kept) return kept;
12
15
  }
13
16
  if (defaultName && names.includes(defaultName)) return defaultName;
14
- return defaultName || names[0] || "";
17
+ return defaultName || (rows.find((r) => !r.unavailable) || rows[0])?.name || "";
15
18
  }
16
19
 
17
20
 
@@ -21,7 +24,8 @@ export const RECENT_KEY = "chi_model_recent";
21
24
  // sampling (the configured request parameters as /model words them) when
22
25
  // the model has some, and llm_context (the LLM context it starts under,
23
26
  // LLMContextStrategy::Explained#summary) when the server sent one, and
24
- // configured when the host declares the id (hosts.<name>.models). The
27
+ // configured when the host declares the id (hosts.<name>.models), and
28
+ // unavailable when that host failed to list its models. The
25
29
  // default host's names are bare (name == id); the server's own default,
26
30
  // unshifted when no host lists it, has no host and goes with them, shown
27
31
  // by its name.
@@ -36,6 +40,7 @@ export function modelRows(payload) {
36
40
  const row = { name, host, id, isDefaultHost: !host || name === id };
37
41
  if (m.sampling) row.sampling = String(m.sampling);
38
42
  if (m.configured === true) row.configured = true;
43
+ if (m.unavailable === true) row.unavailable = true;
39
44
  if (m.llm_context && typeof m.llm_context === "object") row.llm_context = m.llm_context;
40
45
  return row;
41
46
  });
@@ -44,9 +49,9 @@ export function modelRows(payload) {
44
49
  const byId = (a, b) => (b.configured === true) - (a.configured === true) ||
45
50
  a.id.localeCompare(b.id, "en", { sensitivity: "base" });
46
51
 
47
- // The rows under their hosts, [{host, isDefault, rows}]: the default host
48
- // first, the others in the server's order; inside a host the configured
49
- // ids first, each part A-Z.
52
+ // The rows under their hosts, [{host, isDefault, unavailable, rows}]: the
53
+ // default host first, the others in the server's order; inside a host the
54
+ // configured ids first, each part A-Z. unavailable: every row's host is down.
50
55
  export function groupRows(rows) {
51
56
  const groups = [];
52
57
  const find = (key) => groups.find((g) => g.key === key);
@@ -61,23 +66,25 @@ export function groupRows(rows) {
61
66
  group.rows.push(row);
62
67
  }
63
68
  return groups.map(({ host, isDefault, rows: rs }) => ({
64
- host: host || "default", isDefault, rows: [...rs].sort(byId),
69
+ host: host || "default", isDefault, unavailable: rs.every((r) => r.unavailable === true), rows: [...rs].sort(byId),
65
70
  }));
66
71
  }
67
72
 
68
- // The word after a row's id: "default" for the default model, else
69
- // "config" for an id the host declares (hosts.<name>.models), else "".
73
+ // The words after a row's id: "default" for the default model, else
74
+ // "config" for an id the host declares (hosts.<name>.models), else "";
75
+ // then "host down" when its host failed to list its models.
70
76
  export function rowNote(row, defaultName) {
71
- if (row.name === defaultName) return "default";
72
- return row.configured ? "config" : "";
77
+ const word = row.name === defaultName ? "default" : row.configured ? "config" : "";
78
+ return [word, row.unavailable ? "host down" : ""].filter(Boolean).join(" · ");
73
79
  }
74
80
 
75
- // A row's tooltip: its configured sampling, and where a configured id
76
- // comes from; "" when neither.
81
+ // A row's tooltip: its configured sampling, where a configured id comes
82
+ // from, and that its host is down; "" when none.
77
83
  export function rowTitle(row) {
78
84
  const parts = [];
79
85
  if (row.sampling) parts.push(`sampling: ${row.sampling}`);
80
86
  if (row.configured) parts.push(`served by config (hosts.${row.host}.models)`);
87
+ if (row.unavailable) parts.push(`host ${row.host} didn't list its models (down?); a chat on it may fail`);
81
88
  return parts.join("; ");
82
89
  }
83
90
 
@@ -50,16 +50,19 @@ export function createModelPicker({ button, panel, input, list, composer, center
50
50
 
51
51
  const isOpen = () => !panel.hidden;
52
52
 
53
+ // Greyed while the chosen row's host is down (the button's tooltip,
54
+ // app.js', carries the host's warning).
53
55
  function setChosen(name) {
54
56
  chosen = name;
55
57
  button.querySelector(".model-pick-name").textContent = name;
58
+ button.classList.toggle("unavailable", rows.some((r) => r.name === name && r.unavailable === true));
56
59
  }
57
60
 
58
61
  function rowHtml(row, i, { hostMarks = [], idMarks = [], showHost = !row.isDefaultHost } = {}) {
59
62
  const host = showHost ? `<span class="model-host">${markedHtml(row.host, hostMarks)}</span><span class="model-dot"> · </span>` : "";
60
63
  const noteText = rowNote(row, defaultName);
61
64
  const note = noteText ? `<span class="model-note">${noteText}</span>` : "";
62
- const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}`;
65
+ const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}${row.unavailable ? " unavailable" : ""}`;
63
66
  const titleText = rowTitle(row);
64
67
  const title = titleText ? ` title="${escapeHtml(titleText)}"` : "";
65
68
  return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}"${title}>` +
@@ -83,7 +86,7 @@ export function createModelPicker({ button, panel, input, list, composer, center
83
86
  recent.forEach((row) => add(row));
84
87
  }
85
88
  for (const group of groupRows(rows)) {
86
- html += head(group.host, group.isDefault ? "default host" : "");
89
+ html += head(group.host, [group.isDefault ? "default host" : "", group.unavailable ? "host down" : ""].filter(Boolean).join(" · "));
87
90
  group.rows.forEach((row) => add(row, { showHost: false }));
88
91
  }
89
92
  }
@@ -217,7 +220,7 @@ export function createModelPicker({ button, panel, input, list, composer, center
217
220
  close();
218
221
  rows = next;
219
222
  defaultName = String(payload.default || "");
220
- setChosen(pickModel(rows.map((r) => r.name), payload.default, stored || chosen));
223
+ setChosen(pickModel(rows, payload.default, stored || chosen));
221
224
  return true;
222
225
  },
223
226
  chosen: () => chosen,
@@ -25,6 +25,13 @@ const CONTINUE_CLOSED = {
25
25
  replaced: "Asked again",
26
26
  };
27
27
 
28
+ // A close reason that reads as words on any card, not as "(reason)": a
29
+ // question asked in a --non-interactive run (QuestionDesk's
30
+ // UNANSWERABLE_REASON) had no one to answer it.
31
+ const CLOSED_PLAIN = {
32
+ non_interactive: "No one could answer (non-interactive run)",
33
+ };
34
+
28
35
  // A delegate's approval relayed to this session's user (the approval
29
36
  // relay): who asks (the chain, "ab12 → cd34" for a grandchild's) and its
30
37
  // task, or null.
@@ -97,6 +104,7 @@ function relayClosedText(relay, reason) {
97
104
  export function resultText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
98
105
  const approval = isApproval(pq);
99
106
  const relay = relayView(pq);
107
+ if (cancelled && Object.hasOwn(CLOSED_PLAIN, reason)) return CLOSED_PLAIN[reason];
100
108
  if (cancelled && relay && reason !== "dismissed") return relayClosedText(relay, reason);
101
109
  if (isContinue(pq)) return continueResultText({ answer, cancelled, reason });
102
110
  if (cancelled) {
@@ -19,6 +19,8 @@ export function emptySessionState() {
19
19
  promptNotes: [],
20
20
  // A delegated session's parent (the info bar's "delegated by" chip).
21
21
  parentId: null,
22
+ // The previous link of its chain (its `continues`), or null.
23
+ continues: null,
22
24
  ctxPct: null,
23
25
  // Window the kernel resolved for the running generation (:generation_started).
24
26
  ctxWindow: null,
@@ -125,9 +125,11 @@ export function flattenFamilies(fams) {
125
125
  return fams.flatMap((f) => [f.head, ...f.members.map((m) => m.session)]);
126
126
  }
127
127
 
128
- // Whether any of a family (its head or a member) waits on the user.
128
+ // Whether any of a family (its head or a member) waits on the user, or
129
+ // another link of its chain folded into its card (chains.js foldChains).
129
130
  export function familyWaits(f) {
130
- return !!waitingOn(f.head) || f.members.some((m) => waitingOn(m.session));
131
+ return !!waitingOn(f.head) || f.members.some((m) => waitingOn(m.session))
132
+ || (!!f.chain?.folded && f.chain.links.some((s) => s.id !== f.head.id && waitingOn(s)));
131
133
  }
132
134
 
133
135
  const WAITING_BADGES = {
@@ -208,12 +208,16 @@ export function noticeLine(entry = {}) {
208
208
  }
209
209
 
210
210
  // The live turn's status while the provider is asked again after an error
211
- // (generation_retrying): "↻ retrying (503) in 4 s, 2/5" (retry 2 of 5).
211
+ // (generation_retrying): "↻ retrying (503) in 4.0s, 2/5" (retry 2 of 5).
212
+ // The TUI words it the same (Formatting#format_generation_retry_line;
213
+ // spec/shared/labels_matrix.json).
212
214
  export function retryStatusLine({ attempt, max_retries, next_delay, status, error_class, restarted } = {}) {
213
215
  // restarted: a dropped stream's step asked again (the chat loop's).
214
216
  const why = restarted ? "stream dropped" : status || String(error_class || "").split("::").pop();
215
- const delay = Number(next_delay);
216
- const when = Number.isFinite(delay) ? ` in ${delay < 1 ? delay.toFixed(1) : Math.round(delay)} s` : "";
217
+ // null/"" are no delay (Number would make them 0).
218
+ const delay = next_delay === null || next_delay === "" ? NaN : Number(next_delay);
219
+ // Rounded as Ruby's Float#round does (half away from zero), then one decimal.
220
+ const when = Number.isFinite(delay) ? ` in ${(Math.round(delay * 10) / 10).toFixed(1)}s` : "";
217
221
  const count = attempt ? `, ${attempt}${max_retries ? `/${max_retries}` : ""}` : "";
218
222
  return `↻ retrying${why ? ` (${why})` : ""}${when}${count}`;
219
223
  }
@@ -429,12 +433,16 @@ export function restoreInto(refill, current) {
429
433
  // What the composer holds once a /turn ack lands (4.13): the composer still
430
434
  // carries the text that was just sent, so that text goes (it is the history's
431
435
  // now) — but text typed since the send stays, and a prompt restored before
432
- // this ack (restoreOnAck's refill) is put back without replacing it.
433
- // @param sent the composer's text when the turn was sent
436
+ // this ack (restoreOnAck's refill) is put back without replacing it. The
437
+ // sent text is trimmed (turnText), so the composer's is too before they are
438
+ // compared: a trailing newline or space must not keep the message; and an
439
+ // image-only send (placeholders sent) leaves nothing worth keeping.
440
+ // @param sent the text the turn was sent with
434
441
  // @param current the composer's text as the ack lands
435
442
  // @param refill the prompt an early restore handed back, if any
436
443
  export function composerAfterAck({ sent = "", current = "", refill = "" } = {}) {
437
- const kept = String(current ?? "") === String(sent ?? "") ? "" : current;
444
+ const typed = String(current ?? "").trim();
445
+ const kept = !typed || typed === String(sent ?? "").trim() ? "" : current;
438
446
  return restoreInto(refill, kept);
439
447
  }
440
448
 
@@ -63,6 +63,9 @@ module Samagotchi
63
63
  # parent_id too but isn't one. The parent's children chip counts
64
64
  # delegates only, as the TUI's segment and /children do.
65
65
  delegate: session.delegate?,
66
+ # The session this one continues (the previous link of its
67
+ # chain), else nil: the lists draw chains from it.
68
+ continues: session.continues,
66
69
  first_preview: first_preview_for(session),
67
70
  owner: owner&.kind,
68
71
  # The saved recap's first sentence, for the session card.
@@ -850,14 +850,18 @@ module Samagotchi
850
850
  # start only, so the reports its loop merged are not in the
851
851
  # conversation: their rings stay for the next turn, even when the turn
852
852
  # itself stays (a reminder or continue turn). Not one raised after the
853
- # turn ended (the Engine's post-turn work): the turn was kept as
854
- # completed, the reports in it.
853
+ # turn ended (the Engine's post-turn work): the turn was kept as it
854
+ # ended, completed or canceled, the reports in it (a turn TurnFlow
855
+ # takes back is a restore, above).
855
856
  def merges_lost?(error)
856
857
  return false if error.nil? || error.is_a?(LLM::FailedTurn)
857
858
 
858
- @session.last_turn&.dig("outcome") != "completed"
859
+ !KEPT_TURN_OUTCOMES.include?(@session.last_turn&.dig("outcome"))
859
860
  end
860
861
 
862
+ # The Engine's turn endings that keep the turn's conversation (#merges_lost?).
863
+ KEPT_TURN_OUTCOMES = %w[completed canceled].freeze
864
+
861
865
  # A delegate child's turn that was its parent's (#reports_to_parent?)
862
866
  # rings the parent, after the save: the parent reads a settled child.
863
867
  # A question it rang about that still waits makes the next turn ring
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.46.0
4
+ version: 0.47.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -395,6 +395,7 @@ files:
395
395
  - lib/samagotchi/served_model.rb
396
396
  - lib/samagotchi/session.rb
397
397
  - lib/samagotchi/session_archive_command.rb
398
+ - lib/samagotchi/session_chain.rb
398
399
  - lib/samagotchi/session_commands.rb
399
400
  - lib/samagotchi/session_delete_command.rb
400
401
  - lib/samagotchi/session_inbox.rb
@@ -499,6 +500,7 @@ files:
499
500
  - lib/samagotchi/web/public/annotations.js
500
501
  - lib/samagotchi/web/public/app.js
501
502
  - lib/samagotchi/web/public/card.js
503
+ - lib/samagotchi/web/public/chains.js
502
504
  - lib/samagotchi/web/public/chunk_router.js
503
505
  - lib/samagotchi/web/public/command_complete.js
504
506
  - lib/samagotchi/web/public/command_list.js