samagotchi 0.152.0 → 0.153.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -1
  3. data/docs/bundle-authoring.md +1 -1
  4. data/docs/bundles.md +84 -0
  5. data/docs/cli.md +3 -3
  6. data/docs/configuration.md +19 -14
  7. data/docs/guardrails.md +7 -5
  8. data/docs/hooks.md +4 -3
  9. data/docs/internals/anthropic.md +3 -3
  10. data/docs/internals/attached-context.md +3 -0
  11. data/docs/internals/engine.md +9 -5
  12. data/docs/internals/participants.md +1 -1
  13. data/docs/internals/prompt-caching.md +7 -3
  14. data/docs/internals/web-frontend.md +20 -17
  15. data/docs/plugins.md +29 -15
  16. data/docs/releasing.md +1 -1
  17. data/docs/sessions.md +1 -1
  18. data/docs/testing.md +2 -2
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
  21. data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
  22. data/lib/samagotchi/engine.rb +10 -5
  23. data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
  24. data/lib/samagotchi/llm_context_stale.rb +2 -2
  25. data/lib/samagotchi/message_parts.rb +3 -2
  26. data/lib/samagotchi/toolkit.rb +8 -5
  27. data/lib/samagotchi/toolkit_binding.rb +30 -5
  28. data/lib/samagotchi/toolkit_report_command.rb +9 -5
  29. data/lib/samagotchi/toolkit_suggest.rb +12 -6
  30. data/lib/samagotchi/tools/builtin_calls.rb +14 -10
  31. data/lib/samagotchi/tools/deferred_tools.rb +2 -2
  32. data/lib/samagotchi/tools/registry.rb +44 -20
  33. data/lib/samagotchi/tools/tool_index.rb +27 -21
  34. data/lib/samagotchi/version.rb +1 -1
  35. data/lib/samagotchi/web/public/app.js +61 -357
  36. data/lib/samagotchi/web/public/composer_mode.js +110 -0
  37. data/lib/samagotchi/web/public/owner_claim.js +81 -0
  38. data/lib/samagotchi/web/public/owner_verify.js +112 -0
  39. data/lib/samagotchi/web/public/session_fields.js +3 -3
  40. data/lib/samagotchi/web/public/strip_grip.js +3 -3
  41. data/lib/samagotchi/web/public/strip_hidden.js +65 -0
  42. data/lib/samagotchi/web/public/version_notices.js +132 -0
  43. data/lib/samagotchi/worker.rb +6 -0
  44. metadata +8 -1
