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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +22 -7
  5. data/docs/configuration.md +105 -6
  6. data/docs/desktop.md +2 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-live.md +111 -0
  10. data/docs/sessions.md +2 -2
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +5 -0
  12. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/github-pr/plugin.rb +26 -5
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/config.rb +61 -4
  17. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  18. data/lib/samagotchi/engine.rb +41 -9
  19. data/lib/samagotchi/host_model.rb +123 -0
  20. data/lib/samagotchi/host_registry.rb +21 -10
  21. data/lib/samagotchi/kernel_loop.rb +2 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +109 -7
  23. data/lib/samagotchi/llm/errors.rb +8 -2
  24. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  25. data/lib/samagotchi/llm_context_strategy.rb +1 -0
  26. data/lib/samagotchi/model_catalog.rb +22 -16
  27. data/lib/samagotchi/model_price.rb +54 -0
  28. data/lib/samagotchi/model_profile.rb +7 -4
  29. data/lib/samagotchi/models_command.rb +12 -5
  30. data/lib/samagotchi/process_group.rb +56 -9
  31. data/lib/samagotchi/question_desk.rb +29 -4
  32. data/lib/samagotchi/recap_setup.rb +1 -1
  33. data/lib/samagotchi/self_report.rb +2 -1
  34. data/lib/samagotchi/served_model.rb +9 -0
  35. data/lib/samagotchi/session_commands.rb +27 -17
  36. data/lib/samagotchi/session_metrics.rb +40 -5
  37. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  38. data/lib/samagotchi/terminal_ui/formatting.rb +47 -15
  39. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  40. data/lib/samagotchi/terminal_ui.rb +15 -0
  41. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  42. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +43 -6
  45. data/lib/samagotchi/web/public/app.js +7 -3
  46. data/lib/samagotchi/web/public/ctx.js +20 -7
  47. data/lib/samagotchi/web/public/format.js +7 -2
  48. data/lib/samagotchi/web/public/index.html +3 -0
  49. data/lib/samagotchi/web/public/model_pick.js +36 -9
  50. data/lib/samagotchi/web/public/model_picker.js +11 -6
  51. data/lib/samagotchi/web/public/question_card.js +8 -0
  52. data/lib/samagotchi/web/public/stage_view.js +1 -0
  53. data/lib/samagotchi/web/public/turn_events.js +10 -5
  54. data/lib/samagotchi/web/public/turn_model.js +16 -0
  55. data/lib/samagotchi/web/public/turn_view.js +19 -0
  56. data/lib/samagotchi/worker.rb +20 -1
  57. 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"); "" for
81
- // none or zero. As Formatting#cost_text.
82
- export function costText(cost) {
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, and the memory indexes
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 = costText(tokens.cost_sum);
164
- if (cost) lines.push(`cost: ${cost}`);
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
- export function modelLabel(model, served, servedFor) {
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 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
 
@@ -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. The
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) => a.id.localeCompare(b.id, "en", { sensitivity: "base" });
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 default host
45
- // first, the others in the server's order, ids A-Z inside a host.
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 note = row.name === defaultName ? `<span class="model-note">default</span>` : "";
61
- const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}`;
62
- const title = row.sampling ? ` title="${escapeHtml(`sampling: ${row.sampling}`)}"` : "";
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.map((r) => r.name), payload.default, stored || chosen));
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 s, 2/5" (retry 2 of 5).
212
- export function retryStatusLine({ attempt, max_retries, next_delay, status, error_class } = {}) {
213
- const why = status || String(error_class || "").split("::").pop();
214
- const delay = Number(next_delay);
215
- const when = Number.isFinite(delay) ? ` in ${delay < 1 ? delay.toFixed(1) : Math.round(delay)} s` : "";
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);
@@ -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.45.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