samagotchi 0.14.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 +77 -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/plugins.md +3 -3
- 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 +58 -17
- data/lib/samagotchi/kernel_loop.rb +16 -5
- data/lib/samagotchi/llm/chat_loop.rb +17 -9
- 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/plugin_session_state.rb +30 -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/self_report.rb +24 -4
- data/lib/samagotchi/session.rb +15 -2
- data/lib/samagotchi/session_manager.rb +5 -2
- data/lib/samagotchi/session_metrics.rb +18 -1
- data/lib/samagotchi/session_retention.rb +6 -1
- 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 +88 -12
- 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/terminal_ui.rb +11 -0
- 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 +24 -0
- data/lib/samagotchi/web/public/app.js +32 -11
- data/lib/samagotchi/web/public/index.html +9 -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 +16 -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/strip.js +8 -1
- data/lib/samagotchi/web/public/timing.js +22 -5
- data/lib/samagotchi/web/public/turn_events.js +9 -0
- data/lib/samagotchi/web/public/turn_view.js +38 -13
- metadata +7 -1
|
@@ -21,7 +21,7 @@ import { emptySessionState } from "./session_state.js";
|
|
|
21
21
|
import { createModelPicker } from "./model_picker.js";
|
|
22
22
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
23
23
|
import { escapeHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
24
|
-
import { createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
24
|
+
import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
25
25
|
import { createStageView } from "./stage_view.js";
|
|
26
26
|
import { appendQuote } from "./annotations.js";
|
|
27
27
|
import { parsePresets } from "./annotate_presets.js";
|
|
@@ -38,13 +38,13 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
|
38
38
|
import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
|
|
39
39
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
40
40
|
import { stripColumns, stripShowParts } from "./strip.js";
|
|
41
|
-
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, withChildrenAfterParents } from "./sessions_list.js";
|
|
41
|
+
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
|
|
42
42
|
import { createNotifications } from "./notifications.js";
|
|
43
43
|
import {
|
|
44
44
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
45
45
|
} from "./timing.js";
|
|
46
46
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
47
|
-
import { clientLabel, commandView, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
47
|
+
import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
48
48
|
|
|
49
49
|
const $ = (s) => document.querySelector(s);
|
|
50
50
|
const topStripEl = $("#topStrip");
|
|
@@ -262,7 +262,8 @@ function filteredSessions(list) {
|
|
|
262
262
|
(s.first_preview || "").toLowerCase().includes(q) ||
|
|
263
263
|
s.short_id.toLowerCase().includes(q) ||
|
|
264
264
|
s.id.toLowerCase().includes(q) ||
|
|
265
|
-
s.status.toLowerCase().includes(q)
|
|
265
|
+
s.status.toLowerCase().includes(q) ||
|
|
266
|
+
waitingSearchText(s).includes(q),
|
|
266
267
|
);
|
|
267
268
|
}
|
|
268
269
|
|
|
@@ -385,11 +386,13 @@ function renderTop(sessions) {
|
|
|
385
386
|
$("#stripHide").addEventListener("click", () => setStripHidden(true));
|
|
386
387
|
}
|
|
387
388
|
|
|
388
|
-
// The hidden strip's pill: "▾ 5 sessions · ● 1 live
|
|
389
|
+
// The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting",
|
|
390
|
+
// following the list.
|
|
389
391
|
function renderStripShow() {
|
|
390
|
-
const { count, live } = stripShowParts(shownSessions());
|
|
392
|
+
const { count, live, waiting } = stripShowParts(shownSessions());
|
|
391
393
|
const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
|
|
392
|
-
|
|
394
|
+
const waitingHtml = waiting ? ` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${escapeHtml(waiting)}</span>` : "";
|
|
395
|
+
$("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}${waitingHtml}`;
|
|
393
396
|
}
|
|
394
397
|
|
|
395
398
|
// A delegated session sits right after its parent: applied on every
|
|
@@ -773,6 +776,9 @@ function renderSessionView(data) {
|
|
|
773
776
|
// A question the worker is blocked on (also after a reload).
|
|
774
777
|
if (data.pending_question && data.pending_question.id && data.pending_question.status === "pending") {
|
|
775
778
|
questionCards.renderQuestion(data.pending_question);
|
|
779
|
+
// The turn's replay drew it as asked; the snapshot knows whether it is
|
|
780
|
+
// relayed to the parent now.
|
|
781
|
+
questionCards.markRelayed(data.pending_question.id, data.pending_question.relayed_to || null);
|
|
776
782
|
}
|
|
777
783
|
// A turn ran out of iterations and waits for a yes or no.
|
|
778
784
|
if (data.continue_offer) questionCards.renderContinue(data.continue_offer);
|
|
@@ -894,12 +900,25 @@ function addEndBubble(text) {
|
|
|
894
900
|
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
895
901
|
}
|
|
896
902
|
|
|
903
|
+
// The notice of a turn that ended with no answer (turn_summary.empty_answer),
|
|
904
|
+
// as a reload draws it (turn_view.js EMPTY_ANSWER_CLASS).
|
|
905
|
+
function emptyAnswerEl(emptyAnswer) {
|
|
906
|
+
const el = document.createElement("div");
|
|
907
|
+
el.className = EMPTY_ANSWER_CLASS;
|
|
908
|
+
el.textContent = emptyAnswerLine(emptyAnswer);
|
|
909
|
+
return el;
|
|
910
|
+
}
|
|
911
|
+
|
|
897
912
|
// Everything a turn's end (completed, canceled, failed) closes: then its
|
|
898
913
|
// end line (+endText+, the cancel or failed line), then the step cards that
|
|
899
914
|
// left the collapsed block (card.js leavesBlock), where a reload puts them.
|
|
900
|
-
function endTurnView(kind, endText = null) {
|
|
915
|
+
function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
|
|
901
916
|
setTurnRunning(false);
|
|
902
|
-
|
|
917
|
+
// A completed turn with no answer: its notice where the answer goes
|
|
918
|
+
// (the turn view places it), else after the turn; before the timing.
|
|
919
|
+
const endNote = emptyAnswer ? emptyAnswerEl(emptyAnswer) : null;
|
|
920
|
+
const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
|
|
921
|
+
if (endNote && !endNote.isConnected) appendToHistory(endNote);
|
|
903
922
|
finishLiveTurnTiming(null, { canceled: kind === "canceled" });
|
|
904
923
|
if (endText) addEndBubble(endText);
|
|
905
924
|
if (kept.length) {
|
|
@@ -1200,7 +1219,7 @@ function reloadedTurnEls(before) {
|
|
|
1200
1219
|
// What goes after a reloaded turn's own rows and cards: its answer, else
|
|
1201
1220
|
// its timing line; null when it has neither.
|
|
1202
1221
|
function turnAnswerEl(turnEls) {
|
|
1203
|
-
return turnEls.filter((el) => el.matches(".bubble.output")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
|
|
1222
|
+
return turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
|
|
1204
1223
|
}
|
|
1205
1224
|
|
|
1206
1225
|
// A finished turn's question (the snapshot's cards keep it with its
|
|
@@ -1523,7 +1542,7 @@ const streamHandlers = {
|
|
|
1523
1542
|
startLiveTurnTiming(data.started_at);
|
|
1524
1543
|
}),
|
|
1525
1544
|
turn_completed: async (data) => {
|
|
1526
|
-
endTurnView("completed");
|
|
1545
|
+
endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
|
|
1527
1546
|
const out = turnOutput(data);
|
|
1528
1547
|
if (out) appendChunk(out);
|
|
1529
1548
|
// after_turn hooks are running and may present the answer: its
|
|
@@ -1640,6 +1659,8 @@ const streamHandlers = {
|
|
|
1640
1659
|
question_answered: (data) => {
|
|
1641
1660
|
questionCards.resolveQuestion(data.id, { answer: data.answer });
|
|
1642
1661
|
},
|
|
1662
|
+
// A delegate's question relayed to its parent's card, or no longer.
|
|
1663
|
+
question_relay: (data) => questionCards.markRelayed(data.id, data.relayed_to),
|
|
1643
1664
|
question_cancelled: (data) => {
|
|
1644
1665
|
questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
|
|
1645
1666
|
},
|
|
@@ -253,6 +253,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
253
253
|
.bubble.question.approval.answered.denied .question-result{color:var(--danger)}
|
|
254
254
|
.bubble.question.approval.answered.denied .question-option.selected{border-color:var(--danger)}
|
|
255
255
|
.approval-details{margin-bottom:8px;font-size:13px;color:var(--text-2)}
|
|
256
|
+
.approval-delegate{font-size:12px;color:var(--text-muted);margin-bottom:4px;overflow-wrap:anywhere}
|
|
257
|
+
.approval-delegate a{color:var(--text);font-weight:600}
|
|
258
|
+
.question-relayed{font-size:12px;color:var(--text-muted);margin-bottom:6px}
|
|
259
|
+
.question-relayed.hidden{display:none}
|
|
260
|
+
.question-relayed a{color:var(--text);font-weight:600}
|
|
256
261
|
.approval-tool{font-weight:700;color:var(--text);margin-bottom:4px}
|
|
257
262
|
.approval-what{margin:0 0 6px;padding:8px 10px;background:var(--bg-inset);border:1px solid var(--warn-border);border-radius:6px;white-space:pre-wrap;word-break:break-all;color:var(--warn-bright);font-size:12.5px}
|
|
258
263
|
.approval-where,.approval-why{font-size:12px;color:var(--text-muted);word-break:break-all}
|
|
@@ -429,6 +434,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
429
434
|
.turn-stage[data-phase="answered"]{border-left-color:var(--ok)}
|
|
430
435
|
.turn-stage[data-phase="canceled"],.turn-stage[data-phase="waiting for you"]{border-left-color:var(--warn)}
|
|
431
436
|
.turn-stage[data-phase="failed"]{border-left-color:var(--danger)}
|
|
437
|
+
.turn-stage[data-phase="no answer"]{border-left-color:var(--warn)}
|
|
432
438
|
.turn-stage.enter{animation:ts-rise .35s cubic-bezier(.3,.7,.2,1)}
|
|
433
439
|
@keyframes ts-rise{from{opacity:0;transform:translateY(14px)}}
|
|
434
440
|
.turn-stage.leaving{transition:height .4s cubic-bezier(.3,.7,.2,1),opacity .3s ease;opacity:0}
|
|
@@ -441,6 +447,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
441
447
|
.turn-stage[data-phase="answered"] .ts-mark,.turn-stage[data-phase="answered"] .ts-phase{color:var(--ok)}
|
|
442
448
|
.turn-stage[data-phase="canceled"] .ts-mark,.turn-stage[data-phase="canceled"] .ts-phase,.turn-stage[data-phase="waiting for you"] .ts-phase{color:var(--warn-text)}
|
|
443
449
|
.turn-stage[data-phase="failed"] .ts-mark,.turn-stage[data-phase="failed"] .ts-phase{color:var(--danger-text)}
|
|
450
|
+
.turn-stage[data-phase="no answer"] .ts-mark,.turn-stage[data-phase="no answer"] .ts-phase{color:var(--text-muted)}
|
|
444
451
|
.turn-stage[data-phase="waiting for you"] .ts-mark{background:var(--warn)}
|
|
445
452
|
.ts-phase{color:var(--accent);font-weight:600}
|
|
446
453
|
.ts-timing{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
@@ -636,6 +643,8 @@ body.has-session #scope .scope-where{display:none}
|
|
|
636
643
|
#stripShow{display:none;white-space:nowrap;flex-shrink:0}
|
|
637
644
|
#stripShow .live{color:var(--ok-text);white-space:nowrap}
|
|
638
645
|
#stripShow .live-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px;vertical-align:1px}
|
|
646
|
+
#stripShow .waiting{color:var(--warn-text);white-space:nowrap}
|
|
647
|
+
#stripShow .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
|
|
639
648
|
#stripHide{align-self:flex-end;border:0;padding:0 2px;border-radius:4px}
|
|
640
649
|
#stripHide:hover{text-decoration:underline;color:var(--text-2)}
|
|
641
650
|
body.strip-hidden #stripShow{display:block}
|
|
@@ -30,16 +30,25 @@ export function waitingOn(summary) {
|
|
|
30
30
|
return null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// How long a delegated child's new approval waits before it notifies: its
|
|
34
|
+
// parent relays it within a poll or two when it waits on the child, and
|
|
35
|
+
// then the parent's card notifies instead (one bell, not two).
|
|
36
|
+
export const RELAY_HOLD_MS = 1500;
|
|
37
|
+
|
|
33
38
|
// What changed between two summaries of one session that needs the user.
|
|
34
39
|
// @param prev the summary held before (null for a session not seen yet)
|
|
35
40
|
// @param next the new summary
|
|
36
41
|
// @return null | {reason: "question"|"approval"|"card"|"failed"|"done", sessionId, key}
|
|
37
|
-
// key names this one event, the same in every tab (the Notification tag)
|
|
42
|
+
// key names this one event, the same in every tab (the Notification tag);
|
|
43
|
+
// holdMs, when there, delays it (a delegated child's approval)
|
|
38
44
|
export function attentionFor(prev, next) {
|
|
39
45
|
if (!next || !next.id) return null;
|
|
40
46
|
const q = next.pending_question;
|
|
41
47
|
if (q && q.id && q.id !== prev?.pending_question?.id) {
|
|
42
|
-
|
|
48
|
+
// Relayed to the parent already: the parent's card notifies.
|
|
49
|
+
if (q.relayed_to) return null;
|
|
50
|
+
const attention = { reason: questionReason(q), sessionId: next.id, key: `${next.id}:${q.id}` };
|
|
51
|
+
return next.parent_id && q.kind === "approval" ? { ...attention, holdMs: RELAY_HOLD_MS } : attention;
|
|
43
52
|
}
|
|
44
53
|
const card = next.pending_card;
|
|
45
54
|
if (card && card.id && card.id !== prev?.pending_card?.id) {
|
|
@@ -74,7 +83,9 @@ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
|
|
|
74
83
|
export function closedKeys(prev, next) {
|
|
75
84
|
const keys = [];
|
|
76
85
|
const q = prev?.pending_question;
|
|
77
|
-
|
|
86
|
+
const nq = next?.pending_question;
|
|
87
|
+
// Gone, or relayed to the parent (its card notifies and badges instead).
|
|
88
|
+
if (q && q.id && (nq?.id !== q.id || (nq.relayed_to && !q.relayed_to))) keys.push(`${prev.id}:${q.id}`);
|
|
78
89
|
const card = prev?.pending_card;
|
|
79
90
|
if (card && card.id && next?.pending_card?.id !== card.id) keys.push(cardKey(prev.id, card.id));
|
|
80
91
|
return keys;
|
|
@@ -178,10 +189,12 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
|
|
|
178
189
|
},
|
|
179
190
|
// A new attention in a tab behind: +deliver+ (notify, badge) unless
|
|
180
191
|
// another tab has it in front. @return whether it may still be delivered
|
|
192
|
+
// An attention's own holdMs (a delegated child's approval) holds it
|
|
193
|
+
// longer, alone too.
|
|
181
194
|
offer(attention, deliver) {
|
|
182
195
|
const key = attention.key;
|
|
183
196
|
if (seen.has(key)) return false;
|
|
184
|
-
if (!channel) {
|
|
197
|
+
if (!channel && !attention.holdMs) {
|
|
185
198
|
deliver();
|
|
186
199
|
return true;
|
|
187
200
|
}
|
|
@@ -189,7 +202,7 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
|
|
|
189
202
|
pending.set(key, schedule(() => {
|
|
190
203
|
pending.delete(key);
|
|
191
204
|
if (!seen.has(key)) deliver();
|
|
192
|
-
}, holdMs));
|
|
205
|
+
}, Math.max(holdMs, attention.holdMs || 0)));
|
|
193
206
|
return true;
|
|
194
207
|
},
|
|
195
208
|
// Closed before its hold was up (a question answered at once).
|
|
@@ -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,22 @@ 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] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// What the all view's search matches for a session that waits on the user:
|
|
92
|
+
// "waiting" and its badge's word ("question", "approval", "needs you");
|
|
93
|
+
// "" for one that doesn't.
|
|
94
|
+
export function waitingSearchText(summary) {
|
|
95
|
+
const badge = waitingBadge(summary);
|
|
96
|
+
return badge ? `waiting ${badge.text}` : "";
|
|
82
97
|
}
|
|
83
98
|
|
|
84
99
|
// The sessions that wait on the user (waitingOn) first, the rest after,
|
|
@@ -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.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { waitingOn } from "./notify.js";
|
|
2
|
+
|
|
1
3
|
// How many session cards the top strip shows for its width in px: a card
|
|
2
4
|
// needs about 360 px plus a 12 px gap next to the 150 px "All sessions" tile;
|
|
3
5
|
// never fewer than 3 (narrow screens scroll them), never more than 6.
|
|
@@ -13,13 +15,18 @@ export function stripColumns(width) {
|
|
|
13
15
|
// The hidden strip's "▾ sessions" pill carries the list's count, and how
|
|
14
16
|
// many of those sessions a worker runs (the cards' green "live" badge), so a
|
|
15
17
|
// user who hid the strip still sees there is a live session to go back to.
|
|
16
|
-
//
|
|
18
|
+
// And how many wait on the user (notify.js waitingOn: a question, an
|
|
19
|
+
// approval, a card), so a hidden strip still says one needs an answer.
|
|
20
|
+
// Plain text parts; live and waiting are "" when none. Zero sessions keep
|
|
21
|
+
// the bare label.
|
|
17
22
|
export function stripShowParts(sessions) {
|
|
18
23
|
const list = sessions || [];
|
|
19
24
|
const n = list.length;
|
|
20
25
|
const liveN = list.filter((s) => s && s.owner === "worker").length;
|
|
26
|
+
const waitingN = list.filter((s) => waitingOn(s)).length;
|
|
21
27
|
return {
|
|
22
28
|
count: n ? `${n} ${n === 1 ? "session" : "sessions"}` : "sessions",
|
|
23
29
|
live: liveN ? `${liveN} live` : "",
|
|
30
|
+
waiting: waitingN ? `${waitingN} waiting` : "",
|
|
24
31
|
};
|
|
25
32
|
}
|
|
@@ -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) {
|
|
@@ -105,18 +107,23 @@ export function turnGroups(items, timing) {
|
|
|
105
107
|
turn.merged.push(i);
|
|
106
108
|
} else if (item?.role === "user") {
|
|
107
109
|
turnIndex += 1;
|
|
108
|
-
turn = { kind: "turn", turnIndex, user: i, texts: [], steers: [], merged: [] };
|
|
110
|
+
turn = { kind: "turn", turnIndex, turnId: item.turn_id, user: i, texts: [], steers: [], merged: [] };
|
|
109
111
|
groups.push(turn);
|
|
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
|
|
|
@@ -138,10 +145,20 @@ function withSteers(group, steerIndexes, items) {
|
|
|
138
145
|
return { ...group, steps, steers: left };
|
|
139
146
|
}
|
|
140
147
|
|
|
148
|
+
// A prompt's turn record: the one with its turn id (the engine saves it
|
|
149
|
+
// on the prompt), so a turn whose prompt left the conversation (a failed
|
|
150
|
+
// one's went back to the user, !rollback) shifts no other; a prompt saved
|
|
151
|
+
// before turn ids pairs by its place among the prompts.
|
|
152
|
+
function promptRecord(timing, g) {
|
|
153
|
+
if (typeof g.turnId !== "string" || !g.turnId) return turnRecordAt(timing, g.turnIndex);
|
|
154
|
+
return (timing?.turnRecords || []).find((r) => r.id === g.turnId) || null;
|
|
155
|
+
}
|
|
156
|
+
|
|
141
157
|
function turnGroup(g, items, timing) {
|
|
142
|
-
const record =
|
|
158
|
+
const record = promptRecord(timing, g);
|
|
143
159
|
const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
|
|
144
|
-
|
|
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;
|
|
145
162
|
if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
|
|
146
163
|
const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
|
|
147
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 = {}) {
|