samagotchi 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +14 -5
  5. data/docs/configuration.md +102 -5
  6. data/docs/internals/background-tasks.md +1 -0
  7. data/docs/internals/engine.md +1 -1
  8. data/docs/internals/llm-context-live.md +111 -0
  9. data/docs/sessions.md +2 -2
  10. data/lib/samagotchi/bridge/turn_accumulator.rb +5 -0
  11. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  13. data/lib/samagotchi/config.rb +57 -4
  14. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  15. data/lib/samagotchi/engine.rb +39 -6
  16. data/lib/samagotchi/host_model.rb +116 -0
  17. data/lib/samagotchi/host_registry.rb +15 -9
  18. data/lib/samagotchi/kernel_loop.rb +2 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +89 -7
  20. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  21. data/lib/samagotchi/model_catalog.rb +17 -12
  22. data/lib/samagotchi/model_price.rb +54 -0
  23. data/lib/samagotchi/model_profile.rb +3 -1
  24. data/lib/samagotchi/process_group.rb +56 -9
  25. data/lib/samagotchi/recap_setup.rb +1 -1
  26. data/lib/samagotchi/self_report.rb +2 -1
  27. data/lib/samagotchi/session_commands.rb +23 -14
  28. data/lib/samagotchi/session_metrics.rb +40 -5
  29. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  30. data/lib/samagotchi/terminal_ui/formatting.rb +26 -9
  31. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  32. data/lib/samagotchi/terminal_ui.rb +13 -0
  33. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  34. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/app.rb +5 -2
  37. data/lib/samagotchi/web/public/app.js +7 -3
  38. data/lib/samagotchi/web/public/ctx.js +20 -7
  39. data/lib/samagotchi/web/public/format.js +7 -2
  40. data/lib/samagotchi/web/public/model_pick.js +23 -3
  41. data/lib/samagotchi/web/public/model_picker.js +5 -3
  42. data/lib/samagotchi/web/public/stage_view.js +1 -0
  43. data/lib/samagotchi/web/public/turn_events.js +3 -2
  44. data/lib/samagotchi/web/public/turn_model.js +16 -0
  45. data/lib/samagotchi/web/public/turn_view.js +19 -0
  46. data/lib/samagotchi/worker.rb +16 -1
  47. metadata +4 -1
@@ -84,10 +84,11 @@ module Samagotchi
84
84
  outcomes << outcome.line
85
85
  return with_outcomes(canceled_result(child_id, reports: reports), outcomes) if outcome.stopped
86
86
 
87
- # Wait on with the child as it is now: a turn that ends with no
88
- # reply after the relay still ends the wait, and the relayed
89
- # question isn't reported again.
90
- baseline = ReplyWait.baseline_of(Session.load(child_id, state_dir: sd), question_id: wait.question[:id])
87
+ # Wait on from the child as it was before the relay (read when it
88
+ # asked), the relayed question no longer reported: a turn that
89
+ # ends with no reply during the relay's POST or after it still
90
+ # ends the wait.
91
+ baseline = ReplyWait.baseline_of(wait.session, question_id: wait.question[:id])
91
92
  end
92
93
  rescue ArgumentError => e
93
94
  "Error: #{e.message}"
@@ -66,6 +66,8 @@ module Samagotchi
66
66
  err: output_io
67
67
  )
68
68
  Process.detach(pid)
69
+ # Read now, compared before each signal: a pid reused later is not this task.
70
+ pid_started = ProcessGroup.start_time(pid)
69
71
 
70
72
  now = timestamp
