samagotchi 0.76.0 → 0.78.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +10 -6
  5. data/docs/internals/anthropic.md +73 -5
  6. data/docs/internals/engine.md +2 -1
  7. data/docs/internals/prompt-caching.md +3 -1
  8. data/docs/plugins.md +42 -0
  9. data/docs/sessions.md +1 -0
  10. data/lib/samagotchi/answer_command.rb +5 -5
  11. data/lib/samagotchi/bridge.rb +3 -3
  12. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  13. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  14. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/child_move.rb +2 -2
  17. data/lib/samagotchi/child_reports.rb +3 -3
  18. data/lib/samagotchi/children_status.rb +2 -2
  19. data/lib/samagotchi/engine.rb +21 -12
  20. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  21. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  22. data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
  23. data/lib/samagotchi/llm/anthropic_messages.rb +210 -13
  24. data/lib/samagotchi/llm/anthropic_wire.rb +22 -0
  25. data/lib/samagotchi/llm/chat_loop.rb +49 -2
  26. data/lib/samagotchi/llm/errors.rb +6 -4
  27. data/lib/samagotchi/llm/http.rb +2 -1
  28. data/lib/samagotchi/llm/openai_chat.rb +12 -2
  29. data/lib/samagotchi/memory_bundle/builder.rb +2 -0
  30. data/lib/samagotchi/message_parts.rb +272 -0
  31. data/lib/samagotchi/note_delivery.rb +2 -2
  32. data/lib/samagotchi/parent_report.rb +30 -22
  33. data/lib/samagotchi/pending_question.rb +49 -0
  34. data/lib/samagotchi/plugin/sessions.rb +2 -2
  35. data/lib/samagotchi/question_desk.rb +39 -32
  36. data/lib/samagotchi/relay.rb +8 -9
  37. data/lib/samagotchi/relay_verifier.rb +2 -2
  38. data/lib/samagotchi/relay_watcher.rb +2 -2
  39. data/lib/samagotchi/reply_wait.rb +6 -7
  40. data/lib/samagotchi/row_refs.rb +1 -1
  41. data/lib/samagotchi/session.rb +22 -14
  42. data/lib/samagotchi/session_commands.rb +4 -1
  43. data/lib/samagotchi/session_manager.rb +0 -1
  44. data/lib/samagotchi/session_metrics/totals.rb +4 -3
  45. data/lib/samagotchi/session_metrics.rb +9 -0
  46. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  48. data/lib/samagotchi/terminal_ui/formatting.rb +6 -106
  49. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  50. data/lib/samagotchi/terminal_ui.rb +7 -5
  51. data/lib/samagotchi/tool_output_cut.rb +1 -1
  52. data/lib/samagotchi/tool_response.rb +13 -13
  53. data/lib/samagotchi/tool_row_fields.rb +1 -1
  54. data/lib/samagotchi/tool_runner.rb +16 -9
  55. data/lib/samagotchi/tools/delegate.rb +2 -2
  56. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  57. data/lib/samagotchi/tools/delegate_result.rb +2 -2
  58. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  59. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  60. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +2 -2
  63. data/lib/samagotchi/web/public/app.js +234 -74
  64. data/lib/samagotchi/web/public/composer_fold.js +58 -0
  65. data/lib/samagotchi/web/public/composer_size.js +10 -0
  66. data/lib/samagotchi/web/public/ctx.js +12 -1
  67. data/lib/samagotchi/web/public/index.html +29 -4
  68. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  69. data/lib/samagotchi/web/public/stream_handlers.js +7 -3
  70. data/lib/samagotchi/web/public/toasts.js +69 -0
  71. data/lib/samagotchi/worker.rb +1 -0
  72. metadata +7 -2
  73. data/lib/samagotchi/web/message_parts.rb +0 -274
@@ -590,10 +590,25 @@ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
590
590
  #lightbox[hidden]{display:none}
591
591
  #lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
592
592
  #composer textarea{flex:1 1 auto;min-height:72px;resize:none;background:transparent;border:0;color:var(--text);padding:2px;border-radius:0;font-size:15px;line-height:1.45}
593
- #composeGrip{position:absolute;left:0;right:0;top:-5px;height:10px;cursor:ns-resize;z-index:2;touch-action:none}
593
+ #composeGrip{position:relative;height:10px;margin:-5px 0;cursor:ns-resize;z-index:2;touch-action:none}
594
594
  #composeGrip::after{content:"";position:absolute;left:50%;top:3px;width:36px;height:4px;margin-left:-18px;border-radius:2px;background:var(--border-strong);opacity:0;transition:opacity .12s ease}
