samagotchi 0.40.0 → 0.42.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/README.md +1 -1
  4. data/bin/chi +19 -2
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +34 -3
  7. data/docs/guardrails.md +15 -3
  8. data/docs/internals/prompt-caching.md +2 -1
  9. data/docs/memory.md +108 -1
  10. data/docs/sessions.md +5 -5
  11. data/docs/testing.md +16 -0
  12. data/lib/samagotchi/bridge/card_store.rb +6 -7
  13. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +23 -0
  14. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -5
  15. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  17. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  18. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  19. data/lib/samagotchi/child_reports.rb +8 -4
  20. data/lib/samagotchi/config.rb +3 -0
  21. data/lib/samagotchi/context_command.rb +5 -2
  22. data/lib/samagotchi/engine.rb +121 -16
  23. data/lib/samagotchi/events.rb +8 -1
  24. data/lib/samagotchi/guardrails/model_size.rb +3 -4
  25. data/lib/samagotchi/guardrails/rules.rb +27 -17
  26. data/lib/samagotchi/guardrails/targets.rb +40 -1
  27. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  28. data/lib/samagotchi/llm/errors.rb +33 -1
  29. data/lib/samagotchi/llm_context_command.rb +151 -0
  30. data/lib/samagotchi/llm_context_override.rb +180 -0
  31. data/lib/samagotchi/llm_context_strategy.rb +63 -13
  32. data/lib/samagotchi/memory_bundle/index_size.rb +103 -0
  33. data/lib/samagotchi/memory_bundle/index_sync.rb +3 -2
  34. data/lib/samagotchi/model_match.rb +54 -0
  35. data/lib/samagotchi/model_notes.rb +205 -0
  36. data/lib/samagotchi/parent_report.rb +12 -2
  37. data/lib/samagotchi/plugin/context.rb +3 -2
  38. data/lib/samagotchi/plugin/sessions.rb +3 -1
  39. data/lib/samagotchi/prompt_note.rb +53 -0
  40. data/lib/samagotchi/reply_wait.rb +3 -17
  41. data/lib/samagotchi/self_report.rb +47 -0
  42. data/lib/samagotchi/send_command.rb +22 -2
  43. data/lib/samagotchi/session.rb +44 -4
  44. data/lib/samagotchi/session_commands.rb +45 -6
  45. data/lib/samagotchi/session_manager.rb +5 -2
  46. data/lib/samagotchi/session_metrics.rb +47 -7
  47. data/lib/samagotchi/system_prompt.rb +79 -11
  48. data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
  49. data/lib/samagotchi/terminal_ui/attached_loop.rb +32 -12
  50. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
  51. data/lib/samagotchi/terminal_ui/formatting.rb +40 -0
  52. data/lib/samagotchi/terminal_ui.rb +16 -6
  53. data/lib/samagotchi/tool_runner.rb +26 -1
  54. data/lib/samagotchi/tools/memory.rb +70 -3
  55. data/lib/samagotchi/turn_flow.rb +44 -8
  56. data/lib/samagotchi/turn_note.rb +40 -1
  57. data/lib/samagotchi/version.rb +1 -1
  58. data/lib/samagotchi/web/app.rb +35 -6
  59. data/lib/samagotchi/web/public/app.js +47 -7
  60. data/lib/samagotchi/web/public/ctx.js +28 -5
  61. data/lib/samagotchi/web/public/format.js +29 -2
  62. data/lib/samagotchi/web/public/index.html +12 -0
  63. data/lib/samagotchi/web/public/live_turn.js +1 -0
  64. data/lib/samagotchi/web/public/llm_context_chip.js +194 -0
  65. data/lib/samagotchi/web/public/session_state.js +6 -0
  66. data/lib/samagotchi/web/public/timing.js +8 -2
  67. data/lib/samagotchi/web/public/turn_view.js +15 -2
  68. data/lib/samagotchi/web/session_summary.rb +4 -0
  69. data/lib/samagotchi/worker.rb +36 -18
  70. data/lib/samagotchi/worker_wakes.rb +2 -1
  71. metadata +8 -1
