samagotchi 0.152.0 → 0.154.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 (229) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/README.md +2 -3
  4. data/bin/chi +23 -24
  5. data/docs/architecture.md +30 -33
  6. data/docs/broadcast.md +5 -5
  7. data/docs/bundle-authoring.md +1 -1
  8. data/docs/bundles.md +93 -12
  9. data/docs/cli.md +83 -99
  10. data/docs/configuration.md +46 -40
  11. data/docs/context.md +3 -3
  12. data/docs/desktop.md +1 -1
  13. data/docs/guardrails.md +15 -15
  14. data/docs/hooks.md +13 -12
  15. data/docs/internals/anthropic.md +3 -3
  16. data/docs/internals/attached-context.md +5 -2
  17. data/docs/internals/background-tasks.md +3 -4
  18. data/docs/internals/block-renderers.md +4 -0
  19. data/docs/internals/bridge.md +3 -3
  20. data/docs/internals/context-telemetry.md +1 -1
  21. data/docs/internals/engine.md +42 -12
  22. data/docs/internals/objectives.md +6 -5
  23. data/docs/internals/participants.md +8 -8
  24. data/docs/internals/prompt-caching.md +8 -4
  25. data/docs/internals/remote-sessions.md +7 -7
  26. data/docs/internals/web-frontend.md +20 -17
  27. data/docs/internals/web-push.md +1 -1
  28. data/docs/memory.md +2 -2
  29. data/docs/plugins.md +52 -43
  30. data/docs/releasing.md +1 -1
  31. data/docs/sessions.md +25 -25
  32. data/docs/sub-agent.md +2 -2
  33. data/docs/system-one.md +1 -1
  34. data/docs/testing.md +18 -8
  35. data/lib/samagotchi/answer_text.rb +34 -0
  36. data/lib/samagotchi/bridge.rb +43 -32
  37. data/lib/samagotchi/bridge_client.rb +10 -9
  38. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  39. data/lib/samagotchi/broadcast/triage.rb +1 -1
  40. data/lib/samagotchi/broadcast_command.rb +5 -5
  41. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  42. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  43. data/lib/samagotchi/bundle_command.rb +2 -2
  44. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  45. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  46. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  48. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  49. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  50. data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
  51. data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
  52. data/lib/samagotchi/child_move.rb +3 -3
  53. data/lib/samagotchi/commands/registry.rb +6 -6
  54. data/lib/samagotchi/composer_route.rb +4 -5
  55. data/lib/samagotchi/config.rb +14 -12
  56. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  57. data/lib/samagotchi/context_candidates.rb +4 -3
  58. data/lib/samagotchi/continue_offer.rb +1 -1
  59. data/lib/samagotchi/debug_log.rb +1 -1
  60. data/lib/samagotchi/degrade.rb +75 -0
  61. data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
  62. data/lib/samagotchi/engine/cards.rb +87 -0
  63. data/lib/samagotchi/engine/context_judge.rb +138 -0
  64. data/lib/samagotchi/engine/events.rb +131 -0
  65. data/lib/samagotchi/engine/guardrails.rb +87 -0
  66. data/lib/samagotchi/engine/hooks.rb +119 -0
  67. data/lib/samagotchi/engine/messages.rb +124 -0
  68. data/lib/samagotchi/engine/model_settings.rb +127 -0
  69. data/lib/samagotchi/engine/models.rb +261 -0
  70. data/lib/samagotchi/engine/objectives.rb +116 -0
  71. data/lib/samagotchi/engine/participants.rb +86 -0
  72. data/lib/samagotchi/engine/plugins.rb +208 -0
  73. data/lib/samagotchi/engine/questions.rb +98 -0
  74. data/lib/samagotchi/engine/recap.rb +130 -0
  75. data/lib/samagotchi/engine/session.rb +131 -0
  76. data/lib/samagotchi/engine/side_requests.rb +135 -0
  77. data/lib/samagotchi/engine/steering.rb +134 -0
  78. data/lib/samagotchi/engine/toolkit.rb +269 -0
  79. data/lib/samagotchi/engine/turn_end.rb +180 -0
  80. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  81. data/lib/samagotchi/engine/verify.rb +153 -0
  82. data/lib/samagotchi/engine.rb +63 -2642
  83. data/lib/samagotchi/grants.rb +575 -0
  84. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  85. data/lib/samagotchi/guardrails/context.rb +2 -3
  86. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  87. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  88. data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
  89. data/lib/samagotchi/hook_routes.rb +3 -3
  90. data/lib/samagotchi/idle_recap.rb +4 -5
  91. data/lib/samagotchi/idle_reminders.rb +3 -4
  92. data/lib/samagotchi/iteration_limit.rb +2 -2
  93. data/lib/samagotchi/kernel_loop.rb +10 -11
  94. data/lib/samagotchi/launch_mode.rb +13 -32
  95. data/lib/samagotchi/level_setting_command.rb +3 -2
  96. data/lib/samagotchi/llm_context_command.rb +3 -2
  97. data/lib/samagotchi/llm_context_stale.rb +2 -2
  98. data/lib/samagotchi/log.rb +7 -7
  99. data/lib/samagotchi/log_line.rb +1 -1
  100. data/lib/samagotchi/log_path.rb +1 -1
  101. data/lib/samagotchi/message_parts.rb +3 -2
  102. data/lib/samagotchi/note_command.rb +1 -1
  103. data/lib/samagotchi/note_delivery.rb +18 -8
  104. data/lib/samagotchi/objectives.rb +24 -3
  105. data/lib/samagotchi/objectives_check.rb +8 -27
  106. data/lib/samagotchi/objectives_command.rb +4 -69
  107. data/lib/samagotchi/objectives_edit.rb +87 -0
  108. data/lib/samagotchi/output_formatter.rb +7 -1
  109. data/lib/samagotchi/owner_lock.rb +7 -1
  110. data/lib/samagotchi/parent_answers_command.rb +5 -4
  111. data/lib/samagotchi/participant_exposure.rb +42 -0
  112. data/lib/samagotchi/participant_mode.rb +1 -1
  113. data/lib/samagotchi/participant_name.rb +1 -1
  114. data/lib/samagotchi/participants.rb +17 -17
  115. data/lib/samagotchi/participants_watch.rb +1 -1
  116. data/lib/samagotchi/pending_input_queue.rb +2 -2
  117. data/lib/samagotchi/plugin/api.rb +3 -2
  118. data/lib/samagotchi/plugin/context.rb +21 -7
  119. data/lib/samagotchi/plugin/peer.rb +2 -1
  120. data/lib/samagotchi/plugin/sessions.rb +6 -4
  121. data/lib/samagotchi/plugin_tasks.rb +1 -2
  122. data/lib/samagotchi/private_files.rb +29 -0
  123. data/lib/samagotchi/project_env.rb +2 -2
  124. data/lib/samagotchi/question_desk.rb +8 -7
  125. data/lib/samagotchi/recap_command.rb +2 -1
  126. data/lib/samagotchi/recap_setup.rb +1 -1
  127. data/lib/samagotchi/recap_store.rb +1 -1
  128. data/lib/samagotchi/redact.rb +86 -0
  129. data/lib/samagotchi/reminder_queue.rb +2 -2
  130. data/lib/samagotchi/remote_attach_command.rb +1 -1
  131. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  132. data/lib/samagotchi/review_start.rb +1 -1
  133. data/lib/samagotchi/run_options.rb +6 -6
  134. data/lib/samagotchi/self_report.rb +42 -39
  135. data/lib/samagotchi/send_command/busy.rb +1 -1
  136. data/lib/samagotchi/send_command.rb +4 -1
  137. data/lib/samagotchi/session.rb +11 -2
  138. data/lib/samagotchi/session_access/contract.rb +3 -3
  139. data/lib/samagotchi/session_access/local.rb +9 -9
  140. data/lib/samagotchi/session_access/remote.rb +17 -7
  141. data/lib/samagotchi/session_access.rb +4 -4
  142. data/lib/samagotchi/session_archive_command.rb +2 -2
  143. data/lib/samagotchi/session_chain.rb +8 -6
  144. data/lib/samagotchi/session_commands.rb +45 -48
  145. data/lib/samagotchi/session_continue.rb +1 -1
  146. data/lib/samagotchi/session_delete_command.rb +4 -4
  147. data/lib/samagotchi/session_manager.rb +59 -36
  148. data/lib/samagotchi/session_metrics.rb +2 -2
  149. data/lib/samagotchi/session_model_settings.rb +3 -2
  150. data/lib/samagotchi/session_participants.rb +1 -1
  151. data/lib/samagotchi/session_retention.rb +3 -3
  152. data/lib/samagotchi/sessions_command.rb +4 -4
  153. data/lib/samagotchi/share_command.rb +10 -10
  154. data/lib/samagotchi/steer.rb +1 -1
  155. data/lib/samagotchi/system_prompt.rb +102 -109
  156. data/lib/samagotchi/task_watch.rb +1 -1
  157. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  158. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  159. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  160. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  161. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  162. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
  163. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  164. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  165. data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
  166. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  167. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  168. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  169. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  170. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  171. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  172. data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
  173. data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
  174. data/lib/samagotchi/terminal_ui.rb +142 -1387
  175. data/lib/samagotchi/title_command.rb +2 -2
  176. data/lib/samagotchi/tool_runner.rb +14 -16
  177. data/lib/samagotchi/toolkit.rb +8 -5
  178. data/lib/samagotchi/toolkit_binding.rb +30 -5
  179. data/lib/samagotchi/toolkit_command.rb +2 -1
  180. data/lib/samagotchi/toolkit_report_command.rb +9 -5
  181. data/lib/samagotchi/toolkit_suggest.rb +12 -6
  182. data/lib/samagotchi/tools/builtin_calls.rb +14 -10
  183. data/lib/samagotchi/tools/deferred_tools.rb +2 -2
  184. data/lib/samagotchi/tools/delegate.rb +3 -1
  185. data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
  186. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  187. data/lib/samagotchi/tools/objectives_tool.rb +2 -2
  188. data/lib/samagotchi/tools/registry.rb +44 -20
  189. data/lib/samagotchi/tools/send_note.rb +2 -1
  190. data/lib/samagotchi/tools/tool_index.rb +27 -21
  191. data/lib/samagotchi/turn_endings.rb +2 -2
  192. data/lib/samagotchi/turn_flow.rb +4 -5
  193. data/lib/samagotchi/update_hint.rb +2 -2
  194. data/lib/samagotchi/version.rb +1 -1
  195. data/lib/samagotchi/web/app.rb +39 -50
  196. data/lib/samagotchi/web/participant_access.rb +2 -2
  197. data/lib/samagotchi/web/participant_review.rb +5 -5
  198. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  199. data/lib/samagotchi/web/participant_view.rb +2 -82
  200. data/lib/samagotchi/web/participants_admin.rb +1 -1
  201. data/lib/samagotchi/web/public/app.js +61 -357
  202. data/lib/samagotchi/web/public/block_host.js +35 -8
  203. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  204. data/lib/samagotchi/web/public/chains.js +2 -2
  205. data/lib/samagotchi/web/public/claim_link.js +4 -4
  206. data/lib/samagotchi/web/public/composer_mode.js +110 -0
  207. data/lib/samagotchi/web/public/format.js +3 -3
  208. data/lib/samagotchi/web/public/owner_claim.js +81 -0
  209. data/lib/samagotchi/web/public/owner_verify.js +112 -0
  210. data/lib/samagotchi/web/public/session_fields.js +3 -3
  211. data/lib/samagotchi/web/public/strip_grip.js +3 -3
  212. data/lib/samagotchi/web/public/strip_hidden.js +65 -0
  213. data/lib/samagotchi/web/public/version_notices.js +132 -0
  214. data/lib/samagotchi/web/push/store.rb +1 -1
  215. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  216. data/lib/samagotchi/web/session_control.rb +12 -8
  217. data/lib/samagotchi/web/session_create.rb +1 -1
  218. data/lib/samagotchi/web/session_hub.rb +2 -2
  219. data/lib/samagotchi/web/session_show.rb +2 -2
  220. data/lib/samagotchi/web/session_summary.rb +1 -1
  221. data/lib/samagotchi/worker.rb +106 -9
  222. data/lib/samagotchi/worker_link.rb +3 -3
  223. data/lib/samagotchi/working_set_command.rb +3 -2
  224. metadata +36 -6
  225. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  226. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  227. data/lib/samagotchi/web/grants.rb +0 -575
  228. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  229. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -42,6 +42,11 @@ const NOT_AN_IMAGE = "the renderer's SVG can't be shown as an image";
