samagotchi 0.71.0 → 0.73.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/docs/architecture.md +15 -9
  4. data/docs/cli.md +12 -5
  5. data/docs/configuration.md +23 -13
  6. data/docs/hooks.md +5 -3
  7. data/docs/internals/anthropic.md +1 -1
  8. data/docs/internals/background-tasks.md +2 -2
  9. data/docs/internals/engine.md +38 -13
  10. data/docs/internals/llm-context-live.md +1 -1
  11. data/docs/internals/prompt-caching.md +6 -0
  12. data/docs/plugins.md +18 -7
  13. data/docs/sessions.md +5 -3
  14. data/docs/testing.md +10 -1
  15. data/lib/samagotchi/bootstrap/config_writer.rb +2 -3
  16. data/lib/samagotchi/bridge.rb +5 -2
  17. data/lib/samagotchi/broadcast/scope_card.rb +2 -2
  18. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -7
  19. data/lib/samagotchi/bundles/claude-peer/plugin.rb +2 -2
  20. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -6
  21. data/lib/samagotchi/bundles/codex-peer/plugin.rb +2 -2
  22. data/lib/samagotchi/bundles/coordinator/manifest.yml +8 -6
  23. data/lib/samagotchi/bundles/coordinator/plugin.rb +10 -0
  24. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +2 -1
  25. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  27. data/lib/samagotchi/config.rb +7 -25
  28. data/lib/samagotchi/context_note.rb +2 -6
  29. data/lib/samagotchi/effective_model.rb +1 -1
  30. data/lib/samagotchi/engine.rb +91 -213
  31. data/lib/samagotchi/engine_peers.rb +2 -0
  32. data/lib/samagotchi/event_routes.rb +83 -0
  33. data/lib/samagotchi/hook_routes.rb +16 -37
  34. data/lib/samagotchi/host_config.rb +1 -1
  35. data/lib/samagotchi/host_keys.rb +0 -2
  36. data/lib/samagotchi/paths.rb +13 -0
  37. data/lib/samagotchi/plugin/api.rb +11 -5
  38. data/lib/samagotchi/plugin/peer.rb +5 -8
  39. data/lib/samagotchi/prompt_files.rb +2 -4
  40. data/lib/samagotchi/session.rb +7 -1
  41. data/lib/samagotchi/session_chain.rb +24 -0
  42. data/lib/samagotchi/session_continue.rb +3 -1
  43. data/lib/samagotchi/session_manager.rb +6 -16
  44. data/lib/samagotchi/session_model_settings.rb +227 -0
  45. data/lib/samagotchi/session_setup.rb +2 -1
  46. data/lib/samagotchi/system_prompt.rb +18 -13
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -14
  48. data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
  49. data/lib/samagotchi/tool_activity.rb +1 -0
  50. data/lib/samagotchi/tool_declarations.rb +4 -0
  51. data/lib/samagotchi/toolkit.rb +55 -24
  52. data/lib/samagotchi/toolkit_binding.rb +7 -5
  53. data/lib/samagotchi/toolkit_command.rb +28 -5
  54. data/lib/samagotchi/tools/builtins.rb +2 -1
  55. data/lib/samagotchi/tools/delegate.rb +32 -6
  56. data/lib/samagotchi/tools/execute.rb +1 -15
  57. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  58. data/lib/samagotchi/tools/peers.rb +5 -2
  59. data/lib/samagotchi/tools/register_reminder.rb +1 -0
  60. data/lib/samagotchi/tools/registry.rb +1 -1
  61. data/lib/samagotchi/tools/task_runtime.rb +69 -6
  62. data/lib/samagotchi/tools/tool_index.rb +15 -4
  63. data/lib/samagotchi/turn_note.rb +4 -1
  64. data/lib/samagotchi/version.rb +1 -1
  65. data/lib/samagotchi/web/app.rb +40 -13
  66. data/lib/samagotchi/web/public/app.js +53 -28
  67. data/lib/samagotchi/web/public/bubbles.js +5 -3
  68. data/lib/samagotchi/web/public/cards_place.js +6 -10
  69. data/lib/samagotchi/web/public/chains.js +22 -2
  70. data/lib/samagotchi/web/public/chip_popover.js +3 -1
  71. data/lib/samagotchi/web/public/composer.js +13 -6
  72. data/lib/samagotchi/web/public/context_chips.js +25 -12
  73. data/lib/samagotchi/web/public/data.js +14 -1
  74. data/lib/samagotchi/web/public/format.js +5 -0
  75. data/lib/samagotchi/web/public/index.html +39 -2
  76. data/lib/samagotchi/web/public/notice_links.js +55 -0
  77. data/lib/samagotchi/web/public/question_cards.js +33 -1
  78. data/lib/samagotchi/web/public/sessions_list.js +2 -3
  79. data/lib/samagotchi/web/public/stage_frame.js +1 -1
  80. data/lib/samagotchi/web/public/stage_slots.js +35 -3
  81. data/lib/samagotchi/web/public/stage_view.js +17 -6
  82. data/lib/samagotchi/web/public/stream_handlers.js +6 -3
  83. data/lib/samagotchi/web/public/timing.js +1 -1
  84. data/lib/samagotchi/web/public/toolkit_chip.js +3 -1
  85. data/lib/samagotchi/web/public/turn_view.js +3 -2
  86. data/lib/samagotchi/web/session_hub.rb +8 -0
  87. metadata +4 -1