@@ -0,0 +1,194 @@
1
+ // The session bar's LLM context chip: the strategy the next turn runs under
2
+ // ("llm ctx stale,forget"; "· session" when the session set its own), and a
3
+ // popover that sets the session's own strategy, apply rule and budget by
4
+ // running /llm-context in its worker (the command's reply shows in the
5
+ // conversation, like a typed one). The values and where each came from are
6
+ // the session's llm_context (LLMContextStrategy::Explained#summary: the
7
+ // worker's snapshot, a command_ran, or the server's own reading of the
8
+ // session file).
9
+
10
+ export const STRATEGIES = [
11
+ { value: "none", label: "none" },
12
+ { value: "stale", label: "stale" },
13
+ { value: "stale,forget", label: "stale + forget (experimental)" },
14
+ ];
15
+ export const APPLIES = ["payoff", "next_request", "turn_end"];
16
+
17
+ const DEFAULT = "default";
18
+ const LAYER_ORDER = ["stale", "forget"];
19
+
20
+ // Layers as one value, in chi's order (stale before forget); unknown ones after.
21
+ export function strategyValue(layers) {
22
+ if (!layers.length) return "none";
23
+ const rank = (name) => { const at = LAYER_ORDER.indexOf(name); return at < 0 ? LAYER_ORDER.length : at; };
24
+ return [...layers].sort((a, b) => rank(a) - rank(b)).join(",");
25
+ }
26
+
27
+ // The session's own values as the form shows them: a strategy value, an
28
+ // apply rule and a budget word ("64000", "off"); "default" / "" for unset.
29
+ export function ownValues(summary) {
30
+ const own = summary?.own || {};
31
+ const strategy = Array.isArray(own.strategy) ? strategyValue(own.strategy) : DEFAULT;
32
+ const budget = typeof own.budget_tokens === "number" ? (own.budget_tokens > 0 ? String(own.budget_tokens) : "off") : "";
33
+ return { strategy, apply: own.apply || DEFAULT, budget };
34
+ }
35
+
36
+ // The chip: its text, tooltip and whether the session set its own; null
37
+ // without a summary (an older worker).
38
+ export function chipModel(summary) {
39
+ if (!summary || !summary.strategy) return null;
40
+ const own = summary.strategy_source === "session" || !!summary.own;
41
+ const budget = summary.budget_tokens ? `${summary.budget_tokens} tokens` : "off";
42
+ const title = `LLM context strategy: ${summary.strategy} (${summary.strategy_where}); apply ${summary.apply} ` +
43
+ `(${summary.apply_where}); budget ${budget} (${summary.budget_where}). Click to change it for this session.`;
44
+ return { text: `llm ctx ${summary.strategy}${own ? " · session" : ""}`, title, own };
45
+ }
46
+
47
+ export function chipHtml(summary, escapeHtml) {
48
+ const chip = chipModel(summary);
49
+ if (!chip) return "";
50
+ return `<button type="button" class="model llmctx-chip${chip.own ? " own" : ""}" title="${escapeHtml(chip.title)}" ` +
51
+ `aria-haspopup="dialog">${escapeHtml(chip.text)}</button>`;
52
+ }
53
+
54
+ // The /llm-context line for the form's values. With +initial+ (the values
55
+ // the form opened with) only the fields the user changed go, so a value the
56
+ // form can't show is never unset by a Set; null when nothing changed.
57
+ // Without it every field goes, "default" for one left to the model, and all
58
+ // of them default is a reset.
59
+ export function commandLine({ strategy = DEFAULT, apply = DEFAULT, budget = "" } = {}, initial = null) {
60
+ const words = { strategy, apply, budget: String(budget).trim() || DEFAULT };
61
+ const was = initial && { strategy: initial.strategy, apply: initial.apply, budget: String(initial.budget).trim() || DEFAULT };
62
+ const fields = Object.keys(words).filter((field) => !was || words[field] !== was[field]);
63
+ if (!fields.length) return null;
64
+ if (!was && fields.every((field) => words[field] === DEFAULT)) return "/llm-context reset";
65
+ return `/llm-context ${fields.map((field) => `${field} ${words[field]}`).join(" ")}`;
66
+ }
67
+
68
+ // The strategy list, with the session's own value when it isn't one of them
69
+ // (forget alone, a newer chi's layer).
70
+ export function strategyOptions(chosen) {
71
+ if (chosen === DEFAULT || STRATEGIES.some((s) => s.value === chosen)) return STRATEGIES;
72
+ return [...STRATEGIES, { value: chosen, label: chosen }];
73
+ }
74
+
75
+ function option(value, label, chosen, escapeHtml) {
76
+ return `<option value="${escapeHtml(value)}"${value === chosen ? " selected" : ""}>${escapeHtml(label)}</option>`;
77
+ }
78
+
79
+ // The popover's form: the values now (each with its source), and the
80
+ // session's own to change.
81
+ export function nowText(summary) {
82
+ const budget = summary.budget_tokens ? `${summary.budget_tokens} tokens` : "off";
83
+ return `Now: ${summary.strategy} (${summary.strategy_where}) · apply ${summary.apply} (${summary.apply_where}) · ` +
84
+ `budget ${budget} (${summary.budget_where})`;
85
+ }
86
+
87
+ export function formHtml(summary, escapeHtml, { running = false } = {}) {
88
+ const own = ownValues(summary);
89
+ const follow = (field) => (summary[`${field}_source`] === "session" ? "follow the model" : `follow the model (${summary[field]})`);
90
+ return `<form class="llmctx-form"><div class="ctx-pop-head"><strong>LLM context</strong>` +
91
+ `<span class="ctx-pop-kind">this session</span>` +
92
+ `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
93
+ `<div class="ctx-pop-meta llmctx-now">${escapeHtml(nowText(summary))}</div>` +
94
+ `<label class="llmctx-field"><span>Strategy</span><select name="strategy">` +
95
+ option(DEFAULT, follow("strategy"), own.strategy, escapeHtml) +
96
+ strategyOptions(own.strategy).map((s) => option(s.value, s.label, own.strategy, escapeHtml)).join("") + `</select></label>` +
97
+ `<label class="llmctx-field"><span>Apply</span><select name="apply">` +
98
+ option(DEFAULT, follow("apply"), own.apply, escapeHtml) +
99
+ APPLIES.map((a) => option(a, a, own.apply, escapeHtml)).join("") + `</select></label>` +
100
+ `<label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off" ` +
101
+ `placeholder="follow the model (64000, 64k or off)" value="${escapeHtml(own.budget)}"></label>` +
102
+ `<div class="ctx-pop-meta">From the next turn's start. Turning forget on or off re-reads the whole prompt once.` +
103
+ `${running ? " A turn is running: set it when it ends." : ""}</div>` +
104
+ `<div class="ctx-pop-actions"><button type="button" class="ctx-pop-detach" data-act="reset">follow the model</button>` +
105
+ `<button type="submit" class="llmctx-set"${running ? " disabled" : ""}>Set</button></div></form>`;
106
+ }
107
+
108
+ // The chip's popover. el: where the chip is drawn (#infoText, redrawn every
109
+ // tick: clicks are delegated); summary(): the open session's llm_context;
110
+ // session(): the open session's id; running(): a turn runs; run(line):
111
+ // sends the command.
112
+ export function createLlmContextControl({ el, escapeHtml, summary, session = () => null, running = () => false, run,
113
+ doc = document, win = window }) {
114
+ const pop = doc.createElement("div");
115
+ pop.id = "llmContextPopover";
116
+ pop.className = "ctx-popover llmctx-popover";
117
+ pop.setAttribute("role", "dialog");
118
+ pop.hidden = true;
119
+ doc.body.appendChild(pop);
120
+ // The values the form opened with, and the session it is for.
121
+ let initial = null;
122
+ let openFor = null;
123
+
124
+ function place(anchor) {
125
+ const rect = anchor.getBoundingClientRect();
126
+ const width = Math.min(360, win.innerWidth - 32);
127
+ pop.style.width = `${width}px`;
128
+ pop.style.left = `${Math.max(16, Math.min(rect.left, win.innerWidth - width - 16))}px`;
129
+ pop.style.bottom = `${Math.max(16, win.innerHeight - rect.top + 8)}px`;
130
+ }
131
+
132
+ function open(anchor) {
133
+ const data = summary();
134
+ if (!data) return;
135
+ initial = ownValues(data);
136
+ openFor = session();
137
+ pop.innerHTML = formHtml(data, escapeHtml, { running: running() });
138
+ pop.hidden = false;
139
+ place(anchor);
140
+ }
141
+
142
+ function close() {
143
+ pop.hidden = true;
144
+ initial = null;
145
+ openFor = null;
146
+ }
147
+
148
+ function isOpen() { return !pop.hidden; }
149
+
150
+ // A new summary (a command_ran, a re-read): an open form keeps what the
151
+ // user typed; only its "Now:" line and Set's state follow. Another
152
+ // session closes it.
153
+ function refresh() {
154
+ if (!isOpen()) return;
155
+ const data = summary();
156
+ if (!data || session() !== openFor) return close();
157
+ const now = pop.querySelector(".llmctx-now");
158
+ if (now) now.textContent = nowText(data);
159
+ const set = pop.querySelector(".llmctx-set");
160
+ if (set) set.disabled = running();
161
+ }
162
+
163
+ el.addEventListener("click", (e) => {
164
+ const anchor = e.target.closest(".llmctx-chip");
165
+ if (!anchor) return;
166
+ e.stopPropagation();
167
+ if (isOpen()) close();
168
+ else open(anchor);
169
+ });
170
+ pop.addEventListener("submit", (e) => {
171
+ e.preventDefault();
172
+ const form = e.target;
173
+ const line = commandLine({ strategy: form.elements.strategy.value, apply: form.elements.apply.value,
174
+ budget: form.elements.budget.value }, initial);
175
+ close();
176
+ if (line) run(line);
177
+ });
178
+ pop.addEventListener("click", (e) => {
179
+ const act = e.target.closest("[data-act]")?.dataset.act;
180
+ if (act === "close") close();
181
+ else if (act === "reset") {
182
+ close();
183
+ run(commandLine());
184
+ }
185
+ });
186
+ doc.addEventListener("click", (e) => {
187
+ if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(".llmctx-chip")) close();
188
+ });
189
+ doc.addEventListener("keydown", (e) => {
190
+ if (e.key === "Escape" && isOpen()) close();
191
+ });
192
+
193
+ return { close, refresh, get open() { return isOpen(); } };
194
+ }
@@ -14,6 +14,9 @@ export function emptySessionState() {
14
14
  // in the info bar's tooltip).
15
15
  preloadedMemories: [],
16
16
  mutedMemories: [],
17
+ // The model notes the session's prompt carried (its prompt_notes: name,
18
+ // scope, chars, digest; the info bar's notes chip).
19
+ promptNotes: [],
17
20
  // A delegated session's parent (the info bar's "delegated by" chip).
18
21
  parentId: null,
19
22
  ctxPct: null,
@@ -33,6 +36,9 @@ export function emptySessionState() {
33
36
  // worker_version / worker_features): the info bar's stale-worker badge.
34
37
  workerVersion: null,
35
38
  workerFeatures: [],
39
+ // The LLM context strategy, apply rule and budget the next turn runs
40
+ // under, each with its source (the info bar's llm ctx chip), or null.
41
+ llmContext: null,
36
42
  timing: normalizeTiming(),
37
43
  };
38
44
  }
