samagotchi 0.154.1 → 0.156.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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +1 -1
  4. data/docs/architecture.md +12 -7
  5. data/docs/bundles.md +21 -0
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +2 -2
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/anthropic.md +5 -5
  10. data/docs/internals/attached-context.md +9 -6
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/block-renderers.md +8 -1
  13. data/docs/internals/engine.md +642 -831
  14. data/docs/internals/inline-widgets.md +136 -0
  15. data/docs/internals/llm-context-forget.md +8 -3
  16. data/docs/internals/objectives.md +8 -6
  17. data/docs/internals/participants.md +5 -5
  18. data/docs/internals/prompt-caching.md +13 -6
  19. data/docs/internals/remote-sessions.md +3 -1
  20. data/docs/internals/web-frontend.md +1 -1
  21. data/docs/internals/working-set.md +2 -1
  22. data/docs/plugins.md +2 -0
  23. data/docs/releasing.md +1 -1
  24. data/docs/sessions.md +4 -4
  25. data/docs/system-one.md +3 -2
  26. data/docs/testing.md +1 -1
  27. data/lib/samagotchi/block_renderers.rb +5 -0
  28. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  29. data/lib/samagotchi/broadcast_command.rb +3 -3
  30. data/lib/samagotchi/bundle_command/check.rb +1 -1
  31. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  33. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  34. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/child_move.rb +7 -51
  38. data/lib/samagotchi/children_status.rb +1 -1
  39. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  40. data/lib/samagotchi/config.rb +17 -112
  41. data/lib/samagotchi/context_command.rb +1 -2
  42. data/lib/samagotchi/context_window.rb +3 -3
  43. data/lib/samagotchi/desktop/macos.rb +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +21 -119
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/models.rb +5 -2
  48. data/lib/samagotchi/engine/objectives.rb +30 -80
  49. data/lib/samagotchi/engine/participants.rb +15 -10
  50. data/lib/samagotchi/engine/plugins.rb +3 -1
  51. data/lib/samagotchi/engine/questions.rb +1 -1
  52. data/lib/samagotchi/engine/side_requests.rb +26 -13
  53. data/lib/samagotchi/engine/toolkit.rb +35 -191
  54. data/lib/samagotchi/engine/turn_end.rb +1 -1
  55. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  56. data/lib/samagotchi/engine/verify.rb +7 -143
  57. data/lib/samagotchi/engine.rb +166 -98
  58. data/lib/samagotchi/engine_peers.rb +1 -1
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +28 -11
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/host_config.rb +72 -1
  67. data/lib/samagotchi/host_model.rb +2 -6
  68. data/lib/samagotchi/host_registry.rb +1 -1
  69. data/lib/samagotchi/inline_widgets.rb +325 -0
  70. data/lib/samagotchi/kernel_loop.rb +215 -313
  71. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  72. data/lib/samagotchi/llm/backend.rb +3 -1
  73. data/lib/samagotchi/llm/chat_loop.rb +87 -49
  74. data/lib/samagotchi/llm/native_backend.rb +3 -2
  75. data/lib/samagotchi/llm_context_apply.rb +54 -1
  76. data/lib/samagotchi/llm_context_forget.rb +5 -3
  77. data/lib/samagotchi/llm_context_notice.rb +1 -1
  78. data/lib/samagotchi/llm_context_strategy.rb +7 -9
  79. data/lib/samagotchi/llm_context_view.rb +9 -0
  80. data/lib/samagotchi/model_binding.rb +3 -3
  81. data/lib/samagotchi/model_price.rb +4 -10
  82. data/lib/samagotchi/model_profile.rb +6 -6
  83. data/lib/samagotchi/model_settings.rb +37 -2
  84. data/lib/samagotchi/note_command.rb +1 -2
  85. data/lib/samagotchi/objectives_audit.rb +4 -3
  86. data/lib/samagotchi/participants.rb +2 -2
  87. data/lib/samagotchi/plugin/context.rb +1 -1
  88. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  89. data/lib/samagotchi/plugin/sessions.rb +2 -5
  90. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  91. data/lib/samagotchi/project_env.rb +1 -1
  92. data/lib/samagotchi/question_desk.rb +1 -1
  93. data/lib/samagotchi/reasoning_history.rb +3 -3
  94. data/lib/samagotchi/reminder_carry.rb +1 -1
  95. data/lib/samagotchi/reprefill_meter.rb +45 -0
  96. data/lib/samagotchi/review_command.rb +1 -1
  97. data/lib/samagotchi/revise_anchor.rb +1 -1
  98. data/lib/samagotchi/sampling_settings.rb +3 -3
  99. data/lib/samagotchi/self_report.rb +7 -7
  100. data/lib/samagotchi/session.rb +50 -7
  101. data/lib/samagotchi/session_access/local.rb +7 -5
  102. data/lib/samagotchi/session_access/remote.rb +17 -3
  103. data/lib/samagotchi/session_archive_command.rb +2 -2
  104. data/lib/samagotchi/session_chain.rb +7 -6
  105. data/lib/samagotchi/session_continue.rb +6 -5
  106. data/lib/samagotchi/session_delete_command.rb +1 -1
  107. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  108. data/lib/samagotchi/session_manager/emptiness.rb +138 -0
  109. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  110. data/lib/samagotchi/session_manager/listing.rb +189 -0
  111. data/lib/samagotchi/session_manager/removal.rb +190 -0
  112. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  113. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  114. data/lib/samagotchi/session_manager.rb +26 -1540
  115. data/lib/samagotchi/session_metrics.rb +1 -1
  116. data/lib/samagotchi/sessions_command.rb +1 -1
  117. data/lib/samagotchi/share_command.rb +1 -1
  118. data/lib/samagotchi/starting_marker.rb +65 -0
  119. data/lib/samagotchi/steer_cut_after.rb +3 -3
  120. data/lib/samagotchi/system_one/recorder.rb +49 -0
  121. data/lib/samagotchi/system_prompt.rb +37 -30
  122. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  123. data/lib/samagotchi/terminal_ui.rb +2 -6
  124. data/lib/samagotchi/testing/boundary.rb +7 -0
  125. data/lib/samagotchi/testing/bundle.rb +7 -5
  126. data/lib/samagotchi/testing/install.rb +3 -2
  127. data/lib/samagotchi/testing/sessions.rb +1 -1
  128. data/lib/samagotchi/thinking.rb +3 -3
  129. data/lib/samagotchi/token_usage.rb +1 -1
  130. data/lib/samagotchi/tool_activity.rb +15 -7
  131. data/lib/samagotchi/tool_declarations.rb +6 -6
  132. data/lib/samagotchi/tool_output_cut.rb +17 -0
  133. data/lib/samagotchi/tool_response.rb +4 -3
  134. data/lib/samagotchi/tool_runner.rb +96 -102
  135. data/lib/samagotchi/toolkit_binding.rb +40 -14
  136. data/lib/samagotchi/toolkit_command.rb +1 -1
  137. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  138. data/lib/samagotchi/tools/delegate.rb +10 -5
  139. data/lib/samagotchi/tools/delegate_result.rb +4 -4
  140. data/lib/samagotchi/tools/index_hint.rb +14 -0
  141. data/lib/samagotchi/tools/registry.rb +64 -44
  142. data/lib/samagotchi/tools/send_note.rb +4 -6
  143. data/lib/samagotchi/tools/tool_index.rb +150 -102
  144. data/lib/samagotchi/tools/tool_surface.rb +187 -0
  145. data/lib/samagotchi/turn_context.rb +68 -0
  146. data/lib/samagotchi/turn_stages/context_judge.rb +172 -0
  147. data/lib/samagotchi/turn_stages/objectives.rb +150 -0
  148. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +257 -0
  149. data/lib/samagotchi/turn_stages/verify_anchors.rb +168 -0
  150. data/lib/samagotchi/turn_stages/warmup.rb +82 -0
  151. data/lib/samagotchi/turn_state.rb +46 -4
  152. data/lib/samagotchi/version.rb +1 -1
  153. data/lib/samagotchi/vision_support.rb +3 -3
  154. data/lib/samagotchi/web/app.rb +15 -8
  155. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  156. data/lib/samagotchi/web/message_display.rb +1 -1
  157. data/lib/samagotchi/web/participant_access.rb +2 -2
  158. data/lib/samagotchi/web/public/app.js +5 -1
  159. data/lib/samagotchi/web/public/block_host.js +74 -199
  160. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  161. data/lib/samagotchi/web/public/block_source.js +7 -4
  162. data/lib/samagotchi/web/public/block_watch.js +256 -0
  163. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  164. data/lib/samagotchi/web/public/index.html +28 -9
  165. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  166. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  167. data/lib/samagotchi/web/public/participant.js +1 -1
  168. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  169. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  170. data/lib/samagotchi/web/public/text_roots.js +1 -1
  171. data/lib/samagotchi/web/public/verify_target.js +1 -1
  172. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  173. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  174. data/lib/samagotchi/web/public/widgets.css +70 -0
  175. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  176. data/lib/samagotchi/web/session_control.rb +3 -3
  177. data/lib/samagotchi/web/session_create.rb +3 -3
  178. data/lib/samagotchi/web/session_hub.rb +1 -1
  179. data/lib/samagotchi/web/session_show.rb +3 -3
  180. data/lib/samagotchi/web/session_summary.rb +3 -4
  181. data/lib/samagotchi/worker.rb +55 -27
  182. data/lib/samagotchi/worker_boot.rb +184 -0
  183. data/lib/samagotchi/worker_signals.rb +1 -1
  184. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  185. metadata +29 -1
