samagotchi 0.61.0 → 0.62.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/docs/architecture.md +3 -0
  4. data/docs/cli.md +22 -8
  5. data/docs/configuration.md +32 -19
  6. data/docs/context.md +8 -0
  7. data/docs/guardrails.md +2 -1
  8. data/docs/hooks.md +8 -0
  9. data/docs/internals/engine.md +24 -11
  10. data/docs/internals/prompt-caching.md +6 -1
  11. data/docs/plugins.md +42 -3
  12. data/docs/sessions.md +4 -4
  13. data/docs/testing.md +4 -1
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  16. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  18. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  19. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  20. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/engine.rb +9 -0
  23. data/lib/samagotchi/engine_peers.rb +1 -0
  24. data/lib/samagotchi/kernel_loop.rb +7 -16
  25. data/lib/samagotchi/llm/chat_loop.rb +23 -15
  26. data/lib/samagotchi/plugin/api.rb +36 -18
  27. data/lib/samagotchi/reasoning_history.rb +22 -4
  28. data/lib/samagotchi/row_refs.rb +126 -0
  29. data/lib/samagotchi/session_metrics.rb +24 -2
  30. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  31. data/lib/samagotchi/terminal_ui.rb +26 -3
  32. data/lib/samagotchi/tool_activity.rb +8 -4
  33. data/lib/samagotchi/tool_response.rb +5 -4
  34. data/lib/samagotchi/tool_row_fields.rb +4 -2
  35. data/lib/samagotchi/tool_runner.rb +16 -3
  36. data/lib/samagotchi/tools/builtins.rb +9 -2
  37. data/lib/samagotchi/tools/execute.rb +15 -3
  38. data/lib/samagotchi/tools/peers.rb +3 -2
  39. data/lib/samagotchi/tools/registry.rb +5 -3
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +14 -1
  42. data/lib/samagotchi/web/message_parts.rb +17 -9
  43. data/lib/samagotchi/web/public/activity.js +4 -1
  44. data/lib/samagotchi/web/public/app.js +59 -15
  45. data/lib/samagotchi/web/public/chains.js +18 -20
  46. data/lib/samagotchi/web/public/context_chips.js +92 -64
  47. data/lib/samagotchi/web/public/families_view.js +51 -39
  48. data/lib/samagotchi/web/public/index.html +61 -10
  49. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  50. data/lib/samagotchi/web/public/lists_view.js +13 -13
  51. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  52. data/lib/samagotchi/web/public/question_card.js +11 -0
  53. data/lib/samagotchi/web/public/question_cards.js +60 -5
  54. data/lib/samagotchi/web/public/ref_menu.js +18 -5
  55. data/lib/samagotchi/web/public/refs.js +113 -15
  56. data/lib/samagotchi/web/public/scroll.js +42 -0
  57. data/lib/samagotchi/web/public/stage_model.js +22 -3
  58. data/lib/samagotchi/web/public/stage_view.js +299 -70
  59. data/lib/samagotchi/web/public/timing.js +1 -1
  60. data/lib/samagotchi/web/public/turn_html.js +5 -4
  61. data/lib/samagotchi/web/public/turn_model.js +49 -0
  62. data/lib/samagotchi/web/public/turn_view.js +104 -10
  63. data/lib/samagotchi/worker.rb +3 -2
  64. data/lib/samagotchi/worker_inbound.rb +5 -3
  65. metadata +3 -1
@@ -152,6 +152,20 @@ export function notice(turn, { hook, text, level } = {}) {
152
152
  return { gen };
153
153
  }
154
154
 
155
+ // The whole text of the reply that asked the user (a question or an
156
+ // approval blocks its call): the last gen's text, trimmed, while one of its
157
+ // calls runs and the turn goes on; else "". Only that step's text: a hook's
158
+ // question (asked before its call's row, or between steps) has no running
159
+ // row there, so it gets none rather than another step's (or the answer's)
160
+ // text. The stage shows it while the card waits; the card keeps it once
161
+ // answered.
162
+ export function askingText(turn) {
163
+ if (!turn || turn.ended) return "";
164
+ const gen = turn.gens[turn.gens.length - 1];
165
+ if (!gen?.text || !gen.tools.some((row) => row.status === "running")) return "";
166
+ return gen.text.trim();
167
+ }
168
+
155
169
  // The turn is over (completed, canceled, failed, the worker gone).
