samagotchi 0.78.0 → 0.80.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/README.md +7 -5
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +11 -6
  6. data/docs/internals/anthropic.md +12 -5
  7. data/docs/internals/bundles.md +4 -0
  8. data/docs/internals/engine.md +15 -5
  9. data/docs/plugins.md +144 -3
  10. data/docs/releasing.md +2 -2
  11. data/docs/sessions.md +1 -1
  12. data/docs/testing.md +32 -2
  13. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  14. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  15. data/lib/samagotchi/bootstrap_command.rb +92 -15
  16. data/lib/samagotchi/bridge.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  19. data/lib/samagotchi/continue_offer.rb +5 -5
  20. data/lib/samagotchi/engine.rb +40 -37
  21. data/lib/samagotchi/guardrails/approval.rb +6 -4
  22. data/lib/samagotchi/guardrails/gate.rb +7 -3
  23. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  24. data/lib/samagotchi/hooks/registry.rb +44 -3
  25. data/lib/samagotchi/host_registry.rb +12 -4
  26. data/lib/samagotchi/llm/adapter_common.rb +8 -4
  27. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +198 -0
  28. data/lib/samagotchi/llm/anthropic_messages.rb +39 -178
  29. data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
  30. data/lib/samagotchi/llm/chat_loop.rb +21 -12
  31. data/lib/samagotchi/llm/http.rb +12 -7
  32. data/lib/samagotchi/llm/model_listing.rb +29 -1
  33. data/lib/samagotchi/llm/refusal_memo.rb +3 -2
  34. data/lib/samagotchi/plugin/card.rb +44 -0
  35. data/lib/samagotchi/plugin/context.rb +53 -8
  36. data/lib/samagotchi/plugin/http.rb +154 -0
  37. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  38. data/lib/samagotchi/plugin/loader.rb +29 -10
  39. data/lib/samagotchi/question_answer.rb +29 -0
  40. data/lib/samagotchi/question_desk.rb +30 -22
  41. data/lib/samagotchi/reasoning_history.rb +2 -1
  42. data/lib/samagotchi/relay_verifier.rb +1 -1
  43. data/lib/samagotchi/testing/boundary.rb +156 -0
  44. data/lib/samagotchi/testing/bundle.rb +349 -0
  45. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  46. data/lib/samagotchi/testing/fake_http.rb +137 -0
  47. data/lib/samagotchi/testing/helpers.rb +56 -0
  48. data/lib/samagotchi/testing/host.rb +76 -0
  49. data/lib/samagotchi/testing/minitest.rb +31 -0
  50. data/lib/samagotchi/testing/recorder.rb +138 -0
  51. data/lib/samagotchi/testing/recording_api.rb +27 -0
  52. data/lib/samagotchi/testing/rspec.rb +31 -0
  53. data/lib/samagotchi/testing/sandbox.rb +163 -0
  54. data/lib/samagotchi/testing/sessions.rb +77 -0
  55. data/lib/samagotchi/testing/verdict.rb +28 -0
  56. data/lib/samagotchi/testing/waiting.rb +36 -0
  57. data/lib/samagotchi/testing.rb +26 -0
  58. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  59. data/lib/samagotchi/tools/tool_index.rb +3 -0
  60. data/lib/samagotchi/turn_state.rb +28 -1
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/public/app.js +79 -245
  63. data/lib/samagotchi/web/public/composer_grip.js +231 -0
  64. data/lib/samagotchi/web/public/images.js +33 -0
  65. data/lib/samagotchi/web/public/index.html +73 -40
  66. data/lib/samagotchi/web/public/level_chip.js +11 -11
  67. data/lib/samagotchi/web/public/lists_view.js +19 -14
  68. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  69. data/lib/samagotchi/web/public/model_picker.js +5 -6
  70. data/lib/samagotchi/web/public/scope.js +3 -3
  71. data/lib/samagotchi/web/public/stage_view.js +3 -2
  72. data/lib/samagotchi/web/public/storage.js +23 -0
  73. data/lib/samagotchi/web/public/strip.js +35 -9
  74. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  75. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  76. data/lib/samagotchi/web/public/toasts.js +5 -4
  77. metadata +24 -1
@@ -21,14 +21,13 @@
21
21
  // confirmDelete(id), setArchived(id, archive), archiveOne(id, archive)
22
22
  // updateInfoBar(), updateSessionTitle(), showToast(text, action)
23
23
  // allViewOpen(), openAllView(), leaveAllView(hash)
24
- // setStripHidden(hidden)
25
24
 
26
25
  import { ownerBadge, relativeTime, delegatedBy } from "./format.js";
27
26
  import { html, nothing, render } from "./vendor/lit-html.js";
28
27
  import { cardCtxText, ctxBarTip } from "./ctx.js";
29
28
  import { allScopeHref, cardFolder } from "./scope.js";
30
29
  import { sessionHash } from "./route.js";
