samagotchi 0.119.0 → 0.121.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/bin/chi +6 -1
  4. data/docs/architecture.md +35 -5
  5. data/docs/cli.md +33 -8
  6. data/docs/configuration.md +74 -20
  7. data/docs/internals/participants.md +111 -12
  8. data/docs/internals/prompt-caching.md +7 -6
  9. data/docs/internals/remote-sessions.md +6 -1
  10. data/docs/internals/web-push.md +4 -2
  11. data/docs/plugins.md +24 -8
  12. data/docs/sessions.md +4 -3
  13. data/docs/testing.md +13 -2
  14. data/lib/samagotchi/answer_command.rb +2 -0
  15. data/lib/samagotchi/bridge.rb +56 -13
  16. data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
  17. data/lib/samagotchi/bridge_client.rb +58 -19
  18. data/lib/samagotchi/bridge_secret.rb +35 -0
  19. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/config.rb +3 -0
  26. data/lib/samagotchi/engine.rb +1 -0
  27. data/lib/samagotchi/kernel_loop.rb +5 -0
  28. data/lib/samagotchi/participant_messages.rb +5 -0
  29. data/lib/samagotchi/participants.rb +16 -6
  30. data/lib/samagotchi/plugin/api.rb +6 -3
  31. data/lib/samagotchi/review_command.rb +3 -1
  32. data/lib/samagotchi/run_options.rb +3 -1
  33. data/lib/samagotchi/session_access/contract.rb +4 -1
  34. data/lib/samagotchi/session_access/local.rb +15 -4
  35. data/lib/samagotchi/session_access/remote.rb +43 -7
  36. data/lib/samagotchi/session_access.rb +11 -1
  37. data/lib/samagotchi/session_manager.rb +1 -1
  38. data/lib/samagotchi/sessions_command.rb +1 -2
  39. data/lib/samagotchi/share_command.rb +29 -8
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
  41. data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  43. data/lib/samagotchi/tool_declarations.rb +1 -1
  44. data/lib/samagotchi/tool_runner.rb +37 -1
  45. data/lib/samagotchi/toolkit.rb +50 -26
  46. data/lib/samagotchi/toolkit_binding.rb +32 -0
  47. data/lib/samagotchi/toolkit_command.rb +23 -0
  48. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  49. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  50. data/lib/samagotchi/tools/registry.rb +20 -6
  51. data/lib/samagotchi/tools/tool_index.rb +103 -20
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +117 -43
  54. data/lib/samagotchi/web/grants.rb +127 -16
  55. data/lib/samagotchi/web/link_codes.rb +24 -15
  56. data/lib/samagotchi/web/participant_access.rb +69 -13
  57. data/lib/samagotchi/web/participant_exposure.rb +44 -0
  58. data/lib/samagotchi/web/participant_review.rb +71 -9
  59. data/lib/samagotchi/web/participants_admin.rb +12 -6
  60. data/lib/samagotchi/web/proxy_gate.rb +18 -3
  61. data/lib/samagotchi/web/public/annotate_bar.css +18 -0
  62. data/lib/samagotchi/web/public/annotate_bar.js +10 -3
  63. data/lib/samagotchi/web/public/app.js +66 -155
  64. data/lib/samagotchi/web/public/composer.css +73 -0
  65. data/lib/samagotchi/web/public/composer_grip.js +21 -15
  66. data/lib/samagotchi/web/public/composer_mount.js +196 -0
  67. data/lib/samagotchi/web/public/forms.css +12 -0
  68. data/lib/samagotchi/web/public/index.html +14 -88
  69. data/lib/samagotchi/web/public/level_chip.js +2 -2
  70. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  71. data/lib/samagotchi/web/public/notifications.js +58 -8
  72. data/lib/samagotchi/web/public/participant.html +46 -10
  73. data/lib/samagotchi/web/public/participant.js +101 -18
  74. data/lib/samagotchi/web/public/participant_annotate.js +75 -0
  75. data/lib/samagotchi/web/public/participant_model.js +53 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +37 -13
  77. data/lib/samagotchi/web/public/question_card.js +15 -1
  78. data/lib/samagotchi/web/public/question_cards.js +11 -2
  79. data/lib/samagotchi/web/public/review_history.js +60 -0
  80. data/lib/samagotchi/web/public/scope.js +12 -8
  81. data/lib/samagotchi/web/public/stage_frame.js +9 -4
  82. data/lib/samagotchi/web/public/stage_view.js +4 -4
  83. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  84. data/lib/samagotchi/web/push/notifier.rb +17 -3
  85. data/lib/samagotchi/web/running.rb +7 -4
  86. data/lib/samagotchi/web/server.rb +34 -11
  87. data/lib/samagotchi/web/session_create.rb +7 -7
  88. data/lib/samagotchi/web/start_dir.rb +26 -0
  89. data/lib/samagotchi/web/tunnel.rb +10 -1
  90. data/lib/samagotchi/worker_sidecar.rb +22 -11
  91. metadata +10 -1
@@ -6,10 +6,13 @@
6
6
  <link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
7
7
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
8
8
  <link rel="stylesheet" href="/assets/tokens.css">
9
+ <link rel="stylesheet" href="/assets/annotate_bar.css">
10
+ <link rel="stylesheet" href="/assets/composer.css">
11
+ <link rel="stylesheet" href="/assets/forms.css">
9
12
  <style>