@@ -61,6 +61,9 @@ export function normalizeTiming(data = {}) {
61
61
  activeTurn: validRecord(data.active_turn) ? data.active_turn : null,
62
62
  // The session's token sums and speeds (SessionMetrics' tokens block).
63
63
  tokens: validRecord(data.tokens) ? data.tokens : null,
64
+ // The memory indexes the session's prompt holds (SessionMetrics'
65
+ // memory_index), for the ctx tooltip.
66
+ memoryIndex: validRecord(data.memory_index) ? data.memory_index : null,
64
67
  };
65
68
  }
66
69
 
@@ -96,7 +99,8 @@ export function mergeTiming(timing, data = {}) {
96
99
  if (!replaceById(toolRecords, record)) toolRecords.push(record);
97
100
  });
98
101
  const tokens = fresh.tokens || timing?.tokens || null;
99
- return { timing: { ...fresh, turnRecords, toolRecords, tokens }, short: turnRecords.length < count };
102
+ const memoryIndex = fresh.memoryIndex || timing?.memoryIndex || null;
103
+ return { timing: { ...fresh, turnRecords, toolRecords, tokens, memoryIndex }, short: turnRecords.length < count };
100
104
  }
101
105
 
102
106
  function replaceById(records, record) {
@@ -229,8 +233,10 @@ function turnGroup(g, items, timing) {
229
233
 
230
234
  // A canceled turn that ran more than one step or any call has no answer:
231
235
  // its partial text stays in the block, as it does live (turn_view.js
232
- // turnEnded promotes only a completed turn or a lone step with no tools).
236
+ // turnEnded promotes only a completed turn or a lone step with no tools). A
237
+ // failed turn never answered: its texts are steps.
233
238
  function canceledSteps(g, record, tools) {
239
+ if (record?.status === "failed") return true;
234
240
  return record?.status === "canceled" && (g.texts.length > 1 || tools.length > 0);
235
241
  }
236
242
 
@@ -11,7 +11,7 @@
11
11
  // removed at the end: the answer bubble takes its place.
12
12
  import { leavesBlock } from "./card.js";
13
13
  import { copySourceAttr } from "./copy.js";
14
- import { escapeHtml, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
14
+ import { escapeHtml, failedTurnText, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
15
15
  import { thumbsHtml } from "./images.js";
16
16
  import { shouldFollowScroll } from "./scroll.js";
17
17
  import { hookNoticeLabel } from "./turn_events.js";
@@ -701,6 +701,17 @@ function setLabel(summary, gen) {
701
701
  summary.innerHTML = html;
702
702
  }
703
703
 
704
+ // A re-rendered failed turn's line (a .bubble.cancel, as live's
705
+ // addEndBubble), in turn_failed's words (failedTurnText) from what the turn
706
+ // record kept of it (SessionMetrics' failure: summary, message,
707
+ // error_class, kept_steps); "" for any other record, or a failed one saved
708
+ // before records kept that.
709
+ function failedLineHtml(record) {
710
+ const failure = record?.status === "failed" ? record.failure : null;
711
+ if (!failure || typeof failure !== "object") return "";
712
+ return `<div class="bubble cancel">${escapeHtml(failedTurnText(failure))}</div>`;
713
+ }
714
+
704
715
  // A reloaded history in the turn view (renderHistory's object path): per
705
716
  // turn the prompt, a collapsed block of its steps when it had any (texts,
706
717
  // and with the server's parts the thinking, params and output, from the
@@ -789,8 +800,10 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
789
800
  // timing after its prompt and steps, where the live view leaves it.
790
801
  else if (group.answer === null) parts.push(timingHtml(group));
791
802
  else parts.push(answerHtml(items[group.answer], timingHtml(group)));
792
- // A canceled turn ends with its cancel line, as the live view left it.
803
+ // A canceled turn ends with its cancel line, a failed one whose steps
804
+ // stayed with its failure line, as the live view left them.
793
805
  parts.push(cancelLineHtml(group.record, escapeHtml));
806
+ parts.push(failedLineHtml(group.record));
794
807
  return parts.join("");
795
808
  }).join("");
796
809
  }
@@ -47,6 +47,8 @@ module Samagotchi
47
47
  # The session's --memory and --mute lists (info-bar tooltip).
48
48
  preloaded_memory_names: Array(session.preloaded_memory_names),
49
49
  muted_memory_names: Array(session.muted_memory_names),
50
+ # The model notes its prompt carried (the info bar's notes chip).
51
+ prompt_notes: Array(session.prompt_notes).map(&:to_h),
50
52
  # The session that delegated this one (the `delegate` tool), else nil.
51
53
  parent_id: session.parent_id,
52
54
  # Started by the delegate tool (Session#delegate?); a fork has a
@@ -62,6 +64,8 @@ module Samagotchi
62
64
  # one read of analytics.json.
63
65
  ctx_pct: saved&.ctx_pct&.round(1),
64
66
  tokens: saved&.tokens,
67
+ # The memory indexes its prompt held (the card's ctx tooltip).
68
+ memory_index: saved&.memory_index,
65
69
  # Hidden from the strip and the list unless "include archived".
66
70
  archived: ArchiveStore.archived?(session_dir),
67
71
  project_root: session.project_root(cache: root_cache),
@@ -413,10 +413,9 @@ module Samagotchi
413
413
  run_engine_turn(nil, continue: true, origin: context_wake.origin,
414
414
  id: context_wake.turn_id, max_iterations: IterationLimit.for) do |result, error|
415
415
  if error
416
- note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message,
417
- wake: "the change in attached context #{name}")
418
- @turn_flow.prompt_turn_failed(note: note)
419
- @inbound.unmark_wake(context_wake)
416
+ kept = @turn_flow.prompt_turn_failed(error: error, wake: "the change in attached context #{name}") == :kept
417
+ # A turn that got somewhere stays, its wake mark with it.
418
+ @inbound.unmark_wake(context_wake) unless kept
420
419
  @wakes.pause!