@@ -168,6 +168,11 @@ export function goneSessionNotice(id, why) {
168
168
  return `Session ${String(id).slice(0, 8)} was ${why === "deleted" ? "deleted" : "not found"}.`;
169
169
  }
170
170
 
171
+ // A failed action's toast: what failed and why (api()'s error says why).
172
+ export function cantText(what, error) {
173
+ return `Can't ${what}: ${String(error?.message ?? error)}`;
174
+ }
175
+
171
176
  // A session in the list after its live status changed (a turn started or
172
177
  // ended): that is activity now, so its card's "N min ago" says so too, and
173
178
  // the change came from a worker's stream, so the card says "live".
@@ -326,6 +326,22 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
326
326
  .bubble.question>summary .question-said{max-width:72ch;margin:2px 0 1px 1.05em;font-size:12.5px;font-weight:400;line-height:1.5;color:var(--text-muted);white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden}
327
327
  .bubble.question[open]>summary .question-said{display:none}
328
328
  .question-said-full{margin:4px 0 6px;font-size:13px;line-height:1.55;color:var(--text-muted);white-space:pre-wrap;overflow-wrap:anywhere}
329
+ /* Its markdown once the card is first opened (question_cards.js renderSaidOnOpen), as compact as the stage's. */
330
+ .question-said-full.markdown{white-space:normal}
331
+ .question-said-full.markdown :is(h1,h2,h3,h4,h5,h6){font-size:13.5px;font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--text-2)}
332
+ .question-said-full.markdown :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
333
+ .question-said-full.markdown>:first-child{margin-top:0}
334
+ .question-said-full.markdown>:last-child{margin-bottom:0}
335
+ .question-said-full.markdown :is(ul,ol){padding-left:1.3em}
336
+ .question-said-full.markdown li>:is(ul,ol){margin:0}
337
+ .question-said-full.markdown hr{border:0;border-top:1px solid var(--border);margin:8px 0}
338
+ .question-said-full.markdown blockquote{border-left:3px solid var(--border-strong);padding-left:10px}
339
+ .question-said-full.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
340
+ .question-said-full.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:0 4px}
341
+ .question-said-full.markdown pre{overflow:auto;padding:6px 8px;border-radius:6px;background:var(--bg-inset);border:1px solid var(--border);font-size:12px;line-height:1.45;white-space:pre}
342
+ .question-said-full.markdown table{border-collapse:collapse;display:block;max-width:100%;overflow:auto}
343
+ .question-said-full.markdown :is(th,td){border:1px solid var(--border-strong);padding:3px 7px;font-size:12.5px}
344
+ .question-said-full.markdown th{background:var(--bg-inset);font-weight:600}
329
345
  .bubble.command{border-left:3px solid var(--text-faint);background:var(--bg-panel);font-size:13px}
330
346
  .bubble.note{border-left:3px dotted var(--text-faint);background:transparent;font-size:12px;color:var(--text-muted);opacity:.85}
331
347
  .note-line{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-faint);margin-bottom:2px}