595
- #composer:hover #composeGrip::after,#composeGrip.dragging::after{opacity:1}
595
+ #composeGrip:hover::after,#composeGrip:has(+ #composer:hover)::after,#composeGrip.dragging::after{opacity:1}
596
596
  #composeGrip:hover::after,#composeGrip.dragging::after{background:var(--accent)}
597
+ /* Folded (app.js, composer_fold.js): the grip is the handle on the info bar's top edge, drawn always, wider so it reads as a control;
598
+ draft = the accent, waiting = the attention colour; .will-fold = a drag past the fold threshold (released it hides the box). */
599
+ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin-left:-24px}
600
+ #composeGrip.draft::after{background:var(--accent)}
601
+ #composeGrip.waiting::after{background:var(--warn)}
602
+ #composeGrip.will-fold::after{opacity:1;background:var(--accent)}
603
+ #composeGrip:focus-visible{outline:none}
604
+ #composeGrip:focus-visible::after{opacity:1;box-shadow:0 0 0 2px var(--accent-ring)}
605
+ /* A phone has no hover: the pill is always there and the hit zone is taller, ~96 px wide and centred (full width would cover the
606
+ info bar's buttons while folded, the stage card's bottom in the stage view); it reaches 7 px below the edge, the info bar's
607
+ border and padding, so it never covers a button of the bar. */
608
+ @media(max-width:600px){
609
+ #composeGrip{width:96px;height:26px;margin:-19px auto -7px}
610
+ #composeGrip::after{top:17px;opacity:1}
611
+ }
597
612
  #composer textarea::placeholder{color:var(--text-faint)}
598
613
  /* Key hint: its line is always reserved, so focusing never shifts the layout. */
599
614
  #composeHint{order:1;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;line-height:14px;color:var(--text-faint);padding-left:2px;opacity:0;transition:opacity .12s ease;user-select:none}
@@ -851,6 +866,12 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
851
866
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
852
867
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
853
868
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
869
+ /* The composer folded away (composer_fold.js, the fold button): the info bar is the page's bottom edge.
870
+ Stop turn comes into the bar while folded and a turn runs. */
871
+ body.has-session.composer-folded #composer,body.has-session.composer-folded #initRow{display:none}
872
+ body.has-session.composer-folded #infoBar{border-top-color:transparent}
873
+ #infoBar #infoCancelBtn{background:var(--bg-raised);color:var(--danger-text);border:1px solid color-mix(in srgb,var(--danger) 55%,transparent)}
874
+ #infoBar #infoCancelBtn:hover:not(:disabled){color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
854
875
  /* The LLM context and thinking chips (llm_context_chip.js, thinking_chip.js): model-like chips that open their popovers; accent when the session set its own. */
855
876
  #infoBar .meta .llmctx-chip,#infoBar .meta .think-chip,#infoBar .meta .rh-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
856
877
  #infoBar .meta .llmctx-chip:hover,#infoBar .meta .think-chip:hover,#infoBar .meta .rh-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
@@ -932,6 +953,8 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
932
953
  #infoBar>button::before{content:"";display:block;width:14px;height:14px;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
933
954
  #infoBar #infoArchiveBtn{--icon:var(--icon-archive)}
934
955
  #infoBar #infoArchiveBtn.archived{--icon:var(--icon-unarchive)}
956
+ #infoBar #infoContinueBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8h9M8.5 4.5 12 8l-3.5 3.5'/%3E%3C/svg%3E")}
957
+ #infoBar #infoCancelBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='4' width='8' height='8' rx='1.5'/%3E%3C/svg%3E")}
935
958
  #infoBar #infoStopBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v5.5M4.6 4.4a5 5 0 1 0 6.8 0'/%3E%3C/svg%3E")}
936
959
  #infoBar #infoDeleteBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.5 4.5h11M6 4.5V2.75h4V4.5M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6M6.75 7.25v4.5M9.25 7.25v4.5'/%3E%3C/svg%3E")}
937
960
  }
@@ -1121,8 +1144,9 @@ body.all-view #center, body.all-view #topStrip{display:none}
1121
1144
  .card.archived:hover{opacity:.85}
1122
1145
  .card .archived-badge,.family-row .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
1123
1146
  /* bottom: above the session's composer card when one shows (--toast-bottom, app.js placeToast). */
1124
- #toast{position:fixed;left:50%;bottom:var(--toast-bottom,24px);transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
1147
+ #toast{position:fixed;left:50%;bottom:var(--toast-bottom,24px);transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;flex-direction:column;align-items:center;gap:8px}
1125
1148
  #toast[hidden]{display:none}