421
420
  else
422
421
  @continue_offer.after_turn(result)
@@ -508,17 +507,24 @@ module Samagotchi
508
507
  @wakes.hand_over(reports)
509
508
  Log.info(:worker, "delegate_wake_turn", reports: reports.size, in_a_row: @wakes.count!)
510
509
  @turn_flow.before_prompt_turn
510
+ restores = @turn_flow.restores
511
511
  run_engine_turn(nil, continue: true, origin: reports.first.origin, max_iterations: IterationLimit.for) do |result, error|
512
512
  if error
513
- # The rings stay (settle_child_reports): the user's next turn takes them.
514
- note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message,
515
- wake: reports.size == 1 ? "a delegate's report" : "#{reports.size} delegate reports",
516
- kept: "chi keeps #{reports.size == 1 ? "the report and brings it" : "the reports and brings them"} " \
517
- "again with the user's next message.")
518
- @turn_flow.prompt_turn_failed(note: note)
513
+ # Rolled back, the rings stay (settle_child_reports): the user's
514
+ # next turn takes them. A turn that got somewhere stays, and read them.
515
+ @turn_flow.prompt_turn_failed(
516
+ error: error, wake: reports.size == 1 ? "a delegate's report" : "#{reports.size} delegate reports",
517
+ wake_kept: "chi keeps #{reports.size == 1 ? "the report and brings it" : "the reports and brings them"} " \
518
+ "again with the user's next message."
519
+ )
519
520
  @wakes.pause!