31
- import { stripColumns, stripShowParts } from "./strip.js";
30
+ import { stripColumns, stripStatusParts } from "./strip.js";
32
31
  import { createFamilyPop, familyChipTemplate, familyRowsTemplate } from "./families_view.js";
33
32
  import { allViewChains, chainChipTemplate, chainRowsTemplate, chainTitle, foldChains } from "./chains.js";
34
33
  import {
@@ -192,21 +191,23 @@ function ownerBadgeTemplate(s) {
192
191
  return badge ? html`<span class=${`owner ${badge.kind}`} title=${badge.title}>${badge.text}</span>` : nothing;
193
192
  }
194
193
 
195
- // The strip's end: the All sessions tile (with its count) and hide ▴.
196
- // entering / anim: as a card's (cardTemplate). id and title are bound, as
197
- // class is, so the attributes keep their order.
194
+ // The strip's end: the All sessions tile (with its count), the strip's own
195
+ // last cell (the grip on the edge is the strip's handle now, not a button
196
+ // in here). entering / anim: as a card's (cardTemplate). id and title are
197
+ // bound, as class is, so the attributes keep their order.
198
198
  export function stripTileTemplate(count, { entering = false, anim = "" } = {}) {
199
- return html`<div class="strip-side"><button type="button" class=${entering ? "card entering" : "card"} id=${"allTile"} style=${anim || nothing} title=${"All sessions, with search"}>All sessions →<span class="count">${count}</span></button><button type="button" class="strip-toggle" id="stripHide" title="Hide the latest sessions">hide ▴</button></div>`;
199
+ return html`<button type="button" class=${entering ? "card entering" : "card"} id=${"allTile"} style=${anim || nothing} title=${"All sessions, with search"}>All sessions →<span class="count">${count}</span></button>`;
200
200
  }
201
201
 
202
- // The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting"
203
- // (strip.js stripShowParts).
202
+ // The hidden strip's status pill: "5 sessions · ● 1 live · ● 1 waiting"
203
+ // (strip.js stripStatusParts). Not a toggle any more (the grip hides and
204
+ // shows the strip): a click opens the all-sessions view, as the tile does.
204
205
  // The count (and the dot after it) in its own span: a phone's narrow top
205
206
  // bar drops it when live or waiting shows (index.html).
206
207
  export function stripPillTemplate({ count, live, waiting }) {
207
208
  const liveT = live ? html`<span class="live"><span class="live-dot" aria-hidden="true"></span>${live}</span>` : nothing;
208
209
  const waitingT = waiting ? html`${live ? " · " : ""}<span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${waiting}</span>` : nothing;
209
- return html`<span class="pill-arrow" aria-hidden="true">\u25BE</span> <span class="pill-count">${count}${live || waiting ? " · " : ""}</span>${liveT}${waitingT}`;
210
+ return html`<span class="pill-count">${count}${live || waiting ? " · " : ""}</span>${liveT}${waitingT}`;
210
211
  }
211
212
 
212
213
  // Draws +template+ into +el+ as new nodes, as an innerHTML write did: no
@@ -222,10 +223,15 @@ export function createListsView(deps) {
222
223
  const {
223
224
  doc, win, topStripEl, allListEl, sessions, selected, scope, scopeError, eventsLive, refresh, select,
224
225
  confirmDelete, setArchived, archiveOne, updateInfoBar, updateSessionTitle, showToast,
225
- allViewOpen, openAllView, leaveAllView, setStripHidden,
226
+ allViewOpen, openAllView, leaveAllView,
226
227
  } = deps;
227
228
  const $ = (s) => doc.querySelector(s);
228
229
 
230
+ // The hidden strip's status pill opens the all-sessions view (as the tile
231
+ // does), saving the step of showing the strip first. Bound once: the pill
232
+ // element stays, only its content is drawn again (draw()).
233
+ $("#stripStatus").addEventListener("click", openAllView);
234
+
229
235
  // The strip, its count and the hidden strip's pill leave archived sessions
230
236
  // out; the all view shows them with "include archived". sessions() keeps
231
237
  // them all (parent chips, the open session).
@@ -469,7 +475,7 @@ export function createListsView(deps) {
469
475
  // to all of them.
470
476
  function renderTop(cards) {
471
477
  topStripEl.classList.remove("loading");
472
- renderStripShow();
478
+ renderStripStatus();
473
479
  stripFamilies = new Map(cards.map((f) => [f.head.id, f]));
474
480
  noteChainFamilies(cards);
475
481
  const refused = scopeError();
@@ -501,7 +507,6 @@ export function createListsView(deps) {
501
507
  },
502
508
  });
503
509
  $("#allTile").addEventListener("click", openAllView);
504
- $("#stripHide").addEventListener("click", () => setStripHidden(true));
505
510
  familyPop.refresh();
506
511
  if (chainFrom === topStripEl) chainPop.refresh();
507
512
  }
@@ -517,8 +522,8 @@ export function createListsView(deps) {
517
522
  // The hidden strip's pill, following the list: its sessions, the number
518
523
  // the All sessions tile shows, and the live and waiting ones (a waiting
519
524
  // delegate too).
520
- function renderStripShow() {
521
- draw($("#stripShow"), stripPillTemplate(stripShowParts(shownSessions())));
525
+ function renderStripStatus() {
526
+ draw($("#stripStatus"), stripPillTemplate(stripStatusParts(shownSessions())));
522
527
  }
523
528
 
524
529
  // A parent's card holds its delegates (families, by the newest member: a
@@ -7,7 +7,7 @@
7
7
  // worker's snapshot, a command_ran, or the server's own reading of the
8
8
  // session file).
9
9
  //
10
- // The start page has one too (kind "new chat"): it shows the picked
10
+ // The start page has one too (kind "new session"): it shows the picked
11
11
  // model's values (GET /api/models' llm_context) and its form keeps a
12
12
  // choice for the next create only (POST /api/sessions' llm_context), never
13
13
  // remembered: nothing is sent unless the user sets one.
@@ -22,7 +22,7 @@ const STRATEGIES = [
22
22
  ];
23
23
  export const APPLIES = ["payoff", "next_request", "turn_end"];
24
24
  export const SESSION = "session";
25
- export const NEW_CHAT = "new chat";
25
+ export const NEW_SESSION = "new session";
26
26
 
27
27
  const DEFAULT = "default";
28
28
  const LAYER_ORDER = ["stale", "forget"];
@@ -43,16 +43,16 @@ export function ownValues(summary) {
43
43
  return { strategy, apply: own.apply || DEFAULT, budget };
44
44
  }
45
45
 
46
- // The start page's choice for the new chat ({strategy: [layers], apply,
46
+ // The start page's choice for the new session ({strategy: [layers], apply,
47
47
  // budget_tokens}, the session file's shape; 0 is off) laid over the
48
48
  // model's summary: what the chip shows, with the chosen fields' source
49
- // "session" ("the new chat"). The model's own summary without a choice.
49
+ // "session" ("the new session"). The model's own summary without a choice.
50
50
  export function withChoice(summary, choice) {
51
51
  if (!summary || !choice) return summary;
52
52
  const merged = { ...summary, own: choice };
53
53
  // (the budget's source and where are budget_source / budget_where)
54
54
  const set = (field, key, value) => Object.assign(merged, { [key]: value, [`${field}_source`]: SESSION,
55
- [`${field}_where`]: "the new chat" });
55
+ [`${field}_where`]: "the new session" });
56
56
  if (Array.isArray(choice.strategy)) set("strategy", "strategy", strategyValue(choice.strategy));
57
57
  if (choice.apply) set("apply", "apply", choice.apply);
58
58
  if (typeof choice.budget_tokens === "number") {
@@ -61,13 +61,13 @@ export function withChoice(summary, choice) {
61
61
  return merged;
62
62
  }
63
63
 
64
- // The chip: its text, tooltip and whether the session (or the new chat)
64
+ // The chip: its text, tooltip and whether the session (or the new session)
65
65
  // set its own; null without a summary (an older worker).
66
66
  export function chipModel(summary, { kind = SESSION } = {}) {
67
67
  if (!summary || !summary.strategy) return null;
68
68
  const own = summary.strategy_source === "session" || !!summary.own;
69
69
  const budget = summary.budget_tokens ? `${summary.budget_tokens} tokens` : "off";
70
- const what = kind === NEW_CHAT ? "the new chat" : "this session";
70
+ const what = kind === NEW_SESSION ? "the new session" : "this session";
71
71
  const title = `LLM context strategy: ${summary.strategy} (${summary.strategy_where}); apply ${summary.apply} ` +
72
72
  `(${summary.apply_where}); budget ${budget} (${summary.budget_where}). Click to change it for ${what}.`;
73
73
  return { text: `llm ctx ${summary.strategy}${own ? ` · ${kind}` : ""}`, title, own };
@@ -107,7 +107,7 @@ export function parseBudget(text) {
107
107
  return tokens >= 4000 && tokens <= 10_000_000 ? tokens : null;
108
108
  }
109
109
 
110
- // The new chat's choice after a Set: +choice+ with the fields the user
110
+ // The new session's choice after a Set: +choice+ with the fields the user
111
111
  // changed (vs +initial+, the values the form opened with) applied, a field
112
112
  // set back to default dropped; null when nothing is left. Throws on a
113
113
  // budget that isn't one.
@@ -160,8 +160,8 @@ export function nowText(summary) {
160
160
  `budget ${budget} (${summary.budget_where})`;
161
161
  }
162
162
 
163
- // The popover's form (a lit-html template). For the new chat (kind
164
- // NEW_CHAT), +summary+ is the model's and +choice+ the values the form
163
+ // The popover's form (a lit-html template). For the new session (kind
164
+ // NEW_SESSION), +summary+ is the model's and +choice+ the values the form
165
165
  // shows; +now+ is the summary its "Now:" line reads (refresh passes a newer
166
166
  // one, the rest stays as the form opened, so lit leaves what the user typed
167
167
  // alone); +error+ a failed Set's message. The selects take their value as
@@ -169,14 +169,14 @@ export function nowText(summary) {
169
169
  // alone, the first pass renderForm makes.
170
170
  export function formTemplate(summary, { running = false, kind = SESSION, choice = null, now = summary, error = null,
171
171
  unset = false } = {}) {
172
- const fresh = kind === NEW_CHAT;
172
+ const fresh = kind === NEW_SESSION;
173
173
  const own = fresh ? ownValues({ own: choice }) : ownValues(summary);
174
174
  const follow = (field) => (summary[`${field}_source`] === "session" ? "follow the model" : `follow the model (${summary[field]})`);
175
- const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
175
+ const when = fresh ? "For the new session's first turn on; not remembered for the next one."
176
176
  : "From the next turn's start. Turning forget on or off re-reads the whole prompt once.";
177
177
  const chosen = (value) => (unset ? nothing : value);
178
178
  return html`<form class="llmctx-form"><div class="ctx-pop-head"><strong>LLM context</strong
179
- ><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
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
182
  ><label class="llmctx-field"><span>Strategy</span><select name="strategy" .value=${chosen(own.strategy)}>${
@@ -206,7 +206,7 @@ export function renderForm(target, summary, opts = {}) {
206
206
  // (#infoText, info_bar.js: clicks are delegated); summary(): the open
207
207
  // session's llm_context; session(): the open session's id; running(): a
208
208
  // turn runs; run(line): sends the command.
209
- // The start page's (kind NEW_CHAT): summary() is the picked model's,
209
+ // The start page's (kind NEW_SESSION): summary() is the picked model's,
210
210
  // choice() the pending choice, and choose(next) takes a Set's (null for
211
211
  // "follow the model") instead of a command running. id and anchor (the
212
212
  // chip's selector) keep the two controls apart.
@@ -214,7 +214,7 @@ export function createLlmContextControl({ el, summary, session = () => null, run
214
214
  kind = SESSION, choice = () => null, choose = null,
215
215
  id = "llmContextPopover", anchor = ".llmctx-chip",
216
216
  doc = document, win = window }) {
217
- const fresh = kind === NEW_CHAT;
217
+ const fresh = kind === NEW_SESSION;
218
218
  // The values the form opened with.
219
219
  let initial = null;
220
220
  // What the open form was drawn from: the summary, the form's options and
@@ -10,6 +10,7 @@
10
10
  // ↑/↓ PageUp/PageDown move, ⏎ picks, Esc and Tab close without a change.
11
11
 
12
12
  import { moveSelection } from "./command_complete.js";
13
+ import { readStored, writeStored } from "./storage.js";
13
14
  import {
14
15
  MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows, rowNote, rowTitle,
15
16
  } from "./model_pick.js";
@@ -39,7 +40,7 @@ function headTemplate(label, note = "") {
39
40
 
40
41
  function readRecent() {
41
42
  try {
42
- const list = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
43
+ const list = JSON.parse(readStored(RECENT_KEY) || "[]");
43
44
  return Array.isArray(list) ? list : [];
44
45
  } catch (_) {
45
46
  return [];
@@ -47,7 +48,7 @@ function readRecent() {
47
48
  }
48
49
 
49
50
  // +onPick+ runs after a pick (the panel is closed by then). +remember+:
50
- // whether a pick is this browser's choice for a new chat (stored, and in
51
+ // whether a pick is this browser's choice for a new session (stored, and in
51
52
  // Recent); false on a fresh chain link, where a pick is that link's only.
52
53
  export function createModelPicker({ button, panel, input, list, composer, center, onPick, remember = () => true }) {
53
54
  let rows = [];
@@ -163,10 +164,8 @@ export function createModelPicker({ button, panel, input, list, composer, center
163
164
  if (!row) return;
164
165
  setChosen(row.name);
165
166
  if (remember()) {
166
- try {
167
- localStorage.setItem(MODEL_KEY, row.name);
168
- localStorage.setItem(RECENT_KEY, JSON.stringify(pushRecent(readRecent(), row.name)));
169
- } catch (_) {}
167
+ writeStored(MODEL_KEY, row.name);
168
+ writeStored(RECENT_KEY, JSON.stringify(pushRecent(readRecent(), row.name)));
170
169
  }
171
170
  close();
172
171
  onPick?.(row.name);
@@ -47,7 +47,7 @@ export function cardFolder(workingDirectory, projectName) {
47
47
 
48
48
  // The header's scope chip: "<project> · all" in a project view (all links
49
49
  // to the same place without ?dir); "all projects" otherwise, with a link
50
- // back to the project it came from and where a new chat starts on the
50
+ // back to the project it came from and where a new session starts on the
51
51
  // start page. +scope+: { dir, projectName, projectDir, serverDir, backDir,
52
52
  // backName }; +error+: why the ?dir= folder can't be shown; +where+:
53
53
  // () => { hash, search } (the page's location, read again at a click: the
@@ -63,6 +63,6 @@ export function scopeTemplate(scope, { error = null, where = () => ({ hash: "",
63
63
  const back = scope.backName
64
64
  ? html`<a class="scope-all scope-back" href=${projectScopeHref(scope.backDir, at.hash, at.search)} title=${`Sessions of ${scope.backName} only`} @click=${(e) => { e.currentTarget.href = backHref(); }}>← ${scope.backName}</a>`
65
65
  : nothing;
66
- const newChat = dir ? html`<span class="scope-where" title=${`New chats start in ${dir}`}>new chat in<span class="path"><bdi>${dir}</bdi></span></span>` : nothing;
67
- return html`<span class="scope-name" title="Sessions of every project">all projects</span>${back}${newChat}`;
66
+ const newSession = dir ? html`<span class="scope-where" title=${`New sessions start in ${dir}`}>new session in<span class="path"><bdi>${dir}</bdi></span></span>` : nothing;
67
+ return html`<span class="scope-name" title="Sessions of every project">all projects</span>${back}${newSession}`;
68
68
  }
@@ -22,6 +22,7 @@ import { createStageFrame, reducedMotion } from "./stage_frame.js";
22
22
  import { createInUseWatch } from "./stage_watch.js";
23
23
  import { formatDuration } from "./timing.js";
24
24
  import { easeScrollTo } from "./scroll.js";
25
+ import { readStored, writeStored } from "./storage.js";
25
26
 
26
27
  const COLLAPSE_KEY = "chi_stage_collapsed";
27
28
  // The turn-end read (markdown, answer_display) settles within this, or the
@@ -41,7 +42,7 @@ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.contin
41
42
  const ASKS = ".plugin-card[data-in-turn] .card-action";
42
43
 
43
44
  function readCollapsed() {
44
- try { return localStorage.getItem(COLLAPSE_KEY) === "1"; } catch (_) { return false; }
45
+ return readStored(COLLAPSE_KEY) === "1";
45
46
  }
46
47
 
47
48
  // +historyNearBottom+: the history's own follow check (the hand-off scrolls
@@ -289,7 +290,7 @@ export function createStageView({
289
290
  function setCollapsed(value) {
290
291
  collapsed = value;
291
292
  revealed = false;
292
- try { localStorage.setItem(COLLAPSE_KEY, value ? "1" : "0"); } catch (_) {}
293
+ writeStored(COLLAPSE_KEY, value ? "1" : "0");
293
294
  applyCollapsed();
294
295
  }
295
296
 
@@ -0,0 +1,23 @@
1
+ // The page's per-browser choices in localStorage, guarded: storage can be
2
+ // missing or throw (a private window, blocked site data, a full quota), and
3
+ // a choice that can't be read or written is just not remembered. Reads
4
+ // globalThis.localStorage at each call, never at import (the specs install
5
+ // their own).
6
+
7
+ // The stored string, or +fallback+ when there is none or it can't be read.
8
+ export function readStored(key, fallback = null) {
9
+ try {
10
+ const value = globalThis.localStorage.getItem(key);
11
+ return value === null ? fallback : value;
12
+ } catch (_) {
13
+ return fallback;
14
+ }
15
+ }
16
+
17
+ export function writeStored(key, value) {
18
+ try { globalThis.localStorage.setItem(key, value); } catch (_) {}
19
+ }
20
+
21
+ export function removeStored(key) {
22
+ try { globalThis.localStorage.removeItem(key); } catch (_) {}
23
+ }
@@ -1,3 +1,4 @@
1
+ import { GRIP_OPEN_PX } from "./composer_size.js";
1
2
  import { waitingOn } from "./notify.js";
2
3
 
3
4
  // How many session cards the top strip shows for its width in px: a card
@@ -12,15 +13,16 @@ export function stripColumns(width) {
12
13
  return Math.min(6, Math.max(3, fit));
13
14
  }
14
15
 
15
- // The hidden strip's "▾ sessions" pill carries the list's count, and how
16
- // many of those sessions a worker runs (the cards' green "live" badge), so a
17
- // user who hid the strip still sees there is a live session to go back to.
18
- // And how many wait on the user (notify.js waitingOn: a question, an
19
- // approval, a card), so a hidden strip still says one needs an answer.
20
- // Plain text parts; live and waiting are "" when none. Zero sessions keep
21
- // the bare label. The count is sessions (a folded delegate counts), the
22
- // number the "All sessions" tile shows.
23
- export function stripShowParts(sessions) {
16
+ // The hidden strip's status pill carries the list's count, and how many of
17
+ // those sessions a worker runs (the cards' green "live" badge), so a user
18
+ // who hid the strip still sees there is a live session to go back to. And
19
+ // how many wait on the user (notify.js waitingOn: a question, an approval, a
20
+ // card), so a hidden strip still says one needs an answer; a click on it
21
+ // opens the all-sessions view (lists_view.js). Plain text parts; live and
22
+ // waiting are "" when none. Zero sessions keep the bare label. The count is
23
+ // sessions (a folded delegate counts), the number the "All sessions" tile
24
+ // shows.
25
+ export function stripStatusParts(sessions) {
24
26
  const list = sessions || [];
25
27
  const n = list.length;
26
28
  const liveN = list.filter((s) => s && s.owner === "worker").length;
@@ -37,6 +39,30 @@ export function stripShowParts(sessions) {
37
39
  // cut off below the strip.
38
40
  export const SHORT_WINDOW_QUERY = "(max-height: 700px)";
39
41
 
42
+ // The strip's grip: the handle on the edge between the strip and the chat
43
+ // (the composer's grip's twin, mirrored: that one folds the box down, this
44
+ // one puts the strip away up). +dy+ is the release y minus the press y, so a
45
+ // drag up is negative. Decided on release only (the strip snaps, it does not
46
+ // follow the pointer): |dy| below GRIP_OPEN_PX is a tap, which toggles either
47
+ // way (a hidden strip's grip has no other way to come back, and a shown
48
+ // one's neither); a longer drag only fires the way it points: up (past
49
+ // STRIP_HIDE_PX) hides the shown strip, down (past GRIP_OPEN_PX) shows the
50
+ // hidden one. Anything else is null: a short drag the wrong way does nothing.
51
+ export const STRIP_HIDE_PX = 32; // a drag up this far hides the shown strip
52
+ export function stripGripAction({ hidden, dy }) {
53
+ if (Math.abs(dy) < GRIP_OPEN_PX) return hidden ? "show" : "hide"; // a tap
54
+ if (hidden) return dy >= GRIP_OPEN_PX ? "show" : null;
55
+ return dy <= -STRIP_HIDE_PX ? "hide" : null;
56
+ }
57
+
58
+ // The grip's title (D7: the grip owns it; composer_grip.js's drawFoldControls
59
+ // writes the composer's the same way). willToggle: the drag in progress would
60
+ // act on release.
61
+ export function stripGripTitle({ hidden, willToggle }) {
62
+ if (willToggle) return hidden ? "Release to show the latest sessions" : "Release to hide the latest sessions";
63
+ return hidden ? "Drag down or click to show the latest sessions" : "Drag up or click to hide the latest sessions";
64
+ }
65
+
40
66
  // Whether the strip is hidden: the user's own hide (saved), or a short
41
67
  // window unless the user showed it (the pill) since it got short.
42
68
  export function stripAutoHidden({ saved, short, shownWhileShort }) {
@@ -0,0 +1,76 @@
1
+ import { GRIP_OPEN_PX } from "./composer_size.js";
2
+ import { stripGripAction, stripGripTitle } from "./strip.js";
3
+
4
+ // The latest-sessions strip's grip (used by app.js): the handle on the edge
5
+ // between the strip and the chat, the composer's grip's twin, mirrored. A
6
+ // drag up (or a tap) hides the strip, a drag down (or a tap) shows it, and
7
+ // Enter or Space toggles too. The strip snaps: unlike the composer's box it
8
+ // does not follow the pointer, so nothing is measured while dragging — only
9
+ // whether the release would act (.will-toggle, the pill in the accent).
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.
13
+ // No click listener: a click still fires after pointerdown's preventDefault
14
+ // and would toggle twice. No document listener either: the composer's grip
15
+ // needs one for typing on the page, this one acts only on itself.
16
+ // +setHidden(true)+ hides the strip, +isHidden()+ says whether it is hidden.
17
+ export function createStripGrip({ gripEl, isHidden, setHidden }) {
18
+ let willToggle = false;
19
+
20
+ function refresh() {
21
+ const hidden = isHidden();
22
+ gripEl.setAttribute("aria-expanded", hidden ? "false" : "true");
23
+ gripEl.title = stripGripTitle({ hidden, willToggle });
24
+ }
25
+
26
+ function setWillToggle(will) {
27
+ if (will === willToggle) return;
28
+ willToggle = will;
29
+ gripEl.classList.toggle("will-toggle", will);
30
+ refresh();
31
+ }
32
+
33
+ gripEl.addEventListener("pointerdown", (e) => {
34
+ e.preventDefault();
35
+ gripEl.setPointerCapture(e.pointerId);
36
+ gripEl.classList.add("dragging");
37
+ const startY = e.clientY;
38
+ // Past a tap's size, and the release would act: the pill promises. A
39
+ // tap-sized move never flickers it (that stays a tap).
40
+ const willAfter = (dy) => Math.abs(dy) >= GRIP_OPEN_PX && stripGripAction({ hidden: isHidden(), dy }) !== null;
41
+ const move = (ev) => setWillToggle(willAfter(ev.clientY - startY));
42
+ const end = () => {
43
+ gripEl.classList.remove("dragging");
44
+ gripEl.removeEventListener("pointermove", move);
45
+ gripEl.removeEventListener("pointerup", up);
46
+ gripEl.removeEventListener("pointercancel", cancel);
47
+ };
48
+ const up = (ev) => {
49
+ const action = stripGripAction({ hidden: isHidden(), dy: ev.clientY - startY });
50
+ end();
51
+ setWillToggle(false);
52
+ if (action) setHidden(action === "hide");
53
+ refresh();
54
+ };
55
+ const cancel = () => {
56
+ end();
57
+ setWillToggle(false);
58
+ refresh();
59
+ };
60
+ gripEl.addEventListener("pointermove", move);
61
+ gripEl.addEventListener("pointerup", up);
62
+ gripEl.addEventListener("pointercancel", cancel);
63
+ });
64
+
65
+ // Enter or Space on the focused handle toggles (preventDefault: Space
66
+ // would scroll the page).
67
+ gripEl.addEventListener("keydown", (e) => {
68
+ if (e.key !== "Enter" && e.key !== " ") return;
69
+ e.preventDefault();
70
+ setHidden(!isHidden());
71
+ refresh();
72
+ });
73
+
74
+ refresh();
75
+ return refresh;
76
+ }
@@ -6,7 +6,7 @@
6
6
  // {level, source, own}; the worker's snapshot, a command_ran, or the
7
7
  // server's own reading of the session file). A config on level_chip.js.
8
8
  //
9
- // The start page has one too (kind "new chat"): it shows the picked
9
+ // The start page has one too (kind "new session"): it shows the picked
10
10
  // model's level (GET /api/models' thinking) and keeps a choice for the
11
11
  // next create only (POST /api/sessions' thinking), never remembered:
12
12
  // nothing is sent unless the user sets one.
@@ -26,20 +26,20 @@ export const CONFIG = Object.freeze({
26
26
  formClass: "think-form",
27
27
  popoverClass: "think-popover",
28
28
  nowClass: "think-now",
29
- when: (fresh) => (fresh ? "For the new chat's first turn on; not remembered for the next one."
29
+ when: (fresh) => (fresh ? "For the new session's first turn on; not remembered for the next one."
30
30
  : "From the next turn's start. On Gemma the whole prompt is read again; a hosted API reads the whole conversation again."),
31
31
  });
32
32
 
33
33
  export const { nowText } = level;
34
34
 
35
- // The new chat's choice (a level, or null) laid over the model's summary:
35
+ // The new session's choice (a level, or null) laid over the model's summary:
36
36
  // what the chip shows. The model's own summary without a choice.
37
37
  export function withChoice(summary, choice) {
38
38
  if (!summary || !choice) return summary;
39
39
  return { ...summary, level: choice, source: SESSION, own: choice };
40
40
  }
41
41
 
42
- // The chip: its text, tooltip and whether the session (or the new chat)
42
+ // The chip: its text, tooltip and whether the session (or the new session)
43
43
  // set its own; null without a summary (an older worker). The default with
44
44
  // nothing set anywhere shows too ("think default"): the chip is where the
45
45
  // level is set.
@@ -67,7 +67,7 @@ export function formTemplate(summary, opts) {
67
67
  }
68
68
 
69
69
  // The chip's popover (level_chip.js createLevelControl); the start page's
70
- // passes kind NEW_CHAT, choice and choose.
70
+ // passes kind NEW_SESSION, choice and choose.
71
71
  export function createThinkingControl({ id = "thinkingPopover", ...opts }) {
72
72
  return level.createLevelControl({ cfg: CONFIG, id, ...opts });
73
73
  }
@@ -37,7 +37,7 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
37
37
  }
38
38
 
39
39
  function show(text, action = null, { sticky = false } = {}) {
40
- const candidate = { el: doc.createElement("div"), timer: null, sticky, key: null };
40
+ const candidate = { el: doc.createElement("div"), timer: null, sticky, action, key: null };
41
41
  candidate.el.className = "toast-item";
42
42
  draw(candidate, text, action, sticky);
43
43
  candidate.key = candidate.el.querySelector(".toast-message").textContent;
@@ -46,12 +46,13 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
46
46
  if (duplicate) {
47
47
  if (duplicate.timer !== null) clearTimeout(duplicate.timer);
48
48
  duplicate.timer = null;
49
- duplicate.sticky = sticky;
49
+ duplicate.sticky = sticky || duplicate.sticky;
50
+ duplicate.action = action || duplicate.action;
50
51
  duplicate.key = candidate.key;
51
- draw(duplicate, text, action, sticky);
52
+ draw(duplicate, text, duplicate.action, duplicate.sticky);
52
53
  entries.push(...entries.splice(entries.indexOf(duplicate), 1));
53
54
  el.appendChild(duplicate.el);
54
- if (!sticky) duplicate.timer = setTimeout(() => remove(duplicate), TOAST_MS);
55
+ if (!duplicate.sticky) duplicate.timer = setTimeout(() => remove(duplicate), TOAST_MS);
55
56
  sync();
56
57
  return;
57
58
  }
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.78.0
4
+ version: 0.80.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -337,6 +337,7 @@ files:
337
337
  - lib/samagotchi/llm/adapter_common.rb
338
338
  - lib/samagotchi/llm/adapters.rb
339
339
  - lib/samagotchi/llm/anthropic_assembly.rb
340
+ - lib/samagotchi/llm/anthropic_binding_recovery.rb
340
341
  - lib/samagotchi/llm/anthropic_messages.rb
341
342
  - lib/samagotchi/llm/anthropic_wire.rb
342
343
  - lib/samagotchi/llm/api_key.rb
@@ -416,7 +417,10 @@ files:
416
417
  - lib/samagotchi/pending_question.rb
417
418
  - lib/samagotchi/plugin/api.rb
418
419
  - lib/samagotchi/plugin/attached_context.rb
420
+ - lib/samagotchi/plugin/card.rb
419
421
  - lib/samagotchi/plugin/context.rb
422
+ - lib/samagotchi/plugin/http.rb
423
+ - lib/samagotchi/plugin/http_transport.rb
420
424
  - lib/samagotchi/plugin/loader.rb
421
425
  - lib/samagotchi/plugin/peer.rb
422
426
  - lib/samagotchi/plugin/service.rb
@@ -434,6 +438,7 @@ files:
434
438
  - lib/samagotchi/prompt_literal_guard.rb
435
439
  - lib/samagotchi/prompt_note.rb
436
440
  - lib/samagotchi/prompt_warmup.rb
441
+ - lib/samagotchi/question_answer.rb
437
442
  - lib/samagotchi/question_desk.rb
438
443
  - lib/samagotchi/queued_command.rb
439
444
  - lib/samagotchi/reasoning_history.rb
@@ -500,6 +505,21 @@ files:
500
505
  - lib/samagotchi/terminal_ui/surface.rb
501
506
  - lib/samagotchi/terminal_ui/thinking_line.rb
502
507
  - lib/samagotchi/terminal_ui/version_lines.rb
508
+ - lib/samagotchi/testing.rb
509
+ - lib/samagotchi/testing/boundary.rb
510
+ - lib/samagotchi/testing/bundle.rb
511
+ - lib/samagotchi/testing/fake_executable.rb
512
+ - lib/samagotchi/testing/fake_http.rb
513
+ - lib/samagotchi/testing/helpers.rb
514
+ - lib/samagotchi/testing/host.rb
515
+ - lib/samagotchi/testing/minitest.rb
516
+ - lib/samagotchi/testing/recorder.rb
517
+ - lib/samagotchi/testing/recording_api.rb
518
+ - lib/samagotchi/testing/rspec.rb
519
+ - lib/samagotchi/testing/sandbox.rb
520
+ - lib/samagotchi/testing/sessions.rb
521
+ - lib/samagotchi/testing/verdict.rb
522
+ - lib/samagotchi/testing/waiting.rb
503
523
  - lib/samagotchi/text_diff.rb
504
524
  - lib/samagotchi/thinking.rb
505
525
  - lib/samagotchi/thinking_command.rb
@@ -595,6 +615,7 @@ files:
595
615
  - lib/samagotchi/web/public/commands.js
596
616
  - lib/samagotchi/web/public/composer.js
597
617
  - lib/samagotchi/web/public/composer_fold.js
618
+ - lib/samagotchi/web/public/composer_grip.js
598
619
  - lib/samagotchi/web/public/composer_size.js
599
620
  - lib/samagotchi/web/public/context_chips.js
600
621
  - lib/samagotchi/web/public/copy.js
@@ -647,8 +668,10 @@ files:
647
668
  - lib/samagotchi/web/public/stage_slots.js
648
669
  - lib/samagotchi/web/public/stage_view.js
649
670
  - lib/samagotchi/web/public/stage_watch.js
671
+ - lib/samagotchi/web/public/storage.js
650
672
  - lib/samagotchi/web/public/stream_handlers.js
651
673
  - lib/samagotchi/web/public/strip.js
674
+ - lib/samagotchi/web/public/strip_grip.js
652
675
  - lib/samagotchi/web/public/tally.js
653
676
  - lib/samagotchi/web/public/task_stop.js
654
677
  - lib/samagotchi/web/public/thinking_chip.js