10
13
  /* The participant page (participant.js): the thread with the composer pinned, and for a review session its subjects
11
14
  (the Plan): beside the thread on a wide screen, a Plan | Thread toggle on a narrow one (the composer pinned in both).
12
- The owner's participant view is the same page with no composer. Colours are tokens.css's. */
15
+ The owner's participant view is the same page with no composer (nothing mounted, the dock hidden). Colours are tokens.css's. */
13
16
  *{box-sizing:border-box;margin:0;padding:0}
14
17
  /* No iOS text autosizing (index.html's twin). */
15
18
  html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
@@ -107,9 +110,29 @@ main{flex:1;min-height:0;overflow-y:auto;padding:16px 16px 8px}
107
110
  .p-queued span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
108
111
  .p-queue-note{font-size:12.5px;color:var(--text-muted)}
109
112
  .p-error{font-size:13px;color:var(--danger-text);background:var(--danger-bg-dim);border-radius:8px;padding:6px 10px}
110
- .p-compose{display:flex;gap:8px;align-items:flex-end}
111
- .p-compose textarea{flex:1;resize:none;min-height:42px;max-height:40vh;font:inherit;color:var(--text);background:var(--bg-inset);border:1px solid var(--border-strong);border-radius:10px;padding:9px 12px}
112
- .p-compose textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-ring)}
113
+ /* The comment box: the shared composer (composer.css, composer_mount.js), as the owner's session composer lays it out
114
+ (participant.js sets body.has-session for a participant: one row, the text field and Send at its bottom right). Its
115
+ grip straddles the dock's top border; folded, the dock keeps the queue and the grip as the handle. No `/` list,
116
+ delivery line or model picker here. */
117
+ #composer{background:var(--bg-inset);border:1px solid var(--border-strong);border-radius:12px}
118
+ #composer:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-ring)}
119
+ body.has-session #composer{padding:6px 6px 6px 10px}
120
+ #composer textarea{font:inherit;line-height:1.45;padding:2px 0}
121
+ #composer textarea:disabled{cursor:not-allowed}
122
+ #composer .actions-col #send{height:30px;min-height:30px;line-height:30px;padding:0 16px;font-weight:600}
123
+ .p-dock #composeGrip{margin:-15px 0 5px}
124
+ body.composer-folded .p-hint{display:none}
125
+ /* The participant's own usage (participant_model.js usageItems: the fields their grant's exposure shows), one line
126
+ beside the hint; a phone shows it alone, one line, the end cut. Folded, it goes with the hint. */
127
+ .p-foot{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 14px;min-width:0}
128
+ .p-foot .p-hint{flex:1 1 320px;min-width:0}
129
+ .p-usage{display:flex;align-items:baseline;min-width:0;max-width:100%;font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
130
+ .p-usage span{flex:0 0 auto}
131
+ .p-usage span+span::before{content:"·";padding:0 6px;color:var(--text-faint)}
132
+ .p-usage span.model{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
133
+ .p-usage .warn{color:var(--warn-text)}
134
+ .p-usage .over{color:var(--danger-text);font-weight:600}
135
+ body.composer-folded .p-foot{display:none}
113
136
  button{font:inherit;cursor:pointer;border-radius:8px;border:1px solid var(--border-strong);background:var(--bg-raised);color:var(--text);padding:6px 12px}
114
137
  button:hover{background:var(--bg-hover)}
115
138
  button.primary{background:var(--primary);border-color:var(--primary);color:var(--on-solid);height:42px;padding:0 18px}
@@ -129,7 +152,9 @@ button.small{font-size:12px;padding:2px 8px}
129
152
  .p-hint,.p-note{display:none}
130
153
  }
131
154
  /* Under 16px iOS Safari zooms the page on focus (and a Home Screen app never zooms back). */
132
- @media (max-width:600px),(pointer:coarse){.p-compose textarea{font-size:16px}}
155
+ @media (max-width:600px),(pointer:coarse){#composer textarea{font-size:16px}}
156
+ /* A phone's grip (composer.css: 26 px tall, the pill at 17 px): the pill on the dock's top border. */
157
+ @media (max-width:600px){.p-dock #composeGrip{margin:-29px auto 3px}}
133
158
  </style>
134
159
  </head>
135
160
  <body>
@@ -157,14 +182,25 @@ button.small{font-size:12px;padding:2px 8px}
157
182
  <div class="p-thread" id="thread"></div>
158
183
  </main>
159
184
  <footer class="p-dock" id="dock" hidden>
185
+ <div id="composeGrip" class="edge-grip" tabindex="0" aria-label="Comment box" title="Drag to make the comment box taller (double-click resets)"></div>
160
186
  <div class="p-dock-in">
161
187
  <div class="p-queue" id="queue"></div>
162
188
  <div class="p-error" id="error" role="alert" hidden></div>
163
- <form class="p-compose" id="compose">
164
- <textarea id="comment" rows="1" placeholder="Comment or ask" aria-label="Comment or ask"></textarea>
165
- <button class="primary" id="send" type="submit">Send</button>
166
- </form>
167
- <div class="p-hint">⏎ sends, ⇧⏎ a new line. The model answers in a read-only turn; the owner sees everything.</div>
189
+ <div id="composer">
190
+ <div class="compose-col">
191
+ <div id="chips" hidden></div>
192
+ <textarea id="comment" rows="1" placeholder="Comment or ask" aria-label="Comment or ask"></textarea>
193
+ </div>
194
+ <div class="actions-col">
195
+ <button type="button" id="attachBtn" class="attach-btn" title="Add images" aria-label="Add images" hidden></button>
196
+ <input type="file" id="attachInput" accept="image/*" multiple hidden>
197
+ <div class="split"><button type="button" class="primary" id="send">Send</button></div>
198
+ </div>
199
+ </div>
200
+ <div class="p-foot">
201
+ <div class="p-usage" id="usage" aria-label="Your usage" hidden></div>
202
+ <div class="p-hint">⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. The model answers in a read-only turn; the owner sees everything.</div>
203
+ </div>
168
204
  </div>
169
205
  </footer>
170
206
  </div>
@@ -4,14 +4,27 @@
4
4
  // GET /api/sessions/:id/review (the data) and the filtered stream; the
5
5
  // page re-reads its data at every turn boundary and draws the turn in
6
6
  // progress from the stream in between. A participant's comment is
7
- // POST …/turn (text only); their queued ones come back with Dequeue.
7
+ // POST …/turn (text only, always queued: a participant never steers); their
8
+ // queued ones come back with Dequeue. The comment box is the owner's
9
+ // composer (composer_mount.js) with a subset on: its height, grip and fold,
10
+ // ↑/↓ over this grant's own comments (review_history.js); no `/` list, no
11
+ // delivery choices, no images yet. Under it, the participant's own usage
12
+ // (spend vs cap, queue, link expiry, model). The owner's participant view
13
+ // mounts none.
8
14
  import {
9
15
  authorColor, participantAuthor, authorLabel, toolLine, toolsSummary, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
10
- liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries, threadGaps,
16
+ liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries, threadGaps, usageItems,
11
17
  } from "./participant_model.js";
12
18
  import { fullTime } from "./format.js";
13
19
  import { createAuthLost, lostAnswer } from "./auth_lost.js";
14
20
  import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
21
+ import { createAnnotateBar } from "./annotate_bar.js";
22
+ import { parsePresets } from "./annotate_presets.js";
23
+ import { appendQuote } from "./annotations.js";
24
+ import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
25
+ import { mountComposer } from "./composer_mount.js";
26
+ import { PROMPT_LINE_PX } from "./composer_size.js";
27
+ import { forgetReviewHistory, readReviewHistory, recordReviewComment, rememberReviewHistory } from "./review_history.js";
15
28
 
16
29
  // A link's #code= opened in a browser that already has a cookie: traded
17
30
  // first, the page reloads without it.
@@ -35,6 +48,7 @@ let liveStale = false;
35
48
  let source = null;
36
49
  let reloadTimer = null;
37
50
  let retryTimer = null;
51
+ let usageTimer = null;
38
52
  // Reads of the review that failed in a row (the retry's backoff), and the
39
53
  // latest read asked for (an older one answering after it is dropped).
40
54
  let failures = 0;
@@ -80,6 +94,10 @@ function showEnded(json) {
80
94
  if (ended) return;
81
95
  ended = true;
82
96
  source?.close();
97
+ clearTimeout(usageTimer);
98
+ usageTimer = null;
99
+ // The link is over: its comments' ↑/↓ list goes with it.
100
+ forgetReviewHistory(data?.viewer?.id, ownerView ? null : sessionId);
83
101
  for (const id of ["head", "main", "dock", "plan"]) $(id).hidden = true;
84
102
  $("endedText").textContent = endedText(json);
85
103
  $("ended").hidden = false;
@@ -108,6 +126,7 @@ async function load() {
108
126
  failures = 0;
109
127
  $("offline").hidden = true;
110
128
  data = json;
129
+ if (!ownerView) rememberReviewHistory(sessionId, data?.viewer?.id);
111
130
  live = liveFrom(data.current_turn);
112
131
  liveStale = false;
113
132
  render();
@@ -218,6 +237,8 @@ function renderPlan() {
218
237
  }));
219
238
  $("planPath").textContent = subject.path;
220
239
  const doc = $("planDoc");
240
+ // An annotation's label names it (participant_annotate.js).
241
+ doc.dataset.path = subject.path;
221
242
  const shown = subjectBody(subject);
222
243
  doc.className = `p-doc${shown.kind === "html" ? "" : " plain"}`;
223
244
  // The server's rendered markdown (MarkdownRenderer: raw HTML escaped,
@@ -247,6 +268,8 @@ function messageNode(message) {
247
268
  if (when) node.title = when;
248
269
  if (user) {
249
270
  node.style.setProperty("--author-color", authorColor(message.author));
271
+ // Whose it is, for an annotation's label (participant_annotate.js).
272
+ Object.assign(node.dataset, messageAuthorData(message));
250
273
  const who = el("div", "p-who");
251
274
  who.append(el("b", "", authorLabel(message)));
252
275
  if (message.merged) who.append(el("span", "p-tag", "added mid-turn"));
@@ -403,8 +426,29 @@ function renderDock() {
403
426
  const send = $("send");
404
427
  const comment = $("comment");
405
428
  comment.disabled = !viewer.can_comment;
406
- send.disabled = !viewer.can_comment;
429
+ send.disabled = !viewer.can_comment || sending;
407
430
  comment.placeholder = viewer.cap_reached ? "Your spending cap is reached: ask the owner to raise it" : "Comment or ask";
431
+ renderUsage(viewer.usage);
432
+ }
433
+
434
+ // The participant's own usage, one line under the comment box (only what
435
+ // their grant's exposure shows; the server sends nothing else). Spent and
436
+ // queued change on re-reads; the link's time redraws each minute without one.
437
+ function renderUsage(usage) {
438
+ clearTimeout(usageTimer);
439
+ usageTimer = null;
440
+ const items = usageItems(usage);
441
+ const row = $("usage");
442
+ row.hidden = items.length === 0;
443
+ row.replaceChildren(...items.map((item) => {
444
+ const span = el("span", [item.key === "model" ? "model" : "", item.level].filter(Boolean).join(" "), item.text);
445
+ span.dataset.key = item.key;
446
+ span.title = item.title;
447
+ return span;
448
+ }));
449
+ if (!ended && Number.isFinite(Date.parse(usage?.expires_at || ""))) {
450
+ usageTimer = setTimeout(() => renderUsage(usage), 60_000);
451
+ }
408
452
  }
409
453
 
410
454
  function showError(text) {
@@ -417,9 +461,8 @@ function showError(text) {
417
461
  // disabled Send button alone doesn't stop a second Enter.
418
462
  let sending = false;
419
463
 
420
- async function sendComment(event) {
421
- event.preventDefault();
422
- if (sending) return;
464
+ async function sendComment() {
465
+ if (sending || !data?.viewer?.can_comment) return;
423
466
  const comment = $("comment");
424
467
  const prompt = comment.value.trim();
425
468
  if (!prompt) return;
@@ -437,8 +480,11 @@ async function sendComment(event) {
437
480
  const { ok, status, body: json } = reply;
438
481
  if (ended) return;
439
482
  if (ok) {
483
+ recordReviewComment(data?.viewer?.id, prompt);
440
484
  comment.value = "";
441
- autosize();
485
+ composer.fit();
486
+ composer.drawFoldControls();
487
+ composer.refreshHistory();
442
488
  showError("");
443
489
  await load();
444
490
  } else {
@@ -460,25 +506,62 @@ async function dequeue(enqueuedId, button) {
460
506
  load();
461
507
  }
462
508
 
463
- function autosize() {
464
- const comment = $("comment");
465
- comment.style.height = "auto";
466
- comment.style.height = `${comment.scrollHeight + 2}px`;
509
+ // The comment box (composer_mount.js), a participant's only: the owner's
510
+ // participant view mounts none (its dock stays hidden). Enter or Send posts
511
+ // the comment: always a queued review turn, so Alt+Enter is Enter here.
512
+ const composer = ownerView ? null : mountComposer({
513
+ els: {
514
+ composer: $("composer"), prompt: $("comment"), button: $("send"), chips: $("chips"),
515
+ attachBtn: $("attachBtn"), attachInput: $("attachInput"), grip: $("composeGrip"),
516
+ },
517
+ minPx: () => PROMPT_LINE_PX,
518
+ toast: showError,
519
+ loadHistory: () => Promise.resolve(readReviewHistory(data?.viewer?.id)),
520
+ send: () => sendComment(),
521
+ imagesOff: "Images can't go in a review comment yet: describe what it shows, or ask the owner.",
522
+ fold: { session: () => sessionId || null },
523
+ });
524
+ if (composer) {
525
+ // The session composer's layout (composer.css), and the fold saved in this browser.
526
+ document.body.classList.add("has-session");
527
+ composer.newVisit();
467
528
  }
468
529
 
469
- $("compose").addEventListener("submit", sendComment);
470
530
  for (const button of $("viewSeg").querySelectorAll("button")) {
471
531
  button.addEventListener("click", () => {
472
532
  setView(button.dataset.view);
473
533
  if (button.dataset.view === "thread") $("main").scrollTop = $("main").scrollHeight;
474
534
  });
475
535
  }
476
- $("comment").addEventListener("input", autosize);
477
- $("comment").addEventListener("keydown", (event) => {
478
- if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
479
- event.preventDefault();
480
- $("compose").requestSubmit();
481
- }
536
+
537
+ // Annotate (annotate_bar.js, participant_annotate.js): a selection in the
538
+ // Plan or the thread offers "Annotate" and the presets
539
+ // (web.annotate_presets), which append a quote to the comment box; nothing
540
+ // is sent. No plugin pills: a review turn refuses `/` lines. Not in the
541
+ // owner's participant view, nor while this participant can't comment.
542
+ const annotateBar = createAnnotateBar({
543
+ presets: parsePresets(body.annotatePresets),
544
+ canAnnotate: () => !ownerView && !ended && !!data?.viewer?.can_comment,
545
+ roots: () => [$("planDoc"), $("thread")],
546
+ isLive: (root) => !!root.closest?.(".p-msg.live"),
547
+ onQuote: quoteIntoComment,
548
+ source: participantSource,
549
+ quote: participantQuote,
550
+ dock: () => $("dock"),
482
551
  });
552
+ $("main").addEventListener("scroll", annotateBar.schedule);
553
+ $("planScroll").addEventListener("scroll", annotateBar.schedule);
554
+
555
+ function quoteIntoComment(text) {
556
+ const comment = $("comment");
557
+ // A folded box opens for this visit (nothing saved).
558
+ composer.openForVisit();
559
+ comment.value = appendQuote(comment.value, text);
560
+ composer.fit();
561
+ composer.drawFoldControls();
562
+ comment.focus();
563
+ comment.setSelectionRange(comment.value.length, comment.value.length);
564
+ comment.scrollTop = comment.scrollHeight;
565
+ }
483
566
 
484
567
  load();
@@ -0,0 +1,75 @@
1
+ // Annotate on the participant page (participant.js): a selection in the
2
+ // Plan (the review's subject) or in the thread quotes it into the comment
3
+ // box, with where it came from, the way the owner's page does
4
+ // (annotations.js, annotate_bar.js):
5
+ //
6
+ // From docs/plan.md:
7
+ // > step 3
8
+ //
9
+ // Is step 3 safe?
10
+ //
11
+ // A model reply carries no label (the owner's answers carry none); a
12
+ // comment names its author ("From Alex's comment:", "From my earlier
13
+ // comment:"). Not annotatable: the reply still streaming, tool rows,
14
+ // status lines, time gaps, the header. The bar's source and quote options
15
+ // (createAnnotateBar); no DOM state beyond Element#closest, so it runs
16
+ // under `node --test` with stand-ins. A module of its own: static JS is
17
+ // cached at unversioned URLs, and a new export on an old module would
18
+ // blank the page.
19
+
20
+ import { quoteBlock } from "./annotations.js";
21
+
22
+ // Where a node on the participant page came from: { kind, root, … } where
23
+ // root is the element a selection is clipped to, or null.
24
+ // plan: { path } the subject shown (#planDoc's data-path)
25
+ // reply: a model reply's bubble
26
+ // comment: { by, name } a prompt's bubble; by is "me", "participant",
27
+ // "owner" or "automatic" (its .p-msg's data-by), name the
28
+ // participant's (data-name)
29
+ export function participantSource(node) {
30
+ const el = node && node.nodeType === 3 ? node.parentElement : node;
31
+ if (!el?.closest) return null;
32
+ const plan = el.closest("#planDoc");
33
+ if (plan) {
34
+ // The "only the first part is shown" note isn't the subject's text.
35
+ if (el.closest(".p-plan-note")) return null;
36
+ return { kind: "plan", path: plan.dataset?.path || "", root: plan };
37
+ }
38
+ const bubble = el.closest(".p-bubble");
39
+ const msg = bubble?.closest(".p-msg");
40
+ if (!msg || msg.classList.contains("live")) return null;
41
+ if (msg.classList.contains("reply")) return { kind: "reply", root: bubble };
42
+ if (msg.classList.contains("user")) {
43
+ return { kind: "comment", by: msg.dataset?.by || "owner", name: msg.dataset?.name || "", root: bubble };
44
+ }
45
+ return null;
46
+ }
47
+
48
+ // The line naming +source+ above the quote, or null (a reply).
49
+ export function participantLabel(source) {
50
+ if (source?.kind === "plan") return source.path ? `From ${source.path}:` : "From the plan:";
51
+ if (source?.kind !== "comment") return null;
52
+ if (source.by === "me") return "From my earlier comment:";
53
+ if (source.by === "participant") return `From ${source.name || "a participant"}'s comment:`;
54
+ if (source.by === "automatic") return "From the automatic turn:";
55
+ return "From the owner's message:";
56
+ }
57
+
58
+ // The quote block for the selected +text+ (annotations.js quoteBlock's
59
+ // lines) under +source+'s label; null when it holds no text.
60
+ export function participantQuote(text, source) {
61
+ const block = quoteBlock(text, { kind: "answer" });
62
+ if (!block) return null;
63
+ const label = participantLabel(source);
64
+ return label ? `${label}\n${block}` : block;
65
+ }
66
+
67
+ // What a message's .p-msg carries for participantSource: data-by and, for
68
+ // another participant, data-name. +message+ is a review message (the
69
+ // participant route's: mine, author, author_name, automatic).
70
+ export function messageAuthorData(message) {
71
+ if (message?.mine) return { by: "me" };
72
+ if (message?.author) return { by: "participant", name: message.author_name || "" };
73
+ if (message?.automatic) return { by: "automatic" };
74
+ return { by: "owner" };
75
+ }
@@ -3,7 +3,7 @@
3
3
  // refusal in words, the stream's URL, and the live turn as filtered events
4
4
  // build it (ParticipantView on the server decides what arrives at all).
5
5
 
6
- import { gapText } from "./format.js";
6
+ import { fullTime, gapText } from "./format.js";
7
7
 
8
8
  // A participant's author: "p:" and its grant id, as the server writes it
9
9
  // (Steer.author_for / PARTICIPANT_PREFIX): the one place the web spells it.
@@ -246,3 +246,55 @@ export function subjectsKey(subjects) {
246
246
  export function narrowView(wanted, subjects) {
247
247
  return wanted === "plan" && (subjects || []).length ? "plan" : "thread";
248
248
  }
249
+
250
+ // A participant's usage row (the review data's viewer.usage: the fields
251
+ // their grant's exposure shows, ParticipantExposure): one short item per
252
+ // field it has, each {key, text, title, level}; level "" / "warn" (most of
253
+ // the cap spent, the queue full) / "over" (the cap reached). Only the
254
+ // participant's own figures ever arrive; a field the level doesn't show is
255
+ // absent and draws nothing. [] with no usage (the owner's view).
256
+ export const USAGE_WARN = 0.8;
257
+ const MINUTE_MS = 60 * 1000;
258
+ const HOUR_MS = 60 * MINUTE_MS;
259
+ const DAY_MS = 24 * HOUR_MS;
260
+ const dollars = (n) => `$${(Number(n) || 0).toFixed(2)}`;
261
+ const isNum = (n) => typeof n === "number" && Number.isFinite(n);
262
+
263
+ // "6 d left", "5 h left", "12 min left", "ended"; "" for no or bad input.
264
+ export function leftText(iso, now = Date.now()) {
265
+ const at = Date.parse(iso || "");
266
+ if (!Number.isFinite(at)) return "";
267
+ const left = at - now;
268
+ if (left <= 0) return "ended";
269
+ if (left >= 2 * DAY_MS) return `${Math.round(left / DAY_MS)} d left`;
270
+ if (left >= 2 * HOUR_MS) return `${Math.floor(left / HOUR_MS)} h left`;
271
+ return `${Math.max(1, Math.floor(left / MINUTE_MS))} min left`;
272
+ }
273
+
274
+ export function usageItems(usage, now = Date.now()) {
275
+ if (!usage || typeof usage !== "object") return [];
276
+ const items = [];
277
+ if (isNum(usage.spent)) {
278
+ const cap = isNum(usage.cap) ? usage.cap : null;
279
+ const share = cap ? usage.spent / cap : 0;
280
+ const level = cap === null ? "" : usage.spent >= cap ? "over" : share >= USAGE_WARN ? "warn" : "";
281
+ items.push({ key: "spent", text: cap === null ? `${dollars(usage.spent)} spent` : `${dollars(usage.spent)} of ${dollars(cap)} cap`,
282
+ title: cap === null ? "What your comments have cost the owner so far"
283
+ : `What your comments have cost the owner so far, of the ${dollars(cap)} cap they set`, level });
284
+ }
285
+ if (isNum(usage.queued) && isNum(usage.queue_limit)) {
286
+ items.push({ key: "queued", text: `${usage.queued}/${usage.queue_limit} queued`,
287
+ title: `Your comments waiting for their turn: up to ${usage.queue_limit} can wait behind a running one`,
288
+ level: usage.queued >= usage.queue_limit ? "warn" : "" });
289
+ }
290
+ const left = leftText(usage.expires_at, now);
291
+ if (left) {
292
+ const day = Date.parse(usage.expires_at) - now < DAY_MS;
293
+ items.push({ key: "expires_at", text: left === "ended" ? "link ended" : `link: ${left}`,
294
+ title: `Your link ends ${fullTime(usage.expires_at)}`, level: day ? "warn" : "" });
295
+ }
296
+ if (typeof usage.model === "string" && usage.model) {
297
+ items.push({ key: "model", text: usage.model, title: "The model answering in this session", level: "" });
298
+ }
299
+ return items;
300
+ }
@@ -2,7 +2,9 @@
2
2
  // bar's chip (how many, someone here now) and its popover, the panel: each
3
3
  // participant's state, expiry, spend against their cap, turns and when they
4
4
  // were last seen, with Revoke and a new cap; Invite (name, person or agent,
5
- // expiry, cap) gives the link once, to copy, share (a phone's share sheet)
5
+ // expiry, cap, and behind an auth proxy that names the signed-in user their
6
+ // email, which binds the link to that account) gives the link once, to
7
+ // copy, share (a phone's share sheet)
6
8
  // or scan (its QR); and the participant view, the participant page as a
7
9
  // participant sees this session (through the server's filter), in a new
8
10
  // tab. The panel reads GET /api/sessions/:id/participants, again after
@@ -61,6 +63,7 @@ export function rowModel(grant, now) {
61
63
  id: grant.id,
62
64
  name: grant.name,
63
65
  kind: grant.kind,
66
+ identity: typeof grant.identity === "string" ? grant.identity : null,
64
67
  active,
65
68
  present,
66
69
  color: authorColor(participantAuthor(grant.id)),
@@ -98,12 +101,21 @@ export function chipTemplate(chip) {
98
101
  title=${chip.title} aria-label=${chip.title} aria-haspopup="dialog"><span class="pc-dot" aria-hidden="true"></span>${chip.text}</button>`;
99
102
  }
100
103
 
101
- // What POST …/participants gets from the Invite form's values.
104
+ // An email as chi binds a link to it (Grants.clean_identity, which has the
105
+ // last word): one @, no spaces, commas, quotes or angle brackets.
106
+ const EMAIL_RE = /^[^@\s,<>"\\]+@[^@\s,<>"\\]+$/;
107
+
108
+ // What POST …/participants gets from the Invite form's values. +identity+
109
+ // (behind a proxy that names the user): their email, which binds the link;
110
+ // the name defaults to its local part.
102
111
  // @return {{body}|{error}}
103
- export function inviteBody({ name, kind, days, cap }) {
104
- const clean = String(name || "").trim();
112
+ export function inviteBody({ name, kind, days, cap, identity }) {
113
+ const email = String(identity || "").trim().toLowerCase();
114
+ if (email && !EMAIL_RE.test(email)) return { error: "An email is one address: name@domain." };
115
+ const clean = String(name || "").trim() || email.split("@")[0];
105
116
  if (!clean) return { error: "Give them a name: it is what the model and the thread show." };
106
117
  const body = { name: clean, kind: kind === "agent" ? "agent" : "human" };
118
+ if (email) body.identity = email;
107
119
  const d = Number(days);
108
120
  body.ttl = Math.round((EXPIRY_DAYS.includes(d) ? d : DEFAULT_EXPIRY_DAYS) * 24 * 60 * 60);
109
121
  const capText = String(cap ?? "").trim();
@@ -138,13 +150,14 @@ function rowTemplate(row, { capFor }) {
138
150
  <div class="pp-who"><span class=${`pp-dot${row.present ? " present" : ""}`} aria-hidden="true"></span><b>${row.name}</b>${
139
151
  row.kind === "agent" ? html`<span class="pp-tag">agent</span>` : nothing}<span class="pp-state">${row.state}</span></div>
140
152
  <div class="pp-meta"><span class=${row.capReached ? "pp-capped" : ""}>${row.spend}</span> · ${row.turns}${
141
- row.expires ? html` · ${row.expires}` : nothing}</div>${actions}</li>`;
153
+ row.expires ? html` · ${row.expires}` : nothing}${row.identity ? html` · <span class="pp-bound">bound to ${row.identity}</span>` : nothing}</div>${actions}</li>`;
142
154
  }
143
155
 
144
156
  function inviteTemplate(invite, { canShare }) {
145
157
  if (!invite) return nothing;
146
158
  return html`<div class="pp-invite-done">
147
- <div class="ctx-pop-meta">${invite.name}'s link: shown once (chi keeps only its digest). It opens this session only.${
159
+ <div class="ctx-pop-meta">${invite.name}'s link: shown once (chi keeps only its digest). It opens this session only${
160
+ invite.identity ? `, for ${invite.identity} only` : ""}.${
148
161
  invite.lan ? nothing : " This chi web has no LAN address: the link opens on this machine only."}</div>
149
162
  ${invite.warning ? html`<div class="pp-warning" role="note">${invite.warning}</div>` : nothing}
150
163
  <div class="pp-link"><input readonly .value=${invite.link} aria-label="The review link" @focus=${(e) => e.target.select()}><button
@@ -157,8 +170,11 @@ function inviteTemplate(invite, { canShare }) {
157
170
  // gave), the participant view.
158
171
  // +working+: the server's warning for a session that isn't a review session
159
172
  // (shown before the Invite, with Start a review of it), null for one.
173
+ // +identityHeader+: the header chi web's auth proxy names the signed-in
174
+ // user in (the list's identity_header), null without one: then the Invite
175
+ // has no email field (a bound link could never open).
160
176
  export function panelTemplate(grants, { now, sessionId, invite = null, error = null, capFor = null, canShare = false,
161
- busy = false, working = null, reviewing = false } = {}) {
177
+ busy = false, working = null, reviewing = false, identityHeader = null } = {}) {
162
178
  const rows = (grants || []).map((g) => rowModel(g, now));
163
179
  const active = rows.filter((row) => row.active);
164
180
  const ended = rows.filter((row) => !row.active);
@@ -173,9 +189,13 @@ export function panelTemplate(grants, { now, sessionId, invite = null, error = n
173
189
  data-act="start-review" ?disabled=${reviewing}>${reviewing ? "Starting…" : "Start a review of it"}</button></div>` : nothing}
174
190
  ${invite ? inviteTemplate(invite, { canShare }) : html`<form class="pp-invite">
175
191
  <div class="pp-invite-row"><input name="name" type="text" placeholder="Name (Alex)" autocomplete="off" aria-label="Their name" maxlength="64"
176
- ><select name="kind" aria-label="A person or an agent"><option value="human">person</option><option value="agent">agent</option></select></div>
177
- <div class="pp-invite-row"><label>Expires <select name="days">${EXPIRY_DAYS.map((d) => html`<option value=${d}
178
- ?selected=${d === DEFAULT_EXPIRY_DAYS}>${d === 1 ? "1 day" : `${d} days`}</option>`)}</select></label
192
+ ><span class="select-wrap"><select name="kind" aria-label="A person or an agent"><option value="human">person</option><option value="agent">agent</option></select></span></div>
193
+ ${identityHeader ? html`<div class="pp-invite-row"><input name="identity" type="email" placeholder="Their email (alex@example.com)"
194
+ autocomplete="off" aria-label="Their email: the link opens only for that account"></div>
195
+ <div class="ctx-pop-meta pp-bind-note">With their email the link opens only for them, signed in through the proxy.
196
+ Without one, anyone the proxy lets in who gets the link can open it.</div>` : nothing}
197
+ <div class="pp-invite-row"><label>Expires <span class="select-wrap"><select name="days">${EXPIRY_DAYS.map((d) => html`<option value=${d}
198
+ ?selected=${d === DEFAULT_EXPIRY_DAYS}>${d === 1 ? "1 day" : `${d} days`}</option>`)}</select></span></label
179
199
  ><label>Cap $<input name="cap" type="text" inputmode="decimal" placeholder="2" autocomplete="off" aria-label="Spending cap, dollars"></label
180
200
  ><button type="submit" class="pp-invite-btn" ?disabled=${busy}>Invite</button></div></form>`}
181
201
  <div class="ctx-pop-actions pp-foot">${invite ? html`<button type="button" data-act="another">Invite another</button>` : nothing}<a
@@ -198,6 +218,9 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
198
218
  // The open session's share warning (GET …/participants), null for a
199
219
  // review session.
200
220
  let working = null;
221
+ // The header chi web's proxy names the user in (GET …/participants), null
222
+ // without one: the Invite's email field.
223
+ let identityHeader = null;
201
224
  let loadedFor = null;
202
225
  let invite = null;
203
226
  let error = null;
@@ -230,7 +253,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
230
253
 
231
254
  function draw() {
232
255
  render(panelTemplate(grants, { now: clock(), sessionId: session(), invite, error, capFor, busy,
233
- working: startReview ? working : null, reviewing: reviewing(),
256
+ working: startReview ? working : null, reviewing: reviewing(), identityHeader,
234
257
  canShare: typeof win.navigator?.share === "function" }), shell.pop);
235
258
  shell.replace();
236
259
  }
@@ -249,6 +272,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
249
272
  if (id !== session()) return;
250
273
  grants = Array.isArray(body.participants) ? body.participants : [];
251
274
  working = typeof body.warning === "string" ? body.warning : null;
275
+ identityHeader = typeof body.identity_header === "string" && body.identity_header ? body.identity_header : null;
252
276
  loadedFor = id;
253
277
  if (setParticipantNames(grants)) onNames();
254
278
  } catch {
@@ -265,7 +289,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
265
289
  }
266
290
 
267
291
  async function submitInvite(form) {
268
- const values = Object.fromEntries(["name", "kind", "days", "cap"].map((k) => [k, form.elements[k]?.value]));
292
+ const values = Object.fromEntries(["name", "kind", "days", "cap", "identity"].map((k) => [k, form.elements[k]?.value]));
269
293
  const { body, error: problem } = inviteBody(values);
270
294
  if (problem) {
271
295
  error = problem;
@@ -278,7 +302,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
278
302
  const answer = await api.invite(asked.id, body);
279
303
  if (!stillOpen(asked)) return dropped(asked);
280
304
  invite = { name: answer.participant?.name || body.name, link: answer.link, qr_svg: answer.qr_svg, lan: answer.lan,
281
- warning: answer.warning || null, copied: false };
305
+ warning: answer.warning || null, identity: answer.participant?.identity || null, copied: false };
282
306
  error = null;
283
307
  } catch (err) {
284
308
  if (!stillOpen(asked)) return dropped(asked);
@@ -15,6 +15,20 @@ export function isContinue(pq) {
15
15
  return !!pq && pq.kind === "continue";
16
16
  }
17
17
 
18
+ // A model's recommended option: its label ends in "(Recommended)" (the
19
+ // ask_user_question description asks for it first, the mark at the end).
20
+ // The answer keeps the full label; only what the card shows drops the mark.
21
+ const RECOMMENDED = /\s*\(Recommended\)$/;
22
+ export function isRecommended(label) {
23
+ return RECOMMENDED.test(String(label ?? "").trim());
24
+ }
25
+
26
+ // An option's label as the card shows it: without the recommended mark.
27
+ export function optionText(label) {
28
+ const s = String(label ?? "");
29
+ return isRecommended(s) ? s.trim().replace(RECOMMENDED, "") || s : s;
30
+ }
31
+
18
32
  export const CONTINUE_REASON_PLACEHOLDER = "Stop, because… (the model reads it)";
19
33
 
20
34
  // Why a step-limit question closed unanswered (its question_cancelled reason).
@@ -135,7 +149,7 @@ export function resultText(pq, { answer = null, cancelled = false, reason = "" }
135
149
  return `Allowed: ${sel[0]}`;
136
150
  }
137
151
  const parts = [];
138
- if (sel.length) parts.push(sel.join(", "));
152
+ if (sel.length) parts.push(sel.map(optionText).join(", "));
139
153
  if (freeform) parts.push(freeform);
140
154
  return parts.length ? `Answered: ${parts.join(" · ")}` : "Answered";
141
155
  }
@@ -16,8 +16,8 @@ import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
16
16
  import { diffTemplate } from "./diff_view.js";
17
17
  import { render } from "./vendor/lit-html.js";
18
18
  import {
19
- answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue, keepsAskingText,
20
- nextCardAction, relayedToView, relayView, resultText, summaryText,
19
+ answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue, isRecommended,
20
+ keepsAskingText, nextCardAction, optionText, relayedToView, relayView, resultText, summaryText,
21
21
  } from "./question_card.js";
22
22
  import { sessionHash } from "./route.js";
23
23
  import { clientLabel, isOwn } from "./turn_events.js";
@@ -129,6 +129,15 @@ export function createQuestionCards({
129
129
  text.textContent = String(label);
130
130
  row.appendChild(input);
131
131
  row.appendChild(text);
132
+ // The model's pick: marked, its mark a tag, nothing pre-checked.
133
+ if (!approval && isRecommended(label)) {
134
+ row.classList.add("recommended");
135
+ text.textContent = optionText(label);
136
+ const tag = doc.createElement("span");
137
+ tag.className = "question-option-tag";
138
+ tag.textContent = "recommended";
139
+ row.appendChild(tag);
140
+ }
132
141
  list.appendChild(row);
133
142
  });
134
143
  card.appendChild(list);