@@ -438,6 +454,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
438
454
  .gen .activity-body>.hook-notice:first-child{border-top:none}
439
455
  .gen .hook-notice.warn{border-left-color:var(--warn);color:var(--warn-text)}
440
456
  .hook-notice.llm-context{border-left-style:dashed;cursor:help}
457
+ .hook-notice a{color:inherit;text-decoration:underline;text-underline-offset:2px}
458
+ .hook-notice a:hover{text-decoration-thickness:2px}
441
459
  /* A plugin's steer (a nudge into the running turn): a row of its step, or a bubble; its text collapsed. */
442
460
  .steer-row{font-size:13px;color:var(--text-3);border-left:2px solid var(--accent-border)}
443
461
  .steer-row>summary{cursor:pointer;list-style:none;font-style:italic}
@@ -708,9 +726,28 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
708
726
  .ts-headline.thinking{font-size:13.5px;font-style:italic;color:var(--text-muted)}
709
727
  .ts-think-tag{font-style:normal;font-size:12.5px;color:var(--think-text);margin-right:6px}
710
728
  .ts-headline.fresh{animation:ts-fade .3s ease}
711
- /* While a question or approval waits: the whole reply that asked it, raw as a closed step's text, in the headline's
712
- place; capped so the card keeps its room, read from the top (stage_view.js drawAsking). */
729
+ /* While a question or approval waits: the whole reply that asked it, in the headline's place; raw as a closed step's
730
+ text, then its markdown when web.markdown is on (.markdown, stage_slots.js drawAsking); capped so the card keeps
731
+ its room, read from the top. */
713
732
  .ts-asking{max-height:calc(var(--stage-max,60vh) * .35);overflow:auto;scrollbar-width:thin;white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.55;color:var(--narr);border-left:2px solid var(--border-strong);padding-left:10px}
733
+ /* Its markdown, compact: about the raw text's metrics, so the swap barely moves the slot. */
734
+ .ts-asking.markdown{white-space:normal}
735
+ .ts-asking.markdown :is(h1,h2,h3,h4,h5,h6){font-size:14.5px;font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--text-bright)}
736
+ .ts-asking.markdown :is(h4,h5,h6){font-size:14px;color:var(--text-2)}
737
+ .ts-asking.markdown :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
738
+ .ts-asking.markdown>:first-child{margin-top:0}
739
+ .ts-asking.markdown>:last-child{margin-bottom:0}
740
+ .ts-asking.markdown :is(ul,ol){padding-left:1.3em}
741
+ .ts-asking.markdown li>:is(ul,ol){margin:0}
742
+ .ts-asking.markdown strong{color:var(--text-bright)}
743
+ .ts-asking.markdown hr{border:0;border-top:1px solid var(--border);margin:8px 0}
744
+ .ts-asking.markdown blockquote{border-left:3px solid var(--border-strong);padding-left:10px;color:var(--text-muted)}
745
+ .ts-asking.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
746
+ .ts-asking.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:0 4px;color:var(--text)}
747
+ .ts-asking.markdown pre{overflow:auto;padding:6px 8px;border-radius:6px;background:var(--bg-inset);border:1px solid var(--border);font-size:12.5px;line-height:1.45;white-space:pre}
748
+ .ts-asking.markdown table{border-collapse:collapse;display:block;max-width:100%;overflow:auto}
749
+ .ts-asking.markdown :is(th,td){border:1px solid var(--border-strong);padding:3px 7px;font-size:13px}
750
+ .ts-asking.markdown th{background:var(--bg-inset);font-weight:600}
714
751
  .turn-stage.asking .ts-headline{display:none}
715
752
  @keyframes ts-fade{from{opacity:.35}}
716
753
  .ts-now{height:var(--ts-now-h);display:flex;align-items:center;gap:8px;min-width:0;padding:0 9px;border-radius:8px;background:var(--accent-tint);font-size:13px}
