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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -1
- data/docs/cli.md +2 -1
- data/docs/configuration.md +10 -6
- data/docs/internals/anthropic.md +73 -5
- data/docs/internals/engine.md +2 -1
- data/docs/internals/prompt-caching.md +3 -1
- data/docs/plugins.md +42 -0
- data/docs/sessions.md +1 -0
- data/lib/samagotchi/answer_command.rb +5 -5
- data/lib/samagotchi/bridge.rb +3 -3
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +2 -2
- data/lib/samagotchi/child_reports.rb +3 -3
- data/lib/samagotchi/children_status.rb +2 -2
- data/lib/samagotchi/engine.rb +21 -12
- data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
- data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
- data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +210 -13
- data/lib/samagotchi/llm/anthropic_wire.rb +22 -0
- data/lib/samagotchi/llm/chat_loop.rb +49 -2
- data/lib/samagotchi/llm/errors.rb +6 -4
- data/lib/samagotchi/llm/http.rb +2 -1
- data/lib/samagotchi/llm/openai_chat.rb +12 -2
- data/lib/samagotchi/memory_bundle/builder.rb +2 -0
- data/lib/samagotchi/message_parts.rb +272 -0
- data/lib/samagotchi/note_delivery.rb +2 -2
- data/lib/samagotchi/parent_report.rb +30 -22
- data/lib/samagotchi/pending_question.rb +49 -0
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/question_desk.rb +39 -32
- data/lib/samagotchi/relay.rb +8 -9
- data/lib/samagotchi/relay_verifier.rb +2 -2
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +6 -7
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/session.rb +22 -14
- data/lib/samagotchi/session_commands.rb +4 -1
- data/lib/samagotchi/session_manager.rb +0 -1
- data/lib/samagotchi/session_metrics/totals.rb +4 -3
- data/lib/samagotchi/session_metrics.rb +9 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +6 -106
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
- data/lib/samagotchi/terminal_ui.rb +7 -5
- data/lib/samagotchi/tool_output_cut.rb +1 -1
- data/lib/samagotchi/tool_response.rb +13 -13
- data/lib/samagotchi/tool_row_fields.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +16 -9
- data/lib/samagotchi/tools/delegate.rb +2 -2
- data/lib/samagotchi/tools/delegate_relay.rb +7 -7
- data/lib/samagotchi/tools/delegate_result.rb +2 -2
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/list_sessions.rb +2 -2
- data/lib/samagotchi/tools/web_fetch.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -2
- data/lib/samagotchi/web/public/app.js +234 -74
- data/lib/samagotchi/web/public/composer_fold.js +58 -0
- data/lib/samagotchi/web/public/composer_size.js +10 -0
- data/lib/samagotchi/web/public/ctx.js +12 -1
- data/lib/samagotchi/web/public/index.html +29 -4
- data/lib/samagotchi/web/public/markdown_place.js +53 -0
- data/lib/samagotchi/web/public/stream_handlers.js +7 -3
- data/lib/samagotchi/web/public/toasts.js +69 -0
- data/lib/samagotchi/worker.rb +1 -0
- metadata +7 -2
- 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:
|
|
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
|
-
#
|
|
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;
|
|
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()
|
|
78
|
+
const current = state();
|
|
79
|
+
let contextChanged = current.ctxUnknownAfterSwitch !== false;
|
|
80
|
+
current.ctxUnknownAfterSwitch = false;
|
|
79
81
|
if (data?.context_window_tokens) {
|
|
80
|
-
const
|
|
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 =
|
|
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
|
+
}
|
data/lib/samagotchi/worker.rb
CHANGED
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.
|
|
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
|