156
170
  export function turnEnded(turn) {
157
171
  const closed = closeCurrent(turn);
@@ -215,6 +229,41 @@ export function genLabelParts(gen) {
215
229
  return { head: "thinking", calls: "", repeats: false };
216
230
  }
217
231
 
232
+ // A step that said something (its text is not the turn's answer): a row of
233
+ // its own in a finished block's outline.
234
+ export function isNarrated(gen) {
235
+ return !gen.answer && !!String(gen.text || "").trim();
236
+ }
237
+
238
+ // A finished block's outline (+gens+: its steps in order, as the block holds
239
+ // them): each narrated step a row, each run of the others between (tool-only,
240
+ // thinking-only) one fold ({fold: [indices]}), so every mid-turn text's first
241
+ // line reads without a click and the steps that said nothing take one line.
242
+ // [] when no step said anything: the block stays closed, as before.
243
+ // A last run of one thinking-only step stays a row (no "· 1 step" at the end).
244
+ export function outlineRuns(gens) {
245
+ if (!gens.some(isNarrated)) return [];
246
+ const runs = [];
247
+ gens.forEach((gen, i) => {
248
+ if (isNarrated(gen)) return runs.push({ step: i });
249
+ const last = runs[runs.length - 1];
250
+ if (last?.fold) last.fold.push(i);
251
+ else runs.push({ fold: [i] });
252
+ });
253
+ // A turn doesn't end on a "1 step" fold for a lone thinking-only step
254
+ // (the answer's own thinking): it stays a row, its "thinking" label.
255
+ const last = runs[runs.length - 1];
256
+ const lone = last.fold?.length === 1 && gens[last.fold[0]];
257
+ if (lone && lone.tools.length === 0) runs[runs.length - 1] = { step: last.fold[0] };
258
+ return runs;
259
+ }
260
+
261
+ // A finished turn's steps as its block holds them: the answer's step left it
262
+ // when the answer was all it had (turn_view.js promote).
263
+ export function blockGens(turn) {
264
+ return turn.gens.filter((gen) => !(gen.answer && !gen.thinking && gen.tools.length === 0));
265
+ }
266
+
218
267
  // The block's summary line: "working · 3 steps · 8 tool calls · execute ×7 · …"
219
268
  // while it runs, without the prefix after; the tally (tally.js) from 3 calls.
220
269
  export function blockSummary(turn, { running = false } = {}) {
@@ -22,6 +22,7 @@
22
22
  // removed at the end: the answer bubble takes its place.
23
23
  import { html, nothing, render } from "./vendor/lit-html.js";
24
24
  import { setAnswerRefs } from "./answer_refs.js";
25
+ import { bindRowRefs } from "./refs.js";
25
26
  import { leavesBlock } from "./card.js";
26
27
  import { copySourceAttr } from "./copy.js";
27
28
  import { escapeHtml, failedTurnText, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
@@ -116,6 +117,67 @@ function rawNodes(markup) {
116
117
  return t.content;
117
118
  }
118
119
 
120
+ // ---- A finished block's outline ----
121
+
122
+ // A finished block as its outline (turn_model.js outlineRuns of +gens+, its
123
+ // steps in order): open, each narrated step a one-line row as before, each
124
+ // run of steps that said nothing hidden behind one "N steps" fold that
125
+ // shows them. The live turn's end and a reload call it alike. The folds sit
126
+ // between the steps, not in them (`:scope > .gen` still counts steps), each
127
+ // before its run's first step and before that step's lit marker comment, so
128
+ // a later render of the step leaves it alone. False when there is nothing
129
+ // to outline (no step said anything): the block stays as it is.
130
+ export function outlineBlock(blockEl, gens) {
131
+ const runs = model.outlineRuns(gens);
132
+ const steps = blockEl ? [...blockEl.querySelectorAll(":scope > .gen")] : [];
133
+ if (!runs.length || steps.length !== gens.length) return false;
134
+ blockEl.classList.add("outline");
135
+ // A folded step → its fold's opener: a ref's "show in turn" (refs.js
136
+ // showInTurn dispatches ref:reveal on the row before it scrolls) unfolds
137
+ // the run its row is in, or the row stays hidden.
138
+ const unfold = new Map();
139
+ for (const { fold } of runs) {
140
+ if (!fold) continue;
141
+ const folded = fold.map((i) => steps[i]);
142
+ folded.forEach((el) => el.classList.add("folded"));
143
+ const marker = document.createElement("div");
144
+ marker.className = "outline-fold";
145
+ marker.setAttribute("role", "button");
146
+ marker.tabIndex = 0;
147
+ marker.setAttribute("aria-expanded", "false");
148
+ marker.textContent = `${fold.length} step${fold.length === 1 ? "" : "s"}`;
149
+ const toggle = () => {
150
+ const open = marker.classList.toggle("open");
151
+ marker.setAttribute("aria-expanded", String(open));
152
+ folded.forEach((el) => el.classList.toggle("unfolded", open));
153
+ };
154
+ folded.forEach((el) => unfold.set(el, () => { if (!marker.classList.contains("open")) toggle(); }));
155
+ marker.addEventListener("click", toggle);
156
+ marker.addEventListener("keydown", (e) => {
157
+ if (e.key !== "Enter" && e.key !== " ") return;
158
+ e.preventDefault();
159
+ toggle();
160
+ });
161
+ const first = folded[0];
162
+ const anchor = first.previousSibling?.nodeType === 8 ? first.previousSibling : first;
163
+ anchor.before(marker);
164
+ }
165
+ blockEl.addEventListener("ref:reveal", (e) => {
166
+ const step = e.target.closest?.(".gen.folded");
167
+ if (step?.parentElement === blockEl) unfold.get(step)?.();
168
+ });
169
+ blockEl.open = true;
170
+ return true;
171
+ }
172
+
173
+ // Reloaded tool rows (turn_html.js toolRowHtml) as nodes, each row's
174
+ // plugin refs bound to its title (refs.js bindRowRefs).
175
+ function rowNodes(rows, thumbs) {
176
+ const nodes = rawNodes(rows.map((r) => toolRowHtml(r, { thumbs })).join(""));
177
+ [...nodes.children].forEach((el, i) => bindRowRefs(el, rows[i]));
178
+ return nodes;
179
+ }
180
+
119
181
  // ---- The live view ----
120
182
 
121
183
  // The parts of a drawn tool row a user may have changed (the command's
@@ -445,12 +507,16 @@ export function createTurnView({
445
507
  const inner = toolRowInnerHtml(r.row, { thumbs: rowThumbs });
446
508
  if (!el) {
447
509
  el = htmlElement(toolRowHtml(r.row, { thumbs: rowThumbs }));
510
+ bindRowRefs(el, r.row);
448
511
  rowHtml.set(el, inner);
449
512
  entry.rows.set(r.row.key, el);
450
513
  addItem(entry, el);
451
514
  } else {
452
515
  if (!r.gen.text) entry.placeholder = false;
453
- if (rowHtml.get(el) !== inner) redrawRow(el, inner);
516
+ if (rowHtml.get(el) !== inner) {
517
+ redrawRow(el, inner);
518
+ bindRowRefs(el, r.row);
519
+ }
454
520
  rowHtml.set(el, inner);
455
521
  // A late result in a closed step: its label and failed mark.
456
522
  draw(entry);
@@ -603,7 +669,11 @@ export function createTurnView({
603
669
  if (blockEl) {
604
670
  updateBlockSummary();
605
671
  blockEl.classList.add("done");
606
- if (!blockToggled) blockEl.open = false;
672
+ // The outline (turn_model.js outlineRuns): open on its narrated steps'
673
+ // rows, as a reload draws it; a block with nothing said closes.
674
+ if (newestFirst) chronological();
675
+ const outlined = outlineBlock(blockEl, model.blockGens(turn));
676
+ if (!blockToggled) blockEl.open = outlined;
607
677
  for (const el of blockEl.querySelectorAll(".plugin-card")) {
608
678
  if (leavesBlock({ warn: el.classList.contains("warn"), kind })) kept.push(el);
609
679
  }
@@ -615,6 +685,20 @@ export function createTurnView({
615
685
  return kept;
616
686
  }
617
687
 
688
+ // The steps back in chronological order after newestFirst (a no-op
689
+ // otherwise); a fold (outlineBlock) stays before its run's first step.
690
+ function chronological() {
691
+ if (!blockEl || !turn) return;
692
+ for (const gen of turn.gens) {
693
+ const entry = genEls.get(gen);
694
+ if (entry?.el.parentNode !== blockEl) continue;
695
+ const nodes = stepNodes(entry);
696
+ const fold = nodes[0].previousSibling;
697
+ if (fold?.classList?.contains("outline-fold")) nodes.unshift(fold);
698
+ blockEl.append(...nodes);
699
+ }
700
+ }
701
+
618
702
  function reset() {
619
703
  hold.finish();
620
704
  turn = null;
@@ -712,17 +796,14 @@ export function createTurnView({
712
796
  // block, a step's elements by index, and the steps back in
713
797
  // chronological order after newestFirst.
714
798
  current() { return { turn, blockEl }; },
799
+ // The running turn's asking reply (turn_model.js askingText): what a
800
+ // question or approval card keeps once answered.
801
+ askingText: () => model.askingText(turn),
715
802
  step(i) {
716
803
  const entry = turn && genEls.get(turn.gens[i]);
717
804
  return entry ? { el: entry.el, summary: entry.el.querySelector(":scope > summary"), thinkingWrap: thinkingWrap(entry) } : null;
718
805
  },
719
- chronological() {
720
- if (!blockEl || !turn) return;
721
- for (const gen of turn.gens) {
722
- const entry = genEls.get(gen);
723
- if (entry?.el.parentNode === blockEl) blockEl.append(...stepNodes(entry));
724
- }
725
- },
806
+ chronological,
726
807
  };
727
808
  }
728
809
 
@@ -737,6 +818,14 @@ function defaultPlaceAnswer(appendToHistory) {
737
818
 
738
819
  // ---- A reload ----
739
820
 
821
+ // The text of a reloaded block's step +iteration+ (1-based, as the snapshot's
822
+ // cards count), "" when it has none: a reloaded question card's asking
823
+ // reply.
824
+ export function stepText(block, iteration) {
825
+ const step = block?.querySelectorAll(":scope > .gen")[Number(iteration) - 1];
826
+ return step?.querySelector(":scope > .gen-text")?.textContent || "";
827
+ }
828
+
740
829
  // A re-rendered failed turn's line (a .bubble.cancel, as live's
741
830
  // addEndBubble), in turn_failed's words (failedTurnText) from what the turn
742
831
  // record kept of it (SessionMetrics' failure: summary, message,
@@ -806,6 +895,8 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
806
895
  };
807
896
  const steerHtml = (i, bubble) => steerRowHtml({ source: items[i].source, text: items[i].content }, { bubble });
808
897
  const parts = [];
898
+ // Each block's steps, in order: its outline once the nodes exist.
899
+ const outlines = [];
809
900
  for (const group of turnGroups(items, timing)) {
810
901
  if (group.kind === "note") { parts.push(`<div class="bubble note">${noteHtml(items[group.i])}</div>`); continue; }
811
902
  if (group.kind === "steer") { parts.push(steerHtml(group.i, true)); continue; }
@@ -827,9 +918,10 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
827
918
  const steps = gens.map((gen, k) => stepTemplate(gen, {
828
919
  // The steers the step answered first, where the live rows went.
829
920
  steers: (group.steps[k].steers || []).map((i) => rawNodes(steerHtml(i, false))),
830
- items: gen.tools.length ? [rawNodes(gen.tools.map((r) => toolRowHtml(r, { thumbs })).join(""))] : [],
921
+ items: gen.tools.length ? [rowNodes(gen.tools, thumbs)] : [],
831
922
  }));
832
923
  parts.push(html`<details class="turn-work done"><summary>${model.blockSummary({ gens, ended: true })}</summary>${steps}</details>`);
924
+ outlines.push(gens);
833
925
  }
834
926
  // Steers with no step to sit in, or in a turn that shows no block: on
835
927
  // their own after the prompt.
@@ -856,6 +948,8 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
856
948
  parts.push(failedLineHtml(group.record));
857
949
  }
858
950
  const out = fragmentOf(parts);
951
+ // A finished turn's block as the live end leaves it: its outline, open.
952
+ [...out.children].filter((el) => el.matches(".turn-work")).forEach((block, i) => outlineBlock(block, outlines[i] || []));
859
953
  // Each answer bubble knows its message's refs (answer_refs.js; the
860
954
  // page pairs them after the render), the index stays out of the DOM.
861
955
  for (const bubble of out.querySelectorAll("[data-answer-at]")) {
@@ -1077,8 +1077,9 @@ module Samagotchi
1077
1077
  # A wake turn starts with its first line, whoever sent it.
1078
1078
  lines[0] = lines[0].with(mark: mark) if mark && lines.any?
1079
1079
  # The turn so far, with what this boundary merges (its input files
1080
- # are gone now), on disk at most every TurnCheckpoint::INTERVAL.
1081
- @checkpoint.boundary(@engine.live_conversation, merging: lines, turn_id: @turn_id)
1080
+ # are gone now), on disk at most every TurnCheckpoint::INTERVAL;
1081
+ # its metrics so far with it, so a turn a KILL ends keeps them.
1082
+ @engine.checkpoint_metrics if @checkpoint.boundary(@engine.live_conversation, merging: lines, turn_id: @turn_id)
1082
1083
  lines
1083
1084
  end
1084
1085
  end
@@ -201,9 +201,11 @@ module Samagotchi
201
201
 
202
202
  private
203
203
 
204
- # +unless_stopped+: not when the session was stopped meanwhile: the stop
205
- # (chi stop, from another process) saved its status, and this save
206
- # would write the worker's over it.
204
+ # +unless_stopped+: skipped when the session was stopped meanwhile (chi
205
+ # sessions stop, from another process: its marker file,
206
+ # Session.mark_stopped, which a load reads over the saved status). The
207
+ # save couldn't hide the stop; it is left out so the file stays as the
208
+ # stop found it, the worker's idle status unwritten.
207
209
  def save(at, unless_stopped: false)
208
210
  self.class.save_or_log(at) do
209
211
  @session.save(state_dir: @state_dir) unless unless_stopped && @stopped.call
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.61.0
4
+ version: 0.62.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -416,6 +416,7 @@ files:
416
416
  - lib/samagotchi/reminder_queue.rb
417
417
  - lib/samagotchi/reminder_store.rb
418
418
  - lib/samagotchi/reply_wait.rb
419
+ - lib/samagotchi/row_refs.rb
419
420
  - lib/samagotchi/sampling_settings.rb
420
421
  - lib/samagotchi/self_report.rb
421
422
  - lib/samagotchi/send_command.rb
@@ -557,6 +558,7 @@ files:
557
558
  - lib/samagotchi/web/public/index.html
558
559
  - lib/samagotchi/web/public/info_bar.js
559
560
  - lib/samagotchi/web/public/init_row.js
561
+ - lib/samagotchi/web/public/keep_focus.js
560
562
  - lib/samagotchi/web/public/kept_commands.js
561
563
  - lib/samagotchi/web/public/level_chip.js
562
564
  - lib/samagotchi/web/public/lists_view.js