@@ -0,0 +1,55 @@
1
+ // A hook notice line's http(s) URLs as links (a source-links `sources:`
2
+ // line with markdown off): the terminals need the line as text, the web can
3
+ // click it. Only http(s) URLs that refs.js httpUrl accepts (no userinfo, not
4
+ // over-long) are linked; everything else stays text, escaped by lit (no
5
+ // unsafeHTML). A link opens in a new tab: refs.js installRefClicks leaves a
6
+ // plain <a> (no data-ref-kind) to the browser.
7
+ import { html } from "./vendor/lit-html.js";
8
+ import { httpUrl } from "./refs.js";
9
+
10
+ // A candidate: the scheme, then anything up to a space or an angle bracket
11
+ // (`<https://x/a>` links https://x/a).
12
+ const CANDIDATE = /https?:\/\/[^\s<>]+/gi;
13
+ const TRAILING = /[.,;:!?'"]+$/;
14
+
15
+ // +url+ cut at its first ) that no ( inside it balances: `(https://x/a)`
16
+ // ends before the ), `…/Foo_(bar)` keeps it (source_links.rb trim_url_end).
17
+ function cutUnbalancedParen(url) {
18
+ let depth = 0;
19
+ for (let i = 0; i < url.length; i++) {
20
+ if (url[i] === "(") depth++;
21
+ else if (url[i] === ")") {
22
+ if (depth === 0) return url.slice(0, i);
23
+ depth--;
24
+ }
25
+ }
26
+ return url;
27
+ }
28
+
29
+ // A candidate without what ends the sentence around it: trailing
30
+ // punctuation (the sources line's ", "), an unbalanced ), a ] no [ opens.
31
+ function trimUrl(candidate) {
32
+ let url = cutUnbalancedParen(candidate.replace(TRAILING, ""));
33
+ if (url.endsWith("]") && !url.includes("[")) url = url.replace(/\]+$/, "");
34
+ return url.replace(TRAILING, "");
35
+ }
36
+
37
+ // +text+ with each http(s) URL a link (a lit template's array of strings
38
+ // and <a> parts); +text+ itself when it links nothing, so a URL-less line
39
+ // renders exactly as before.
40
+ export function linkedLine(text) {
41
+ if (typeof text !== "string" || !text) return text;
42
+ const parts = [];
43
+ let last = 0;
44
+ for (const match of text.matchAll(CANDIDATE)) {
45
+ const shown = trimUrl(match[0]);
46
+ const href = httpUrl(shown);
47
+ if (!href) continue;
48
+ if (match.index > last) parts.push(text.slice(last, match.index));
49
+ parts.push(html`<a href=${href} target="_blank" rel="noopener noreferrer">${shown}</a>`);
50
+ last = match.index + shown.length;
51
+ }
52
+ if (!parts.length) return text;
53
+ if (last < text.length) parts.push(text.slice(last));
54
+ return parts;
55
+ }
@@ -31,6 +31,9 @@ import { continueLine } from "./commands.js";
31
31
  // @param removeHintIfEmpty () drops the history's placeholder hint
32
32
  // @param askingText () => the running turn's asking reply (turn_model.js
33
33
  // askingText via the page's view), kept by a question or own approval card
34
+ // @param renderMarkdown (text) => Promise<html|null>: the kept reply's
35
+ // markdown (the page's POST /api/render; null keeps the raw text)
36
+ // @param linkRefs (el) the page's path:line ref pass on it
34
37
  // @param api {sendAnswer, dismissQuestion, sendCommand} (data.js)
35
38
  // @param doc the page's document
36
39
  // @return {renderQuestion(pq, {said}), resolveQuestion(id, opts), removeQuestion(),
@@ -38,6 +41,7 @@ import { continueLine } from "./commands.js";
38
41
  // renderContinue(offer), resolveContinue(data), forgetContinue()}