@@ -0,0 +1,110 @@
1
+ // The composer's mode on the owner page (used by app.js, the store's
2
+ // `composer` redraw): its placeholder, key hint, action button, the attach
3
+ // button, the start page's empty state and the delivery line's row, from the
4
+ // open session, its owner and whether its turn runs.
5
+
6
+ // A phone's composer fits a few words and has no Shift+Enter: the short
7
+ // placeholder there.
8
+ export const NARROW_QUERY = "(max-width: 600px)";
9
+
10
+ // The key hint under the composer (shown while it has focus; on the start
11
+ // page only: in a session the placeholder carries it, the hint row is gone).
12
+ export const SEND_HINT = "Enter to send · Shift+Enter for a newline · ↑ history";
13
+
14
+ // What the composer shows, pure: +selected+ the open session's id (null: the
15
+ // start page), +owner+ its owner, +running+ whether its turn runs, +narrow+ a
16
+ // phone's width. While a turn runs, Send still sends: the prompt merges into
17
+ // the turn at its next step (steering); Cancel is its own button then.
18
+ // @return {placeholder, hint, button, disabled, empty, clearHistory,
19
+ // attachHidden, delivery}: +disabled+ undefined while a turn runs (the
20
+ // button keeps whatever a send in flight set); +empty+ the start page's
21
+ // empty state shown, +clearHistory+ #history emptied (the start page only);
22
+ // +delivery+ the delivery line's mode (delivery_line.js show).
23
+ export function composerMode({ selected, owner, running, narrow }) {
24
+ const session = !!selected;
25
+ const tui = session && owner === "tui";
26
+ const live = !!running && session;
27
+ const pick = (long, short) => (narrow ? short : long);
28
+ // No images for a session a terminal owns (it takes no message from here);
29
+ // on a phone none while a turn runs either: the row keeps its room for the
30
+ // message box (steering).
31
+ const attachHidden = tui || (live && !!narrow);
32
+ // The delivery line's row in a session: the key hint while idle, where a
33
+ // message goes in while the turn runs. A terminal owns the session: the
34
+ // composer is off, so is its row.
35
+ const delivery = !session || tui ? "off" : running ? "running" : "idle";
36
+ const base = { attachHidden, delivery, empty: false, clearHistory: false };
37
+ if (live) {
38
+ return {
39
+ ...base,
40
+ placeholder: pick("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…"),
41
+ hint: SEND_HINT, button: "Send", disabled: undefined,
42
+ };
43
+ }
44
+ if (!session) {
45
+ return {
46
+ ...base,
47
+ placeholder: "Ask chi anything…", hint: "Enter to start · Shift+Enter for a newline", button: "Start", disabled: false,
48
+ empty: true, clearHistory: true,
49
+ };
50
+ }
51
+ if (tui) {
52
+ return {
53
+ ...base,
54
+ placeholder: pick("A terminal chi owns this session · run it with chi --shared to send from here", "A terminal chi owns this session"),
55
+ hint: "", button: "Send", disabled: true,
56
+ };
57
+ }
58
+ // The keys are in the delivery line's row above.
59
+ return { ...base, placeholder: "Message chi…", hint: SEND_HINT, button: "Send", disabled: false };
60
+ }
61
+
62
+ // The composer's mode drawn: update() is the store's `composer` redraw, and a
63
+ // window crossing the phone's width redraws it too (an input the store
64
+ // doesn't hold, with the composer as its only reader: the bar doesn't read it).
65
+ //
66
+ // +els+: {body, prompt, hint, button, attach, empty, history}. +store+: the
67
+ // page's session store (selected, current.owner; read only). +liveTurn+:
68
+ // live_turn.js's (running). +startPage+: start_page.js's (restoreStartPick,
69
+ // syncModelPick, dropChoices). +deliveryControl+: delivery_line.js's.
70
+ // +moreSheet+: more_sheet.js's (⋯ is a phone's alone). +fitPrompt()+,
71
+ // +placeToast()+: the page's. +win+: the window (matchMedia).
72
+ export function createComposerMode({
73
+ els, store, liveTurn, startPage, deliveryControl, moreSheet, fitPrompt, placeToast, win = globalThis,
74
+ }) {
75
+ const narrowScreen = win.matchMedia(NARROW_QUERY);
76
+ let hadSession = false;
77
+
78
+ function update() {
79
+ // In a session the box is one line: its minimum follows the class.
80
+ if (els.body.classList.toggle("has-session", !!store.selected) !== hadSession) {
81
+ hadSession = !hadSession;
82
+ // Back on the start page: its remembered model, not the last link's.
83
+ if (!hadSession) startPage.restoreStartPick();
84
+ fitPrompt();
85
+ placeToast();
86
+ }
87
+ startPage.syncModelPick();
88
+ // The start page's choice was for a new session: opening a session drops it.
89
+ if (store.selected) startPage.dropChoices();
90
+ const mode = composerMode({
91
+ selected: store.selected, owner: store.current.owner, running: liveTurn.running, narrow: narrowScreen.matches,
92
+ });
93
+ els.attach.hidden = mode.attachHidden;
94
+ deliveryControl.show(mode.delivery);
95
+ els.prompt.placeholder = mode.placeholder;
96
+ els.hint.textContent = mode.hint;
97
+ els.button.textContent = mode.button;
98
+ if (mode.disabled !== undefined) els.button.disabled = mode.disabled;
99
+ els.empty.classList.toggle("hidden", !mode.empty);
100
+ if (mode.clearHistory) els.history.replaceChildren();
101
+ }
102
+
103
+ narrowScreen.addEventListener("change", () => {
104
+ update();
105
+ // ⋯ is a phone's alone: a window grown past it takes its sheet along.
106
+ if (!narrowScreen.matches && moreSheet.isOpen()) moreSheet.close();
107
+ });
108
+
109
+ return { update };
110
+ }
@@ -0,0 +1,81 @@
1
+ import { ownerClaimHash, participantClaimLink, selectionClaim, showClaim, turnIdOf } from "./claim_link.js";
2
+ import { sessionHash } from "./route.js";
3
+
4
+ // Where a claim's text can be on the owner's page: answers, own messages, steps.
5
+ export const CLAIM_ROOTS = ".bubble.output, .bubble.user .user-message, .gen-text";
6
+
7
+ // The owner page's claim links (used by app.js; claim_link.js does the text
8
+ // and DOM work): a claim link opened on this page (#/s/<id>?t=…&q=…, or the
9
+ // participant's #s=<id>&t=… opened in this browser) shown once its session is
10
+ // drawn, and annotate's "Copy link" for a selection. The participant page has
11
+ // its own copies on purpose (docs/internals/web-frontend.md, Two pages).
12
+ //
13
+ // +historyEl+: #history. +store+: the page's session store (selected,
14
+ // current.dir, sessions; read only). +claimTarget()+: participants_panel.js's
15
+ // (the people a link would open for, or null). +toast(text, action,
16
+ // options)+, +copyText(text)+ (copy.js), +allViewOpen()+: the page's.
17
+ // +home+: the home folder the page shows as ~ (body data-home). +win+: the
18
+ // window (location, history).
19
+ export function createOwnerClaim({
20
+ historyEl, store, claimTarget, toast, copyText, allViewOpen, home = "", win = globalThis,
21
+ }) {
22
+ // A claim link waiting for its session to be drawn (selectFromHash sets it).
23
+ let pendingClaim = null;
24
+
25
+ // How the participant page shows this page's text (claim_link.js).
26
+ function opts() {
27
+ return { home, cwd: store.current.dir || "" };
28
+ }
29
+
30
+ function pending(claim) {
31
+ pendingClaim = claim;
32
+ }
33
+
34
+ // The claim link's quote scrolled to and marked, its text read as the
35
+ // participant page shows it (the session's folder relative, home ~); a
36
+ // quote not found marks its turn; neither found: the top, and a toast.
37
+ // The hash is the session's again (select() already set it when it
38
+ // switched), so the same link opened again is a hashchange.
39
+ function showPending() {
40
+ const claim = pendingClaim;
41
+ if (!claim || claim.sessionId !== store.selected) return;
42
+ pendingClaim = null;
43
+ const { location, history } = win;
44
+ if (!allViewOpen() && location.hash !== sessionHash(store.selected)) {
45
+ history.replaceState(null, "", location.pathname + location.search + sessionHash(store.selected));
46
+ }
47
+ if (showClaim(historyEl, claim, { roots: CLAIM_ROOTS, opts: opts() })) return;
48
+ historyEl.scrollTop = 0;
49
+ toast("The linked turn isn't in this session.");
50
+ }
51
+
52
+ // Annotate's "Copy link" for a selection in a turn's answer, step or own
53
+ // message: the participant page's link when the session has a person
54
+ // active (it opens in the browser their review link was opened in: one
55
+ // link per browser, the cookie), else this page's own.
56
+ function linkFor(source, range) {
57
+ // Not in a Revise stack's switched copy (revise_switch.js: view only, no claim key; Verify isn't offered there either).
58
+ if (!store.selected || !["answer", "step", "user"].includes(source.kind) || !historyEl.contains(source.root)
59
+ || source.root.closest("[data-stack-view]")) return null;
60
+ const turnId = turnIdOf(historyEl, source.root);
61
+ if (!turnId) return null;
62
+ const claim = { turnId, ...selectionClaim(source.root, range, opts()) };
63
+ if (!claim.q) return null;
64
+ const target = claimTarget();
65
+ if (!target) {
66
+ const { location } = win;
67
+ return { url: `${location.origin}${location.pathname}${location.search}${ownerClaimHash(store.selected, claim)}`,
68
+ title: "A link to this on your page (no person is invited to this session)" };
69
+ }
70
+ const whose = target.names.map((name) => `${name}'s`).join(" or ");
71
+ return { url: participantClaimLink(target.base, store.selected, claim),
72
+ title: `A link to this for ${whose} browser: it opens where their review link was opened (one link per browser). Opened here, it shows your page.` };
73
+ }
74
+
75
+ async function copyLink({ url }) {
76
+ if (await copyText(url)) toast("Link copied");
77
+ else toast(`Couldn't copy: ${url}`, null, { sticky: true });
78
+ }
79
+
80
+ return { opts, pending, showPending, linkFor, copyLink };
81
+ }
@@ -0,0 +1,112 @@
1
+ import { selectionClaim } from "./claim_link.js";
2
+ import { CLAIM_ROOTS } from "./owner_claim.js";
3
+ import { authorBadge } from "./participant_names.js";
4
+ import { sessionHash } from "./route.js";
5
+ import { CHECK_NOT_ON_PAGE, ownerCheckHash } from "./verdict_card.js";
6
+ import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
7
+ import { VERIFY_UNAVAILABLE, verifyWorkerUnavailable } from "./verify_preset.js";
8
+ import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
9
+
10
+ // The owner page's Verify (used by app.js): its verdict cards in the history
11
+ // (a live verdict, a session's saved ones), Verify fresh on an unclear
12
+ // verdict's card (verdict_fresh.js), Verify's claim from a selection, and
13
+ // the composer's armed claim (anchor/set/clear: the one holder; the next
14
+ // send makes it a Verify turn).
15
+ // The participant page has its own copies on purpose
16
+ // (docs/internals/web-frontend.md, Two pages).
17
+ //
18
+ // +historyEl+: #history. +store+: the page's session store (it writes
19
+ // current.verdicts and current.verifyFresh through store.update; reads
20
+ // selected, sessions, workerFeatures, bridgeUp). +api(path, opts)+: data.js's.
21
+ // +claim+: owner_claim.js's (its opts()). +liveTurn+: live_turn.js's (whether
22
+ // a turn runs, its id). +toast(text)+: the page's. +win+: the window (a
23
+ // verdict's check in another session opens by its hash).
24
+ export function createOwnerVerify({ historyEl, store, api, claim, liveTurn, toast, win = globalThis }) {
25
+ // Verify fresh's failed starts, by check (verdict_fresh.js keeps them here).
26
+ const freshErrors = {};
27
+ // The composer's armed Verify claim ({turn_id, quote}) or null: set by the
28
+ // Verify preset, a restored Verify turn's draft; let go by an emptied
29
+ // composer, another session, the send's ack.
30
+ let armed = null;
31
+
32
+ function anchor() {
33
+ return armed;
34
+ }
35
+
36
+ function set(next) {
37
+ armed = next;
38
+ }
39
+
40
+ // clear() lets go of whatever is armed; clear(a) only of +a+ (a send's ack
41
+ // for the claim it took, not one armed since).
42
+ function clear(...only) {
43
+ if (only.length && only[0] !== armed) return;
44
+ armed = null;
45
+ }
46
+
47
+ // A verdict ({check_turn_id, verdict, fresh?, by?…}) onto its check's
48
+ // card: into the session's list once, and a fresh one ends its check's
49
+ // "verifying fresh".
50
+ function placeVerdict(record) {
51
+ if (!record) return;
52
+ const known = (store.current.verdicts || []).some((item) => item.check_turn_id === record.check_turn_id);
53
+ const patch = {};
54
+ if (!known) patch.verdicts = [...(store.current.verdicts || []), record];
55
+ // A fresh verdict ends its check's "verifying fresh" (the card shows it).
56
+ if (record.fresh) patch.verifyFresh = settleRunning(store.current.verifyFresh, record);
57
+ store.update({ current: patch });
58
+ const badge = record.by && authorBadge(record.by);
59
+ placeVerdictWithFresh(historyEl, record, {
60
+ who: badge ? badge.name : "You",
61
+ author: record.by || null,
62
+ roots: CLAIM_ROOTS,
63
+ opts: claim.opts(),
64
+ onMissingCheck: (missing) => {
65
+ const hash = ownerCheckHash(store.sessions, store.selected, missing.check_turn_id);
66
+ if (hash) win.location.hash = hash;
67
+ else toast(CHECK_NOT_ON_PAGE);
68
+ },
69
+ }, freshState());
70
+ }
71
+
72
+ // The owner's Verify fresh on an unclear verdict's card (verdict_fresh.js):
73
+ // always theirs to start; its running check links to the child's session.
74
+ function freshState() {
75
+ const id = store.selected;
76
+ const unavailable = verifyWorkerUnavailable(store.current.workerFeatures, store.current.bridgeUp);
77
+ return {
78
+ verdicts: store.current.verdicts || [],
79
+ running: store.current.verifyFresh || [],
80
+ errors: freshErrors,
81
+ canStart: !!id && !unavailable,
82
+ unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
83
+ start: async (of) => {
84
+ try {
85
+ const body = await api(freshURL(id, of), { method: "POST", body: "{}" });
86
+ return { ok: true, child_id: body.child_id || null };
87
+ } catch (error) {
88
+ return { ok: false, error: error.message };
89
+ }
90
+ },
91
+ childHref: (childId) => sessionHash(childId),
92
+ };
93
+ }
94
+
95
+ // The session's saved verdicts, onto the history just drawn.
96
+ function renderSaved() {
97
+ for (const record of store.current.verdicts || []) placeVerdict(record);
98
+ }
99
+
100
+ // Verify's claim: a selection in an ended turn's final answer (the only
101
+ // text Engine#verified_anchor takes; verify_target.js), else null and no
102
+ // Verify on the bar.
103
+ function anchorFor({ source, range }) {
104
+ if (source?.kind !== "answer" || !range) return null;
105
+ const running = liveTurn.running ? { turnId: liveTurn.timing.turnId || null } : null;
106
+ const turnId = verifyTurnOf(historyEl, source.root, OWNER_FINAL, { running });
107
+ const quote = turnId ? selectionClaim(source.root, range, claim.opts()).q : "";
108
+ return turnId && quote ? { turn_id: turnId, quote } : null;
109
+ }
110
+
111
+ return { placeVerdict, freshState, renderSaved, anchorFor, anchor, set, clear };
112
+ }
@@ -10,7 +10,7 @@
10
10
  // - live_timing.js sets `timing().activeTurn` in place (start, finish,