42
42
  // this tall and scrolls sideways instead.
43
43
  const MIN_SHOWN_HEIGHT = 120;
44
44
 
45
+ // The zoom button's words: the picture fitted to the column, or at its natural size.
46
+ const ZOOM_IN = "Show at full size";
47
+ const ZOOM_OUT = "Fit to the column";
48
+
49
+ const ZOOM_ICON = html`<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="7" cy="7" r="4.25" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="m10.2 10.2 3.3 3.3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
45
50
  const ICON = html`<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M5.5 4 2 8l3.5 4M10.5 4 14 8l-3.5 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
46
51
 
47
52
  // The picture's URL and size. The XML parser reads the root, as the image will: a
@@ -286,6 +291,9 @@ export function createBlockHost({
286
291
  return Promise.race([image, bound]).finally(() => clearTimeout(timer));
287
292
  }
288
293
 
294
+ // The picture's name says where its words are: the source <pre> is hidden, so a
295
+ // screen reader reaches it through the toggle. The zoom is a button too, for the
296
+ // keyboard (a click on the picture does the same); it shows on keyboard focus only.
289
297
  function makeFigure(picture, themeName, lang) {
290
298
  const figure = doc.createElement("figure");
291
299
  figure.className = DRAWN;
@@ -294,17 +302,33 @@ export function createBlockHost({
294
302
  scroll.className = "block-scroll";
295
303
  const img = doc.createElement("img");
296
304
  img.className = "block-img";
297
- img.alt = `${lang} diagram`;
305
+ img.alt = `${lang} diagram, its source under the Show the source button`;
298
306
  showPicture(img, picture);
299
307
  scroll.append(img);
300
- const toggle = doc.createElement("button");
301
- toggle.type = "button";
302
- toggle.className = "block-toggle";
303
- litRender(ICON, toggle);
304
- figure.append(scroll, toggle);
308
+ const zoom = button("block-zoom", ZOOM_ICON);
309
+ const toggle = button("block-toggle", ICON);
310
+ figure.append(scroll, zoom, toggle);
311
+ setZoomed(figure, false);
305
312
  return figure;
306
313
  }
307
314
 
315
+ function button(className, icon) {
316
+ const el = doc.createElement("button");
317
+ el.type = "button";
318
+ el.className = className;
319
+ litRender(icon, el);
320
+ return el;
321
+ }
322
+
323
+ function setZoomed(figure, on) {
324
+ figure.classList.toggle("natural", on);
325
+ const zoom = figure.querySelector(":scope > .block-zoom");
326
+ if (!zoom) return;
327
+ zoom.setAttribute("aria-pressed", on ? "true" : "false");
328
+ zoom.setAttribute("aria-label", on ? ZOOM_OUT : ZOOM_IN);
329
+ zoom.title = on ? ZOOM_OUT : ZOOM_IN;
330
+ }
331
+
308
332
  function showPicture(img, { url, width, height }) {
309
333
  if (width && height) {
310
334
  img.setAttribute("width", width);
@@ -476,7 +500,7 @@ export function createBlockHost({
476
500
  scan(root);
477
501
 
478
502
  root.addEventListener("mousedown", (e) => {
479
- if (e.target.closest?.(".block-toggle")) e.preventDefault();
503
+ if (e.target.closest?.(".block-toggle, .block-zoom")) e.preventDefault();
480
504
  });
481
505
  // Delegated, so a Revise clone's work too.
482
506
  root.addEventListener("click", (e) => {
@@ -490,7 +514,10 @@ export function createBlockHost({
490
514
  }
491
515
  const note = e.target.closest?.(".block-note");
492
516
  if (note) { copy(note.dataset.text); return; }
493
- if (e.target.closest?.(".block-img")) e.target.closest(`.${DRAWN}`).classList.toggle("natural");
517
+ if (e.target.closest?.(".block-zoom, .block-img")) {
518
+ const figure = e.target.closest(`.${DRAWN}`);
519
+ setZoomed(figure, !figure.classList.contains("natural"));
520
+ }
494
521
  });
495
522
  }
496
523
 
@@ -1,4 +1,4 @@
1
- /* A drawn fenced block (block_host.js): the picture scales to the column, a click shows its natural size in a scroller; the toggle shows the source, in the block's place. In a .code-wrap the copy button sits top-right, so the toggle sits left of it. */
1
+ /* A drawn fenced block (block_host.js): the picture scales to the column, a click shows its natural size in a scroller; the toggle shows the source, in the block's place. In a .code-wrap the copy button sits top-right, so the toggle sits left of it. The zoom button, the keyboard's way to the natural size, shows on keyboard focus only, left of the toggle. */
2
2
  .block-drawn{position:relative;margin:0 0 12px;white-space:normal}
3
3
  .code-wrap>.block-drawn{margin:0}
4
4
  .code-wrap>.block-drawn.source{height:0}
@@ -8,11 +8,15 @@
8
8
  .block-img{display:block;max-width:100%;max-height:50000px;object-fit:contain;height:auto;margin:0 auto;cursor:zoom-in}
9
9
  .block-drawn.natural .block-img{max-width:50000px;margin:0;cursor:zoom-out}
10
10
  .block-drawn.source .block-scroll{display:none}
11
- .block-toggle{position:absolute;top:5px;right:5px;z-index:1;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:24px;padding:0 5px;border-radius:6px;background:var(--bg-raised);border:1px solid var(--border-strong);color:var(--text-3);opacity:0;transition:opacity .12s}
11
+ .block-toggle,.block-zoom{position:absolute;top:5px;right:5px;z-index:1;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:24px;padding:0 5px;border-radius:6px;background:var(--bg-raised);border:1px solid var(--border-strong);color:var(--text-3);opacity:0;transition:opacity .12s}
12
12
  .code-wrap>.block-drawn>.block-toggle{right:34px}
13
- .block-toggle svg{display:block}
14
- .block-drawn:hover>.block-toggle,.code-wrap:hover>.block-drawn>.block-toggle,.block-toggle:focus-visible,.block-drawn.source>.block-toggle{opacity:1}
15
- .block-toggle:hover:not(:disabled){filter:none}@media(hover:hover){.block-toggle:hover:not(:disabled){color:var(--text-bright)}}
13
+ .block-zoom{right:34px}
14
+ .code-wrap>.block-drawn>.block-zoom{right:63px}
15
+ .block-drawn.source>.block-zoom{display:none}
16
+ .block-zoom:not(:focus-visible){pointer-events:none}
17
+ .block-toggle svg,.block-zoom svg{display:block}
18
+ .block-drawn:hover>.block-toggle,.code-wrap:hover>.block-drawn>.block-toggle,.block-toggle:focus-visible,.block-zoom:focus-visible,.block-drawn.source>.block-toggle{opacity:1}
19
+ .block-toggle:hover:not(:disabled),.block-zoom:hover:not(:disabled){filter:none}@media(hover:hover){.block-toggle:hover:not(:disabled),.block-zoom:hover:not(:disabled){color:var(--text-bright)}}
16
20
  .block-drawn.source:not(:last-child){margin:0}
17
21
  /* Its words are the attribute's, so a bubble's text stays the answer's (block_host.js fail). */
18
22
  .block-note{margin:4px 0 12px;font-size:12px;color:var(--text-3);white-space:normal}
@@ -305,8 +305,8 @@ export function continueRefusal(error, sessions = []) {
305
305
  }
306
306
 
307
307
  // The open-children refusal as the toast shows it: the open delegates the
308
- // continue can't move to the next link (an older chi's worker, a chi
309
- // REPL), each a link to its session with what to do about it.
308
+ // continue can't move to the next link (an older chi's worker, a
309
+ // one-shot run), each a link to its session with what to do about it.
310
310
  // @param hrefFor (id) → its page's href
311
311
  export function openChildrenTemplate(prevId, children, hrefFor) {
312
312
  const n = children.length;
@@ -12,7 +12,7 @@
12
12
  // URL-safe and a space is one "+".
13
13
  //
14
14
  // Normalized: whitespace collapsed, and what the participant page shows
15
- // instead (ParticipantView::Redact): secret shapes "[redacted]" (so a link
15
+ // instead (Samagotchi::Redact): secret shapes "[redacted]" (so a link
16
16
  // never carries one, and matches the participant's redacted text), and on
17
17
  // the owner's page the session's folder relative ("<cwd>/x" → "x",
18
18
  // "<cwd>" → "."), the home folder "~". Not the owner's token (Redact's
@@ -41,10 +41,10 @@ export const HIT_CLASS = "claim-hit";
41
41
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
42
42
 
43
43
  export const MARK = "[redacted]";
44
- // ParticipantView::Redact's PATTERNS and VALUES
45
- // (lib/samagotchi/web/participant_view.rb), as JS: keep them in step.
44
+ // Samagotchi::Redact's PATTERNS and VALUES
45
+ // (lib/samagotchi/redact.rb), as JS: keep them in step.
46
46
  // spec/fixtures/redact_cases.json holds cases both sides read the same
47
- // (participant_view_spec.rb, claim_link.test.js).
47
+ // (redact_spec.rb, claim_link.test.js).
48
48
  const SECRET_PATTERNS = [
49
49
  /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g,
50
50
  /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{16,}/g,
@@ -0,0 +1,110 @@
1
+ // The composer's mode on the owner page (used by app.js, the store's
2
+ // `composer` redraw): its placeholder, key hint, action button, the attach
3
+ // button, the start page's empty state and the delivery line's row, from the
4
+ // open session, its owner and whether its turn runs.
5
+
6
+ // A phone's composer fits a few words and has no Shift+Enter: the short
7
+ // placeholder there.
8
+ export const NARROW_QUERY = "(max-width: 600px)";
9
+
10
+ // The key hint under the composer (shown while it has focus; on the start
11
+ // page only: in a session the placeholder carries it, the hint row is gone).
12
+ export const SEND_HINT = "Enter to send · Shift+Enter for a newline · ↑ history";
13
+
14
+ // What the composer shows, pure: +selected+ the open session's id (null: the
15
+ // start page), +owner+ its owner, +running+ whether its turn runs, +narrow+ a
16
+ // phone's width. While a turn runs, Send still sends: the prompt merges into
17
+ // the turn at its next step (steering); Cancel is its own button then.
18
+ // @return {placeholder, hint, button, disabled, empty, clearHistory,
19
+ // attachHidden, delivery}: +disabled+ undefined while a turn runs (the
20
+ // button keeps whatever a send in flight set); +empty+ the start page's
21
+ // empty state shown, +clearHistory+ #history emptied (the start page only);
22
+ // +delivery+ the delivery line's mode (delivery_line.js show).
23
+ export function composerMode({ selected, owner, running, narrow }) {
24
+ const session = !!selected;
25
+ const tui = session && owner === "tui";
26
+ const live = !!running && session;
27
+ const pick = (long, short) => (narrow ? short : long);
28
+ // No images for a session a terminal owns (it takes no message from here);
29
+ // on a phone none while a turn runs either: the row keeps its room for the
30
+ // message box (steering).
31
+ const attachHidden = tui || (live && !!narrow);
32
+ // The delivery line's row in a session: the key hint while idle, where a
33
+ // message goes in while the turn runs. A terminal owns the session: the
34
+ // composer is off, so is its row.
35
+ const delivery = !session || tui ? "off" : running ? "running" : "idle";
36
+ const base = { attachHidden, delivery, empty: false, clearHistory: false };
37
+ if (live) {
38
+ return {
39
+ ...base,
40
+ placeholder: pick("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…"),
41
+ hint: SEND_HINT, button: "Send", disabled: undefined,
42
+ };
43
+ }
44
+ if (!session) {
45
+ return {
46
+ ...base,
47
+ placeholder: "Ask chi anything…", hint: "Enter to start · Shift+Enter for a newline", button: "Start", disabled: false,
48
+ empty: true, clearHistory: true,
49
+ };
50
+ }
51
+ if (tui) {
52
+ return {
53
+ ...base,
54
+ placeholder: pick("A terminal chi owns this session · its one-turn run (chi --non-interactive) lets go as it ends", "A terminal chi owns this session"),
55
+ hint: "", button: "Send", disabled: true,
56
+ };
57
+ }
58
+ // The keys are in the delivery line's row above.
59
+ return { ...base, placeholder: "Message chi…", hint: SEND_HINT, button: "Send", disabled: false };
60
+ }
61
+
62
+ // The composer's mode drawn: update() is the store's `composer` redraw, and a
63
+ // window crossing the phone's width redraws it too (an input the store
64
+ // doesn't hold, with the composer as its only reader: the bar doesn't read it).
65
+ //
66
+ // +els+: {body, prompt, hint, button, attach, empty, history}. +store+: the
67
+ // page's session store (selected, current.owner; read only). +liveTurn+:
68
+ // live_turn.js's (running). +startPage+: start_page.js's (restoreStartPick,
69
+ // syncModelPick, dropChoices). +deliveryControl+: delivery_line.js's.
70
+ // +moreSheet+: more_sheet.js's (⋯ is a phone's alone). +fitPrompt()+,
71
+ // +placeToast()+: the page's. +win+: the window (matchMedia).
72
+ export function createComposerMode({
73
+ els, store, liveTurn, startPage, deliveryControl, moreSheet, fitPrompt, placeToast, win = globalThis,
74
+ }) {
75
+ const narrowScreen = win.matchMedia(NARROW_QUERY);
76
+ let hadSession = false;
77
+
78
+ function update() {
79
+ // In a session the box is one line: its minimum follows the class.
80
+ if (els.body.classList.toggle("has-session", !!store.selected) !== hadSession) {
81
+ hadSession = !hadSession;
82
+ // Back on the start page: its remembered model, not the last link's.
83
+ if (!hadSession) startPage.restoreStartPick();
84
+ fitPrompt();
85
+ placeToast();
86
+ }
87
+ startPage.syncModelPick();
88
+ // The start page's choice was for a new session: opening a session drops it.
89
+ if (store.selected) startPage.dropChoices();
90
+ const mode = composerMode({
91
+ selected: store.selected, owner: store.current.owner, running: liveTurn.running, narrow: narrowScreen.matches,
92
+ });
93
+ els.attach.hidden = mode.attachHidden;
94
+ deliveryControl.show(mode.delivery);
95
+ els.prompt.placeholder = mode.placeholder;
96
+ els.hint.textContent = mode.hint;
97
+ els.button.textContent = mode.button;
98
+ if (mode.disabled !== undefined) els.button.disabled = mode.disabled;
99
+ els.empty.classList.toggle("hidden", !mode.empty);
100
+ if (mode.clearHistory) els.history.replaceChildren();
101
+ }
102
+
103
+ narrowScreen.addEventListener("change", () => {
104
+ update();
105
+ // ⋯ is a phone's alone: a window grown past it takes its sheet along.
106
+ if (!narrowScreen.matches && moreSheet.isOpen()) moreSheet.close();
107
+ });
108
+
109
+ return { update };
110
+ }
@@ -97,13 +97,13 @@ export function deleteConfirmText(session) {
97
97
  }
98
98
 
99
99
  // The badge on a session a process holds right now (the list's `owner`):
100
- // a worker (shared: every UI can send), or a plain terminal chi (not shared).
100
+ // a worker (every UI can send), or a terminal chi's one-turn run (--non-interactive).
101
101
  export function ownerBadge(owner, id) {
102
102
  if (owner === "worker") {
103
103
  return { kind: "worker", text: "live", title: `A worker runs this session; a terminal joins with: chi --attach ${id}` };
104
104
  }
105
105
  if (owner === "tui") {
106
- return { kind: "tui", text: "terminal", title: "A terminal chi owns this session and doesn't share it (chi --shared would)" };
106
+ return { kind: "tui", text: "terminal", title: "A terminal chi runs one turn in this session (chi --non-interactive) and lets go as it ends" };
107
107
  }
108
108
  return null;
109
109
  }
@@ -171,7 +171,7 @@ export function childrenSummary(parent, sessions) {
171
171
 
172
172
  // Whether the info bar offers "stop": only while a worker runs the session
173
173
  // (the list says so, or this page streams from one a send just woke). A
174
- // stopped or idle-exited session has nothing to stop, and a chi REPL's is
174
+ // stopped or idle-exited session has nothing to stop, and a one-turn run's is
175
175
  // refused by the server.
176
176
  export function canStopSession({ owner, streaming }) {
177
177
  if (owner === "tui") return false;
@@ -0,0 +1,81 @@
1
+ import { ownerClaimHash, participantClaimLink, selectionClaim, showClaim, turnIdOf } from "./claim_link.js";
2
+ import { sessionHash } from "./route.js";
3
+
4
+ // Where a claim's text can be on the owner's page: answers, own messages, steps.
5
+ export const CLAIM_ROOTS = ".bubble.output, .bubble.user .user-message, .gen-text";
6
+
7
+ // The owner page's claim links (used by app.js; claim_link.js does the text
8
+ // and DOM work): a claim link opened on this page (#/s/<id>?t=…&q=…, or the
9
+ // participant's #s=<id>&t=… opened in this browser) shown once its session is
10
+ // drawn, and annotate's "Copy link" for a selection. The participant page has
11
+ // its own copies on purpose (docs/internals/web-frontend.md, Two pages).
12
+ //
13
+ // +historyEl+: #history. +store+: the page's session store (selected,
14
+ // current.dir, sessions; read only). +claimTarget()+: participants_panel.js's
15
+ // (the people a link would open for, or null). +toast(text, action,
16
+ // options)+, +copyText(text)+ (copy.js), +allViewOpen()+: the page's.
17
+ // +home+: the home folder the page shows as ~ (body data-home). +win+: the
18
+ // window (location, history).
19
+ export function createOwnerClaim({
20
+ historyEl, store, claimTarget, toast, copyText, allViewOpen, home = "", win = globalThis,
21
+ }) {
22
+ // A claim link waiting for its session to be drawn (selectFromHash sets it).
23
+ let pendingClaim = null;
24
+
25
+ // How the participant page shows this page's text (claim_link.js).
26
+ function opts() {
27
+ return { home, cwd: store.current.dir || "" };
28
+ }
29
+
30
+ function pending(claim) {
31
+ pendingClaim = claim;
32
+ }
33
+
34
+ // The claim link's quote scrolled to and marked, its text read as the
35
+ // participant page shows it (the session's folder relative, home ~); a
36
+ // quote not found marks its turn; neither found: the top, and a toast.
37
+ // The hash is the session's again (select() already set it when it
38
+ // switched), so the same link opened again is a hashchange.
39
+ function showPending() {
40
+ const claim = pendingClaim;
41
+ if (!claim || claim.sessionId !== store.selected) return;
42
+ pendingClaim = null;
43
+ const { location, history } = win;
44
+ if (!allViewOpen() && location.hash !== sessionHash(store.selected)) {
45
+ history.replaceState(null, "", location.pathname + location.search + sessionHash(store.selected));
46
+ }
47
+ if (showClaim(historyEl, claim, { roots: CLAIM_ROOTS, opts: opts() })) return;
48
+ historyEl.scrollTop = 0;
49
+ toast("The linked turn isn't in this session.");
50
+ }
51
+
52
+ // Annotate's "Copy link" for a selection in a turn's answer, step or own
53
+ // message: the participant page's link when the session has a person
54
+ // active (it opens in the browser their review link was opened in: one
55
+ // link per browser, the cookie), else this page's own.
56
+ function linkFor(source, range) {
57
+ // Not in a Revise stack's switched copy (revise_switch.js: view only, no claim key; Verify isn't offered there either).
58
+ if (!store.selected || !["answer", "step", "user"].includes(source.kind) || !historyEl.contains(source.root)
59
+ || source.root.closest("[data-stack-view]")) return null;
60
+ const turnId = turnIdOf(historyEl, source.root);
61
+ if (!turnId) return null;
62
+ const claim = { turnId, ...selectionClaim(source.root, range, opts()) };
63
+ if (!claim.q) return null;
64
+ const target = claimTarget();
65
+ if (!target) {
66
+ const { location } = win;
67
+ return { url: `${location.origin}${location.pathname}${location.search}${ownerClaimHash(store.selected, claim)}`,
68
+ title: "A link to this on your page (no person is invited to this session)" };
69
+ }
70
+ const whose = target.names.map((name) => `${name}'s`).join(" or ");
71
+ return { url: participantClaimLink(target.base, store.selected, claim),
72
+ title: `A link to this for ${whose} browser: it opens where their review link was opened (one link per browser). Opened here, it shows your page.` };
73
+ }
74
+
75
+ async function copyLink({ url }) {
76
+ if (await copyText(url)) toast("Link copied");
77
+ else toast(`Couldn't copy: ${url}`, null, { sticky: true });
78
+ }
79
+
80
+ return { opts, pending, showPending, linkFor, copyLink };
81
+ }
@@ -0,0 +1,112 @@
1
+ import { selectionClaim } from "./claim_link.js";
2
+ import { CLAIM_ROOTS } from "./owner_claim.js";
3
+ import { authorBadge } from "./participant_names.js";
4
+ import { sessionHash } from "./route.js";
5
+ import { CHECK_NOT_ON_PAGE, ownerCheckHash } from "./verdict_card.js";
6
+ import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
7
+ import { VERIFY_UNAVAILABLE, verifyWorkerUnavailable } from "./verify_preset.js";
8
+ import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
9
+
10
+ // The owner page's Verify (used by app.js): its verdict cards in the history
11
+ // (a live verdict, a session's saved ones), Verify fresh on an unclear
12
+ // verdict's card (verdict_fresh.js), Verify's claim from a selection, and
13
+ // the composer's armed claim (anchor/set/clear: the one holder; the next
14
+ // send makes it a Verify turn).
15
+ // The participant page has its own copies on purpose
16
+ // (docs/internals/web-frontend.md, Two pages).
17
+ //
18
+ // +historyEl+: #history. +store+: the page's session store (it writes
19
+ // current.verdicts and current.verifyFresh through store.update; reads
20
+ // selected, sessions, workerFeatures, bridgeUp). +api(path, opts)+: data.js's.
21
+ // +claim+: owner_claim.js's (its opts()). +liveTurn+: live_turn.js's (whether
22
+ // a turn runs, its id). +toast(text)+: the page's. +win+: the window (a
23
+ // verdict's check in another session opens by its hash).
24
+ export function createOwnerVerify({ historyEl, store, api, claim, liveTurn, toast, win = globalThis }) {
25
+ // Verify fresh's failed starts, by check (verdict_fresh.js keeps them here).
26
+ const freshErrors = {};
27
+ // The composer's armed Verify claim ({turn_id, quote}) or null: set by the
28
+ // Verify preset, a restored Verify turn's draft; let go by an emptied
29
+ // composer, another session, the send's ack.
30
+ let armed = null;
31
+
32
+ function anchor() {
33
+ return armed;
34
+ }
35
+
36
+ function set(next) {
37
+ armed = next;
38
+ }
39
+
40
+ // clear() lets go of whatever is armed; clear(a) only of +a+ (a send's ack
41
+ // for the claim it took, not one armed since).
42
+ function clear(...only) {
43
+ if (only.length && only[0] !== armed) return;
44
+ armed = null;
45
+ }
46
+
47
+ // A verdict ({check_turn_id, verdict, fresh?, by?…}) onto its check's
48
+ // card: into the session's list once, and a fresh one ends its check's
49
+ // "verifying fresh".
50
+ function placeVerdict(record) {
51
+ if (!record) return;
52
+ const known = (store.current.verdicts || []).some((item) => item.check_turn_id === record.check_turn_id);
53
+ const patch = {};
54
+ if (!known) patch.verdicts = [...(store.current.verdicts || []), record];
55
+ // A fresh verdict ends its check's "verifying fresh" (the card shows it).
56
+ if (record.fresh) patch.verifyFresh = settleRunning(store.current.verifyFresh, record);
57
+ store.update({ current: patch });
58
+ const badge = record.by && authorBadge(record.by);
59
+ placeVerdictWithFresh(historyEl, record, {
60
+ who: badge ? badge.name : "You",
61
+ author: record.by || null,
62
+ roots: CLAIM_ROOTS,
63
+ opts: claim.opts(),
64
+ onMissingCheck: (missing) => {
65
+ const hash = ownerCheckHash(store.sessions, store.selected, missing.check_turn_id);
66
+ if (hash) win.location.hash = hash;
67
+ else toast(CHECK_NOT_ON_PAGE);
68
+ },
69
+ }, freshState());
70
+ }
71
+
72
+ // The owner's Verify fresh on an unclear verdict's card (verdict_fresh.js):
73
+ // always theirs to start; its running check links to the child's session.
74
+ function freshState() {
75
+ const id = store.selected;
76
+ const unavailable = verifyWorkerUnavailable(store.current.workerFeatures, store.current.bridgeUp);
77
+ return {
78
+ verdicts: store.current.verdicts || [],
79
+ running: store.current.verifyFresh || [],
80
+ errors: freshErrors,
81
+ canStart: !!id && !unavailable,
82
+ unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
83
+ start: async (of) => {
84
+ try {
85
+ const body = await api(freshURL(id, of), { method: "POST", body: "{}" });
86
+ return { ok: true, child_id: body.child_id || null };
87
+ } catch (error) {
88
+ return { ok: false, error: error.message };
89
+ }
90
+ },
91
+ childHref: (childId) => sessionHash(childId),
92
+ };
93
+ }
94
+
95
+ // The session's saved verdicts, onto the history just drawn.
96
+ function renderSaved() {
97
+ for (const record of store.current.verdicts || []) placeVerdict(record);
98
+ }
99
+
100
+ // Verify's claim: a selection in an ended turn's final answer (the only
101
+ // text Engine#verified_anchor takes; verify_target.js), else null and no
102
+ // Verify on the bar.
103
+ function anchorFor({ source, range }) {
104
+ if (source?.kind !== "answer" || !range) return null;
105
+ const running = liveTurn.running ? { turnId: liveTurn.timing.turnId || null } : null;
106
+ const turnId = verifyTurnOf(historyEl, source.root, OWNER_FINAL, { running });
107
+ const quote = turnId ? selectionClaim(source.root, range, claim.opts()).q : "";
108
+ return turnId && quote ? { turn_id: turnId, quote } : null;
109
+ }
110
+
111
+ return { placeVerdict, freshState, renderSaved, anchorFor, anchor, set, clear };
112
+ }
@@ -10,7 +10,7 @@
10
10
  // - live_timing.js sets `timing().activeTurn` in place (start, finish,
11
11
  // finishLine): the running turn's own clock, which mergeTurnTiming keeps.
12
12
  // - verdict_fresh.js `startFresh` pushes onto `fresh.running`, which is
13
- // `verifyFresh` (verifyFreshState hands the array out): returning the
13
+ // `verifyFresh` (owner_verify.js freshState hands the array out): returning the
14
14
  // entry for an update would leave the card holding the old array.
15
15
  //
16
16
  // A new module: the page's modules are cached at unversioned URLs, and a
@@ -46,12 +46,12 @@ export const FIELDS = {
46
46
  verdicts: {
47
47
  source: (d) => list(d.session?.verdicts),
48
48
  redraws: [],
49
- writers: "placeVerdict (its card draws itself: placeVerdictWithFresh)",
49
+ writers: "owner_verify.js placeVerdict (its card draws itself: placeVerdictWithFresh)",
50
50
  },
51
51
  verifyFresh: {
52
52
  source: (d) => list(d.session?.verify_fresh),
53
53
  redraws: [],
54
- writers: "placeVerdict (settleRunning); verdict_fresh.js startFresh pushes in place (D8)",
54
+ writers: "owner_verify.js placeVerdict (settleRunning); verdict_fresh.js startFresh pushes in place (D8)",
55
55
  },
56
56
  createdAt: { redraws: ["infoBar"], writers: "the payload" },
57
57
  ctxPct: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_chunk, context_status)" },
@@ -1,15 +1,15 @@
1
1
  import { GRIP_OPEN_PX } from "./composer_size.js";
2
2
  import { stripGripAction, stripGripTitle } from "./strip.js";
3
3
 
4
- // The latest-sessions strip's grip (used by app.js): the handle on the edge
4
+ // The latest-sessions strip's grip (used by strip_hidden.js): the handle on the edge
5
5
  // between the strip and the chat, the composer's grip's twin, mirrored. A
6
6
  // drag up (or a tap) hides the strip, a drag down (or a tap) shows it, and
7
7
  // Enter or Space toggles too. The strip snaps: unlike the composer's box it
8
8
  // does not follow the pointer, so nothing is measured while dragging — only
9
9
  // whether the release would act (.will-toggle, the pill in the accent).
10
10
  // The grip owns its title and aria-expanded (composer_grip.js's
11
- // drawFoldControls does the same for the composer's): app.js calls the
12
- // returned refresh() from applyStripHidden, and nothing else writes them.
11
+ // drawFoldControls does the same for the composer's): strip_hidden.js calls
12
+ // the returned refresh() from its apply, and nothing else writes them.
13
13
  // No click listener: a click still fires after pointerdown's preventDefault
14
14
  // and would toggle twice. No document listener either: the composer's grip
15
15
  // needs one for typing on the page, this one acts only on itself.
@@ -0,0 +1,65 @@
1
+ import { readStored, writeStored } from "./storage.js";
2
+ import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
3
+ import { createStripGrip } from "./strip_grip.js";
4
+
5
+ // The saved choice (storage.js): the strip hidden; chi_zen is zen mode's old
6
+ // name, read when the new key was never written.
7
+ export const STRIP_HIDDEN_KEY = "chi_strip_hidden";
8
+ const ZEN_KEY = "chi_zen";
9
+
10
+ // The latest-sessions strip hidden or shown (used by app.js): the body's
11
+ // strip-hidden class, the grip (strip_grip.js) that toggles it, and the short
12
+ // window's rule. The strip can be hidden for more chat room; the choice stays
13
+ // per browser (it replaced zen mode, so a zen user starts hidden). A short
14
+ // window hides the strip by itself, so the stage keeps room for a card that
15
+ // waits (its question and choices); the grip brings it back until the window
16
+ // is tall again. Not saved: the saved choice is the user's own hide or show.
17
+ //
18
+ // +gripEl+: #stripGrip. +body+: the page's body (the class). +onApply()+:
19
+ // after each change (the top bar's title follows: app.js updateSessionTitle).
20
+ // +closeFamilyPop()+: a hide takes the strip's family popover along. +win+:
21
+ // the window (matchMedia). +storage+: storage.js's guarded read and write.
22
+ // Applied once at creation.
23
+ export function createStripHidden({
24
+ gripEl, body = globalThis.document.body, onApply, closeFamilyPop, win = globalThis,
25
+ storage = { read: readStored, write: writeStored },
26
+ }) {
27
+ const shortWindow = win.matchMedia(SHORT_WINDOW_QUERY);
28
+ let shownWhileShort = false;
29
+
30
+ function saved() {
31
+ const value = storage.read(STRIP_HIDDEN_KEY);
32
+ return value === null ? storage.read(ZEN_KEY) === "1" : value === "1";
33
+ }
34
+
35
+ // The grip is given the hidden state and the toggle; it speaks its own
36
+ // title and aria-expanded, refreshed after every change.
37
+ const gripRefresh = createStripGrip({
38
+ gripEl,
39
+ isHidden: () => body.classList.contains("strip-hidden"),
40
+ setHidden: (hidden) => setHidden(hidden),
41
+ });
42
+
43
+ function apply() {
44
+ body.classList.toggle("strip-hidden", stripAutoHidden({
45
+ saved: saved(), short: shortWindow.matches, shownWhileShort,
46
+ }));
47
+ gripRefresh();
48
+ onApply();
49
+ }
50
+
51
+ function setHidden(hidden) {
52
+ if (hidden) closeFamilyPop();
53
+ storage.write(STRIP_HIDDEN_KEY, hidden ? "1" : "0");
54
+ shownWhileShort = !hidden && shortWindow.matches;
55
+ apply();
56
+ }
57
+
58
+ apply();
59
+ shortWindow.addEventListener("change", () => {
60
+ if (!shortWindow.matches) shownWhileShort = false;
61
+ apply();
62
+ });
63
+
64
+ return { setHidden };
65
+ }