samagotchi 0.120.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/docs/configuration.md +29 -10
  4. data/docs/internals/participants.md +3 -1
  5. data/docs/internals/prompt-caching.md +7 -6
  6. data/docs/plugins.md +24 -8
  7. data/docs/testing.md +3 -1
  8. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  9. data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
  10. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/engine.rb +1 -0
  15. data/lib/samagotchi/kernel_loop.rb +5 -0
  16. data/lib/samagotchi/plugin/api.rb +6 -3
  17. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  18. data/lib/samagotchi/tool_declarations.rb +1 -1
  19. data/lib/samagotchi/tool_runner.rb +37 -1
  20. data/lib/samagotchi/toolkit.rb +50 -26
  21. data/lib/samagotchi/toolkit_binding.rb +32 -0
  22. data/lib/samagotchi/toolkit_command.rb +23 -0
  23. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  24. data/lib/samagotchi/tools/registry.rb +20 -6
  25. data/lib/samagotchi/tools/tool_index.rb +103 -20
  26. data/lib/samagotchi/version.rb +1 -1
  27. data/lib/samagotchi/web/participant_review.rb +9 -5
  28. data/lib/samagotchi/web/public/forms.css +12 -0
  29. data/lib/samagotchi/web/public/index.html +7 -5
  30. data/lib/samagotchi/web/public/level_chip.js +2 -2
  31. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  32. data/lib/samagotchi/web/public/participant.html +1 -0
  33. data/lib/samagotchi/web/public/participant.js +13 -4
  34. data/lib/samagotchi/web/public/participants_panel.js +3 -3
  35. data/lib/samagotchi/web/public/question_card.js +15 -1
  36. data/lib/samagotchi/web/public/question_cards.js +11 -2
  37. data/lib/samagotchi/web/public/review_history.js +10 -2
  38. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  39. data/lib/samagotchi/web/tunnel.rb +10 -1
  40. metadata +2 -1
@@ -0,0 +1,12 @@
1
+ /* A form's select box (participants_panel.js' Invite, level_chip.js' Level, llm_context_chip.js' Strategy / Apply):
2
+ one shape in every dialog. Each browser draws its own closed box (macOS a pill with a double chevron), so a select
3
+ in a .select-wrap drops it and keeps its dialog's text-field box (the dialog's own `input, select` rule: height,
4
+ radius, border, background, padding, font); the wrapper draws one thin caret at the right. The open list stays the
5
+ OS menu. Both pages link it, after tokens.css and before their own <style>: span.select-wrap>select outweighs a
6
+ dialog's `… select` rule, so its padding shorthand keeps room for the caret. Colours are tokens.css's. */
7
+ .select-wrap{position:relative;display:inline-flex;min-width:0}
8
+ span.select-wrap>select{appearance:none;-webkit-appearance:none;flex:1;min-width:0;padding-right:26px;cursor:pointer}
9
+ .select-wrap::after{content:"";position:absolute;right:11px;top:50%;width:5px;height:5px;border-right:1.5px solid var(--text-muted);border-bottom:1.5px solid var(--text-muted);transform:translateY(-70%) rotate(45deg);pointer-events:none}
10
+ .select-wrap select option{background:var(--bg-raised);color:var(--text)}
11
+ span.select-wrap>select:disabled{opacity:.6;cursor:default}
12
+ .select-wrap:has(select:disabled)::after{opacity:.6}
@@ -12,6 +12,7 @@
12
12
  <link rel="stylesheet" href="/assets/tokens.css">
13
13
  <link rel="stylesheet" href="/assets/annotate_bar.css">
14
14
  <link rel="stylesheet" href="/assets/composer.css">
15
+ <link rel="stylesheet" href="/assets/forms.css">
15
16
  <style>
16
17
  /* Icon masks shared by the phone info bar and the all view's cards (the stroke only shapes the mask; the icon takes currentColor); the image icon is composer.css's. */