11
11
  // finishLine): the running turn's own clock, which mergeTurnTiming keeps.
12
12
  // - verdict_fresh.js `startFresh` pushes onto `fresh.running`, which is
13
- // `verifyFresh` (verifyFreshState hands the array out): returning the
13
+ // `verifyFresh` (owner_verify.js freshState hands the array out): returning the
14
14
  // entry for an update would leave the card holding the old array.
15
15
  //
16
16
  // A new module: the page's modules are cached at unversioned URLs, and a
@@ -46,12 +46,12 @@ export const FIELDS = {
46
46
  verdicts: {
47
47
  source: (d) => list(d.session?.verdicts),
48
48
  redraws: [],
49
- writers: "placeVerdict (its card draws itself: placeVerdictWithFresh)",
49
+ writers: "owner_verify.js placeVerdict (its card draws itself: placeVerdictWithFresh)",
50
50
  },
51
51
  verifyFresh: {
52
52
  source: (d) => list(d.session?.verify_fresh),
53
53
  redraws: [],
54
- writers: "placeVerdict (settleRunning); verdict_fresh.js startFresh pushes in place (D8)",
54
+ writers: "owner_verify.js placeVerdict (settleRunning); verdict_fresh.js startFresh pushes in place (D8)",
55
55
  },
56
56
  createdAt: { redraws: ["infoBar"], writers: "the payload" },
57
57
  ctxPct: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_chunk, context_status)" },