1149
+ #toast .toast-item{display:flex;gap:12px;align-items:center;width:max-content;max-width:100%;box-sizing:border-box;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
1126
1150
  #toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
1127
1151
  #toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
1128
1152
  #toast .toast-close:hover{color:var(--text)}
@@ -1207,13 +1231,13 @@ a{color:var(--accent)}
1207
1231
  <div id="lightbox" hidden><img alt=""></div>
1208
1232
  <div id="dock">
1209
1233
  <div id="initRow" role="status" aria-live="polite" hidden></div>
1234
+ <div id="composeGrip" tabindex="0" aria-label="Message box" title="Drag to make the message box taller (double-click resets)"></div>
1210
1235
  <div id="composer" style="position:relative">
1211
1236
  <div id="commandList" role="listbox" aria-label="Commands" hidden></div>
1212
1237
  <div id="modelPanel" hidden>
1213
1238
  <label class="model-search"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14" stroke-linecap="round"/></svg><input id="modelSearch" type="text" placeholder="Search models" autocomplete="off" spellcheck="false" role="combobox" aria-label="Search models" aria-autocomplete="list" aria-controls="modelList" aria-expanded="false"></label>
1214
1239
  <div id="modelList" role="listbox" aria-label="Models"></div>
1215
1240
  </div>
1216
- <div id="composeGrip" title="Drag to make the message box taller (double-click resets)"></div>
1217
1241
  <div id="deliveryLine" aria-live="off" hidden></div>
1218
1242
  <div class="compose-col">
1219
1243
  <div id="chips" hidden></div>
@@ -1233,6 +1257,7 @@ a{color:var(--accent)}
1233
1257
  <div id="infoBar">
1234
1258
  <div class="info" id="infoText"></div>
1235
1259
  <div id="contextChips" aria-label="Attached context" hidden></div>
1260
+ <button class="ghost" id="infoCancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
1236
1261
  <button class="ghost" id="infoContinueBtn" title="Continue: start the next link of this session's chain from its recap (this one is archived)" style="display:none">continue →</button>
1237
1262
  <button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
1238
1263
  <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop session</button>
