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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -1
- data/docs/cli.md +14 -8
- data/docs/configuration.md +3 -1
- data/docs/desktop.md +2 -1
- data/docs/plugins.md +6 -0
- data/docs/sessions.md +24 -6
- data/lib/samagotchi/bundles/coordinator/manifest.yml +4 -4
- data/lib/samagotchi/bundles/coordinator/plugin.rb +15 -2
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +20 -0
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +26 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +5 -1
- data/lib/samagotchi/engine.rb +4 -5
- data/lib/samagotchi/host_model.rb +14 -7
- data/lib/samagotchi/host_registry.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +27 -7
- data/lib/samagotchi/llm/errors.rb +8 -2
- data/lib/samagotchi/llm_context_strategy.rb +1 -0
- data/lib/samagotchi/model_catalog.rb +13 -12
- data/lib/samagotchi/model_profile.rb +4 -3
- data/lib/samagotchi/models_command.rb +12 -5
- data/lib/samagotchi/question_desk.rb +29 -4
- data/lib/samagotchi/send_command.rb +59 -9
- data/lib/samagotchi/served_model.rb +9 -0
- data/lib/samagotchi/session.rb +13 -4
- data/lib/samagotchi/session_chain.rb +93 -0
- data/lib/samagotchi/session_commands.rb +8 -7
- data/lib/samagotchi/session_manager.rb +225 -14
- data/lib/samagotchi/sessions_command.rb +5 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -11
- data/lib/samagotchi/terminal_ui.rb +4 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +86 -4
- data/lib/samagotchi/web/public/app.js +200 -18
- data/lib/samagotchi/web/public/chains.js +234 -0
- data/lib/samagotchi/web/public/data.js +12 -0
- data/lib/samagotchi/web/public/families_view.js +3 -2
- data/lib/samagotchi/web/public/index.html +32 -6
- data/lib/samagotchi/web/public/model_pick.js +22 -15
- data/lib/samagotchi/web/public/model_picker.js +6 -3
- data/lib/samagotchi/web/public/question_card.js +8 -0
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/sessions_list.js +4 -2
- data/lib/samagotchi/web/public/turn_events.js +14 -6
- data/lib/samagotchi/web/session_summary.rb +3 -0
- data/lib/samagotchi/worker.rb +7 -3
- 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
|
-
|
|
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 =
|
|
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
|
|
6
|
-
// case), else the server's default,
|
|
7
|
-
|
|
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 ||
|
|
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)
|
|
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
|
|
48
|
-
// first, the others in the server's order; inside a host the
|
|
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
|
|
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
|
-
|
|
72
|
-
return row.
|
|
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,
|
|
76
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
216
|
-
const
|
|
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
|
-
//
|
|
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
|
|
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.
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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")
|
|
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.
|
|
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
|