@@ -1,15 +1,15 @@
1
1
  import { GRIP_OPEN_PX } from "./composer_size.js";
2
2
  import { stripGripAction, stripGripTitle } from "./strip.js";
3
3
 
4
- // The latest-sessions strip's grip (used by app.js): the handle on the edge
4
+ // The latest-sessions strip's grip (used by strip_hidden.js): the handle on the edge
5
5
  // between the strip and the chat, the composer's grip's twin, mirrored. A
6
6
  // drag up (or a tap) hides the strip, a drag down (or a tap) shows it, and
7
7
  // Enter or Space toggles too. The strip snaps: unlike the composer's box it
8
8
  // does not follow the pointer, so nothing is measured while dragging — only
9
9
  // whether the release would act (.will-toggle, the pill in the accent).
10
10
  // The grip owns its title and aria-expanded (composer_grip.js's
11
- // drawFoldControls does the same for the composer's): app.js calls the
12
- // returned refresh() from applyStripHidden, and nothing else writes them.
11
+ // drawFoldControls does the same for the composer's): strip_hidden.js calls
12
+ // the returned refresh() from its apply, and nothing else writes them.
13
13
  // No click listener: a click still fires after pointerdown's preventDefault
14
14
  // and would toggle twice. No document listener either: the composer's grip