39
42
  export function createQuestionCards({
40
43
  sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty, askingText = () => "",
44
+ renderMarkdown = () => Promise.resolve(null), linkRefs = () => {},
41
45
  api = { sendAnswer, dismissQuestion, sendCommand }, doc = globalThis.document,
42
46
  }) {
43
47
  // Live ask_user_question card, keyed by pending question id. Re-rendered from
@@ -403,13 +407,14 @@ export function createQuestionCards({
403
407
  // the toggle's label), and whole, selectable, at the top of the body
404
408
  // when it is open (a drag in the summary would toggle the card).
405
409
  const asked = saidOf.get(questionEl);
410
+ let full = null;
406
411
  if (summary && asked) {
407
412
  const line = doc.createElement("div");
408
413
  line.className = "question-said";
409
414
  line.setAttribute("aria-hidden", "true");
410
415
  line.textContent = asked;
411
416
  summary.appendChild(line);
412
- const full = doc.createElement("div");
417
+ full = doc.createElement("div");
413
418
  full.className = "question-said-full";
414
419
  full.textContent = asked;
415
420
  questionEl.insertBefore(full, summary.nextSibling);
@@ -421,9 +426,36 @@ export function createQuestionCards({
421
426
  const keepNote = questionEl.dataset.toggled || (!cancelled && deniedReason(pending, answer));
422
427
  if (summary && !keepNote) note.classList.add("hidden");
423
428
  if (!questionEl.dataset.toggled) questionEl.open = false;
429
+ // After the collapse: a card left open by hand renders at once.
430
+ if (full) renderSaidOnOpen(questionEl, full, asked);
424
431
  pending = null;
425
432
  }
426
433
 
434
+ // The body's copy as markdown, from the card's first open on (a reload
435
+ // draws every card in the history: rendering them all would ask once per
436
+ // card). Raw until then, and when the render answers null (then the next
437
+ // open asks again).
438
+ function renderSaidOnOpen(card, full, asked) {
439
+ let state = "raw";
440
+ const draw = () => {
441
+ if (!card.open || state !== "raw") return;
442
+ state = "asking";
443
+ Promise.resolve()
444
+ .then(() => renderMarkdown(asked))
445
+ .catch(() => null)
446
+ .then((html) => {
447
+ if (!html) { state = "raw"; return; }
448
+ state = "done";
449
+ // Sanitised on the server (POST /api/render).
450
+ full.innerHTML = html;
451
+ full.classList.add("markdown");
452
+ linkRefs(full);
453
+ });
454
+ };
455
+ card.addEventListener("toggle", draw);
456
+ draw();
457
+ }
458
+
427
459
  function markQuestionSelection(sel) {
428
460
  if (!questionEl || !sel.length) return;
429
461
  questionEl.querySelectorAll(".question-choice").forEach((button) => {
@@ -264,10 +264,9 @@ export function batchTargets(picked, shownIds, archive, sessionsById) {
264
264
  });
265
265
  }
266
266
 
267
- // A refusal's reason as the card shows it: the server's words, without
268
- // the api() status suffix.
267
+ // A refusal's reason as the card shows it: the server's words.
269
268
  function refusalText(error) {
270
- return String(error?.message || "refused").replace(/\s*\(\d{3}\)$/, "");
269
+ return String(error?.message || "refused");
271
270
  }
272
271
 
273
272
  // Runs +act+ over +ids+ one at a time (a live session's stop waits on the
@@ -13,7 +13,7 @@ import { easeScrollTo } from "./scroll.js";
13
13
  const GROW_MS = 180;
14
14
  const GROW_EASE = "cubic-bezier(.2,.7,.3,1)";
15
15
  // The room between the stage and the composer card (index.html's margin-bottom).
16
- const STAGE_GAP = 10;
16
+ export const STAGE_GAP = 10;
17
17
 
18
18
  export function reducedMotion() {
19
19
  return matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -19,10 +19,19 @@ export function node(tag, cls, parent = null) {
19
19
  // +taskStop+: the page's stopper (task_stop.js), for the stop-task button
20
20
  // on a running task_wait; +gens()+ the running turn's gens (the button's
21
21
  // command, read at its click); +onChange+ redraws (the stage's
22
- // scheduleRefresh).
23
- export function createStageSlots({ headline, asking, now, trail, ticks, taskStop = null, gens, onChange }) {
22
+ // scheduleRefresh). +renderMarkdown(text)+: the asking text's HTML
23
+ // (Promise<string|null>; null keeps the raw text: markdown off, a failed
24
+ // read); +linkRefs(el)+: the page's path:line ref pass on the slot.
25
+ export function createStageSlots({
26
+ headline, asking, now, trail, ticks, taskStop = null, gens, onChange,
27
+ renderMarkdown = () => Promise.resolve(null), linkRefs = () => {},
28
+ }) {
24
29
  // What each slot shows, so an unchanged slot is left alone.
25
30
  const shown = { headline: null, now: null, trail: null, asking: null };
31
+ // The last asking text sent to render and its answer (in flight or
32
+ // done, HTML only): the same question drawn again (after a reset, a
33
+ // blank between) asks no second time.
34
+ let rendering = { text: null, html: null };
26
35
 
27
36
  function drawHeadline({ text, thinking }) {
28
37
  const key = `${thinking ? "t" : "n"}|${text}`;
@@ -42,13 +51,35 @@ export function createStageSlots({ headline, asking, now, trail, ticks, taskStop
42
51
 
43
52
  // Patched only when the text changes (a late chunk to the closed gen
44
53
  // rewrites the same text): a selection in it survives. A new text reads
45
- // from its top.
54
+ // from its top: drawn raw at once, then swapped for its markdown when
55
+ // that comes back while the slot still shows the same text (the text is
56
+ // final: it shows only once the call that asks runs).
46
57
  function drawAsking(text) {
47
58
  asking.hidden = !text;
48
59
  if (shown.asking === text) return;
49
60
  shown.asking = text;
61
+ asking.classList.remove("markdown");
50
62
  asking.textContent = text;
51
63
  asking.scrollTop = 0;
64
+ if (!text) return;
65
+ linkRefs(asking);
66
+ if (rendering.text !== text) {
67
+ let html;
68
+ try { html = Promise.resolve(renderMarkdown(text)).catch(() => null); } catch (_) { html = Promise.resolve(null); }
69
+ rendering = { text, html };
70
+ }
71
+ const pending = rendering;
72
+ pending.html.then((html) => {
73
+ // A null isn't kept: the next draw of this text asks again.
74
+ if (!html && rendering === pending) rendering = { text: null, html: null };
75
+ // Swapped once: a blank-then-same draw while in flight waits twice.
76
+ if (!html || shown.asking !== text || asking.classList.contains("markdown")) return;
77
+ // Sanitised on the server (POST /api/render), as an answer's HTML is.
78
+ asking.innerHTML = html;
79
+ asking.classList.add("markdown");
80
+ asking.scrollTop = 0;
81
+ linkRefs(asking);
82
+ });
52
83
  }
53
84
 
54
85
  function drawNow(tool, quiet) {
@@ -123,6 +154,7 @@ export function createStageSlots({ headline, asking, now, trail, ticks, taskStop
123
154
  function reset() {
124
155
  for (const slot of [headline, asking, now, trail, ticks]) slot.replaceChildren();
125
156
  asking.hidden = true;
157
+ asking.classList.remove("markdown");
126
158
  shown.headline = shown.now = shown.trail = shown.asking = null;
127
159
  }
128
160
 
@@ -48,8 +48,12 @@ function readCollapsed() {
48
48
  // the history only for a reader at its end). +dockEl+, +centerEl+ and
49
49
  // +composerEl+ size the stage's cap.
50
50
  // +taskStop+: the page's stopper (task_stop.js), for the stop-task button
51
- // on a running task_wait.
52
- export function createStageView({ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl, taskStop = null }) {
51
+ // on a running task_wait. +renderMarkdown+ and +linkRefs+: the asking
52
+ // slot's markdown read and ref pass (stage_slots.js).
53
+ export function createStageView({
54
+ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl, taskStop = null,
55
+ renderMarkdown, linkRefs,
56
+ }) {
53
57
  // The dock becomes a clear column: the stage's card, then the composer's
54
58
  // (its rows moved into .dock-card, which wears the glass and the focus ring).
55
59
  document.body.classList.add("stage-view");
@@ -82,7 +86,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
82
86
  headline.setAttribute("role", "button");
83
87
  headline.title = "Show this step's reasoning";
84
88
  // While a question or approval waits: the whole reply that asked it, in
85
- // the headline's place (the card says what to answer, this says why).
89
+ // the headline's place (the card says what to answer, this says why),
90
+ // as markdown when web.markdown is on.
86
91
  const asking = node("div", "ts-asking", live);
87
92
  asking.hidden = true;
88
93
  const now = node("div", "ts-now", live);
@@ -111,7 +116,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
111
116
  // "queued"), each to run as a turn of its own; they hand off last.
112
117
  const queuedSlot = node("div", "ts-queued", stage);
113
118
  const slotsView = createStageSlots({
114
- headline, asking, now, trail, ticks, taskStop,
119
+ headline, asking, now, trail, ticks, taskStop, renderMarkdown, linkRefs,
115
120
  gens: () => view.current().turn?.gens,
116
121
  onChange: () => scheduleRefresh(),
117
122
  });
@@ -127,6 +132,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
127
132
  let endedAt = 0;
128
133
  let thinkingLine = null; // { step, text } from the live step's ticker
129
134
  let flash = null; // { kind, text, until } a notice or nudge in the trail
135
+ let flashTimer = null; // its end's redraw
130
136
  let collapsed = readCollapsed();
131
137
  let cloudOpen = false;
132
138
  // A ref's "show in turn" opened a collapsed stage (not kept).
@@ -252,7 +258,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
252
258
  if (!what) return;
253
259
  flash = { ...what, until: Date.now() + FLASH_MS };
254
260
  scheduleRefresh();
255
- setTimeout(scheduleRefresh, FLASH_MS + 50);
261
+ clearTimeout(flashTimer);
262
+ flashTimer = setTimeout(scheduleRefresh, FLASH_MS + 50);
256
263
  }
257
264
 
258
265
  // The answer reads from its top (no tail pin in the stage): scrolled to
@@ -408,6 +415,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
408
415
  promptSlot.classList.remove("full");
409
416
  thinkingLine = null;
410
417
  flash = null;
418
+ clearTimeout(flashTimer);
411
419
  setCloud(false);
412
420
  stage.classList.remove("plain", "ended", "waiting", "held-wait", "asking", "in-use");
413
421
  stage.style.removeProperty("--live-held");
@@ -590,8 +598,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
590
598
  // composer's delivery line).
591
599
  current: () => view.current(),
592
600
  phase: () => liveSlots(view.current().turn, { pending: pendingCard() || asksCard() }).phase,
601
+ // Another session opens, or this one renders again: a turn still
602
+ // sliding away is dropped (its history is about to be drawn anew),
603
+ // never moved into whatever history shows now.
593
604
  reset() {
594
- if (folding) finishFold();
605
+ folding = null;
595
606
  release();
596
607
  },
597
608
  // The stage's extras while it holds a turn (a card, a prompt for its
@@ -150,12 +150,15 @@ export function createStreamHandlers(deps) {
150
150
  // An after_turn hook presented the answer (AnswerDisplay) or attached
151
151
  // refs to it (AnswerRefs: refs, a count; display null when only refs):
152
152
  // it comes after turn_completed, so re-read the answer once that read
153
- // is done.
153
+ // is done: the session's open then, so a switch meanwhile leaves the
154
+ // other session's answers alone.
154
155
  answer_display: async (data) => {
156
+ const id = selected();
155
157
  try {
156
158
  await liveTurn.endRead;
157
- if (!selected() || (!data.display && !data.refs)) return;
158
- const d = await getSession(selected(), { tail: true, recent: true });
159
+ if (!id || id !== selected() || (!data.display && !data.refs)) return;
160
+ const d = await getSession(id, { tail: true, recent: true });
161
+ if (id !== selected()) return;
159
162
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
160
163
  } catch (_) {
161
164
  } finally {
@@ -278,7 +278,7 @@ function noMatch(record) {
278
278
  // every assistant message is one step. Its calls take the turn's records by
279
279
  // tool, in call order (each the next record of its tool, consumed with the
280
280
  // ones it skipped), not by iteration: an empty retry spends an iteration
281
- // that left no message (as the TUI's join, attached_loop.rb join_duration).
281
+ // that left no message (as the TUI's join, attached_loop.rb join_record).
282
282
  // The last message is the answer when it has text and made no call (and the
283
283
  // turn is no canceled multi-step one); its thinking stays behind as a step,
284
284
  // as it does live.
@@ -5,7 +5,8 @@
5
5
  // worker's snapshot or a command_ran; the server's own reading of the
6
6
  // session file has no lists). Left out is reachable (find_tools and
7
7
  // call_tool reach them) and removed (only /toolkit add brings one back).
8
- // Changing it is /toolkit (no popover yet).
8
+ // Changing it is /toolkit (no popover yet). A kit with a prompt text has
9
+ // it on the summary too (prompt), and the tooltip ends with it.
9
10
 
10
11
  import { html, nothing } from "./vendor/lit-html.js";
11
12
 
@@ -24,6 +25,7 @@ export function chipModel(summary) {
24
25
  parts.push(summary.left_out.length ? `Not loaded: ${summary.left_out.join(", ")}.` : "Nothing left out.");
25
26
  }
26
27
  parts.push("/toolkit lists them; /toolkit NAME, add TOOL or remove TOOL changes them from the next turn.");
28
+ if (summary.prompt) parts.push(`Prompt: ${summary.prompt}`);
27
29
  return { text: `tools ${label}${summary.fallback ? " ?" : ""}`, title: parts.join(" ") };
28
30
  }
29
31
 
@@ -37,6 +37,7 @@ import { createSentenceFeed } from "./sentences.js";
37
37
  import { createTicker } from "./thinking_ticker.js";
38
38
  import * as model from "./turn_model.js";
39
39
  import { createHold } from "./hold.js";
40
+ import { linkedLine } from "./notice_links.js";
40
41
  import { taskCommand, taskStopButton } from "./task_stop.js";
41
42
 
42
43
  // ---- The templates: one step, its thinking, its label ----
@@ -99,10 +100,10 @@ export function stepTemplate(gen, st = {}) {
99
100
 
100
101
  // A hook's line (hook_notice) as a step's row. data.line is a whole row of
101
102
  // chi's own (an empty-answer retry), shown as is; data.cls a class of its
102
- // own and data.title its hover (a ✂ row's).
103
+ // own and data.title its hover (a ✂ row's). Its http(s) URLs are links.
103
104
  function noticeTemplate(data) {
104
105
  const cls = "hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
105
- return html`<div class=${cls} title=${data.title || nothing}>${data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`}</div>`;
106
+ return html`<div class=${cls} title=${data.title || nothing}>${linkedLine(data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`)}</div>`;
106
107
  }
107
108
 
108
109
  // +template+ rendered as detached nodes (a fragment).
@@ -172,6 +172,14 @@ module Samagotchi
172
172
  end
173
173
  end
174
174
 
175
+ # A session's folder as the projection holds it (its file's, read at
176
+ # the last scan or touch): what a live read that loads no session
177
+ # file checks refs against. nil for a session the hub hasn't seen.
178
+ # @return [String, nil]
179
+ def working_directory(id)
180
+ @monitor.synchronize { @sessions[id]&.session&.working_directory }
181
+ end
182
+
175
183
  # Register a sink (#call(event)). With +snapshot:+ the current
176
184
  # projection comes back with the handle, taken in the same step, so
177
185
  # every event after it is newer than it.
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.71.0
4
+ version: 0.73.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -279,6 +279,7 @@ files:
279
279
  - lib/samagotchi/empty_answer_retry.rb
280
280
  - lib/samagotchi/engine.rb
281
281
  - lib/samagotchi/engine_peers.rb
282
+ - lib/samagotchi/event_routes.rb
282
283
  - lib/samagotchi/events.rb
283
284
  - lib/samagotchi/extension_load.rb
284
285
  - lib/samagotchi/file_ref.rb
@@ -459,6 +460,7 @@ files:
459
460
  - lib/samagotchi/session_inbox.rb
460
461
  - lib/samagotchi/session_manager.rb
461
462
  - lib/samagotchi/session_metrics.rb
463
+ - lib/samagotchi/session_model_settings.rb
462
464
  - lib/samagotchi/session_observer.rb
463
465
  - lib/samagotchi/session_retention.rb
464
466
  - lib/samagotchi/session_setting.rb
@@ -612,6 +614,7 @@ files:
612
614
  - lib/samagotchi/web/public/llm_context_chip.js
613
615
  - lib/samagotchi/web/public/model_pick.js
614
616
  - lib/samagotchi/web/public/model_picker.js
617
+ - lib/samagotchi/web/public/notice_links.js
615
618
  - lib/samagotchi/web/public/notifications.js
616
619
  - lib/samagotchi/web/public/notify.js
617
620
  - lib/samagotchi/web/public/prompt_history.js