71
73
  record = {
@@ -76,6 +78,7 @@ module Samagotchi
76
78
  "workspace_root" => Dir.pwd,
77
79
  "status" => "running",
78
80
  "pid" => pid,
81
+ "pid_started" => pid_started,
79
82
  "created_at" => now,
80
83
  "started_at" => now,
81
84
  "finished_at" => nil,
@@ -190,7 +193,7 @@ module Samagotchi
190
193
 
191
194
  pid = refreshed["pid"]
192
195
  # Checked again just before the signal: never a group chi didn't start.
193
- return [mark_not_chis(refreshed), nil] unless ProcessGroup.leader?(pid)
196
+ return [mark_not_chis(refreshed), nil] unless chis_group?(refreshed, started: refreshed["pid_started"])
194
197
 
195
198
  refreshed["stop_requested_by"] = by.to_s
196
199
  write_record(refreshed)
@@ -210,13 +213,19 @@ module Samagotchi
210
213
  return record unless record["status"] == "running"
211
214
 
212
215
  pid = record["pid"]
213
- return record if ProcessGroup.leader?(pid)
216
+ return record if chis_group?(record)
214
217
  # Gone: its exit code says how it ended.
215
218
  return mark_finished_without_exit_code(record) if ProcessGroup.signalable?(pid) && !ProcessGroup.alive?(pid)
216
219
 
217
220
  mark_not_chis(record)
218
221
  end
219
222
 
223
+ # Whether the record's pid still leads its own group. With +started+
224
+ # (the start time recorded at task_create), also the same process: a
225
+ # ps per call, so only task_stop asks it, just before the signal;
226
+ # a refresh (each task_wait poll, task_list) checks the group only.
227
+ def chis_group?(record, started: nil) = ProcessGroup.leader?(record["pid"], started: started)
228
+
220
229
  # A running record whose pid isn't a group chi started (NOT_CHIS_PROCESS)
221
230
  # ends as failed, unless its exit code or a stop on disk says more.
222
231
  def mark_not_chis(record)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.45.0"
4
+ VERSION = "0.46.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -443,7 +443,9 @@ module Samagotchi
443
443
  warnings.concat(listing.warnings)
444
444
  # the web's own spelling, kept as it was: bare on the default host
445
445
  listing.rows.each do |row|
446
- models << { name: row.host == default_host ? row.id : "#{row.host}:#{row.id}", host: row.host, id: row.id }
446
+ model = { name: row.host == default_host ? row.id : "#{row.host}:#{row.id}", host: row.host, id: row.id }
447
+ model[:configured] = true if row.configured
448
+ models << model
447
449
  end
448
450
  end
449
451
  rescue StandardError => e
@@ -657,7 +659,8 @@ module Samagotchi
657
659
  session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
658
660
  # What the worker's server said it served for that model (after a turn).
659
661
  if snapshot
660
- session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"])
662
+ session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
663
+ served_expected_by: snapshot["served_expected_by"])
661
664
  # The model notes its prompt carries now (a /model there changes them).
662
665
  session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
663
666
  end
@@ -1156,7 +1156,7 @@ function renderSessionView(data) {
1156
1156
  setCapabilities(data.capabilities);
1157
1157
  current.status = s.status;
1158
1158
  current.model = s.model_name;
1159
- current.served = s.served_model ? [s.served_model, s.served_model_for] : null;
1159
+ current.served = s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null;
1160
1160
  current.dir = s.working_directory;
1161
1161
  current.owner = s.owner || null;
1162
1162
  current.timing = normalizeTiming(data.timing);
@@ -2007,7 +2007,7 @@ const streamHandlers = {
2007
2007
  },
2008
2008
  generation_completed: (data) => {
2009
2009
  liveTurn.timing.setNote("");
2010
- if (data?.served_model) current.served = [data.served_model, data.requested_model];
2010
+ if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
2011
2011
  // The generation's speed and the session's token totals, from the server.
2012
2012
  current.timing.tokens = generationTokens(current.timing.tokens, data);
2013
2013
  if (data?.served_model || data?.tokens) updateInfoBar();
@@ -2212,7 +2212,11 @@ const streamHandlers = {
2212
2212
  llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
2213
2213
  // The provider is asked again after an error, or the turn waits for
2214
2214
  // plugins' setup: a note on the live timing line until it streams.
2215
- generation_retrying: (data) => liveTurn.timing.setNote(retryStatusLine(data)),
2215
+ generation_retrying: (data) => {
2216
+ // A dropped stream's step asked again: what it streamed is void.
2217
+ if (data?.restarted) turnDom.generationRestarted(data);
2218
+ liveTurn.timing.setNote(retryStatusLine(data));
2219
+ },
2216
2220
  plugin_init_wait: (data) => liveTurn.timing.setNote(initWaitLine(data)),
2217
2221
  plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
2218
2222
  plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
@@ -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
  }
@@ -20,7 +20,8 @@ export const RECENT_KEY = "chi_model_recent";
20
20
  // The payload's models as rows: {name, host, id, isDefaultHost}, and
21
21
  // sampling (the configured request parameters as /model words them) when
22
22
  // the model has some, and llm_context (the LLM context it starts under,
23
- // LLMContextStrategy::Explained#summary) when the server sent one. The
23
+ // LLMContextStrategy::Explained#summary) when the server sent one, and
24
+ // configured when the host declares the id (hosts.<name>.models). The
24
25
  // default host's names are bare (name == id); the server's own default,
25
26
  // unshifted when no host lists it, has no host and goes with them, shown
26
27
  // by its name.
@@ -34,15 +35,18 @@ export function modelRows(payload) {
34
35
  const id = host ? String(m.id || name) : name;
35
36
  const row = { name, host, id, isDefaultHost: !host || name === id };
36
37
  if (m.sampling) row.sampling = String(m.sampling);
38
+ if (m.configured === true) row.configured = true;
37
39
  if (m.llm_context && typeof m.llm_context === "object") row.llm_context = m.llm_context;
38
40
  return row;
39
41
  });
40
42
  }