15
15
  // needs one for typing on the page, this one acts only on itself.
@@ -0,0 +1,65 @@
1
+ import { readStored, writeStored } from "./storage.js";
2
+ import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
3
+ import { createStripGrip } from "./strip_grip.js";
4
+
5
+ // The saved choice (storage.js): the strip hidden; chi_zen is zen mode's old
6
+ // name, read when the new key was never written.
7
+ export const STRIP_HIDDEN_KEY = "chi_strip_hidden";
8
+ const ZEN_KEY = "chi_zen";
9
+
10
+ // The latest-sessions strip hidden or shown (used by app.js): the body's
11
+ // strip-hidden class, the grip (strip_grip.js) that toggles it, and the short
12
+ // window's rule. The strip can be hidden for more chat room; the choice stays
13
+ // per browser (it replaced zen mode, so a zen user starts hidden). A short
14
+ // window hides the strip by itself, so the stage keeps room for a card that
15
+ // waits (its question and choices); the grip brings it back until the window
16
+ // is tall again. Not saved: the saved choice is the user's own hide or show.
17
+ //
18
+ // +gripEl+: #stripGrip. +body+: the page's body (the class). +onApply()+:
19
+ // after each change (the top bar's title follows: app.js updateSessionTitle).
20
+ // +closeFamilyPop()+: a hide takes the strip's family popover along. +win+:
21
+ // the window (matchMedia). +storage+: storage.js's guarded read and write.
22
+ // Applied once at creation.
23
+ export function createStripHidden({
24
+ gripEl, body = globalThis.document.body, onApply, closeFamilyPop, win = globalThis,
25
+ storage = { read: readStored, write: writeStored },
26
+ }) {
27
+ const shortWindow = win.matchMedia(SHORT_WINDOW_QUERY);
28
+ let shownWhileShort = false;
29
+
30
+ function saved() {
31
+ const value = storage.read(STRIP_HIDDEN_KEY);
32
+ return value === null ? storage.read(ZEN_KEY) === "1" : value === "1";
33
+ }
34
+
35
+ // The grip is given the hidden state and the toggle; it speaks its own
36
+ // title and aria-expanded, refreshed after every change.
37
+ const gripRefresh = createStripGrip({
38
+ gripEl,
39
+ isHidden: () => body.classList.contains("strip-hidden"),
40
+ setHidden: (hidden) => setHidden(hidden),
41
+ });
42
+
43
+ function apply() {
44
+ body.classList.toggle("strip-hidden", stripAutoHidden({
45
+ saved: saved(), short: shortWindow.matches, shownWhileShort,
46
+ }));
47
+ gripRefresh();
48
+ onApply();
49
+ }
50
+
51
+ function setHidden(hidden) {
52
+ if (hidden) closeFamilyPop();
53
+ storage.write(STRIP_HIDDEN_KEY, hidden ? "1" : "0");
54
+ shownWhileShort = !hidden && shortWindow.matches;
55
+ apply();
56
+ }
57
+
58
+ apply();
59
+ shortWindow.addEventListener("change", () => {
60
+ if (!shortWindow.matches) shownWhileShort = false;
61
+ apply();
62
+ });
63
+
64
+ return { setHidden };
65
+ }
@@ -0,0 +1,132 @@
1
+ import { html } from "./vendor/lit-html.js";
2
+ import { cantText } from "./format.js";
3
+ import { workerBadgeFor } from "./info_bar_model.js";
4
+ import { readStored, writeStored } from "./storage.js";
5
+ import { createUpdateSheet } from "./update_sheet.js";
6
+ import { mergeVersionState, restartConfirmText, updateFrameToast, versionNotice } from "./update_notice.js";
7
+
8
+ // An available release's notice is once per version per browser, not per
9
+ // tab: chi web says it in every snapshot until the owner updates.
10
+ export const AVAILABLE_SHOWN_KEY = "chi_update_available_shown";
11
+
12
+ // The owner page's chi versions (used by app.js): the versions carried by each
13
+ // event snapshot and `chi` frame, the toasts they bring (chi web upgraded, a
14
+ // newer one installed, an aged one available to update to), the update sheet
15
+ // (update_sheet.js) and its `chi_update` frames, and the open session's
16
+ // worker Restart.
17
+ //
18
+ // +store+: the page's session store (selected and current read; it writes no
19
+ // field, only touch("infoBar"): the worker badge and the Restart button read
20
+ // the versions and the restarting set). +toasts+: toasts.js's. +dialog+: the
21
+ // update sheet's <dialog>. +loadedVersion+: the chi this page was loaded
22
+ // from (body data-version). +api+: {updateChi, restartWorkers, restartWeb,
23
+ // restartSession} (data.js). +confirm+, +reload+, +clipboard()+: the
24
+ // browser's; +storage+: storage.js's guarded read and write.
25
+ export function createVersionNotices({
26
+ store, toasts, dialog, loadedVersion, api,
27
+ confirm = (text) => globalThis.confirm(text),
28
+ reload = () => globalThis.location.reload(),
29
+ clipboard = () => globalThis.navigator?.clipboard,
30
+ storage = { read: readStored, write: writeStored },
31
+ }) {
32
+ const toast = (text, action = null, options = {}) => { toasts.show(text, action, options); };
33
+ const sheet = createUpdateSheet({
34
+ dialog,
35
+ update: api.updateChi,
36
+ restartWorkers: api.restartWorkers,
37
+ restartWeb: api.restartWeb,
38
+ });
39
+ // Once per notice per tab: every reconnect's snapshot says it again.
40
+ const versions = { served: null, installed: null };
41
+ // The sessions whose Restart is under way (their button says so): one
42
+ // session's restart never blocks another's.
43
+ const restarting = new Set();
44
+ const shown = new Set();
45
+ const availableShown = storage.read(AVAILABLE_SHOWN_KEY);
46
+ if (availableShown) shown.add(`available:${availableShown}`);
47
+ let versionToast = null;
48
+
49
+ function notice(data) {
50
+ Object.assign(versions, mergeVersionState(versions, data));
51
+ const next = versionNotice({ loaded: loadedVersion, served: versions.served,
52
+ installed: versions.installed, available: versions.available,
53
+ updateBlocked: versions.updateBlocked, auto: versions.auto, restart: versions.restart,
54
+ autoOffers: versions.autoOffers, shown });
55
+ store.touch("infoBar"); // the worker badge compares with the newest installed
56
+ if (!next) return;
57
+ shown.add(next.key);
58
+ if (next.key.startsWith("available:")) storage.write(AVAILABLE_SHOWN_KEY, next.key.slice("available:".length));
59
+ const text = next.releaseUrl
60
+ ? html`${next.text} <a href=${next.releaseUrl} target="_blank" rel="noopener noreferrer">RubyGems</a>`
61
+ : next.text;
62
+ const action = next.reload
63
+ ? { label: "Reload", run: () => reload() }
64
+ : next.updateVersion
65
+ ? { label: "Update", run: () => sheet.install(next.updateVersion) }
66
+ : next.actions
67
+ ? next.actions.map((name) => ({
68
+ label: name === "restartWorkers" ? "Restart workers" : "Restart chi web",
69
+ run: () => (name === "restartWorkers" ? sheet.restartWorkers() : sheet.restartWeb()),
70
+ }))
71
+ : next.command
72
+ ? { label: "Copy", run: () => copyUpdateCommand(next.command) }
73
+ : null;
74
+ // One version toast at a time: a newer notice (Reload after a restart)
75
+ // supersedes the one before it.
76
+ toasts.dismiss(versionToast);
77
+ versionToast = toasts.show(text, action, { sticky: true });
78
+ }
79
+
80
+ // A `chi_update` frame (+snapshot+: the one an event snapshot carries).
81
+ function frame(data, { snapshot = false } = {}) {
82
+ const wasOpen = sheet.isOpen();
83
+ if (!sheet.receive(data)) return;
84
+ if (data.operation === "install" && data.state === "done" && data.version) notice({ installed: data.version });
85
+ const text = updateFrameToast(data, { wasOpen, snapshot });
86
+ if (text) toast(text, { label: "View", run: () => sheet.view() }, { sticky: true });
87
+ }
88
+
89
+ // An event snapshot's version after Restart chi web: the sheet says done.
90
+ function reconnected(version) {
91
+ if (sheet.reconnected(version)) toast(`chi web reconnected on chi ${version}`);
92
+ }
93
+
94
+ function copyUpdateCommand(command) {
95
+ const board = clipboard();
96
+ if (!board?.writeText) {
97
+ toast(`Clipboard unavailable; run ${command}`);
98
+ return;
99
+ }
100
+ board.writeText(command).then(
101
+ () => toast(`Copied ${command}`),
102
+ () => toast(`Couldn't copy ${command}`),
103
+ );
104
+ }
105
+
106
+ // The open session's worker badge (info_bar_model.js workerBadgeFor).
107
+ function badge() {
108
+ return workerBadgeFor(store.selected, store.current, versions);
109
+ }
110
+
111
+ // Restart: the session moves to a new worker on the newest chi; the page
112
+ // follows it (its bridge_up), the composer's draft stays as it is.
113
+ async function restart() {
114
+ const current = badge();
115
+ if (!current || !current.restart || restarting.has(store.selected)) return;
116
+ if (!confirm(restartConfirmText(store.current.workerVersion, current.newest))) return;
117
+ const id = store.selected;
118
+ restarting.add(id);
119
+ store.touch("infoBar");
120
+ try {
121
+ const result = await api.restartSession(id);
122
+ toast(result.version ? `Restarted on chi ${result.version}` : "Restarting: the new worker is starting");
123
+ } catch (e) {
124
+ toast(cantText("restart", e), null, { sticky: true });
125
+ } finally {
126
+ restarting.delete(id);
127
+ store.touch("infoBar");
128
+ }
129
+ }
130
+
131
+ return { notice, frame, reconnected, versions: () => versions, restarting, restart };
132
+ }
@@ -1793,6 +1793,12 @@ module Samagotchi
1793
1793
  # once) and leaves the recap to the conversation's next real pause.