520
521
  else
521
522
  @continue_offer.after_turn(result)
523
+ # Stopped before it got anywhere (a Stop or a before_turn hook's
524
+ # cancel before the model call) and restored: its rings stay, and
525
+ # wait for the user's next message as after a failure, rather
526
+ # than waking again at once.
527
+ @wakes.pause! if result.canceled? && @turn_flow.restores != restores
522
528
  end
523
529
  end
524
530
  true
@@ -635,6 +641,8 @@ module Samagotchi
635
641
  model_name: @engine.effective_model_name }
636
642
  event[:anytime] = true if anytime
637
643
  event[:card] = true if command[:card]
644
+ # /llm-context or /model: what the next turn runs under now (the web's chip).
645
+ event[:llm_context] = @engine.llm_context_summary if changed.intersect?(%i[llm_context model])
638
646
  event[:output_truncated] = true if text.length > COMMAND_OUTPUT_LIMIT
639
647
  @engine.announce(event)
640
648
  end
@@ -654,6 +662,7 @@ module Samagotchi
654
662
  # a fresh worker's first), so the list is this turn's from the start.
655
663
  @merged_this_turn = []
656
664
  @reporting_turn = reports_to_parent?(turn_args[:origin])
665
+ restores = @turn_flow.restores
657
666
  begin