41
43
 
42
- const byId = (a, b) => a.id.localeCompare(b.id, "en", { sensitivity: "base" });
44
+ const byId = (a, b) => (b.configured === true) - (a.configured === true) ||
45
+ a.id.localeCompare(b.id, "en", { sensitivity: "base" });
43
46
 
44
47
  // 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.
48
+ // first, the others in the server's order; inside a host the configured
49
+ // ids first, each part A-Z.
46
50
  export function groupRows(rows) {
47
51
  const groups = [];
48
52
  const find = (key) => groups.find((g) => g.key === key);
@@ -61,6 +65,22 @@ export function groupRows(rows) {
61
65
  }));
62
66
  }
63
67
 
68
+ // The word after a row's id: "default" for the default model, else
69
+ // "config" for an id the host declares (hosts.<name>.models), else "".
70
+ export function rowNote(row, defaultName) {
71
+ if (row.name === defaultName) return "default";
72
+ return row.configured ? "config" : "";
73
+ }
74
+
75
+ // A row's tooltip: its configured sampling, and where a configured id
76
+ // comes from; "" when neither.
77
+ export function rowTitle(row) {
78
+ const parts = [];
79
+ if (row.sampling) parts.push(`sampling: ${row.sampling}`);
80
+ if (row.configured) parts.push(`served by config (hosts.${row.host}.models)`);
81
+ return parts.join("; ");
82
+ }
83
+
64
84
  const SEPARATORS = "/-:._ ";
65
85
  const atSegmentStart = (text, i) => i === 0 || SEPARATORS.includes(text[i - 1]);
66
86
 
@@ -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;
@@ -57,9 +57,11 @@ export function createModelPicker({ button, panel, input, list, composer, center
57
57
 
58
58
  function rowHtml(row, i, { hostMarks = [], idMarks = [], showHost = !row.isDefaultHost } = {}) {
59
59
  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>` : "";
60
+ const noteText = rowNote(row, defaultName);
61
+ const note = noteText ? `<span class="model-note">${noteText}</span>` : "";
61
62
  const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}`;
62
- const title = row.sampling ? ` title="${escapeHtml(`sampling: ${row.sampling}`)}"` : "";
63
+ const titleText = rowTitle(row);
64
+ const title = titleText ? ` title="${escapeHtml(titleText)}"` : "";
63
65
  return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}"${title}>` +
64
66
  `<span class="model-id">${host}${markedHtml(row.id, idMarks)}</span>${note}</div>`;
65
67
  }
@@ -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),
@@ -209,8 +209,9 @@ export function noticeLine(entry = {}) {
209
209
 
210
210
  // The live turn's status while the provider is asked again after an error
211
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();
212
+ export function retryStatusLine({ attempt, max_retries, next_delay, status, error_class, restarted } = {}) {
213
+ // restarted: a dropped stream's step asked again (the chat loop's).
214
+ const why = restarted ? "stream dropped" : status || String(error_class || "").split("::").pop();
214
215
  const delay = Number(next_delay);
215
216
  const when = Number.isFinite(delay) ? ` in ${delay < 1 ? delay.toFixed(1) : Math.round(delay)} s` : "";
216
217
  const count = attempt ? `, ${attempt}${max_retries ? `/${max_retries}` : ""}` : "";
@@ -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,20 @@ 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
854
+ # completed, the reports in it.
855
+ def merges_lost?(error)
856
+ return false if error.nil? || error.is_a?(LLM::FailedTurn)
857
+
858
+ @session.last_turn&.dig("outcome") != "completed"
859
+ end
860
+
846
861
  # A delegate child's turn that was its parent's (#reports_to_parent?)
847
862
  # rings the parent, after the save: the parent reads a settled child.
848
863
  # 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.0
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