@@ -0,0 +1,130 @@
1
+ // A chi-ui picks (docs/internals/inline-widgets.md): rows of things to decide,
2
+ // each with its options as chips, the recommended one marked and picked to
3
+ // start with, a "+ note" per row, and one "Fill reply" that puts the picks in
4
+ // the composer as plain text ("1: fix now (today) · D4: B"). It never sends:
5
+ // the composer text is the widget's only way out, and the owner reads it
6
+ // before sending. Ported from eval 0013's viewer.
7
+ //
8
+ // The data is P1a's normalised JSON (InlineWidgets). Its strings are the
9
+ // model's: they go in as textContent or attributes only, never markup. The
10
+ // picks live here, in the widget: a rewrite of the bubble starts over.
11
+ //
12
+ // A module of its own: static JS is cached at unversioned URLs.
13
+
14
+ // What a row starts with: its recommended option picked, no note.
15
+ export function initialPicks(rows) {
16
+ return (rows || []).map((r) => ({ pick: r.recommended != null ? String(r.recommended) : null, note: "" }));
17
+ }
18
+
19
+ // The reply the picks make: "id: option (note)" per picked row, " · " between
20
+ // them; a row with no pick is left out.
21
+ export function replyText(rows, picks) {
22
+ return (rows || [])
23
+ .map((r, k) => {
24
+ const { pick, note } = picks[k] || {};
25
+ if (!pick) return null;
26
+ const said = String(note ?? "").replace(/\s+/g, " ").trim();
27
+ return `${r.id ?? k + 1}: ${pick}${said ? ` (${said})` : ""}`;
28
+ })
29
+ .filter(Boolean)
30
+ .join(" · ");
31
+ }
32
+
33
+ // The composer's text with +reply+: after a draft on a line of its own, the
34
+ // draft kept as typed.
35
+ export function withDraft(draft, reply) {
36
+ const typed = String(draft ?? "").trimEnd();
37
+ return typed ? `${typed}\n${reply}` : reply;
38
+ }
39
+
40
+ function el(doc, tag, className, text) {
41
+ const node = doc.createElement(tag);
42
+ if (className) node.className = className;
43
+ if (text != null) node.textContent = String(text);
44
+ return node;
45
+ }
46
+
47
+ function button(doc, className, text) {
48
+ const node = el(doc, "button", className, text);
49
+ node.type = "button";
50
+ return node;
51
+ }
52
+
53
+ // The picks as an element, for a widget host's shadow root.
54
+ // @param fill (reply) => puts +reply+ in the composer (never sends)
55
+ export function drawPicks(picks, { fill, doc = document }) {
56
+ const rows = picks.rows || [];
57
+ const state = initialPicks(rows);
58
+
59
+ const box = el(doc, "div", "w picks");
60
+ box.setAttribute("role", "group");
61
+ box.setAttribute("aria-label", `Picks: ${picks.title || "choices"}`);
62
+ const head = el(doc, "div", "w-head");
63
+ const count = el(doc, "span", "w-count");
64
+ head.append(el(doc, "span", "w-kind", "Picks"), el(doc, "span", "w-title", picks.title || ""), count);
65
+ const body = el(doc, "div", "w-body");
66
+ box.append(head, body);
67
+
68
+ const list = el(doc, "div", "wp-list");
69
+ const chips = rows.map((r, k) => {
70
+ const id = String(r.id ?? k + 1);
71
+ const row = el(doc, "div", "wp-row");
72
+ const text = el(doc, "div", "wp-text");
73
+ text.append(el(doc, "div", null, r.text || ""));
74
+ if (r.why) text.append(el(doc, "div", "wp-why", r.why));
75
+ const add = button(doc, "wp-addnote", "+ note");
76
+ add.addEventListener("click", () => {
77
+ const input = el(doc, "input", "wp-note");
78
+ input.type = "text";
79
+ input.placeholder = "a word for this row";
80
+ input.setAttribute("aria-label", `Note for ${id}`);
81
+ input.addEventListener("input", () => { state[k].note = input.value; });
82
+ add.replaceWith(input);
83
+ input.focus();
84
+ });
85
+ text.append(add);
86
+ const opts = el(doc, "div", "wp-opts");
87
+ opts.setAttribute("role", "group");
88
+ opts.setAttribute("aria-label", `Options for ${id}`);
89
+ const own = (r.options || []).map((option) => {
90
+ const o = String(option);
91
+ const b = button(doc, "wp-opt", o);
92
+ if (r.recommended != null && String(r.recommended) === o) {
93
+ b.append(el(doc, "span", "rec", "rec"));
94
+ b.title = "recommended";
95
+ }
96
+ b.addEventListener("click", () => {
97
+ state[k].pick = state[k].pick === o ? null : o;
98
+ paint();
99
+ });
100
+ opts.append(b);
101
+ return [o, b];
102
+ });
103
+ row.append(el(doc, "span", "wp-id", id), text, opts);
104
+ list.append(row);
105
+ return own;
106
+ });
107
+
108
+ const foot = el(doc, "div", "wp-foot");
109
+ const fillBtn = button(doc, "wp-fill", "Fill reply");
110
+ fillBtn.title = "Put the picks in the composer; you send it";
111
+ foot.append(fillBtn, el(doc, "span", "hint", "fills the composer, never sends"));
112
+ body.append(list, foot);
113
+
114
+ fillBtn.addEventListener("click", () => {
115
+ const reply = replyText(rows, state);
116
+ if (reply) fill(reply);
117
+ });
118
+
119
+ function paint() {
120
+ chips.forEach((own, k) => {
121
+ for (const [o, b] of own) b.setAttribute("aria-pressed", String(state[k].pick === o));
122
+ });
123
+ const picked = state.filter((s) => s.pick).length;
124
+ count.textContent = `${picked} of ${rows.length} picked`;
125
+ fillBtn.disabled = picked === 0;
126
+ }
127
+
128
+ paint();
129
+ return box;
130
+ }
@@ -0,0 +1,230 @@
1
+ // A chi-ui walk (docs/internals/inline-widgets.md): a scenario stepped through
2
+ // one step at a time. On top a swimlane, one column per actor and one row per
3
+ // step (the current, the seen and a case's target marked; a click jumps); under
4
+ // it the step's card (the actor, what it does, "You see", the dashed "Behind
5
+ // it"); a fork asks "Which case?" instead of Next; Back, Next and the path
6
+ // taken, and the arrow keys while the widget has the focus. Ported from eval
7
+ // 0013's viewer.
8
+ //
9
+ // The data is P1a's normalised JSON (InlineWidgets): ids are strings, every
10
+ // next and goto names a step, round 1's branches are cases. Its strings are
11
+ // the model's: they go in as textContent or attributes only, never markup.
12
+ // The position lives here, in the widget: a rewrite of the bubble starts over.
13
+ //
14
+ // A module of its own: static JS is cached at unversioned URLs.
15
+
16
+ // A case that is no condition: round 1 wrote the usual path as "always".
17
+ export const ALWAYS = /^(always|otherwise|then|next)$/i;
18
+ export const USUAL = "otherwise (the usual path)";
19
+ // A step's `next: end` (InlineWidgets::END_NEXT): the walk ends there, a
20
+ // branch that ends early.
21
+ export const END = "end";
22
+
23
+ const isAlways = (when) => ALWAYS.test(String(when ?? "").trim());
24
+
25
+ // Where each step of +walk+ goes, by index.
26
+ // - nextOf(k): the step after k when it isn't a fork: its next, else a case
27
+ // that is no condition, else the step below; null at the end, and at a
28
+ // step whose next is `end`.
29
+ // - casesOf(k): a fork's cases, [{when, to}] (to null when the goto names no
30
+ // step), or null when k is no fork. A case that is no condition reads as
31
+ // the usual path, and a step that names a next as well offers it first when
32
+ // no case goes there (round 1's next + branches).
33
+ export function walkRoutes(walk) {
34
+ const steps = walk?.steps || [];
35
+ const byId = new Map(steps.map((s, k) => [String(s.id), k]));
36
+ const idx = (id) => (id == null ? null : byId.get(String(id)) ?? null);
37
+ const casesIn = (k) => (Array.isArray(steps[k]?.cases) ? steps[k].cases : []);
38
+ const isFork = (k) => casesIn(k).some((c) => !isAlways(c.when));
39
+
40
+ function nextOf(k) {
41
+ const step = steps[k];
42
+ if (!step || step.next === END) return null;
43
+ const named = idx(step.next);
44
+ if (named != null) return named;
45
+ const always = casesIn(k).find((c) => isAlways(c.when) && idx(c.goto) != null);
46
+ if (always && !isFork(k)) return idx(always.goto);
47
+ return k + 1 < steps.length ? k + 1 : null;
48
+ }
49
+
50
+ function casesOf(k) {
51
+ if (!isFork(k)) return null;
52
+ const cases = casesIn(k).map((c) => ({ when: isAlways(c.when) ? USUAL : String(c.when ?? ""), to: idx(c.goto) }));
53
+ const named = idx(steps[k].next);
54
+ if (named != null && !cases.some((c) => c.to === named)) cases.unshift({ when: USUAL, to: named });
55
+ return cases;
56
+ }
57
+
58
+ return { steps, nextOf, casesOf };
59
+ }
60
+
61
+ // The swimlane's cells: one per step, in its actor's column (1-based, after
62
+ // the number column) and its own row (after the actors' row), with a tag that
63
+ // says where it leads when that isn't the step below: "N cases", "→ n", "end".
64
+ export function swimlaneCells(walk, routes = walkRoutes(walk)) {
65
+ const actors = (walk?.actors || []).map(String);
66
+ const { steps, nextOf, casesOf } = routes;
67
+ return steps.map((s, k) => {
68
+ const cases = casesOf(k);
69
+ const next = nextOf(k);
70
+ let tag = null;
71
+ if (cases) tag = { text: `${cases.length} cases`, fork: true };
72
+ else if (next != null && next !== k + 1) tag = { text: `→ ${next + 1}`, fork: false };
73
+ else if (next == null && k < steps.length - 1) tag = { text: "end", fork: false };
74
+ return { k, column: Math.max(0, actors.indexOf(String(s.actor))) + 2, row: k + 2, text: String(s.does || s.sees || ""), tag };
75
+ });
76
+ }
77
+
78
+ // Where the walker is: the path taken (step indexes) and the steps seen.
79
+ // next() at the end starts over.
80
+ export function createWalker(routes) {
81
+ let path = [0];
82
+ const seen = new Set([0]);
83
+ return {
84
+ get at() { return path[path.length - 1]; },
85
+ get path() { return [...path]; },
86
+ seen: (k) => seen.has(k),
87
+ go(k) {
88
+ if (k == null || !routes.steps[k] || k === this.at) return;
89
+ path.push(k);
90
+ seen.add(k);
91
+ },
92
+ back() { if (path.length > 1) path.pop(); },
93
+ next() {
94
+ const n = routes.nextOf(this.at);
95
+ if (n != null) { this.go(n); return; }
96
+ path = [0];
97
+ seen.clear();
98
+ seen.add(0);
99
+ },
100
+ };
101
+ }
102
+
103
+ // The path's words under the card, empty before the first move.
104
+ export function trailText(path) {
105
+ return path.length > 1 ? `path ${path.map((k) => k + 1).join(" → ")}` : "";
106
+ }
107
+
108
+ function el(doc, tag, className, text) {
109
+ const node = doc.createElement(tag);
110
+ if (className) node.className = className;
111
+ if (text != null) node.textContent = String(text);
112
+ return node;
113
+ }
114
+
115
+ function button(doc, className, text) {
116
+ const node = el(doc, "button", className, text);
117
+ node.type = "button";
118
+ return node;
119
+ }
120
+
121
+ // The walk as an element, for a widget host's shadow root.
122
+ export function drawWalk(walk, doc = document) {
123
+ const routes = walkRoutes(walk);
124
+ const { steps, nextOf, casesOf } = routes;
125
+ const actors = (walk.actors || []).map(String);
126
+ const walker = createWalker(routes);
127
+
128
+ const box = el(doc, "div", "w walk");
129
+ box.tabIndex = 0;
130
+ box.setAttribute("role", "group");
131
+ box.setAttribute("aria-label", `Walk: ${walk.title || "a scenario"}`);
132
+ const head = el(doc, "div", "w-head");
133
+ const count = el(doc, "span", "w-count");
134
+ head.append(el(doc, "span", "w-kind", "Walk"), el(doc, "span", "w-title", walk.title || ""), count);
135
+ const body = el(doc, "div", "w-body");
136
+ box.append(head, body);
137
+
138
+ const lane = el(doc, "div", "ww-swim");
139
+ lane.style.setProperty("--actors", String(actors.length));
140
+ lane.append(el(doc, "span"));
141
+ for (const actor of actors) lane.append(el(doc, "span", "ww-swim-actor", actor));
142
+ const cells = swimlaneCells(walk, routes).map((cell) => {
143
+ const num = el(doc, "span", "ww-swim-num", cell.k + 1);
144
+ num.style.gridRow = String(cell.row);
145
+ const node = button(doc, "ww-cell");
146
+ node.style.gridColumn = String(cell.column);
147
+ node.style.gridRow = String(cell.row);
148
+ node.append(el(doc, "span", "ww-cell-t", cell.text));
149
+ if (cell.tag) node.append(el(doc, "span", cell.tag.fork ? "ww-tag fork" : "ww-tag", cell.tag.text));
150
+ const s = steps[cell.k];
151
+ node.title = `${s.does || ""}${s.does && s.sees ? "\n" : ""}${s.sees ? `sees: ${s.sees}` : ""}`;
152
+ node.setAttribute("aria-label", `Step ${cell.k + 1}, ${s.actor}: ${cell.text}`);
153
+ node.addEventListener("click", () => { walker.go(cell.k); render(); });
154
+ lane.append(num, node);
155
+ return node;
156
+ });
157
+
158
+ const card = el(doc, "div", "ww-step");
159
+ card.setAttribute("aria-live", "polite");
160
+ const nav = el(doc, "div", "ww-nav");
161
+ const prev = button(doc, "btn", "← Back");
162
+ const next = button(doc, "btn primary", "Next →");
163
+ const trail = el(doc, "span", "ww-trail");
164
+ nav.append(prev, trail, next);
165
+ body.append(lane, card, nav);
166
+
167
+ prev.addEventListener("click", () => { walker.back(); render(); });
168
+ next.addEventListener("click", () => { walker.next(); render(); });
169
+ box.addEventListener("keydown", (e) => {
170
+ if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
171
+ if (e.key === "ArrowRight" && !next.hidden) { e.preventDefault(); next.click(); }
172
+ if (e.key === "ArrowLeft" && !prev.disabled) { e.preventDefault(); prev.click(); }
173
+ });
174
+
175
+ const peek = (k, on) => cells[k]?.classList.toggle("peek", on);
176
+
177
+ function pane(className, label, text) {
178
+ const node = el(doc, "div", `ww-pane ${className}`);
179
+ node.append(el(doc, "span", "eyebrow", label), el(doc, "span", null, text));
180
+ return node;
181
+ }
182
+
183
+ // A keyboard move can remove, hide or disable the focused button (a case,
184
+ // Next at a fork, Back at the start): the focus stays in the widget.
185
+ function render() {
186
+ const focused = box.getRootNode()?.activeElement;
187
+ const hadFocus = !!focused && box.contains(focused);
188
+ const k = walker.at;
189
+ const s = steps[k] || {};
190
+ card.replaceChildren();
191
+ const top = el(doc, "div", "ww-step-top");
192
+ top.append(el(doc, "span", "ww-actor", s.actor || ""), el(doc, "span", "ww-stepno", `${k + 1} / ${steps.length}`));
193
+ card.append(top);
194
+ if (s.does) card.append(el(doc, "div", "ww-does", s.does));
195
+ if (s.sees) card.append(pane("ww-sees", "You see", s.sees));
196
+ if (s.behind) card.append(pane("ww-behind", "Behind it", s.behind));
197
+ const cases = casesOf(k);
198
+ if (cases) {
199
+ const fork = el(doc, "div", "ww-fork");
200
+ fork.append(el(doc, "span", "eyebrow", "Which case?"));
201
+ for (const c of cases) {
202
+ const b = button(doc, "ww-case");
203
+ b.append(el(doc, "span", null, c.when), el(doc, "span", "ww-case-to", c.to != null ? `step ${c.to + 1} →` : "?"));
204
+ b.disabled = c.to == null;
205
+ b.addEventListener("click", () => { walker.go(c.to); render(); });
206
+ for (const [type, on] of [["mouseenter", true], ["mouseleave", false], ["focus", true], ["blur", false]]) {
207
+ b.addEventListener(type, () => peek(c.to, on));
208
+ }
209
+ fork.append(b);
210
+ }
211
+ card.append(fork);
212
+ }
213
+ cells.forEach((cell, j) => {
214
+ cell.classList.toggle("cur", j === k);
215
+ cell.classList.toggle("seen", walker.seen(j) && j !== k);
216
+ cell.classList.remove("peek");
217
+ if (j === k) cell.setAttribute("aria-current", "step");
218
+ else cell.removeAttribute("aria-current");
219
+ });
220
+ count.textContent = `step ${k + 1} of ${steps.length}`;
221
+ prev.disabled = walker.path.length < 2;
222
+ next.hidden = !!cases;
223
+ next.textContent = nextOf(k) == null ? "Start over" : "Next →";
224
+ trail.textContent = trailText(walker.path);
225
+ if (hadFocus && (!focused.isConnected || focused.hidden || focused.disabled)) box.focus({ preventScroll: true });
226
+ }
227
+
228
+ render();
229
+ return box;
230
+ }
@@ -0,0 +1,70 @@
1
+ /* A chi-ui widget (inline_widgets.js), inside its host's shadow root: chi's :root tokens (tokens.css) reach it as inherited custom properties, so light and dark follow the page. Ported from eval 0013's viewer. At a phone's width the swimlane scrolls sideways inside the widget; the page never does. */
2
+ :host{display:block;min-width:0}
3
+ *{box-sizing:border-box}
4
+ [hidden]{display:none!important}
5
+ button{font:inherit;color:inherit;cursor:pointer;margin:0}
6
+ button:disabled{cursor:default}
7
+ button:focus-visible,input:focus-visible,.w:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
8
+ .eyebrow{font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted)}
9
+
10
+ /* The frame both share. The head leaves room on the right for the figure's source toggle and a code block's copy button (block_renderers.css). */
11
+ .w{border:1px solid var(--accent-border);border-radius:10px;background:var(--bg-inset);overflow:hidden;font-size:14px;line-height:1.5;color:var(--text-2)}
12
+ .w-head{display:flex;gap:8px;align-items:baseline;padding:8px 68px 8px 12px;border-bottom:1px solid var(--border);flex-wrap:wrap}
13
+ .w-kind{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);font-weight:600}
14
+ .w-title{font-weight:600;color:var(--text-bright);min-width:0;overflow-wrap:anywhere}
15
+ .w-count{margin-left:auto;color:var(--text-muted);font-size:12px;font-variant-numeric:tabular-nums}
16
+ .w-body{padding:12px;display:grid;gap:12px;min-width:0}
17
+
18
+ /* walk */
19
+ .ww-swim{display:grid;grid-template-columns:22px repeat(var(--actors,2),minmax(84px,1fr));gap:3px 6px;align-items:stretch;font-size:12px;overflow-x:auto;padding-bottom:2px}
20
+ .ww-swim-actor{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted);text-align:center;padding-bottom:2px;border-bottom:1px solid var(--border-strong);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
21
+ .ww-swim-num{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-faint);font-size:11px;align-self:center;text-align:right}
22
+ .ww-cell{display:flex;gap:6px;align-items:center;justify-content:space-between;text-align:left;min-width:0;border:1px solid var(--border);background:var(--bg-panel);border-radius:6px;padding:3px 8px;color:var(--text-3);transition:background .15s,border-color .15s}
23
+ .ww-cell:hover{border-color:var(--border-strong)}
24
+ .ww-cell-t{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
25
+ .ww-cell.seen{border-color:var(--accent-border);color:var(--text-2)}
26
+ .ww-cell.cur{background:var(--accent);border-color:var(--accent);color:var(--on-solid)}
27
+ .ww-cell.peek{border-color:var(--warn);box-shadow:0 0 0 2px var(--warn-bg)}
28
+ .ww-tag{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;color:var(--text-faint)}
29
+ .ww-tag.fork{color:var(--warn)}
30
+ .ww-cell.cur .ww-tag{color:var(--on-solid);opacity:.85}
31
+ .ww-step{display:grid;gap:8px;min-height:120px;border-top:1px solid var(--border);padding-top:12px;align-content:start}
32
+ .ww-step-top{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
33
+ .ww-actor{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--accent);font-size:12px}
34
+ .ww-stepno{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-faint);font-size:11px}
35
+ .ww-does{font-size:16px;color:var(--text-bright);font-weight:500;text-wrap:pretty;overflow-wrap:anywhere}
36
+ .ww-pane{border-radius:8px;padding:8px 10px;display:grid;gap:2px;overflow-wrap:anywhere}
37
+ .ww-sees{background:var(--bg-panel);border:1px solid var(--border);color:var(--text)}
38
+ .ww-behind{background:transparent;border:1px dashed var(--border-strong);color:var(--text-3);font-size:13px}
39
+ .ww-fork{display:grid;gap:6px}
40
+ .ww-case{display:flex;gap:10px;justify-content:space-between;align-items:center;text-align:left;border:1px solid var(--border-strong);background:var(--bg-panel);border-radius:8px;padding:7px 10px;overflow-wrap:anywhere}
41
+ .ww-case:hover:not(:disabled),.ww-case:focus-visible{border-color:var(--warn)}
42
+ .ww-case:disabled{opacity:.5}
43
+ .ww-case-to{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--warn)}
44
+ .ww-nav{display:flex;gap:10px;align-items:center;justify-content:space-between}
45
+ .ww-nav .btn{flex:none;border:1px solid var(--border-strong);background:var(--bg-panel);border-radius:8px;padding:4px 12px}
46
+ .ww-nav .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-solid)}
47
+ .ww-nav .btn:disabled{opacity:.4}
48
+ .ww-trail{font-size:12px;color:var(--text-faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;overflow-wrap:anywhere;text-align:center;flex:1;min-width:0}
49
+ @media (prefers-reduced-motion:reduce){.ww-cell{transition:none}}
50
+
51
+ /* picks */
52
+ .wp-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;align-items:start;padding:8px 0;border-bottom:1px solid var(--border)}
53
+ .wp-row:first-child{padding-top:0}
54
+ .wp-row:last-child{border-bottom:0}
55
+ .wp-id{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-muted);padding-top:2px;min-width:24px}
56
+ .wp-text{color:var(--text);min-width:0;overflow-wrap:anywhere}
57
+ .wp-why{color:var(--text-muted);font-size:12px}
58
+ .wp-note{display:block;margin-top:4px;width:100%;background:var(--bg-panel);border:1px solid var(--border-strong);border-radius:6px;color:var(--text);padding:3px 6px;font:inherit;font-size:12px}
59
+ .wp-opts{display:flex;flex-wrap:wrap;gap:4px;justify-content:flex-end;max-width:320px}
60
+ .wp-opt{border:1px solid var(--border-strong);background:var(--bg-panel);border-radius:6px;padding:2px 10px;font-size:12px;overflow-wrap:anywhere;text-align:left}
61
+ .wp-opt:hover{border-color:var(--accent-border)}
62
+ .wp-opt[aria-pressed="true"]{background:var(--accent);border-color:var(--accent);color:var(--on-solid)}
63
+ .wp-opt .rec{font-size:9px;letter-spacing:.06em;text-transform:uppercase;margin-left:5px;opacity:.75}
64
+ .wp-addnote{background:none;border:0;color:var(--text-faint);font-size:12px;padding:2px 0}
65
+ .wp-addnote:hover{color:var(--text-2)}
66
+ .wp-foot{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
67
+ .wp-fill{background:var(--accent);border:1px solid var(--accent);color:var(--on-solid);border-radius:8px;padding:4px 14px}
68
+ .wp-fill:disabled{opacity:.4}
69
+ .hint{color:var(--text-faint);font-size:12px}
70
+ @media (max-width:560px){.wp-row{grid-template-columns:auto minmax(0,1fr)}.wp-opts{grid-column:2;justify-content:flex-start;max-width:none}}
@@ -78,14 +78,14 @@ module Samagotchi
78
78
  # 404 not_found; 400 ambiguous_id with ids (the sessions it names).
79
79
  def handle_resolve(req)
80
80
  given = req.params["id"].to_s
81
- return error_response(404, "not_found", "no session #{given[0, 80]}") unless Session.valid_id?(given)
81
+ return session_not_found(given) unless Session.valid_id?(given)
82
82
  return participant_resolve(principal(req).grant, given) unless principal(req).owner?
83
83
 
84
84
  json_response(200, { id: session_access.resolve(given) })
85
85
  rescue Session::AmbiguousId => e
86
86
  refusal_response(400, "ambiguous_id", e, ids: e.ids)
87
- rescue ArgumentError
88
- error_response(404, "not_found", "no session #{given[0, 80]}")
87
+ rescue ArgumentError # NotFound, a file that won't load
88
+ session_not_found(given)
89
89
  end
90
90
 
91
91
  # GET /api/sessions/:id?brief=1: the session as a session command
@@ -47,7 +47,7 @@ module Samagotchi
47
47
 
48
48
  def handle_question_answer(req, id)
49
49
  session = @session_class.load(id, state_dir: default_state_dir) rescue nil
50
- return error_response(404, "not_found", "Session not found: #{id}") unless session
50
+ return session_not_found(id) unless session
51
51
 
52
52
  body = parse_json(request_body(req))
53
53
  return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
@@ -239,7 +239,7 @@ module Samagotchi
239
239
  return [] if raw.nil?
240
240
 
241
241
  session_dir = image_session_dir(id)
242
- return "unknown session" unless session_dir
242
+ return Session::NotFound.new(id).message unless session_dir
243
243
 
244
244
  ImageStore.check_refs(session_dir, raw)
245
245
  end
@@ -259,7 +259,7 @@ module Samagotchi
259
259
  # (converted, downscaled); answers the ref the turn then names.
260
260
  def handle_image_upload(req, id)
261
261
  session_dir = image_session_dir(id)
262
- return error_response(404, "not_found", "unknown session") unless session_dir
262
+ return session_not_found(id) unless session_dir
263
263
 
264
264
  length = req.content_length.to_i
265
265
  if length > MAX_IMAGE_UPLOAD_BYTES
@@ -140,7 +140,7 @@ module Samagotchi
140
140
  end
141
141
 
142
142
  # POST /api/sessions with continues: <id, prefix or last:id>: the next
143
- # link of that session's chain (SessionManager.continue_session), in its
143
+ # link of that session's chain (SessionContinue.run), in its
144
144
  # folder and with its own setup (SessionSetup: llm_context, thinking
145
145
  # and reasoning_history levels, settings), so dir, llm_context, thinking and settings are
146
146
  # refused beside it (a create takes no reasoning_history). It runs on
@@ -328,11 +328,11 @@ module Samagotchi
328
328
  [level, nil]
329
329
  end
330
330
 
331
- # SessionManager.continue_session, its refusals as responses: [session,
331
+ # SessionContinue.run, its refusals as responses: [session,
332
332
  # nil] or [nil, the response]. Only its own errors: what fails after
333
333
  # the new link started is not a refusal of it.
334
334
  def continue_from(given, prompt:, preview:, model:)
335
- [@manager.continue_session(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir), nil]
335
+ [SessionContinue.run(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir), nil]
336
336
  rescue SessionContinue::Refused => e
337
337
  extra = e.reason == :continued ? { next_id: e.ids.first } : {}
338
338
  [nil, refusal_response(409, e.reason.to_s, e, session_id: e.session_id, reason: e.reason.to_s, ids: e.ids, **extra)]
@@ -412,7 +412,7 @@ module Samagotchi
412
412
  # read is mtime-cached), so a host added or a window changed while
413
413
  # chi web runs is picked up on the next refresh of a card.
414
414
  def hub_registry
415
- hosts = ConfigFile.hosts_config
415
+ hosts = HostConfig.all
416
416
  if @hub_registry.nil? || hosts != @hub_registry_hosts
417
417
  @hub_registry = HostRegistry.new(hosts_config: hosts)
418
418
  @hub_registry_hosts = hosts
@@ -184,7 +184,7 @@ module Samagotchi
184
184
  # cards from its light GET tail, else the saved ones; the session file
185
185
  # is only stat'ed (the 404), never parsed.
186
186
  def handle_cards_read(id)
187
- return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
187
+ return session_not_found(id) unless session_exists?(id)
188
188
 
189
189
  tail = live_tail(id)
190
190
  json_response(200, { cards: cards_for_display(live_or_saved(tail, "cards") { saved_cards(id) }) })
@@ -201,7 +201,7 @@ module Samagotchi
201
201
  # are the newest turn's (#recent_timing), which the page merges by id
202
202
  # (the page still sends ?recent=1, which is accepted and not needed).
203
203
  def handle_tail_read(req, id)
204
- return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
204
+ return session_not_found(id) unless session_exists?(id)
205
205
 
206
206
  turn_id = req.params["turn_id"].to_s.strip
207
207
  turn_id = nil if turn_id.empty?
@@ -259,7 +259,7 @@ module Samagotchi
259
259
  # the live ones) and turn_count, when the page's merge comes up short.
260
260
  # The worker's GET state, never its messages.
261
261
  def handle_timing_read(id)
262
- return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
262
+ return session_not_found(id) unless session_exists?(id)
263
263
 
264
264
  state = bridge_get_json(id, "state")
265
265
  json_response(200, { timing: timing_payload(id, live_metrics: state && state["session_state_snapshot"]&.fetch("metrics", nil)) })
@@ -146,13 +146,12 @@ module Samagotchi
146
146
  private_class_method :budget_resolver
147
147
 
148
148
  # status is turn state (idle/running). The live worker's snapshot is the
149
- # truth; on disk, a "running" with no live owner was left by a worker
150
- # that died mid-turn.
149
+ # truth; on disk, Session#displayed_status (a "running" with no live
150
+ # owner was left by a worker that died mid-turn).
151
151
  def displayed_status(session, snapshot = nil, owner:)
152
152
  return snapshot["status"] if snapshot.is_a?(Hash) && snapshot["status"]
153
- return session.status unless session.status == Session::STATUS_RUNNING
154
153
 
155
- owner ? session.status : Session::STATUS_IDLE
154
+ session.displayed_status(owner: owner)
156
155
  end
157
156
 
158
157
  def first_preview_for(session)