658
667
  @session.status = Session::STATUS_RUNNING
659
668
  @session.save(state_dir: @state_dir)
@@ -680,15 +689,22 @@ module Samagotchi
680
689
  SessionInbox.write_output(@session_dir, response)
681
690
  end
682
691
  save_or_log(:turn) { save_session }
683
- settle_child_reports(error)
692
+ # The reports the turn merged were read unless its end took the turn
693
+ # back out of the conversation (TurnFlow restored a checkpoint: a
694
+ # failed prompt or wake turn that got nowhere, a canceled continue
695
+ # turn). A failed turn whose work stayed, and a reminder or continue
696
+ # turn that failed (kept with its failed note), read them.
697
+ settle_child_reports(rolled_back: @turn_flow.restores != restores)
684
698
  ring_parent_after_turn
685
699
  end
686
700
 
687
701
  # The delegate reports this turn took: a kept turn moves the cursors on
688
- # and deletes their rings; a failed one (rolled back) leaves the rings
689
- # for the next turn.
690
- def settle_child_reports(error)
691
- error ? @child_reports.release : @child_reports.commit
702
+ # and deletes their rings; a rolled-back one leaves the rings for the
703
+ # next turn. Once either way: the model reads a report in the
704
+ # conversation, or the next turn brings it (unless a later !rollback
705
+ # erases a kept turn, which drops what it read).
706
+ def settle_child_reports(rolled_back:)
707
+ rolled_back ? @child_reports.release : @child_reports.commit
692
708
  end
693
709
 
694
710
  # A delegate child's turn that was its parent's (#reports_to_parent?)
@@ -735,16 +751,18 @@ module Samagotchi
735
751
  # prompt it took (its own and any merged into it) goes back to its
736
752
  # sender, who can send it again. The rollback and the announcements are