17
18
  :root{--icon-archive:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M6.5 9h3'/%3E%3C/svg%3E");--icon-unarchive:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M8 11.5V8.25M6.5 9.75 8 8.25l1.5 1.5'/%3E%3C/svg%3E")}
@@ -425,6 +426,10 @@ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:va
425
426
  .question-option:hover{background:var(--bg-hover)}
426
427
  .question-option.selected{border-color:var(--ok);color:var(--text)}
427
428
  .question-option input{margin:0;accent-color:var(--warn)}
429
+ /* The model's recommended option (a label ending in "(Recommended)"): an accent row and a small tag. */
430
+ .question-option.recommended{border-color:var(--accent-border);background:var(--accent-bg);color:var(--text)}
431
+ .question-option.recommended.selected{border-color:var(--ok)}
432
+ .question-option-tag{margin-left:auto;flex:none;font-size:11px;color:var(--accent);border:1px solid var(--accent-border);border-radius:999px;padding:0 6px}
428
433
  .question-freeform{width:100%;box-sizing:border-box;background:var(--bg-inset);border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:8px;font-size:13px;margin-bottom:8px}
429
434
  .question-error{color:var(--danger);font-size:12px;margin-bottom:8px}
430
435
  .question-error.hidden{display:none}
@@ -1055,12 +1060,9 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1055
1060
  .llmctx-field>span{flex:0 0 58px;font-size:12px;color:var(--text-muted)}
1056
1061
  .llmctx-field select,.llmctx-field input{flex:1;min-width:0;box-sizing:border-box;padding:5px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:inherit;font-size:13px;line-height:1.4}
1057
1062
  .llmctx-field select:focus,.llmctx-field input:focus{outline:none;border-color:var(--accent-border)}
1058
- /* The settings selects draw their own closed box (each browser's native one differs) with the model picker's drawn caret on the label; the open list stays the OS menu. */
1059
- .llmctx-field select{appearance:none;-webkit-appearance:none;padding-right:26px;cursor:pointer}
1060
- .llmctx-field select option{background:var(--bg-raised);color:var(--text)}
1063
+ /* A select's closed box and caret: forms.css (.select-wrap); here the wrapper takes the field's width. */
1064
+ .llmctx-field>.select-wrap{flex:1}
1061
1065
  .llmctx-field input::placeholder{color:var(--text-faint);opacity:1}
1062
- .llmctx-field:has(select){position:relative}
1063
- .llmctx-field:has(select)::after{content:"";position:absolute;right:11px;top:50%;width:5px;height:5px;border-right:1.5px solid var(--text-muted);border-bottom:1.5px solid var(--text-muted);transform:translateY(-70%) rotate(45deg);pointer-events:none}
1064
1066
  .llmctx-form .llmctx-set{background:var(--accent-bg);color:var(--accent);border-color:var(--accent-border)}
1065
1067
  .llmctx-form .llmctx-set:disabled{opacity:.6;cursor:default}
1066
1068
  /* Attached context (context_chips.js): a row of its own under the meta; not the composer's #chips/.chip. */
@@ -69,8 +69,8 @@ export function formTemplate(cfg, summary, { running = false, kind = SESSION, ch
69
69
  ><span class="ctx-pop-kind">${fresh ? NEW_SESSION : "this session"}</span
70
70
  ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
71
71
  ><div class=${`ctx-pop-meta ${cfg.nowClass}`}>${nowText(now)}</div
72
- ><label class="llmctx-field"><span>Level</span><select name="level">${option(DEFAULT, follow, chosen)}${
73
- cfg.levels.map((level) => option(level, level, chosen))}</select></label
72
+ ><label class="llmctx-field"><span>Level</span><span class="select-wrap"><select name="level">${option(DEFAULT, follow, chosen)}${
73
+ cfg.levels.map((level) => option(level, level, chosen))}</select></span></label
74
74
  ><div class="ctx-pop-meta">${cfg.when(fresh)}${running ? " A turn is running: it applies after this turn." : ""}</div
75
75
  ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
76
76
  }
@@ -179,10 +179,10 @@ export function formTemplate(summary, { running = false, kind = SESSION, choice
179
179
  ><span class="ctx-pop-kind">${fresh ? NEW_SESSION : "this session"}</span
180
180
  ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
181
181
  ><div class="ctx-pop-meta llmctx-now">${nowText(now)}</div
182
- ><label class="llmctx-field"><span>Strategy</span><select name="strategy" .value=${chosen(own.strategy)}>${
183
- option(DEFAULT, follow("strategy"))}${strategyOptions(own.strategy).map((s) => option(s.value, s.label))}</select></label
184
- ><label class="llmctx-field"><span>Apply</span><select name="apply" .value=${chosen(own.apply)}>${
185
- option(DEFAULT, follow("apply"))}${APPLIES.map((a) => option(a, a))}</select></label
182
+ ><label class="llmctx-field"><span>Strategy</span><span class="select-wrap"><select name="strategy" .value=${chosen(own.strategy)}>${
183
+ option(DEFAULT, follow("strategy"))}${strategyOptions(own.strategy).map((s) => option(s.value, s.label))}</select></span></label
184
+ ><label class="llmctx-field"><span>Apply</span><span class="select-wrap"><select name="apply" .value=${chosen(own.apply)}>${
185
+ option(DEFAULT, follow("apply"))}${APPLIES.map((a) => option(a, a))}</select></span></label
186
186
  ><label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off"
187
187
  placeholder="follow the model (64000, 64k or off)" .value=${own.budget}></label
188
188
  ><div class="ctx-pop-error llmctx-error" ?hidden=${!error}>${error || ""}</div
@@ -8,6 +8,7 @@
8
8
  <link rel="stylesheet" href="/assets/tokens.css">
9
9
  <link rel="stylesheet" href="/assets/annotate_bar.css">
10
10
  <link rel="stylesheet" href="/assets/composer.css">
11
+ <link rel="stylesheet" href="/assets/forms.css">
11
12
  <style>
12
13
  /* The participant page (participant.js): the thread with the composer pinned, and for a review session its subjects
13
14
  (the Plan): beside the thread on a wide screen, a Plan | Thread toggle on a narrow one (the composer pinned in both).
@@ -24,7 +24,7 @@ import { appendQuote } from "./annotations.js";
24
24
  import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
25
25
  import { mountComposer } from "./composer_mount.js";
26
26
  import { PROMPT_LINE_PX } from "./composer_size.js";
27
- import { forgetReviewHistory, readReviewHistory, recordReviewComment } from "./review_history.js";
27
+ import { forgetReviewHistory, readReviewHistory, recordReviewComment, rememberReviewHistory } from "./review_history.js";
28
28
 
29
29
  // A link's #code= opened in a browser that already has a cookie: traded
30
30
  // first, the page reloads without it.
@@ -48,6 +48,7 @@ let liveStale = false;
48
48
  let source = null;
49
49
  let reloadTimer = null;
50
50
  let retryTimer = null;
51
+ let usageTimer = null;
51
52
  // Reads of the review that failed in a row (the retry's backoff), and the
52
53
  // latest read asked for (an older one answering after it is dropped).
53
54
  let failures = 0;
@@ -93,8 +94,10 @@ function showEnded(json) {
93
94
  if (ended) return;
94
95
  ended = true;
95
96
  source?.close();
97
+ clearTimeout(usageTimer);
98
+ usageTimer = null;
96
99
  // The link is over: its comments' ↑/↓ list goes with it.
97
- forgetReviewHistory(data?.viewer?.id);
100
+ forgetReviewHistory(data?.viewer?.id, ownerView ? null : sessionId);
98
101
  for (const id of ["head", "main", "dock", "plan"]) $(id).hidden = true;
99
102
  $("endedText").textContent = endedText(json);
100
103
  $("ended").hidden = false;
@@ -123,6 +126,7 @@ async function load() {
123
126
  failures = 0;
124
127
  $("offline").hidden = true;
125
128
  data = json;
129
+ if (!ownerView) rememberReviewHistory(sessionId, data?.viewer?.id);
126
130
  live = liveFrom(data.current_turn);
127
131
  liveStale = false;
128
132
  render();
@@ -428,9 +432,11 @@ function renderDock() {
428
432
  }
429
433
 
430
434
  // The participant's own usage, one line under the comment box (only what
431
- // their grant's exposure shows; the server sends nothing else). Redrawn at
432
- // every re-read: a turn's end, a queued or taken-back comment.
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.
433
437
  function renderUsage(usage) {
438
+ clearTimeout(usageTimer);
439
+ usageTimer = null;
434
440
  const items = usageItems(usage);
435
441
  const row = $("usage");
436
442
  row.hidden = items.length === 0;
@@ -440,6 +446,9 @@ function renderUsage(usage) {
440
446
  span.title = item.title;
441
447
  return span;
442
448
  }));
449
+ if (!ended && Number.isFinite(Date.parse(usage?.expires_at || ""))) {
450
+ usageTimer = setTimeout(() => renderUsage(usage), 60_000);
451
+ }
443
452
  }
444
453
 
445
454
  function showError(text) {
@@ -189,13 +189,13 @@ export function panelTemplate(grants, { now, sessionId, invite = null, error = n
189
189
  data-act="start-review" ?disabled=${reviewing}>${reviewing ? "Starting…" : "Start a review of it"}</button></div>` : nothing}
190
190
  ${invite ? inviteTemplate(invite, { canShare }) : html`<form class="pp-invite">
191
191
  <div class="pp-invite-row"><input name="name" type="text" placeholder="Name (Alex)" autocomplete="off" aria-label="Their name" maxlength="64"
192
- ><select name="kind" aria-label="A person or an agent"><option value="human">person</option><option value="agent">agent</option></select></div>
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
193
  ${identityHeader ? html`<div class="pp-invite-row"><input name="identity" type="email" placeholder="Their email (alex@example.com)"
194
194
  autocomplete="off" aria-label="Their email: the link opens only for that account"></div>
195
195
  <div class="ctx-pop-meta pp-bind-note">With their email the link opens only for them, signed in through the proxy.
196
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 <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></label
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
199
199
  ><label>Cap $<input name="cap" type="text" inputmode="decimal" placeholder="2" autocomplete="off" aria-label="Spending cap, dollars"></label
200
200
  ><button type="submit" class="pp-invite-btn" ?disabled=${busy}>Invite</button></div></form>`}
201
201
  <div class="ctx-pop-actions pp-foot">${invite ? html`<button type="button" data-act="another">Invite another</button>` : nothing}<a
@@ -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);
@@ -8,6 +8,7 @@
8
8
  import { readStored, removeStored, writeStored } from "./storage.js";
9
9
 
10
10
  export const REVIEW_HISTORY_PREFIX = "chi_review_history:";
11
+ const REVIEW_HISTORY_SESSION_PREFIX = "chi_review_history_session:";
11
12
  // How many comments a grant's list keeps (the oldest go first).
12
13
  export const REVIEW_HISTORY_MAX = 50;
13
14
 
@@ -46,7 +47,14 @@ export function recordReviewComment(grantId, text) {
46
47
  writeStored(key, JSON.stringify(withComment(readReviewHistory(grantId), text)));
47
48
  }
48
49
 
49
- export function forgetReviewHistory(grantId) {
50
- const key = reviewHistoryKey(grantId);
50
+ export function rememberReviewHistory(sessionId, grantId) {
51
+ if (sessionId && grantId) writeStored(`${REVIEW_HISTORY_SESSION_PREFIX}${sessionId}`, grantId);
52
+ }
53
+
54
+ export function forgetReviewHistory(grantId, sessionId) {
55
+ const sessionKey = sessionId ? `${REVIEW_HISTORY_SESSION_PREFIX}${sessionId}` : null;
56
+ const remembered = sessionKey ? readStored(sessionKey) : null;
57
+ if (sessionKey) removeStored(sessionKey);
58
+ const key = reviewHistoryKey(grantId || remembered);
51
59
  if (key) removeStored(key);
52
60
  }
@@ -16,6 +16,9 @@ export function chipModel(summary) {
16
16
  const label = summary.label || summary.name;
17
17
  const parts = [`Toolkit: ${label}, the tools this session declares.`];
18
18
  if (summary.fallback) parts.push(`${summary.fallback}: every tool is declared (full) until it resolves.`);
19
+ if (Array.isArray(summary.groups)) {
20
+ parts.push(`Source groups: ${summary.groups.length ? summary.groups.join(", ") : "none"}.`);
21
+ }
19
22
  const reachable = Array.isArray(summary.reachable) ? summary.reachable : [];
20
23
  const removed = Array.isArray(summary.removed) ? summary.removed : [];
21
24
  if (reachable.length) {
@@ -103,10 +103,19 @@ module Samagotchi
103
103
  # @return [Result]
104
104
  # @raise [Timeout]
105
105
  # @raise [SystemCallError] the command can't run (ENOENT, EACCES)
106
- def call(argv, timeout:)
106
+ # +before_timeout+ lets a test synchronize the deadline with a
107
+ # child-side ready signal; the timeout starts after it returns.
108
+ def call(argv, timeout:, before_timeout: nil)
107
109
  Open3.popen3(*argv, pgroup: true) do |stdin, out, err, wait|
108
110
  stdin.close
109
111
  readers = [out, err].map { |io| Thread.new { io.read }.tap { |t| t.report_on_exception = false } }
112
+ begin
113
+ before_timeout&.call
114
+ rescue StandardError
115
+ stop(wait.pid)
116
+ readers.each { |reader| reader.join(1) }
117
+ raise
118
+ end
110
119
  unless wait.join(timeout)
111
120
  stop(wait.pid)
112
121
  readers.each { |reader| reader.join(1) }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.120.0
4
+ version: 0.121.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -770,6 +770,7 @@ files:
770
770
  - lib/samagotchi/web/public/families_view.js
771
771
  - lib/samagotchi/web/public/favicon.svg
772
772
  - lib/samagotchi/web/public/format.js
773
+ - lib/samagotchi/web/public/forms.css
773
774
  - lib/samagotchi/web/public/hold.js
774
775
  - lib/samagotchi/web/public/icons/chi-180.png
775
776
  - lib/samagotchi/web/public/icons/chi-192.png