samagotchi 0.15.0 → 0.16.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 +50 -1
- data/docs/architecture.md +10 -0
- data/docs/cli.md +3 -2
- data/docs/configuration.md +3 -1
- data/docs/guardrails.md +16 -2
- data/docs/sessions.md +7 -2
- data/docs/sub-agent.md +15 -3
- data/lib/samagotchi/answer_display.rb +6 -2
- data/lib/samagotchi/bridge/card_store.rb +16 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +6 -0
- data/lib/samagotchi/bridge.rb +84 -0
- data/lib/samagotchi/bridge_client.rb +33 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/empty_answer_retry.rb +2 -1
- data/lib/samagotchi/engine.rb +51 -13
- data/lib/samagotchi/kernel_loop.rb +16 -5
- data/lib/samagotchi/llm/chat_loop.rb +14 -8
- data/lib/samagotchi/llm/model_result.rb +12 -7
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/parent_report.rb +8 -0
- data/lib/samagotchi/question_desk.rb +97 -18
- data/lib/samagotchi/relay.rb +88 -0
- data/lib/samagotchi/relay_desk.rb +73 -0
- data/lib/samagotchi/relay_verifier.rb +113 -0
- data/lib/samagotchi/relay_watcher.rb +46 -0
- data/lib/samagotchi/reply_wait.rb +10 -1
- data/lib/samagotchi/session.rb +15 -2
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +17 -0
- data/lib/samagotchi/sessions_command.rb +10 -4
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +69 -18
- data/lib/samagotchi/terminal_ui/event_renderer.rb +6 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
- data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate_relay.rb +216 -0
- data/lib/samagotchi/tools/delegate_wait.rb +72 -6
- data/lib/samagotchi/tools/peers.rb +6 -4
- data/lib/samagotchi/turn_note.rb +30 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +20 -0
- data/lib/samagotchi/web/public/app.js +24 -6
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notify.js +18 -5
- data/lib/samagotchi/web/public/question_card.js +45 -3
- data/lib/samagotchi/web/public/question_cards.js +49 -1
- data/lib/samagotchi/web/public/sessions_list.js +8 -1
- data/lib/samagotchi/web/public/stage_model.js +8 -3
- data/lib/samagotchi/web/public/stage_view.js +7 -2
- data/lib/samagotchi/web/public/timing.js +11 -3
- data/lib/samagotchi/web/public/turn_events.js +9 -0
- data/lib/samagotchi/web/public/turn_view.js +34 -12
- metadata +6 -1
|
@@ -8,8 +8,26 @@ export function isApproval(pq) {
|
|
|
8
8
|
return !!pq && pq.kind === "approval";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// A delegate's approval relayed to this session's user (the approval
|
|
12
|
+
// relay): who asks (the chain, "ab12 → cd34" for a grandchild's) and its
|
|
13
|
+
// task, or null.
|
|
14
|
+
// @return {null | {who, childId, childShort, task, more}}
|
|
15
|
+
export function relayView(pq) {
|
|
16
|
+
const r = pq && pq.relay;
|
|
17
|
+
if (!r || typeof r !== "object" || !r.child_id) return null;
|
|
18
|
+
const chain = Array.isArray(r.chain) && r.chain.length ? r.chain : [String(r.child_id).slice(0, 8)];
|
|
19
|
+
return {
|
|
20
|
+
who: [...chain].reverse().join(" → "),
|
|
21
|
+
childId: String(r.child_id),
|
|
22
|
+
childShort: r.child_short || String(r.child_id).slice(0, 8),
|
|
23
|
+
task: r.task ? String(r.task) : "",
|
|
24
|
+
more: Number(r.more) || 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// @return {null | {tool, what, isCommand, where, why, preview?, delegate?}};
|
|
29
|
+
// preview is an edit/write's dry-run diff (diff_view.js draws it); delegate
|
|
30
|
+
// is relayView's, for a delegate's approval
|
|
13
31
|
export function approvalView(pq) {
|
|
14
32
|
if (!isApproval(pq)) return null;
|
|
15
33
|
const a = pq.approval || {};
|
|
@@ -33,12 +51,34 @@ export function approvalView(pq) {
|
|
|
33
51
|
where,
|
|
34
52
|
why: `${reason} (${who})`,
|
|
35
53
|
...(a.preview && typeof a.preview === "object" ? { preview: a.preview } : {}),
|
|
54
|
+
...(relayView(pq) ? { delegate: relayView(pq) } : {}),
|
|
36
55
|
};
|
|
37
56
|
}
|
|
38
57
|
|
|
58
|
+
// This session's question waits in its parent's card too (the parent
|
|
59
|
+
// relayed it): the parent's id and short id, or null.
|
|
60
|
+
// @return {null | {parentId, parentShort}}
|
|
61
|
+
export function relayedToView(pq) {
|
|
62
|
+
const r = pq && pq.relayed_to;
|
|
63
|
+
if (!r || typeof r !== "object" || !r.parent_id) return null;
|
|
64
|
+
return { parentId: String(r.parent_id), parentShort: r.parent_short || String(r.parent_id).slice(0, 8) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// How a relayed approval's card closed without an answer here: the
|
|
68
|
+
// delegate's question was answered there, its worker went, or this turn
|
|
69
|
+
// stopped (the question still waits in the delegate). A dismiss here
|
|
70
|
+
// denied it.
|
|
71
|
+
function relayClosedText(relay, reason) {
|
|
72
|
+
if (reason === "answered_on_child") return `Answered in ${relay.who}`;
|
|
73
|
+
if (reason === "child_gone") return `${relay.who}'s worker is gone`;
|
|
74
|
+
return reason ? `Left open in ${relay.who} (${reason})` : `Left open in ${relay.who}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
39
77
|
// The line a resolved card shows.
|
|
40
78
|
export function resultText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
|
|
41
79
|
const approval = isApproval(pq);
|
|
80
|
+
const relay = relayView(pq);
|
|
81
|
+
if (cancelled && relay && reason !== "dismissed") return relayClosedText(relay, reason);
|
|
42
82
|
if (cancelled) {
|
|
43
83
|
const label = approval ? "Denied" : "Cancelled";
|
|
44
84
|
return reason ? `${label} (${reason})` : label;
|
|
@@ -70,8 +110,10 @@ export function truncate(text, n = 80) {
|
|
|
70
110
|
export function summaryText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
|
|
71
111
|
if (cancelled || answer) {
|
|
72
112
|
const result = resultText(pq, { answer, cancelled, reason }).replace(/^Answered: /, "");
|
|
113
|
+
const relay = relayView(pq);
|
|
73
114
|
const asked = summaryAsked(pq);
|
|
74
|
-
|
|
115
|
+
const text = asked ? `${truncate(asked, 60)} → ${result}` : result;
|
|
116
|
+
return truncate(relay ? `${relay.who}: ${text}` : text, 120);
|
|
75
117
|
}
|
|
76
118
|
const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?` : "Question";
|
|
77
119
|
return truncate(pq.header || label);
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
|
|
16
16
|
import { diffHtml } from "./diff_view.js";
|
|
17
|
-
import { approvalAllowed, approvalView, nextCardAction, resultText, summaryText } from "./question_card.js";
|
|
17
|
+
import { approvalAllowed, approvalView, nextCardAction, relayedToView, resultText, summaryText } from "./question_card.js";
|
|
18
|
+
import { sessionHash } from "./route.js";
|
|
18
19
|
import { clientLabel, continueLine, isOwn } from "./turn_events.js";
|
|
19
20
|
|
|
20
21
|
// @param sessionId () => the open session's id (answers go there)
|
|
@@ -64,6 +65,8 @@ export function createQuestionCards({
|
|
|
64
65
|
// left it (rememberToggle-style, as turn_view.js does).
|
|
65
66
|
summary.addEventListener("click", () => { card.dataset.toggled = "1"; });
|
|
66
67
|
|
|
68
|
+
// Relayed to the parent: say where else it waits (QuestionDesk#annotate).
|
|
69
|
+
card.appendChild(renderRelayedTo(pq));
|
|
67
70
|
if (approval) {
|
|
68
71
|
card.appendChild(renderApprovalDetails(approval));
|
|
69
72
|
} else {
|
|
@@ -170,6 +173,23 @@ export function createQuestionCards({
|
|
|
170
173
|
function renderApprovalDetails(view) {
|
|
171
174
|
const box = doc.createElement("div");
|
|
172
175
|
box.className = "approval-details";
|
|
176
|
+
// A delegate's approval: who asks, and its task.
|
|
177
|
+
if (view.delegate) {
|
|
178
|
+
const who = doc.createElement("div");
|
|
179
|
+
who.className = "approval-delegate";
|
|
180
|
+
const label = doc.createElement("span");
|
|
181
|
+
label.className = "approval-label";
|
|
182
|
+
label.textContent = "delegate ";
|
|
183
|
+
who.appendChild(label);
|
|
184
|
+
const name = doc.createElement("a");
|
|
185
|
+
name.className = "approval-delegate-link";
|
|
186
|
+
name.href = sessionHash(view.delegate.childId);
|
|
187
|
+
name.title = "Open the delegate's session";
|
|
188
|
+
name.textContent = view.delegate.who;
|
|
189
|
+
who.appendChild(name);
|
|
190
|
+
if (view.delegate.task) who.appendChild(doc.createTextNode(` · ${view.delegate.task}`));
|
|
191
|
+
box.appendChild(who);
|
|
192
|
+
}
|
|
173
193
|
const tool = doc.createElement("div");
|
|
174
194
|
tool.className = "approval-tool";
|
|
175
195
|
tool.textContent = view.tool;
|
|
@@ -203,6 +223,32 @@ export function createQuestionCards({
|
|
|
203
223
|
return box;
|
|
204
224
|
}
|
|
205
225
|
|
|
226
|
+
// "Waiting for approval in parent ab12: answering here works too", or
|
|
227
|
+
// an empty (hidden) line while the question isn't relayed.
|
|
228
|
+
function renderRelayedTo(pq) {
|
|
229
|
+
const line = doc.createElement("div");
|
|
230
|
+
line.className = "question-relayed";
|
|
231
|
+
const view = relayedToView(pq);
|
|
232
|
+
if (!view) {
|
|
233
|
+
line.classList.add("hidden");
|
|
234
|
+
return line;
|
|
235
|
+
}
|
|
236
|
+
line.appendChild(doc.createTextNode(`Waiting for ${pq.kind === "approval" ? "approval" : "an answer"} in parent `));
|
|
237
|
+
const link = doc.createElement("a");
|
|
238
|
+
link.href = sessionHash(view.parentId);
|
|
239
|
+
link.textContent = view.parentShort;
|
|
240
|
+
line.appendChild(link);
|
|
241
|
+
line.appendChild(doc.createTextNode(": answering here works too"));
|
|
242
|
+
return line;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// The parent relayed the open question, or no longer does (question_relay).
|
|
246
|
+
function markRelayed(id, relayedTo) {
|
|
247
|
+
if (!questionEl || !pending || String(pending.id) !== String(id)) return;
|
|
248
|
+
pending = { ...pending, relayed_to: relayedTo || null };
|
|
249
|
+
questionEl.querySelector(".question-relayed")?.replaceWith(renderRelayedTo(pending));
|
|
250
|
+
}
|
|
251
|
+
|
|
206
252
|
function resolveQuestionCard(id, { answer = null, cancelled = false, reason = "" } = {}) {
|
|
207
253
|
if (!questionEl) return;
|
|
208
254
|
if (id && pending && String(pending.id) !== String(id)) return;
|
|
@@ -225,6 +271,7 @@ export function createQuestionCards({
|
|
|
225
271
|
questionEl.appendChild(note);
|
|
226
272
|
questionEl.classList.remove("submitting");
|
|
227
273
|
disableQuestionInputs();
|
|
274
|
+
questionEl.querySelector(".question-relayed")?.classList.add("hidden");
|
|
228
275
|
// The summary becomes the result; the card collapses to that one line
|
|
229
276
|
// unless the user opened or closed it by hand.
|
|
230
277
|
const summary = questionEl.querySelector("summary");
|
|
@@ -343,6 +390,7 @@ export function createQuestionCards({
|
|
|
343
390
|
return {
|
|
344
391
|
renderQuestion: renderQuestionCard,
|
|
345
392
|
resolveQuestion: resolveQuestionCard,
|
|
393
|
+
markRelayed,
|
|
346
394
|
removeQuestion: removeQuestionCard,
|
|
347
395
|
questionCard: () => questionEl,
|
|
348
396
|
pendingQuestion: () => pending,
|
|
@@ -78,7 +78,14 @@ const WAITING_BADGES = {
|
|
|
78
78
|
// @return null | {kind, text, title}
|
|
79
79
|
export function waitingBadge(summary) {
|
|
80
80
|
const waiting = waitingOn(summary);
|
|
81
|
-
|
|
81
|
+
if (!waiting) return null;
|
|
82
|
+
// A delegate's approval waiting in its parent's card (the approval relay).
|
|
83
|
+
const parent = summary.pending_question?.relayed_to;
|
|
84
|
+
if (parent && waiting.reason !== "card") {
|
|
85
|
+
return { kind: waiting.reason, text: `in parent ${parent}`,
|
|
86
|
+
title: `Waiting for your ${waiting.reason} in parent session ${parent} (answering here works too)` };
|
|
87
|
+
}
|
|
88
|
+
return { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] };
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
// What the all view's search matches for a session that waits on the user:
|
|
@@ -88,6 +88,8 @@ export function isPlain(turn) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
const END_PHASES = { completed: "answered", canceled: "canceled" };
|
|
91
|
+
// A completed turn whose model returned nothing (the turn view's notice).
|
|
92
|
+
const NO_ANSWER = "no answer";
|
|
91
93
|
|
|
92
94
|
// What the live slots show for +turn+:
|
|
93
95
|
// phase the status row's word;
|
|
@@ -100,14 +102,17 @@ const END_PHASES = { completed: "answered", canceled: "canceled" };
|
|
|
100
102
|
// trail the last three finished calls, newest last (a file's title
|
|
101
103
|
// without its dir);
|
|
102
104
|
// ticks one per call: {kind, status, step (the gen's index)}.
|
|
103
|
-
// +pending+: a card waits for the user; +ended+: the turn's end kind
|
|
104
|
-
|
|
105
|
+
// +pending+: a card waits for the user; +ended+: the turn's end kind;
|
|
106
|
+
// +emptyAnswer+: a completed turn ended with no answer
|
|
107
|
+
// (turn_summary.empty_answer), so it reads "no answer", not "answered".
|
|
108
|
+
export function liveSlots(turn, { narration = "", thinking = "", pending = false, ended = null, emptyAnswer = false } = {}) {
|
|
105
109
|
const gens = turn?.gens || [];
|
|
106
110
|
const rows = gens.flatMap((gen, step) => gen.tools.map((row) => ({ row, step })));
|
|
107
111
|
const running = [...rows].reverse().find(({ row }) => row.status === "running")?.row || null;
|
|
108
112
|
const current = gens[gens.length - 1];
|
|
109
113
|
let phase;
|
|
110
|
-
if (ended) phase =
|
|
114
|
+
if (ended === "completed" && emptyAnswer) phase = NO_ANSWER;
|
|
115
|
+
else if (ended) phase = END_PHASES[ended] || "failed";
|
|
111
116
|
else if (pending) phase = "waiting for you";
|
|
112
117
|
else if (running) phase = "running a tool";
|
|
113
118
|
else if (current?.text && isPlain(turn)) phase = "writing the answer";
|
|
@@ -26,7 +26,7 @@ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.contin
|
|
|
26
26
|
// asks the user too, but the turn goes on: it waits only while the turn runs
|
|
27
27
|
// and never holds the hand-off (a turn cut short can leave it open).
|
|
28
28
|
const ASKS = ".plugin-card[data-in-turn] .card-action";
|
|
29
|
-
const MARKS = { answered: "✓", canceled: "■", failed: "✕" };
|
|
29
|
+
const MARKS = { answered: "✓", "no answer": "∅", canceled: "■", failed: "✕" };
|
|
30
30
|
// The mark of a flash in the trail: a warn notice, a hook's info notice, a
|
|
31
31
|
// plugin's nudge (steer).
|
|
32
32
|
const FLASH_MARKS = { warn: "⚠", info: "ℹ", steer: "↪" };
|
|
@@ -93,6 +93,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
93
93
|
// The turn in the stage, from turnStarted until the hand-off (or a reset).
|
|
94
94
|
let holding = false;
|
|
95
95
|
let endKind = null;
|
|
96
|
+
// The ended turn had no answer (turn_summary.empty_answer).
|
|
97
|
+
let emptyAnswer = false;
|
|
96
98
|
let settled = true;
|
|
97
99
|
let settleTimer = null;
|
|
98
100
|
let startedAt = 0;
|
|
@@ -175,7 +177,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
175
177
|
// A question blocks the turn: its card takes the live slots' room. An
|
|
176
178
|
// asking card's turn goes on: the status waits, the slots stay.
|
|
177
179
|
const blocked = pendingCard();
|
|
178
|
-
const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), ended: endKind });
|
|
180
|
+
const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), ended: endKind, emptyAnswer });
|
|
179
181
|
|
|
180
182
|
stage.classList.toggle("plain", plain);
|
|
181
183
|
stage.classList.toggle("ended", !!endKind);
|
|
@@ -452,6 +454,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
452
454
|
clearTimeout(settleTimer);
|
|
453
455
|
holding = false;
|
|
454
456
|
endKind = null;
|
|
457
|
+
emptyAnswer = false;
|
|
455
458
|
settled = true;
|
|
456
459
|
view.reset();
|
|
457
460
|
emptySlots();
|
|
@@ -517,6 +520,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
517
520
|
}
|
|
518
521
|
holding = true;
|
|
519
522
|
endKind = null;
|
|
523
|
+
emptyAnswer = false;
|
|
520
524
|
settled = true;
|
|
521
525
|
startedAt = Date.parse(data?.started_at) || Date.now();
|
|
522
526
|
emptySlots();
|
|
@@ -542,6 +546,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
542
546
|
kept.push(...warn);
|
|
543
547
|
view.chronological();
|
|
544
548
|
endKind = args.kind;
|
|
549
|
+
emptyAnswer = !!args.emptyAnswer;
|
|
545
550
|
endedAt = Date.now();
|
|
546
551
|
// A completed turn's end read (markdown, answer_display) is pending
|
|
547
552
|
// until answerReady; the others have none.
|
|
@@ -88,7 +88,9 @@ export function timedTurnIndexes(items) {
|
|
|
88
88
|
// the last, in order; a text-less iteration leaves no message, so a text
|
|
89
89
|
// can sit one step early: inspection, not truth) and its answer (the last
|
|
90
90
|
// assistant message). Assistant messages before any prompt are answers on
|
|
91
|
-
// their own.
|
|
91
|
+
// their own. A turn that ended with no answer (the server's empty_answer
|
|
92
|
+
// item) has no answer: its texts are steps, and `emptyAnswer` is that
|
|
93
|
+
// item's index. The turn index counts prompts, as timedTurnIndexes does. The
|
|
92
94
|
// user's lines merged into the running turn (`merged`, the server's
|
|
93
95
|
// Steer::INPUT_KIND) stay in it: the turn's `merged` list.
|
|
94
96
|
export function turnGroups(items, timing) {
|
|
@@ -110,13 +112,18 @@ export function turnGroups(items, timing) {
|
|
|
110
112
|
} else if (item?.role === "assistant") {
|
|
111
113
|
if (turn) turn.texts.push(i);
|
|
112
114
|
else groups.push({ kind: "answer", answer: i });
|
|
115
|
+
} else if (item?.role === "empty_answer" && turn) {
|
|
116
|
+
turn.emptyAnswer = i;
|
|
113
117
|
}
|
|
114
118
|
});
|
|
115
119
|
return groups.map((g) => {
|
|
116
120
|
if (g.kind !== "turn") return g;
|
|
117
121
|
const group = withSteers(turnGroup(g, items, timing), g.steers, items);
|
|
122
|
+
// A turn that ended with no answer: its notice (the item's index) in
|
|
123
|
+
// the answer's place.
|
|
124
|
+
const ended = g.emptyAnswer == null ? group : { ...group, emptyAnswer: g.emptyAnswer };
|
|
118
125
|
// A turn with no merged lines keeps its shape.
|
|
119
|
-
return g.merged.length ? { ...
|
|
126
|
+
return g.merged.length ? { ...ended, merged: g.merged } : ended;
|
|
120
127
|
});
|
|
121
128
|
}
|
|
122
129
|
|
|
@@ -150,7 +157,8 @@ function promptRecord(timing, g) {
|
|
|
150
157
|
function turnGroup(g, items, timing) {
|
|
151
158
|
const record = promptRecord(timing, g);
|
|
152
159
|
const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
|
|
153
|
-
|
|
160
|
+
// A turn with no answer has none of its texts as one: they are steps.
|
|
161
|
+
const stepsOnly = canceledSteps(g, record, tools) || g.emptyAnswer != null;
|
|
154
162
|
if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
|
|
155
163
|
const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
|
|
156
164
|
const stepTexts = stepsOnly ? g.texts : g.texts.slice(0, -1);
|
|
@@ -64,6 +64,15 @@ export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy } = {}) {
|
|
|
64
64
|
return `↻ ${what}, asking again (${attempt}/${of})`;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// The one muted line for a turn that ended with no answer (turn_completed's
|
|
68
|
+
// turn_summary.empty_answer, or a reloaded turn's marker): the TUI words
|
|
69
|
+
// it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
|
|
70
|
+
export function emptyAnswerLine({ retries = 0 } = {}) {
|
|
71
|
+
const count = Number(retries) || 0;
|
|
72
|
+
const after = count > 0 ? ` (after ${count} ${count === 1 ? "retry" : "retries"})` : "";
|
|
73
|
+
return `no answer: the model returned nothing${after}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
67
76
|
// The text of a turn's row from a snapshot entry (CardStore): a hook's
|
|
68
77
|
// notice as "<label>: text", the loop's asking-again row as its line.
|
|
69
78
|
export function noticeLine(entry = {}) {
|
|
@@ -16,7 +16,7 @@ import { activityDiffHtml } from "./diff_view.js";
|
|
|
16
16
|
import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
|
|
17
17
|
import { thumbsHtml } from "./images.js";
|
|
18
18
|
import { shouldFollowScroll } from "./scroll.js";
|
|
19
|
-
import { hookNoticeLabel } from "./turn_events.js";
|
|
19
|
+
import { emptyAnswerLine, hookNoticeLabel } from "./turn_events.js";
|
|
20
20
|
import { cancelLineHtml, formatDuration, turnGroups, turnTimingText } from "./timing.js";
|
|
21
21
|
import { createSentenceFeed } from "./sentences.js";
|
|
22
22
|
import { createTicker } from "./thinking_ticker.js";
|
|
@@ -465,11 +465,15 @@ export function createTurnView({
|
|
|
465
465
|
// +heldHeight+ (the live box's height, measured before the step closed) the
|
|
466
466
|
// bubble is held at that height, scrolled to its tail, until answerReady()
|
|
467
467
|
// (the rendered markdown landed) or 1.5 s reveals it.
|
|
468
|
-
|
|
468
|
+
// +endNote+: the notice of a turn that ended with no answer (app.js
|
|
469
|
+
// builds it), placed where the answer bubble would go.
|
|
470
|
+
function promote(gen, { heldHeight = 0, endNote = null } = {}) {
|
|
469
471
|
const entry = genEls.get(gen);
|
|
470
472
|
const bubble = entry.text;
|
|
471
|
-
|
|
472
|
-
|
|
473
|
+
// No text (an empty answer): no bubble, not even an empty one left in
|
|
474
|
+
// the step.
|
|
475
|
+
if (gen.text) bubble.className = "bubble output";
|
|
476
|
+
else bubble.remove();
|
|
473
477
|
// A hidden tab gets no pop: nobody sees it, and its timers are throttled.
|
|
474
478
|
const holding = useHold && heldHeight > 0 && !!gen.text && !document.hidden;
|
|
475
479
|
let holdHeight = 0;
|
|
@@ -512,11 +516,12 @@ export function createTurnView({
|
|
|
512
516
|
}
|
|
513
517
|
pieces.push(...cards);
|
|
514
518
|
if (gen.text) pieces.push(bubble);
|
|
519
|
+
else if (endNote) pieces.push(endNote);
|
|
515
520
|
placeAnswer(pieces, { plain: true, blockEl });
|
|
516
521
|
blockEl = null;
|
|
517
522
|
blockSummaryEl = null;
|
|
518
523
|
} else {
|
|
519
|
-
placeAnswer(gen.text ? [...cards, bubble] : cards, { plain: false, blockEl });
|
|
524
|
+
placeAnswer(gen.text ? [...cards, bubble] : [...cards, ...(endNote ? [endNote] : [])], { plain: false, blockEl });
|
|
520
525
|
// The last step, left with its thinking only, reads "thinking" once.
|
|
521
526
|
if (!gen.tools.length && gen.thinking) unwrapThinking(entry);
|
|
522
527
|
}
|
|
@@ -524,8 +529,10 @@ export function createTurnView({
|
|
|
524
529
|
}
|
|
525
530
|
|
|
526
531
|
// Returns the cards that left the block (card.js leavesBlock), detached:
|
|
527
|
-
// app.js puts them after the turn's end line.
|
|
528
|
-
|
|
532
|
+
// app.js puts them after the turn's end line. +endNote+ (a completed turn
|
|
533
|
+
// with no answer): its notice, in the answer's place; left unplaced
|
|
534
|
+
// with no turn to end (app.js then shows it itself).
|
|
535
|
+
function turnEnded({ kind, endNote = null }) {
|
|
529
536
|
// Once per turn: a second end (a re-dispatched event) would promote again.
|
|
530
537
|
if (!turn || turn.ended) return [];
|
|
531
538
|
const last = model.currentGen(turn);
|
|
@@ -541,7 +548,8 @@ export function createTurnView({
|
|
|
541
548
|
const heldHeight = box ? box.getBoundingClientRect().height : 0;
|
|
542
549
|
if (pendingSteers.length) flushSteers();
|
|
543
550
|
apply(model.turnEnded(turn));
|
|
544
|
-
if (promoting) promote(last, { heldHeight });
|
|
551
|
+
if (promoting) promote(last, { heldHeight, endNote });
|
|
552
|
+
else if (endNote) placeAnswer([endNote], { plain: false, blockEl });
|
|
545
553
|
const kept = [];
|
|
546
554
|
if (blockEl) {
|
|
547
555
|
updateBlockSummary();
|
|
@@ -678,6 +686,13 @@ function reloadRowHtml(r, thumbs) {
|
|
|
678
686
|
`${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
|
|
679
687
|
}
|
|
680
688
|
|
|
689
|
+
// The notice of a turn that ended with no answer: a muted row like the
|
|
690
|
+
// loop's retry row, where the answer would be (live: promote()).
|
|
691
|
+
export const EMPTY_ANSWER_CLASS = "bubble hook-notice empty-answer";
|
|
692
|
+
function emptyAnswerHtml(item) {
|
|
693
|
+
return `<div class="${EMPTY_ANSWER_CLASS}">${escapeHtml(emptyAnswerLine(item))}</div>`;
|
|
694
|
+
}
|
|
695
|
+
|
|
681
696
|
// A reloaded history in the turn view (renderHistory's object path): per
|
|
682
697
|
// turn the prompt, a collapsed block of its steps when it had any (texts,
|
|
683
698
|
// and with the server's parts the thinking, params and output, from the
|
|
@@ -708,9 +723,13 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
708
723
|
const prompt = items[group.user];
|
|
709
724
|
const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}><div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
|
|
710
725
|
const [only] = group.steps;
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
726
|
+
const ended = group.answer !== null || group.emptyAnswer != null;
|
|
727
|
+
const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
|
|
728
|
+
// A lone thinking step before an answer (or the notice of a turn with
|
|
729
|
+
// none): no block, as live.
|
|
730
|
+
const plainThinking = group.steps.length === 1 && ended && textless && !only.tools.length && only.thinking;
|
|
731
|
+
const noBlock = !group.steps.length || plainThinking;
|
|
732
|
+
if (plainThinking) {
|
|
714
733
|
// A plain answer with thinking: the collapsed thinking block, as live.
|
|
715
734
|
parts.push(`<details class="bubble thinking"><summary>thinking</summary><div class="thinking-body">${escapeHtml(only.thinking)}</div></details>`);
|
|
716
735
|
} else if (group.steps.length) {
|
|
@@ -736,9 +755,12 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
736
755
|
// after its steps and before its answer.
|
|
737
756
|
parts.push((group.merged || []).map((i) =>
|
|
738
757
|
`<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}><div class="user-message">${messageBodyHtml(items[i]).body}${thumbs(items[i].images)}</div><span class="state-badge">steered</span></div>`).join(""));
|
|
758
|
+
// A turn that ended with no answer: chi's muted notice where the answer
|
|
759
|
+
// would be, before the timing, as live.
|
|
760
|
+
if (group.emptyAnswer != null) parts.push(`${emptyAnswerHtml(items[group.emptyAnswer])}${timingHtml(group)}`);
|
|
739
761
|
// A turn with no answer (canceled, or running: no record yet) shows its
|
|
740
762
|
// timing after its prompt and steps, where the live view leaves it.
|
|
741
|
-
if (group.answer === null) parts.push(timingHtml(group));
|
|
763
|
+
else if (group.answer === null) parts.push(timingHtml(group));
|
|
742
764
|
else parts.push(answerHtml(items[group.answer], timingHtml(group)));
|
|
743
765
|
// A canceled turn ends with its cancel line, as the live view left it.
|
|
744
766
|
parts.push(cancelLineHtml(group.record, escapeHtml));
|
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.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -310,6 +310,10 @@ files:
|
|
|
310
310
|
- lib/samagotchi/question_desk.rb
|
|
311
311
|
- lib/samagotchi/recap_setup.rb
|
|
312
312
|
- lib/samagotchi/recap_store.rb
|
|
313
|
+
- lib/samagotchi/relay.rb
|
|
314
|
+
- lib/samagotchi/relay_desk.rb
|
|
315
|
+
- lib/samagotchi/relay_verifier.rb
|
|
316
|
+
- lib/samagotchi/relay_watcher.rb
|
|
313
317
|
- lib/samagotchi/reminder_queue.rb
|
|
314
318
|
- lib/samagotchi/reminder_store.rb
|
|
315
319
|
- lib/samagotchi/reply_wait.rb
|
|
@@ -366,6 +370,7 @@ files:
|
|
|
366
370
|
- lib/samagotchi/tools/builtins.rb
|
|
367
371
|
- lib/samagotchi/tools/cancel_reminder.rb
|
|
368
372
|
- lib/samagotchi/tools/delegate.rb
|
|
373
|
+
- lib/samagotchi/tools/delegate_relay.rb
|
|
369
374
|
- lib/samagotchi/tools/delegate_result.rb
|
|
370
375
|
- lib/samagotchi/tools/delegate_wait.rb
|
|
371
376
|
- lib/samagotchi/tools/edit.rb
|