737
753
  # one step of the event log: a snapshot shows the failed turn's messages
738
- # or the restored ones, never the one without the other.
754
+ # or the restored ones, never the one without the other. A turn that
755
+ # got somewhere stays instead (TurnFlow#prompt_turn_failed), with the
756
+ # prompts it took: nothing goes back.
739
757
  # @param prompts [Array<Array(String, Hash|nil, Array|nil)>] [prompt,
740
758
  # origin, images] (a web client gets its image chips back)
741
759
  # @param error [Exception, nil] what failed: its one line stays in the
742
760
  # conversation as a turn note
743
761
  # Not saved here: run_engine_turn saves after its block.
744
762
  def restore_failed_turn(prompts, error: nil)
745
- note = error && TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message, restored: true)
746
763
  @engine.synchronize_events do
747
- @turn_flow.prompt_turn_failed(note: note)
764
+ next if @turn_flow.prompt_turn_failed(error: error) == :kept
765
+
748
766
  prompts.each do |prompt, origin, images|
749
767
  restored = { type: :prompt_restored, prompt: prompt, origin: origin }
750
768
  restored[:images] = images unless Array(images).empty?
@@ -73,7 +73,8 @@ module Samagotchi
73
73
  # A wake turn starts. @return [Integer] the wake turns in a row now
74
74
  def count! = @in_a_row += 1
75
75
 
76
- # A wake turn failed: none starts again until a human's input.
76
+ # A wake turn failed (or was stopped before it got anywhere): none
77
+ # starts again until a human's input.
77
78
  def pause!
78
79
  @paused = true
79
80
  end
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.40.0
4
+ version: 0.42.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -312,8 +312,10 @@ files:
312
312
  - lib/samagotchi/llm/usage.rb
313
313
  - lib/samagotchi/llm/utf8_scrub.rb
314
314
  - lib/samagotchi/llm_context_apply.rb
315
+ - lib/samagotchi/llm_context_command.rb
315
316
  - lib/samagotchi/llm_context_edit.rb
316
317
  - lib/samagotchi/llm_context_forget.rb
318
+ - lib/samagotchi/llm_context_override.rb
317
319
  - lib/samagotchi/llm_context_stale.rb
318
320
  - lib/samagotchi/llm_context_strategy.rb
319
321
  - lib/samagotchi/llm_context_view.rb
@@ -323,6 +325,7 @@ files:
323
325
  - lib/samagotchi/log_subscriber.rb
324
326
  - lib/samagotchi/memory_bundle.rb
325
327
  - lib/samagotchi/memory_bundle/builder.rb
328
+ - lib/samagotchi/memory_bundle/index_size.rb
326
329
  - lib/samagotchi/memory_bundle/index_sync.rb
327
330
  - lib/samagotchi/memory_bundle/index_updater.rb
328
331
  - lib/samagotchi/memory_bundle/installer.rb
@@ -341,6 +344,8 @@ files:
341
344
  - lib/samagotchi/memory_paths.rb
342
345
  - lib/samagotchi/model_catalog.rb
343
346
  - lib/samagotchi/model_list_store.rb
347
+ - lib/samagotchi/model_match.rb
348
+ - lib/samagotchi/model_notes.rb
344
349
  - lib/samagotchi/model_overlay.rb
345
350
  - lib/samagotchi/model_profile.rb
346
351
  - lib/samagotchi/model_ref.rb
@@ -368,6 +373,7 @@ files:
368
373
  - lib/samagotchi/prompt.rb
369
374
  - lib/samagotchi/prompt_history.rb
370
375
  - lib/samagotchi/prompt_literal_guard.rb
376
+ - lib/samagotchi/prompt_note.rb
371
377
  - lib/samagotchi/prompt_warmup.rb
372
378
  - lib/samagotchi/question_desk.rb
373
379
  - lib/samagotchi/recap_setup.rb
@@ -507,6 +513,7 @@ files:
507
513
  - lib/samagotchi/web/public/init_row.js
508
514
  - lib/samagotchi/web/public/live_timing.js
509
515
  - lib/samagotchi/web/public/live_turn.js
516
+ - lib/samagotchi/web/public/llm_context_chip.js
510
517
  - lib/samagotchi/web/public/model_pick.js
511
518
  - lib/samagotchi/web/public/model_picker.js
512
519
  - lib/samagotchi/web/public/notifications.js