samagotchi 0.45.0 → 0.46.1
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 +92 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +22 -7
- data/docs/configuration.md +105 -6
- data/docs/desktop.md +2 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-live.md +111 -0
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +5 -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/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +61 -4
- data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
- data/lib/samagotchi/engine.rb +41 -9
- data/lib/samagotchi/host_model.rb +123 -0
- data/lib/samagotchi/host_registry.rb +21 -10
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +109 -7
- data/lib/samagotchi/llm/errors.rb +8 -2
- data/lib/samagotchi/llm/turn_settings.rb +5 -3
- data/lib/samagotchi/llm_context_strategy.rb +1 -0
- data/lib/samagotchi/model_catalog.rb +22 -16
- data/lib/samagotchi/model_price.rb +54 -0
- data/lib/samagotchi/model_profile.rb +7 -4
- data/lib/samagotchi/models_command.rb +12 -5
- data/lib/samagotchi/process_group.rb +56 -9
- data/lib/samagotchi/question_desk.rb +29 -4
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/self_report.rb +2 -1
- data/lib/samagotchi/served_model.rb +9 -0
- data/lib/samagotchi/session_commands.rb +27 -17
- data/lib/samagotchi/session_metrics.rb +40 -5
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +47 -15
- data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
- data/lib/samagotchi/terminal_ui.rb +15 -0
- data/lib/samagotchi/tools/delegate_wait.rb +5 -4
- data/lib/samagotchi/tools/task_runtime.rb +11 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +43 -6
- data/lib/samagotchi/web/public/app.js +7 -3
- data/lib/samagotchi/web/public/ctx.js +20 -7
- data/lib/samagotchi/web/public/format.js +7 -2
- data/lib/samagotchi/web/public/index.html +3 -0
- data/lib/samagotchi/web/public/model_pick.js +36 -9
- data/lib/samagotchi/web/public/model_picker.js +11 -6
- data/lib/samagotchi/web/public/question_card.js +8 -0
- data/lib/samagotchi/web/public/stage_view.js +1 -0
- data/lib/samagotchi/web/public/turn_events.js +10 -5
- data/lib/samagotchi/web/public/turn_model.js +16 -0
- data/lib/samagotchi/web/public/turn_view.js +19 -0
- data/lib/samagotchi/worker.rb +20 -1
- metadata +4 -1
|
@@ -77,12 +77,24 @@ export function speedText(tps, source) {
|
|
|
77
77
|
return `${source === "estimate" ? "~" : ""}${number} tok/s`;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
// A cost in USD: "$0.42"; under a cent, four decimals ("$0.0012"); ""
|
|
81
|
-
// none or zero. As
|
|
82
|
-
|
|
80
|
+
// A cost in USD: "$0.42"; under a cent, four decimals ("$0.0012"); "~$0.12"
|
|
81
|
+
// for an estimate (hosts.<name>.models prices); "" for none or zero. As
|
|
82
|
+
// Formatting#cost_text.
|
|
83
|
+
export function costText(cost, { estimate = false } = {}) {
|
|
83
84
|
const value = num(cost);
|
|
84
85
|
if (value === null || value <= 0) return "";
|
|
85
|
-
return value >= 0.01 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`;
|
|
86
|
+
return `${estimate ? "~" : ""}${value >= 0.01 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The ctx tooltip's cost: the reported cost as is; with an estimate (an
|
|
90
|
+
// older worker or saved file has none) the total with "~" and both parts.
|
|
91
|
+
function costLine(tokens) {
|
|
92
|
+
const reported = num(tokens.cost_sum) || 0;
|
|
93
|
+
const estimated = num(tokens.cost_estimate_sum) || 0;
|
|
94
|
+
if (estimated <= 0) return reported > 0 ? `cost: ${costText(reported)}` : "";
|
|
95
|
+
const total = costText(reported + estimated, { estimate: true });
|
|
96
|
+
const parts = reported > 0 ? `${costText(reported)} reported, ${costText(estimated, { estimate: true })} estimated` : "estimated";
|
|
97
|
+
return `cost: ${total} (${parts})`;
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
function count(n) {
|
|
@@ -142,7 +154,8 @@ export function ctxWindowText(window) {
|
|
|
142
154
|
|
|
143
155
|
// The ctx tooltip (info bar and card): +title+, then the session's tokens
|
|
144
156
|
// (in summed over every request, cached with its share, out with the
|
|
145
|
-
// reasoning part) and its cost, when it has any
|
|
157
|
+
// reasoning part) and its cost, when it has any (an estimated part with
|
|
158
|
+
// "~", costLine), and the memory indexes
|
|
146
159
|
// its prompt holds (+memoryIndex+, the snapshot's memory_index); with
|
|
147
160
|
// +window+ (ctxWindowText's) the window line under the title. A
|
|
148
161
|
// delegated session keeps its own metrics, so these are this session's only.
|
|
@@ -160,8 +173,8 @@ export function tokensTipText(tokens, title = "Context used after the last turn"
|
|
|
160
173
|
if (cached > 0 && prompt > 0) parts.push(`cached ${count(cached)} (${Math.round((cached * 100) / prompt)}%)`);
|
|
161
174
|
parts.push(`out ${count(completion)}${reasoning > 0 ? ` (reasoning ${count(reasoning)})` : ""}`);
|
|
162
175
|
const lines = [title, `tokens: ${parts.join(" · ")}`];
|
|
163
|
-
const cost =
|
|
164
|
-
if (cost) lines.push(
|
|
176
|
+
const cost = costLine(tokens);
|
|
177
|
+
if (cost) lines.push(cost);
|
|
165
178
|
lines.push("this session only, all requests");
|
|
166
179
|
return `${lines.join("\n")}${memoryLine}`;
|
|
167
180
|
}
|
|
@@ -255,8 +255,13 @@ export function servedModelDiffers(asked, served) {
|
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
// The session's model for the info bar: the served model with a marker when
|
|
258
|
-
// the server serves another one than +servedFor+ (the name asked)
|
|
259
|
-
|
|
258
|
+
// the server serves another one than +servedFor+ (the name asked), unless
|
|
259
|
+
// +expectedBy+ names the host whose hosts.<name>.models served: lists it.
|
|
260
|
+
export function modelLabel(model, served, servedFor, expectedBy = null) {
|
|
261
|
+
if (expectedBy && servedModelDiffers(servedFor, served)) {
|
|
262
|
+
const name = model || "";
|
|
263
|
+
return { text: name.slice(0, 40), title: `${name}; served: ${served} (expected: hosts.${expectedBy}.models)`, mismatch: false };
|
|
264
|
+
}
|
|
260
265
|
if (servedModelDiffers(servedFor, served)) {
|
|
261
266
|
return { text: `${served.slice(0, 40)} ⚠`, title: `served: ${served}; asked for ${model}`, mismatch: true };
|
|
262
267
|
}
|
|
@@ -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:
|
|
@@ -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
|
|
|
@@ -20,7 +23,9 @@ export const RECENT_KEY = "chi_model_recent";
|
|
|
20
23
|
// The payload's models as rows: {name, host, id, isDefaultHost}, and
|
|
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
|
-
// LLMContextStrategy::Explained#summary) when the server sent one
|
|
26
|
+
// LLMContextStrategy::Explained#summary) when the server sent one, and
|
|
27
|
+
// configured when the host declares the id (hosts.<name>.models), and
|
|
28
|
+
// unavailable when that host failed to list its models. The
|
|
24
29
|
// default host's names are bare (name == id); the server's own default,
|
|
25
30
|
// unshifted when no host lists it, has no host and goes with them, shown
|
|
26
31
|
// by its name.
|
|
@@ -34,15 +39,19 @@ export function modelRows(payload) {
|
|
|
34
39
|
const id = host ? String(m.id || name) : name;
|
|
35
40
|
const row = { name, host, id, isDefaultHost: !host || name === id };
|
|
36
41
|
if (m.sampling) row.sampling = String(m.sampling);
|
|
42
|
+
if (m.configured === true) row.configured = true;
|
|
43
|
+
if (m.unavailable === true) row.unavailable = true;
|
|
37
44
|
if (m.llm_context && typeof m.llm_context === "object") row.llm_context = m.llm_context;
|
|
38
45
|
return row;
|
|
39
46
|
});
|
|
40
47
|
}
|
|
41
48
|
|
|
42
|
-
const byId = (a, b) =>
|
|
49
|
+
const byId = (a, b) => (b.configured === true) - (a.configured === true) ||
|
|
50
|
+
a.id.localeCompare(b.id, "en", { sensitivity: "base" });
|
|
43
51
|
|
|
44
|
-
// The rows under their hosts, [{host, isDefault, rows}]: the
|
|
45
|
-
// first, the others in the server's order
|
|
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.
|
|
46
55
|
export function groupRows(rows) {
|
|
47
56
|
const groups = [];
|
|
48
57
|
const find = (key) => groups.find((g) => g.key === key);
|
|
@@ -57,10 +66,28 @@ export function groupRows(rows) {
|
|
|
57
66
|
group.rows.push(row);
|
|
58
67
|
}
|
|
59
68
|
return groups.map(({ host, isDefault, rows: rs }) => ({
|
|
60
|
-
host: host || "default", isDefault, rows: [...rs].sort(byId),
|
|
69
|
+
host: host || "default", isDefault, unavailable: rs.every((r) => r.unavailable === true), rows: [...rs].sort(byId),
|
|
61
70
|
}));
|
|
62
71
|
}
|
|
63
72
|
|
|
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.
|
|
76
|
+
export function rowNote(row, defaultName) {
|
|
77
|
+
const word = row.name === defaultName ? "default" : row.configured ? "config" : "";
|
|
78
|
+
return [word, row.unavailable ? "host down" : ""].filter(Boolean).join(" · ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A row's tooltip: its configured sampling, where a configured id comes
|
|
82
|
+
// from, and that its host is down; "" when none.
|
|
83
|
+
export function rowTitle(row) {
|
|
84
|
+
const parts = [];
|
|
85
|
+
if (row.sampling) parts.push(`sampling: ${row.sampling}`);
|
|
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`);
|
|
88
|
+
return parts.join("; ");
|
|
89
|
+
}
|
|
90
|
+
|
|
64
91
|
const SEPARATORS = "/-:._ ";
|
|
65
92
|
const atSegmentStart = (text, i) => i === 0 || SEPARATORS.includes(text[i - 1]);
|
|
66
93
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { escapeHtml } from "./format.js";
|
|
13
13
|
import { moveSelection } from "./command_complete.js";
|
|
14
14
|
import {
|
|
15
|
-
MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows,
|
|
15
|
+
MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows, rowNote, rowTitle,
|
|
16
16
|
} from "./model_pick.js";
|
|
17
17
|
|
|
18
18
|
const PAGE = 8;
|
|
@@ -50,16 +50,21 @@ 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
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
+
const noteText = rowNote(row, defaultName);
|
|
64
|
+
const note = noteText ? `<span class="model-note">${noteText}</span>` : "";
|
|
65
|
+
const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}${row.unavailable ? " unavailable" : ""}`;
|
|
66
|
+
const titleText = rowTitle(row);
|
|
67
|
+
const title = titleText ? ` title="${escapeHtml(titleText)}"` : "";
|
|
63
68
|
return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}"${title}>` +
|
|
64
69
|
`<span class="model-id">${host}${markedHtml(row.id, idMarks)}</span>${note}</div>`;
|
|
65
70
|
}
|
|
@@ -81,7 +86,7 @@ export function createModelPicker({ button, panel, input, list, composer, center
|
|
|
81
86
|
recent.forEach((row) => add(row));
|
|
82
87
|
}
|
|
83
88
|
for (const group of groupRows(rows)) {
|
|
84
|
-
html += head(group.host, group.isDefault ? "default host" : "");
|
|
89
|
+
html += head(group.host, [group.isDefault ? "default host" : "", group.unavailable ? "host down" : ""].filter(Boolean).join(" · "));
|
|
85
90
|
group.rows.forEach((row) => add(row, { showHost: false }));
|
|
86
91
|
}
|
|
87
92
|
}
|
|
@@ -215,7 +220,7 @@ export function createModelPicker({ button, panel, input, list, composer, center
|
|
|
215
220
|
close();
|
|
216
221
|
rows = next;
|
|
217
222
|
defaultName = String(payload.default || "");
|
|
218
|
-
setChosen(pickModel(rows
|
|
223
|
+
setChosen(pickModel(rows, payload.default, stored || chosen));
|
|
219
224
|
return true;
|
|
220
225
|
},
|
|
221
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) {
|
|
@@ -560,6 +560,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
560
560
|
},
|
|
561
561
|
generationStarted: (data) => view.generationStarted(data),
|
|
562
562
|
chunk: (data) => view.chunk(data),
|
|
563
|
+
generationRestarted: (data) => view.generationRestarted(data),
|
|
563
564
|
generationCompleted: () => view.generationCompleted(),
|
|
564
565
|
toolStarted: (data) => view.toolStarted(data),
|
|
565
566
|
toolCompleted: (data) => view.toolCompleted(data),
|
|
@@ -208,11 +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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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).
|
|
214
|
+
export function retryStatusLine({ attempt, max_retries, next_delay, status, error_class, restarted } = {}) {
|
|
215
|
+
// restarted: a dropped stream's step asked again (the chat loop's).
|
|
216
|
+
const why = restarted ? "stream dropped" : status || String(error_class || "").split("::").pop();
|
|
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` : "";
|
|
216
221
|
const count = attempt ? `, ${attempt}${max_retries ? `/${max_retries}` : ""}` : "";
|
|
217
222
|
return `↻ retrying${why ? ` (${why})` : ""}${when}${count}`;
|
|
218
223
|
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// a chunk with no iteration goes to the current gen, unless that one is
|
|
13
13
|
// textDone or already has tool rows (an older server's replay), which
|
|
14
14
|
// opens a new gen.
|
|
15
|
+
// - generation_retrying with restarted (a dropped stream's step asked
|
|
16
|
+
// again) empties its gen's thinking and text.
|
|
15
17
|
// - generation_completed marks the current gen textDone; its tool rows
|
|
16
18
|
// follow it (the kernel emits them after).
|
|
17
19
|
// - tool events go to the gen of their iteration, else to the current gen,
|
|
@@ -105,6 +107,20 @@ export function chunk(turn, { text = "", thinking = "", iteration = null } = {})
|
|
|
105
107
|
return r;
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
// A step asked again after its stream dropped (generation_retrying with
|
|
111
|
+
// restarted): the open gen of +iteration+ (else the current one) loses what
|
|
112
|
+
// it streamed; null when there is none.
|
|
113
|
+
export function generationRestarted(turn, iteration = null) {
|
|
114
|
+
const current = currentGen(turn);
|
|
115
|
+
const gen = iteration == null ? current
|
|
116
|
+
: [...turn.gens].reverse().find((g) => !g.closed && g.iteration === iteration) ?? null;
|
|
117
|
+
if (!gen) return null;
|
|
118
|
+
gen.thinking = "";
|
|
119
|
+
gen.text = "";
|
|
120
|
+
gen.textDone = false;
|
|
121
|
+
return { gen, opened: false, closed: null };
|
|
122
|
+
}
|
|
123
|
+
|
|
108
124
|
export function generationCompleted(turn) {
|
|
109
125
|
const gen = currentGen(turn);
|
|
110
126
|
if (gen) gen.textDone = true;
|
|
@@ -615,6 +615,25 @@ export function createTurnView({
|
|
|
615
615
|
apply(r);
|
|
616
616
|
scheduleFlush(r.gen);
|
|
617
617
|
},
|
|
618
|
+
// A dropped stream's step asked again (generation_retrying with
|
|
619
|
+
// restarted): its gen's thinking and narration so far go, the step
|
|
620
|
+
// shows "…" until the new stream's text comes.
|
|
621
|
+
generationRestarted(data) {
|
|
622
|
+
if (!turn) return;
|
|
623
|
+
const r = model.generationRestarted(turn, data?.iteration ?? null);
|
|
624
|
+
const entry = r && genEls.get(r.gen);
|
|
625
|
+
if (!entry) return;
|
|
626
|
+
dirty.delete(r.gen);
|
|
627
|
+
if (entry.thinkingWrap) {
|
|
628
|
+
if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
|
|
629
|
+
entry.ticker.close();
|
|
630
|
+
entry.thinkingWrap.remove();
|
|
631
|
+
}
|
|
632
|
+
Object.assign(entry, { thinking: null, thinkingWrap: null, thinkingLine: null, ticker: null, thinkingWritten: 0,
|
|
633
|
+
box: null, feed: createSentenceFeed() });
|
|
634
|
+
entry.text.textContent = "…";
|
|
635
|
+
onChange();
|
|
636
|
+
},
|
|
618
637
|
generationCompleted() {
|
|
619
638
|
if (!turn) return;
|
|
620
639
|
const r = model.generationCompleted(turn);
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -16,6 +16,7 @@ require_relative "session_manager"
|
|
|
16
16
|
require_relative "archive_store"
|
|
17
17
|
require_relative "log"
|
|
18
18
|
require_relative "turn_flow"
|
|
19
|
+
require_relative "llm/errors"
|
|
19
20
|
require_relative "continue_offer"
|
|
20
21
|
require_relative "iteration_limit"
|
|
21
22
|
require_relative "session_commands"
|
|
@@ -830,7 +831,7 @@ module Samagotchi
|
|
|
830
831
|
# failed prompt or wake turn that got nowhere, a canceled continue
|
|
831
832
|
# turn). A failed turn whose work stayed, and a reminder or continue
|
|
832
833
|
# turn that failed (kept with its failed note), read them.
|
|
833
|
-
settle_child_reports(rolled_back: @turn_flow.restores != restores)
|
|
834
|
+
settle_child_reports(rolled_back: @turn_flow.restores != restores || merges_lost?(error))
|
|
834
835
|
ring_parent_after_turn
|
|
835
836
|
end
|
|
836
837
|
|
|
@@ -843,6 +844,24 @@ module Samagotchi
|
|
|
843
844
|
rolled_back ? @child_reports.release : @child_reports.commit
|
|
844
845
|
end
|
|
845
846
|
|
|
847
|
+
# Whether +error+ ended the turn without its loop's conversation: a
|
|
848
|
+
# failure the loops didn't mark (LLM::FailedTurn), one past their end
|
|
849
|
+
# (the Engine's own end of the turn, say). The Engine kept the turn's
|
|
850
|
+
# start only, so the reports its loop merged are not in the
|
|
851
|
+
# conversation: their rings stay for the next turn, even when the turn
|
|
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 it
|
|
854
|
+
# ended, completed or canceled, the reports in it (a turn TurnFlow
|
|
855
|
+
# takes back is a restore, above).
|
|
856
|
+
def merges_lost?(error)
|
|
857
|
+
return false if error.nil? || error.is_a?(LLM::FailedTurn)
|
|
858
|
+
|
|
859
|
+
!KEPT_TURN_OUTCOMES.include?(@session.last_turn&.dig("outcome"))
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
# The Engine's turn endings that keep the turn's conversation (#merges_lost?).
|
|
863
|
+
KEPT_TURN_OUTCOMES = %w[completed canceled].freeze
|
|
864
|
+
|
|
846
865
|
# A delegate child's turn that was its parent's (#reports_to_parent?)
|
|
847
866
|
# rings the parent, after the save: the parent reads a settled child.
|
|
848
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.46.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -136,6 +136,7 @@ files:
|
|
|
136
136
|
- docs/internals/gemma4-contract.md
|
|
137
137
|
- docs/internals/llm-context-bench.md
|
|
138
138
|
- docs/internals/llm-context-forget.md
|
|
139
|
+
- docs/internals/llm-context-live.md
|
|
139
140
|
- docs/internals/prompt-caching.md
|
|
140
141
|
- docs/internals/tool-guardrails.md
|
|
141
142
|
- docs/memory.md
|
|
@@ -285,6 +286,7 @@ files:
|
|
|
285
286
|
- lib/samagotchi/hooks/loader.rb
|
|
286
287
|
- lib/samagotchi/hooks/registry.rb
|
|
287
288
|
- lib/samagotchi/hooks/stream_watch.rb
|
|
289
|
+
- lib/samagotchi/host_model.rb
|
|
288
290
|
- lib/samagotchi/host_registry.rb
|
|
289
291
|
- lib/samagotchi/idle_client.rb
|
|
290
292
|
- lib/samagotchi/idle_recap.rb
|
|
@@ -348,6 +350,7 @@ files:
|
|
|
348
350
|
- lib/samagotchi/model_match.rb
|
|
349
351
|
- lib/samagotchi/model_notes.rb
|
|
350
352
|
- lib/samagotchi/model_overlay.rb
|
|
353
|
+
- lib/samagotchi/model_price.rb
|
|
351
354
|
- lib/samagotchi/model_profile.rb
|
|
352
355
|
- lib/samagotchi/model_ref.rb
|
|
353
356
|
- lib/samagotchi/models_command.rb
|