1794
1794
  def left(result)
1795
1795
  deleting = result == :exit_requested && @exit_deletes
1796
+ # The attached context goes with this process: a change that landed after
1797
+ # the leaving pass looked at it has no later pass to note it, so note it
1798
+ # here (no wake turn: the worker is on its way out). Not into a session
1799
+ # /exit --delete deletes next: its save could land after the delete and
1800
+ # write the session back.
1801
+ absorb_context unless deleting
1796
1802
  # The watch goes with this process: a task that finished since the
1797
1803
  # last look (nothing held the exit for it: queue, a note-only task,
1798
1804
  # /exit) is a plain note for the next turn, not lost. Not into a
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.152.0
4
+ version: 0.153.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -289,6 +289,8 @@ files:
289
289
  - lib/samagotchi/bundles/system/manifest.yml
290
290
  - lib/samagotchi/bundles/system/memory_guide.md
291
291
  - lib/samagotchi/bundles/system/self_map.md
292
+ - lib/samagotchi/bundles/web-search/manifest.yml
293
+ - lib/samagotchi/bundles/web-search/plugin.rb
292
294
  - lib/samagotchi/bundles/work-cap/manifest.yml
293
295
  - lib/samagotchi/bundles/work-cap/plugin.rb
294
296
  - lib/samagotchi/cache_confirm.rb