@@ -0,0 +1,53 @@
1
+ // Where the page's rendered markdown and its refs go (moved out of app.js):
2
+ // a text as its markdown HTML, the `path:line` refs in one rendered text,
3
+ // a final answer's rendering and refs, and the refs across the page. The
4
+ // page's state comes in as getters (capabilities, roots, currentDir), read
5
+ // at each call.
6
+ export function createMarkdownPlace({
7
+ capabilities, renderText, roots, touchedFiles, currentDir, linkRefs, applyFinalAnswer, findInTurnRoots, normalize,
8
+ pairAnswerRefs,
9
+ }) {
10
+ // A text the page already shows as its markdown HTML (POST /api/render,
11
+ // sanitised there), or null: markdown off here (no request), or a failed
12
+ // read (the raw text stays).
13
+ function renderMarkdown(text) {
14
+ if (capabilities().display !== true) return Promise.resolve(null);
15
+ return renderText(text)
16
+ .then((d) => (typeof d?.html === "string" && d.html ? d.html : null))
17
+ .catch(() => null);
18
+ }
19
+
20
+ // The `path:line` refs in one rendered text (the stage's asking slot, a
21
+ // kept reply), as linkTextRefs links the answers'.
22
+ function linkRefsIn(el) {
23
+ linkRefs(el, touchedFiles(roots()), currentDir());
24
+ }
25
+
26
+ // The turn's answer as its rendered markdown and with its plugin refs,
27
+ // once it is final (the turn-end read, an answer_display re-read:
28
+ // answer_refs.js applyFinalAnswer); then the refs.
29
+ function applyFinalMarkdown(messages) {
30
+ applyFinalAnswer(messages, findInTurnRoots(".bubble.output"), normalize);
31
+ linkTextRefs();
32
+ }
33
+
34
+ // The refs on the answers' and closed steps' texts, on this page: first
35
+ // each answer's own (a plugin's, answer_refs.js), then the `path:line`
36
+ // refs that name a file the session's rows (and cards) touched
37
+ // (ref_scan.js), which leave a plugin's alone. Not mid-stream: at a
38
+ // turn's end and after a reload's render. A ref already made stays as it
39
+ // is.
40
+ function linkTextRefs() {
41
+ const pageRoots = roots();
42
+ const touched = touchedFiles(pageRoots);
43
+ for (const root of pageRoots) {
44
+ for (const el of root.querySelectorAll(".bubble.output")) pairAnswerRefs(el);
45
+ }
46
+ if (!touched.size) return;
47
+ for (const root of pageRoots) {
48
+ for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, currentDir());
49
+ }
50
+ }
51
+
52
+ return { renderMarkdown, linkRefsIn, applyFinalMarkdown, linkTextRefs };
53
+ }
@@ -75,12 +75,16 @@ export function createStreamHandlers(deps) {
75
75
  generation_started: (data) => {
76
76
  // The request after a /model's unresolved window started: whatever it
77
77
  // brings, the ctx tooltip no longer waits for it (ctxBarTip).
78
- state().ctxUnknownAfterSwitch = false;
78
+ const current = state();
79
+ let contextChanged = current.ctxUnknownAfterSwitch !== false;
80
+ current.ctxUnknownAfterSwitch = false;
79
81
  if (data?.context_window_tokens) {
80
- const current = state();
82
+ const source = data.context_window_source || null;
83
+ contextChanged ||= current.ctxWindow !== data.context_window_tokens || current.ctxWindowSource !== source;
81
84
  current.ctxWindow = data.context_window_tokens;
82
- current.ctxWindowSource = data.context_window_source || null;
85
+ current.ctxWindowSource = source;
83
86
  }
87
+ if (contextChanged) updateInfoBar();
84
88
  liveTurn.timing.setNote("");
85
89
  // The step's clock (the composer's delivery line): the page's own at a
86
90
  // live start; a replay's comes from the snapshot's generation age,
@@ -0,0 +1,69 @@
1
+ import { html, nothing, render } from "./vendor/lit-html.js";
2
+
3
+ // The page's toasts (app.js showToast): a stack in #toast, newest last, at
4
+ // most MAX_TOASTS. Each item has its own 6 s timer, or none when sticky: a
5
+ // sticky one stays until its action, its ✕ or a link in it is clicked, so a
6
+ // later notice can't hide an error. +text+: words, or a template
7
+ // (chains.js openChildrenTemplate: a link in it opens that session and hides
8
+ // the toast). The same text again while it shows reuses its item, moved to
9
+ // the end with a fresh timer. When a 4th comes, the oldest non-sticky goes
10
+ // (the oldest of all if every one is sticky).
11
+ const TOAST_MS = 6000;
12
+ const MAX_TOASTS = 3;
13
+
14
+ export function createToasts({ el, onChange = () => {}, doc = globalThis.document }) {
15
+ const entries = [];
16
+
17
+ function sync() {
18
+ el.hidden = entries.length === 0;
19
+ onChange();
20
+ }
21
+
22
+ function remove(entry, notify = true) {
23
+ const index = entries.indexOf(entry);
24
+ if (index < 0) return;
25
+ if (entry.timer !== null) clearTimeout(entry.timer);
26
+ entry.timer = null;
27
+ entries.splice(index, 1);
28
+ entry.el.remove();
29
+ if (notify) sync();
30
+ }
31
+
32
+ function draw(entry, text, action, sticky) {
33
+ render(html`<span class="toast-message" @click=${(e) => { if (e.target.closest?.("a[href]")) remove(entry); }}>${text}</span>${action
34
+ ? html`<button type="button" class="toast-action" @click=${() => { remove(entry); action.run(); }}>${action.label}</button>`
35
+ : nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${() => remove(entry)}>✕</button>` : nothing}`,
36
+ entry.el);
37
+ }
38
+
39
+ function show(text, action = null, { sticky = false } = {}) {
40
+ const candidate = { el: doc.createElement("div"), timer: null, sticky, key: null };
41
+ candidate.el.className = "toast-item";
42
+ draw(candidate, text, action, sticky);
43
+ candidate.key = candidate.el.querySelector(".toast-message").textContent;
44
+
45
+ const duplicate = entries.find((entry) => entry.key === candidate.key);
46
+ if (duplicate) {
47
+ if (duplicate.timer !== null) clearTimeout(duplicate.timer);
48
+ duplicate.timer = null;
49
+ duplicate.sticky = sticky;
50
+ duplicate.key = candidate.key;
51
+ draw(duplicate, text, action, sticky);
52
+ entries.push(...entries.splice(entries.indexOf(duplicate), 1));
53
+ el.appendChild(duplicate.el);
54
+ if (!sticky) duplicate.timer = setTimeout(() => remove(duplicate), TOAST_MS);
55
+ sync();
56
+ return;
57
+ }
58
+
59
+ if (entries.length === MAX_TOASTS) {
60
+ remove(entries.find((entry) => !entry.sticky) || entries[0], false);
61
+ }
62
+ entries.push(candidate);
63
+ el.appendChild(candidate.el);
64
+ if (!sticky) candidate.timer = setTimeout(() => remove(candidate), TOAST_MS);
65
+ sync();
66
+ }
67
+
68
+ return { show };
69
+ }
@@ -4,6 +4,7 @@ require "fileutils"
4
4
 
5
5
  require_relative "client_id"
6
6
  require_relative "config"
7
+ require_relative "engine"
7
8
  require_relative "events"
8
9
  require_relative "session"
9
10
  require_relative "session_inbox"
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.76.0
4
+ version: 0.78.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -393,6 +393,7 @@ files:
393
393
  - lib/samagotchi/memory_bundle/trash.rb
394
394
  - lib/samagotchi/memory_bundle/uninstaller.rb
395
395
  - lib/samagotchi/memory_paths.rb
396
+ - lib/samagotchi/message_parts.rb
396
397
  - lib/samagotchi/model_catalog.rb
397
398
  - lib/samagotchi/model_list_store.rb
398
399
  - lib/samagotchi/model_match.rb
@@ -412,6 +413,7 @@ files:
412
413
  - lib/samagotchi/paths.rb
413
414
  - lib/samagotchi/peer_sources.rb
414
415
  - lib/samagotchi/pending_input_queue.rb
416
+ - lib/samagotchi/pending_question.rb
415
417
  - lib/samagotchi/plugin/api.rb
416
418
  - lib/samagotchi/plugin/attached_context.rb
417
419
  - lib/samagotchi/plugin/context.rb
@@ -485,6 +487,7 @@ files:
485
487
  - lib/samagotchi/terminal_ui/input_support.rb
486
488
  - lib/samagotchi/terminal_ui/line_reader.rb
487
489
  - lib/samagotchi/terminal_ui/live_region.rb
490
+ - lib/samagotchi/terminal_ui/model_segments_text.rb
488
491
  - lib/samagotchi/terminal_ui/one_shot_sink.rb
489
492
  - lib/samagotchi/terminal_ui/plain_surface.rb
490
493
  - lib/samagotchi/terminal_ui/question_prompt.rb
@@ -573,7 +576,6 @@ files:
573
576
  - lib/samagotchi/web/info_probe.rb
574
577
  - lib/samagotchi/web/lan.rb
575
578
  - lib/samagotchi/web/markdown_renderer.rb
576
- - lib/samagotchi/web/message_parts.rb
577
579
  - lib/samagotchi/web/public/activity.js
578
580
  - lib/samagotchi/web/public/annotate_actions.js
579
581
  - lib/samagotchi/web/public/annotate_bar.js
@@ -592,6 +594,7 @@ files:
592
594
  - lib/samagotchi/web/public/command_view.js
593
595
  - lib/samagotchi/web/public/commands.js
594
596
  - lib/samagotchi/web/public/composer.js
597
+ - lib/samagotchi/web/public/composer_fold.js
595
598
  - lib/samagotchi/web/public/composer_size.js
596
599
  - lib/samagotchi/web/public/context_chips.js
597
600
  - lib/samagotchi/web/public/copy.js
@@ -615,6 +618,7 @@ files:
615
618
  - lib/samagotchi/web/public/live_timing.js
616
619
  - lib/samagotchi/web/public/live_turn.js
617
620
  - lib/samagotchi/web/public/llm_context_chip.js
621
+ - lib/samagotchi/web/public/markdown_place.js
618
622
  - lib/samagotchi/web/public/model_pick.js
619
623
  - lib/samagotchi/web/public/model_picker.js
620
624
  - lib/samagotchi/web/public/notice_links.js
@@ -651,6 +655,7 @@ files:
651
655
  - lib/samagotchi/web/public/thinking_ticker.js
652
656
  - lib/samagotchi/web/public/timing.js
653
657
  - lib/samagotchi/web/public/toast_place.js
658
+ - lib/samagotchi/web/public/toasts.js
654
659
  - lib/samagotchi/web/public/toolkit_chip.js
655
660
  - lib/samagotchi/web/public/turn_events.js
656
661
  - lib/samagotchi/web/public/turn_html.js
@@ -1,274 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../model_profile"
4
- require_relative "../tool_call_parser"
5
- require_relative "../tool_activity"
6
- require_relative "../tool_row_fields"
7
- require_relative "../llm/native_tool_normalizer"
8
- require_relative "../tools/builtins"
9
- require_relative "../image_store"
10
- require_relative "../llm_context_edit"
11
- require_relative "../token_usage"
12
- require_relative "../row_refs"
13
- require_relative "../tool_output_cut"
14
-
15
- module Samagotchi
16
- module Web
17
- # What a saved assistant message did, for the web turn view's reload: its
18
- # thinking and its tool calls (name, the params line the live row shows,
19
- # the output from the tool_response messages after it). Two storage
20
- # shapes, best effort:
21
- # * the native loop (a profile, llama.cpp): the raw response as content
22
- # (thinking + tool-call markup, Qwen or Gemma), then one tool_response
23
- # whose content joins every call's output with "\n\n---\n\n";
24
- # * the chat loop (api: openai): the text without thinking, the host's
25
- # reasoning as +thinking+ (older sessions have none), the calls as
26
- # tool_calls ({id, name, arguments}), then one tool_response per call
27
- # (tool_call_id).
28
- # A message that can't be read gives no parts, never an error.
29
- #
30
- # A call's images (a read image, a plugin or MCP tool's) are on its
31
- # tool_response: chat, per call; native, one list for every call, split
32
- # by the saved image_counts (older sessions without them: only a lone
33
- # call gets them).
34
- #
35
- # A call's chi-owned id (ToolIds, saved as tool_ids) is the part's
36
- # tool_id:, and an LLM context edit saved on its output (LLMContextEdit,
37
- # under edits) the part's edit: (.edit_marks), the row's ✂ mark.
38
- #
39
- # A plugin tool's params are what its live row showed (its preview),
40
- # saved with the result as tool_params (native: one per call, chat: one
41
- # per result); its label ("chrome: screenshot") likewise as tool_labels,
42
- # the part's label:, and its row refs as tool_refs (RowRefs, checked
43
- # again here), the part's refs:. The web server has no Engine and runs no plugins, so a
44
- # result without them (older sessions) falls back to the given
45
- # registry: the built-ins', and a plugin tool's arguments as key="value"
46
- # from the parsers' args:, as the live row shows them for a tool
47
- # without a preview.
48
- module MessageParts
49
- JOINER = "\n\n---\n\n"
50
- # A joined output's pieces each start with "[tool]": split there first,
51
- # so an output that holds the joiner itself stays whole.
52
- JOINER_BEFORE_TAG = /\n\n---\n\n(?=\[)/
53
- # The row shows 300 chars and the rest on hover; a read of a big file
54
- # would otherwise ride along with every reload. A longer one keeps its
55
- # head and tail (ToolOutputCut.preview).
56
- OUTPUT_MAX = ToolOutputCut::WEB_PREVIEW_CHARS
57
-
58
- QWEN_THINK = %r{<think>(.*?)</think>}m
59
- QWEN_OPEN_THINK = %r{\A(.*?)</think>}m # the template opened the block
60
- GEMMA_THOUGHT = Regexp.new("#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN)}(.*?)" \
61
- "(?:#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE)}|\\z)", Regexp::MULTILINE)
62
-
63
- CallRef = Struct.new(:name, :arguments)
64
-
65
- module_function
66
-
67
- # @param message [Hash] a saved model message (symbol or string keys)
68
- # @param responses [Array<Hash>] the tool_response messages after it
69
- # @param registry [Tools::Registry] the session's tools
70
- # @param cwd [String, nil] the session's working directory (a file
71
- # tool's title is relative to it)
72
- # @param marks [Hash{String => Hash}] the session's edit marks by
73
- # output id (.edit_marks)
74
- # @return [Hash, nil] { thinking:, tools: [{ tool:, params:, title:,
75
- # output:, output_truncated:, images:, tool_id:, edit: }] } with only what it found; nil for nothing
76
- def for_message(message, responses, registry: Tools::Builtins.default, cwd: nil, marks: {})
77
- content = field(message, :content).to_s
78
- calls = field(message, :tool_calls)
79
- tools = if calls.is_a?(Array) && !calls.empty?
80
- native_tools(calls, responses, registry, cwd, marks)
81
- else
82
- markup_tools(content, responses, registry, cwd, marks)
83
- end
84
- parts = {}
85
- thinking = [field(message, :thinking).to_s.strip, thinking_of(content)].reject(&:empty?).join("\n\n")
86
- parts[:thinking] = thinking unless thinking.empty?
87
- parts[:tools] = tools unless tools.empty?
88
- parts.empty? ? nil : parts
89
- rescue StandardError
90
- nil
91
- end
92
-
93
- def thinking_of(content)
94
- blocks = content.scan(QWEN_THINK).flatten
95
- if blocks.empty? && !content.include?("<think>") && (open = content[QWEN_OPEN_THINK, 1])
96
- blocks = [open]
97
- end
98
- blocks += content.scan(GEMMA_THOUGHT).flatten
99
- blocks.map(&:strip).reject(&:empty?).join("\n\n")
100
- end
101
-
102
- def markup_tools(content, responses, registry, cwd = nil, marks = {})
103
- calls = []
104
- calls.concat(ToolCallParser::Qwen.new(ModelProfile.qwen36).parse(content)) if content.include?("<tool_call>")
105
- calls.concat(ToolCallParser::Gemma.new(ModelProfile.gemma4).parse(content)) if content.include?("<|tool_call>")
106
- return [] if calls.empty?
107
-
108
- joined = responses.empty? ? nil : responses.map { |r| field(r, :content).to_s }.join(JOINER)
109
- outputs = split_outputs(joined, calls.length)
110
- shown = saved_list(responses, :tool_params, calls.length)
111
- labels = saved_list(responses, :tool_labels, calls.length)
112
- diffs = saved_list(responses, :tool_diffs, calls.length)
113
- refs = saved_list(responses, :tool_refs, calls.length)
114
- ids = saved_list(responses, :tool_ids, calls.length)
115
- images = split_images(responses, calls.length)
116
- calls.each_with_index.map do |call, i|
117
- tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], refs: refs[i],
118
- cwd: cwd, tool_id: ids[i], marks: marks)
119
- end
120
- end
121
-
122
- # A per-call list saved with the joined result(s), or none when its
123
- # length doesn't match the calls.
124
- def saved_list(responses, key, count)
125
- list = responses.flat_map { |r| Array(field(r, key)) }
126
- list.length == count ? list : []
127
- end
128
-
129
- # Each call's images from the joined tool_response(s).
130
- def split_images(responses, count)
131
- images = responses.flat_map { |r| Array(field(r, :images)) }
132
- return [] if images.empty?
133
-
134
- counts = responses.flat_map { |r| Array(field(r, :image_counts)) }
135
- return count == 1 ? [images] : [] unless counts.length == count && counts.sum == images.length
136
-
137
- counts.map { |n| images.shift(n) }
138
- end
139
-
140
- def native_tools(calls, responses, registry, cwd = nil, marks = {})
141
- # By id only for an id that names one response: a server may omit
142
- # it (nil), send "" or repeat it; those pair by position.
143
- ids = responses.map { |r| field(r, :tool_call_id).to_s }
144
- unique = ->(id) { !id.empty? && ids.count(id) == 1 }
145
- by_id = responses.each_with_index.to_h { |r, i| [ids[i], r] }.select { |id, _| unique.call(id) }
146
- calls.each_with_index.map do |raw, i|
147
- ref = CallRef.new(field(raw, :name).to_s, field(raw, :arguments))
148
- call = LLM::NativeToolNormalizer.normalize(ref) || { name: ref.name }
149
- response = by_id[field(raw, :id).to_s] || (responses[i] && !unique.call(ids[i]) ? responses[i] : nil)
150
- tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
151
- response && field(response, :images), label: field(response, :tool_labels),
152
- diff: field(response, :tool_diffs),
153
- refs: field(response, :tool_refs), cwd: cwd,
154
- tool_id: Array(field(response, :tool_ids)).first, marks: marks)
155
- end
156
- end
157
-
158
- # The ✂ mark of each output an LLM context edit is saved on, by its
159
- # id, from +messages+ (a session's, symbol or string keys): {kind:
160
- # "stale" | "forget", staged: true while a forget waits for the
161
- # turn's end, note: (a stale stub's reason, "superseded by a later
162
- # read"; the first output of a forget_outputs call's, its note), with:
163
- # (the others of that call's: the first one's id), kept: ("12-40, 50",
164
- # a forget's kept lines)}. A forget call's outputs share the note,
165
- # author and stamp (LLMContextView's key); the first in the session's
166
- # order carries the note.
167
- # @return [Hash{String => Hash}]
168
- def edit_marks(messages)
169
- first = {}
170
- Array(messages).each_with_object({}) do |entry, marks|
171
- next unless field(entry, :role).to_s == "tool_response"
172
-
173
- saved = field(entry, :edits)
174
- next unless saved.is_a?(Hash)
175
-
176
- edits = saved.filter_map { |id, hash| LLMContextEdit.from_h(id, hash) }.to_h { |edit| [edit.id, edit] }
177
- order = Array(field(entry, :tool_ids)).map(&:to_s)
178
- edits.sort_by { |id, _| order.index(id) || order.size }.each do |id, edit|
179
- marks[id] = edit_mark(edit, first)
180
- end
181
- end
182
- rescue StandardError
183
- {}
184
- end
185
-
186
- def edit_mark(edit, first)
187
- mark = { kind: edit.kind.to_s }
188
- mark[:staged] = true unless edit.applied?
189
- if edit.kind == :stale
190
- mark[:note] = edit.note[/superseded by .*\z/] || edit.note
191
- else
192
- key = [edit.note, edit.by, edit.staged_at]
193
- if first.key?(key)
194
- mark[:with] = first[key]
195
- else
196
- first[key] = edit.id
197
- mark[:note] = edit.note
198
- end
199
- kept = edit.keep.map { |from, to| from == to ? from.to_s : "#{from}-#{to}" }
200
- mark[:kept] = kept.join(", ") unless kept.empty?
201
- end
202
- mark
203
- end
204
-
205
- # +output+'s ✂ mark (+marks+ by +id+); an applied stale stub says
206
- # what it frees: its output, ~chars/4.
207
- def mark_of(id, marks, output)
208
- mark = marks[id]
209
- return nil unless mark
210
- return mark unless mark[:kind] == "stale" && !mark[:staged] && output
211
-
212
- mark.merge(tokens: (output.length / TokenUsage::CHARS_PER_TOKEN).ceil)
213
- end
214
-
215
- # +count+ outputs from a joined tool_response: the "[tool]"-tagged
216
- # split when it gives the right count, else the plain one; extra pieces
217
- # stay with the last call, missing ones are nil.
218
- def split_outputs(joined, count)
219
- return [] if joined.nil?
220
- return [joined] if count == 1
221
-
222
- pieces = joined.split(JOINER_BEFORE_TAG, -1)
223
- pieces = joined.split(JOINER, -1) if pieces.length != count
224
- return pieces if pieces.length == count
225
-
226
- head = pieces.first(count - 1)
227
- rest = pieces.drop(count - 1)
228
- head + [rest.empty? ? nil : rest.join(JOINER)]
229
- end
230
-
231
- # +shown+ is the saved params line, when it is a String.
232
- # +diff+ is what an edit/write changed (EditPreview.change), saved as
233
- # tool_diffs. +refs+: a plugin tool's row refs, saved as tool_refs
234
- # (RowRefs.for_display checks them again against +cwd+). +cwd+: the
235
- # session's working directory, for the title.
236
- # +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
237
- def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
238
- tool_id: nil, marks: {})
239
- # A built-in reached through call_tool reloads as that built-in's
240
- # row, with via (its saved params and label are the built-in's).
241
- call = Tools::BuiltinCalls.unwrap_saved(call, output)
242
- name = call[:name].to_s
243
- params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
244
- part = { tool: name, params: params.to_s }
245
- # Its row fields (ToolRowFields: title, called_as, via, view, ref), built from the saved
246
- # call (never saved themselves), so older sessions get them too. The
247
- # saved call is rebuilt by BuiltinCalls, which sets called_as and via again.
248
- part.merge!(ToolRowFields.for(name, call, cwd: cwd))
249
- part[:label] = label if label.is_a?(String) && !label.empty?
250
- unless output.nil?
251
- part[:output] = ToolOutputCut.preview(output, cap: OUTPUT_MAX, tool: name)
252
- part[:output_truncated] = true if output.length > OUTPUT_MAX
253
- end
254
- shots = Array(images).select { |image| image.is_a?(Hash) }
255
- part[:images] = shots.map { |image| ImageStore.symbolize(image).slice(:file, :name, :width, :height) } unless shots.empty?
256
- part[:diff] = diff if diff.is_a?(Hash)
257
- row_refs = RowRefs.for_display(refs, cwd: cwd)
258
- part[:refs] = row_refs unless row_refs.empty?
259
- if tool_id
260
- part[:tool_id] = tool_id.to_s
261
- mark = mark_of(tool_id.to_s, marks, output)
262
- part[:edit] = mark if mark
263
- end
264
- part
265
- end
266
-
267
- def field(hash, key)
268
- return nil unless hash.is_a?(Hash)
269
-
270
- hash.key?(key) ? hash[key] : hash[key.to_s]
271
- end
272
- end
273
- end
274
- end