@@ -855,6 +857,7 @@ files:
855
857
  - lib/samagotchi/web/public/composer.js
856
858
  - lib/samagotchi/web/public/composer_fold.js
857
859
  - lib/samagotchi/web/public/composer_grip.js
860
+ - lib/samagotchi/web/public/composer_mode.js
858
861
  - lib/samagotchi/web/public/composer_mount.js
859
862
  - lib/samagotchi/web/public/composer_size.js
860
863
  - lib/samagotchi/web/public/context_chips.js
@@ -904,6 +907,8 @@ files:
904
907
  - lib/samagotchi/web/public/objectives_moves.js
905
908
  - lib/samagotchi/web/public/objectives_strip.js
906
909
  - lib/samagotchi/web/public/objectives_wiring.js
910
+ - lib/samagotchi/web/public/owner_claim.js
911
+ - lib/samagotchi/web/public/owner_verify.js
907
912
  - lib/samagotchi/web/public/participant.html
908
913
  - lib/samagotchi/web/public/participant.js
909
914
  - lib/samagotchi/web/public/participant_annotate.js
@@ -960,6 +965,7 @@ files:
960
965
  - lib/samagotchi/web/public/stream_handlers.js
961
966
  - lib/samagotchi/web/public/strip.js
962
967
  - lib/samagotchi/web/public/strip_grip.js
968
+ - lib/samagotchi/web/public/strip_hidden.js
963
969
  - lib/samagotchi/web/public/sw.js
964
970
  - lib/samagotchi/web/public/system_one_wrong.js
965
971
  - lib/samagotchi/web/public/tally.js
@@ -993,6 +999,7 @@ files:
993
999
  - lib/samagotchi/web/public/verify_card.css
994
1000
  - lib/samagotchi/web/public/verify_preset.js
995
1001
  - lib/samagotchi/web/public/verify_target.js
1002
+ - lib/samagotchi/web/public/version_notices.js
996
1003
  - lib/samagotchi/web/public/worker_features.js
997
1004
  - lib/samagotchi/web/public/working_set.js
998
1005
  - lib/samagotchi/web/public/working_set_rail.js