samagotchi 0.5.1 → 0.6.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 +64 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +4 -1
- data/docs/desktop.md +3 -1
- data/docs/hooks.md +69 -4
- data/docs/plugins.md +98 -3
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/card_store.rb +30 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
- data/lib/samagotchi/bridge/turn_notice.rb +37 -0
- data/lib/samagotchi/bundle_command.rb +652 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cancellation_controller.rb +42 -1
- data/lib/samagotchi/config.rb +2 -2
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
- data/lib/samagotchi/desktop/macos/Panel.swift +15 -0
- data/lib/samagotchi/engine.rb +138 -618
- data/lib/samagotchi/guardrail_wiring.rb +238 -0
- data/lib/samagotchi/hooks/registry.rb +11 -3
- data/lib/samagotchi/hooks/stream_watch.rb +93 -0
- data/lib/samagotchi/hooks.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +65 -6
- data/lib/samagotchi/llm/chat_loop.rb +73 -11
- data/lib/samagotchi/llm/openai_chat.rb +4 -2
- data/lib/samagotchi/log_subscriber.rb +10 -4
- data/lib/samagotchi/plugin/api.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +12 -3
- data/lib/samagotchi/plugin_tasks.rb +229 -0
- data/lib/samagotchi/question_desk.rb +280 -0
- data/lib/samagotchi/recap_setup.rb +128 -0
- data/lib/samagotchi/session.rb +9 -1
- data/lib/samagotchi/sessions_command.rb +255 -0
- data/lib/samagotchi/steer.rb +19 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/turn_note.rb +20 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -8
- data/lib/samagotchi/web/public/app.js +94 -52
- data/lib/samagotchi/web/public/card.js +8 -1
- data/lib/samagotchi/web/public/format.js +1 -1
- data/lib/samagotchi/web/public/index.html +2 -5
- data/lib/samagotchi/web/public/stage_model.js +44 -0
- data/lib/samagotchi/web/public/stage_view.js +26 -17
- data/lib/samagotchi/web/public/timing.js +9 -3
- data/lib/samagotchi/web/public/turn_events.js +18 -5
- data/lib/samagotchi/web/public/turn_view.js +68 -16
- data/lib/samagotchi/web/server.rb +4 -3
- metadata +9 -2
- data/lib/samagotchi/web/public/chat_view.js +0 -376
|
@@ -21,7 +21,6 @@ import { MODEL_KEY } from "./model_pick.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, messageBodyHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, firstMessageOf, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
24
|
-
import { chatHistoryHtml, createChatView } from "./chat_view.js";
|
|
25
24
|
import { createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
26
25
|
import { createStageView } from "./stage_view.js";
|
|
27
26
|
import { annotateBarLeft, annotationSource, appendQuote, quoteBlock } from "./annotations.js";
|
|
@@ -31,7 +30,7 @@ import { routeChunk } from "./chunk_router.js";
|
|
|
31
30
|
import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
|
|
32
31
|
import { diffHtml } from "./diff_view.js";
|
|
33
32
|
import { approvalAllowed, approvalView, isApproval, nextCardAction, resultText, summaryText } from "./question_card.js";
|
|
34
|
-
import { cardClass, cardInnerHtml, cardPlace, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
33
|
+
import { cardClass, cardInnerHtml, cardPlace, isCard, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
35
34
|
import { gripFloor, promptHeight } from "./composer_size.js";
|
|
36
35
|
import { commandMatches, commandRow, moveSelection, pickCommand } from "./command_complete.js";
|
|
37
36
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
@@ -44,7 +43,7 @@ import {
|
|
|
44
43
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
45
44
|
} from "./timing.js";
|
|
46
45
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
47
|
-
import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
46
|
+
import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
48
47
|
|
|
49
48
|
const $ = (s) => document.querySelector(s);
|
|
50
49
|
const topStripEl = $("#topStrip");
|
|
@@ -73,14 +72,12 @@ let resyncsInFlight = 0;
|
|
|
73
72
|
let resyncAgain = false;
|
|
74
73
|
let allSessions = [];
|
|
75
74
|
let seenContent = new Set();
|
|
76
|
-
// The page's view of a turn (web.view, or ?view=
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
const view = ["
|
|
80
|
-
// The turn
|
|
81
|
-
|
|
82
|
-
// The running turn's DOM: the streaming bubbles, the thinking block and the
|
|
83
|
-
// activity panel, the turn block, or the stage (the turn block in its cloud).
|
|
75
|
+
// The page's view of a turn (web.view, or ?view=stage|turn): the running
|
|
76
|
+
// turn pinned above the composer (the stage, stage_view.js), or one block
|
|
77
|
+
// per turn with its steps in the history (turn_view.js).
|
|
78
|
+
const view = ["stage", "turn"].includes(document.body.dataset.view) ? document.body.dataset.view : "stage";
|
|
79
|
+
// The running turn's DOM: the turn block, or the stage (the turn block in
|
|
80
|
+
// its cloud).
|
|
84
81
|
const viewArgs = {
|
|
85
82
|
historyEl,
|
|
86
83
|
appendToHistory: (el) => appendToHistory(el),
|
|
@@ -90,7 +87,7 @@ const viewArgs = {
|
|
|
90
87
|
};
|
|
91
88
|
const turnDom = view === "stage"
|
|
92
89
|
? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
|
|
93
|
-
:
|
|
90
|
+
: createTurnView(viewArgs);
|
|
94
91
|
// Where a running turn's rows can be: the history, and the stage while it
|
|
95
92
|
// holds a turn (lookups by id, copy, annotate, card actions).
|
|
96
93
|
function turnRoots() {
|
|
@@ -683,7 +680,7 @@ async function resync({ endText = null } = {}) {
|
|
|
683
680
|
let data;
|
|
684
681
|
try {
|
|
685
682
|
try {
|
|
686
|
-
data = await getSession(id, { parts:
|
|
683
|
+
data = await getSession(id, { parts: true });
|
|
687
684
|
} catch (e) {
|
|
688
685
|
if (e.status === 404) return leaveGoneSession(id, "not_found");
|
|
689
686
|
if (id === selected) historyEl.innerHTML = `<div class="hint">Error: ${escapeHtml(e.message)}</div>`;
|
|
@@ -749,9 +746,11 @@ function renderSessionView(data) {
|
|
|
749
746
|
seedSeenFromDom();
|
|
750
747
|
seedInitTasks(initTasks, data.init_tasks);
|
|
751
748
|
renderInitRow();
|
|
752
|
-
// A guardrail file that failed to load, announced before this page
|
|
753
|
-
|
|
754
|
-
|
|
749
|
+
// A guardrail file that failed to load, announced before this page
|
|
750
|
+
// joined: where it came (the snapshot's cards), else at the end.
|
|
751
|
+
const loadWarned = (label) => (data.cards || []).some((c) => c?.type === "guardrail_warning" && (c.label || "guardrails") === label);
|
|
752
|
+
if (!loadWarned("guardrails")) showGuardrailWarning(data.guardrail_warning);
|
|
753
|
+
if (!loadWarned("plugins")) showGuardrailWarning(data.plugin_warning, "plugins");
|
|
755
754
|
// The recap saved with the session (also a stopped one's), else a live
|
|
756
755
|
// worker's from before saved_recap existed.
|
|
757
756
|
if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since);
|
|
@@ -789,10 +788,8 @@ function renderHistory(items) {
|
|
|
789
788
|
historyEl.innerHTML = `<div class="hint">No output yet. Send a message.</div>`;
|
|
790
789
|
return;
|
|
791
790
|
}
|
|
792
|
-
if (typeof items[0] === "object" && items[0].role
|
|
791
|
+
if (typeof items[0] === "object" && items[0].role) {
|
|
793
792
|
historyEl.innerHTML = turnHistoryHtml(items, currentTiming, { thumbs: (images) => thumbsHtml(selected, images) });
|
|
794
|
-
} else if (typeof items[0] === "object" && items[0].role) {
|
|
795
|
-
historyEl.innerHTML = chatHistoryHtml(items, currentTiming, { thumbs: (images) => thumbsHtml(selected, images) });
|
|
796
793
|
} else {
|
|
797
794
|
historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
|
|
798
795
|
}
|
|
@@ -905,7 +902,7 @@ function addEndBubble(text) {
|
|
|
905
902
|
function endTurnView(kind, endText = null) {
|
|
906
903
|
setTurnRunning(false);
|
|
907
904
|
const kept = turnDom.turnEnded({ kind }) || [];
|
|
908
|
-
finishLiveTurnTiming();
|
|
905
|
+
finishLiveTurnTiming(null, { canceled: kind === "canceled" });
|
|
909
906
|
if (endText) addEndBubble(endText);
|
|
910
907
|
if (kept.length) {
|
|
911
908
|
kept.forEach((el) => appendToHistory(el));
|
|
@@ -1034,7 +1031,7 @@ function showRecap(text, turnsSince = 0) {
|
|
|
1034
1031
|
details.appendChild(body);
|
|
1035
1032
|
// A stale one sits where it was written, above the turns it doesn't cover.
|
|
1036
1033
|
// Never in the stage: a recap is between turns.
|
|
1037
|
-
const before = recapPlace(
|
|
1034
|
+
const before = recapPlace(turnPromptBubbles(), turnsSince);
|
|
1038
1035
|
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurnTimingEl)));
|
|
1039
1036
|
}
|
|
1040
1037
|
|
|
@@ -1049,21 +1046,23 @@ function markRecapStale() {
|
|
|
1049
1046
|
|
|
1050
1047
|
// A guardrail hook or rule file that failed to load (once per worker), or
|
|
1051
1048
|
// a bundle's plugin (+label+ "plugins").
|
|
1052
|
-
|
|
1049
|
+
// +before+: the element it goes above (a snapshot's), else the end.
|
|
1050
|
+
function showGuardrailWarning(text, label = "guardrails", { before = null } = {}) {
|
|
1053
1051
|
if (!text) return;
|
|
1054
1052
|
const el = document.createElement("div");
|
|
1055
1053
|
el.className = "bubble guardrail-warning";
|
|
1056
1054
|
el.textContent = `${label}: ${text}`;
|
|
1057
1055
|
const follow = isNearBottom();
|
|
1058
|
-
|
|
1056
|
+
if (before) historyEl.insertBefore(el, before);
|
|
1057
|
+
else appendToHistory(el);
|
|
1059
1058
|
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1060
1059
|
}
|
|
1061
1060
|
|
|
1062
1061
|
// A hook's line to the user (event[:notify]): "<bundle>: text", in the
|
|
1063
1062
|
// warning colour for level warn.
|
|
1064
1063
|
// +before+: the element it goes above (a snapshot's notice), else the end.
|
|
1065
|
-
// A plugin's steers (pending_input_merged's steers):
|
|
1066
|
-
//
|
|
1064
|
+
// A plugin's steers (pending_input_merged's steers): a row of the running
|
|
1065
|
+
// turn's step, else (no turn runs) a bubble.
|
|
1067
1066
|
function showSteers(steers) {
|
|
1068
1067
|
for (const steer of steers || []) {
|
|
1069
1068
|
if (turnDom.steer(steer)) continue;
|
|
@@ -1080,7 +1079,7 @@ function showHookNotice(data, { before = null } = {}) {
|
|
|
1080
1079
|
removeHintIfEmpty();
|
|
1081
1080
|
const el = document.createElement("div");
|
|
1082
1081
|
el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "");
|
|
1083
|
-
el.textContent = `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
1082
|
+
el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
1084
1083
|
const follow = isNearBottom();
|
|
1085
1084
|
if (before) historyEl.insertBefore(el, before);
|
|
1086
1085
|
else appendToHistory(el);
|
|
@@ -1101,6 +1100,9 @@ function cardElement(id) {
|
|
|
1101
1100
|
function drawCard(el, card) {
|
|
1102
1101
|
el.className = cardClass(card);
|
|
1103
1102
|
el.dataset.cardId = card.id;
|
|
1103
|
+
// A running turn's own card (the stage's status waits on one that asks).
|
|
1104
|
+
if (card.in_turn) el.dataset.inTurn = "1";
|
|
1105
|
+
else delete el.dataset.inTurn;
|
|
1104
1106
|
if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
|
|
1105
1107
|
// The same body again from the stream: keep the rendered one.
|
|
1106
1108
|
const body = el.querySelector(".card-body").innerHTML;
|
|
@@ -1139,41 +1141,35 @@ function upsertCard(card, { before = null } = {}) {
|
|
|
1139
1141
|
// A snapshot's cards and notices, each above the first turn after it; a
|
|
1140
1142
|
// turn's own notice where it showed live (placeTurnNotice).
|
|
1141
1143
|
function placeSnapshotCards(entries) {
|
|
1142
|
-
const users =
|
|
1144
|
+
const users = turnPromptBubbles();
|
|
1143
1145
|
for (const entry of entries) {
|
|
1144
1146
|
const before = cardPlace(users, entry);
|
|
1145
|
-
if (entry
|
|
1147
|
+
if (isCard(entry)) upsertCard(entry, { before });
|
|
1148
|
+
else if (entry.type === "question") placeResolvedQuestion(entry, before);
|
|
1149
|
+
else if (entry.type === "guardrail_warning") showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
|
|
1150
|
+
else if (entry.in_turn) placeTurnNotice(entry, before);
|
|
1146
1151
|
else if (entry.type === "hook_notice") showHookNotice(entry, { before });
|
|
1147
|
-
else upsertCard(entry, { before });
|
|
1148
1152
|
}
|
|
1149
1153
|
}
|
|
1150
1154
|
|
|
1151
|
-
// A finished turn's hook notice
|
|
1152
|
-
//
|
|
1153
|
-
// the call it came before, as live; else (
|
|
1154
|
-
// step the history doesn't have) a bubble before the
|
|
1155
|
-
// before its timing line when it has none.
|
|
1155
|
+
// A finished turn's row, a hook's notice or the loop's asking again
|
|
1156
|
+
// (+before+: the next turn's prompt, or null for the last turn): a row of
|
|
1157
|
+
// its step, above the row of the call it came before, as live; else (a
|
|
1158
|
+
// plain answer, a step the history doesn't have) a bubble before the
|
|
1159
|
+
// turn's answer, or before its timing line when it has none.
|
|
1156
1160
|
function placeTurnNotice(entry, before) {
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1159
|
-
turnEls.unshift(el);
|
|
1160
|
-
if (el.matches(".bubble.user")) break;
|
|
1161
|
-
}
|
|
1161
|
+
const line = noticeLine(entry);
|
|
1162
|
+
const turnEls = reloadedTurnEls(before);
|
|
1162
1163
|
const place = turnNoticePlace(entry);
|
|
1163
1164
|
const block = turnEls.find((el) => el.matches(".turn-work"));
|
|
1164
1165
|
const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
|
|
1165
1166
|
if (!step) {
|
|
1166
|
-
|
|
1167
|
-
// partial narration the notice came after.
|
|
1168
|
-
const canceled = !turnView && turnEls.some((el) => el.matches(".bubble.cancel"));
|
|
1169
|
-
const answer = canceled ? null : turnEls.filter((el) => el.matches(".bubble.output")).pop();
|
|
1170
|
-
const timing = turnEls.find((el) => el.matches(".turn-timing"));
|
|
1171
|
-
showHookNotice(entry, { before: answer || timing || before });
|
|
1167
|
+
showHookNotice({ ...entry, line }, { before: turnAnswerEl(turnEls) || before });
|
|
1172
1168
|
return;
|
|
1173
1169
|
}
|
|
1174
1170
|
const el = document.createElement("div");
|
|
1175
1171
|
el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "");
|
|
1176
|
-
el.textContent =
|
|
1172
|
+
el.textContent = line;
|
|
1177
1173
|
let body = step.querySelector(":scope > .activity-body");
|
|
1178
1174
|
if (!body) {
|
|
1179
1175
|
body = document.createElement("div");
|
|
@@ -1184,6 +1180,49 @@ function placeTurnNotice(entry, before) {
|
|
|
1184
1180
|
body.insertBefore(el, row || null);
|
|
1185
1181
|
}
|
|
1186
1182
|
|
|
1183
|
+
// A prompt that started a turn: not a line steered into a running one.
|
|
1184
|
+
const TURN_PROMPT = ".bubble.user:not(.steered)";
|
|
1185
|
+
|
|
1186
|
+
// The history's turn prompts, oldest first (what turns_since counts).
|
|
1187
|
+
function turnPromptBubbles() {
|
|
1188
|
+
return [...historyEl.querySelectorAll(`:scope > ${TURN_PROMPT}`)];
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// The elements of the reloaded turn that ends before +before+ (the next
|
|
1192
|
+
// turn's prompt, or null for the last turn), its prompt first.
|
|
1193
|
+
function reloadedTurnEls(before) {
|
|
1194
|
+
const turnEls = [];
|
|
1195
|
+
for (let el = before ? before.previousElementSibling : historyEl.lastElementChild; el; el = el.previousElementSibling) {
|
|
1196
|
+
turnEls.unshift(el);
|
|
1197
|
+
if (el.matches(TURN_PROMPT)) break;
|
|
1198
|
+
}
|
|
1199
|
+
return turnEls;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// What goes after a reloaded turn's own rows and cards: its answer, else
|
|
1203
|
+
// its timing line; null when it has neither.
|
|
1204
|
+
function turnAnswerEl(turnEls) {
|
|
1205
|
+
return turnEls.filter((el) => el.matches(".bubble.output")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// A finished turn's question (the snapshot's cards keep it with its
|
|
1209
|
+
// answer): the card as it was left live, resolved, before the lines the
|
|
1210
|
+
// user sent into the turn after it (a steered bubble's data-step: the step
|
|
1211
|
+
// that read it) and the turn's answer. A pending one is the running turn's
|
|
1212
|
+
// (pending_question draws it).
|
|
1213
|
+
function placeResolvedQuestion(entry, before) {
|
|
1214
|
+
const pq = entry.pending_question;
|
|
1215
|
+
if (!pq?.id || !(entry.answer || entry.cancelled)) return;
|
|
1216
|
+
renderQuestionCard(pq);
|
|
1217
|
+
const card = questionCardEl;
|
|
1218
|
+
if (!card || card.dataset.qid !== String(pq.id)) return;
|
|
1219
|
+
resolveQuestionCard(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
|
|
1220
|
+
const turnEls = reloadedTurnEls(before);
|
|
1221
|
+
const asked = Number(entry.iteration) || 1;
|
|
1222
|
+
const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
|
|
1223
|
+
historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1187
1226
|
// A live card's body comes as text; the worker's snapshot (through the
|
|
1188
1227
|
// server) has it rendered. One re-read for a burst of cards.
|
|
1189
1228
|
let cardBodiesTimer = null;
|
|
@@ -1337,7 +1376,9 @@ function setLiveTurnNote(text) {
|
|
|
1337
1376
|
if (currentTiming.activeTurn) updateLiveTiming();
|
|
1338
1377
|
}
|
|
1339
1378
|
|
|
1340
|
-
|
|
1379
|
+
// A canceled turn's line says so (+canceled+, or its record's status), as
|
|
1380
|
+
// a reload's does.
|
|
1381
|
+
function finishLiveTurnTiming(record = null, { canceled = false } = {}) {
|
|
1341
1382
|
const duration = Number.isFinite(Number(record?.duration_ms))
|
|
1342
1383
|
? Number(record.duration_ms)
|
|
1343
1384
|
: elapsedSince(currentTiming.activeTurn?.started_at);
|
|
@@ -1345,7 +1386,8 @@ function finishLiveTurnTiming(record = null) {
|
|
|
1345
1386
|
liveTurnNote = "";
|
|
1346
1387
|
liveTurnTimingEl.classList.remove("live");
|
|
1347
1388
|
const at = record ? currentTiming.turnRecords.indexOf(record) : -1;
|
|
1348
|
-
liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration
|
|
1389
|
+
liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration,
|
|
1390
|
+
{ canceled: canceled || record?.status === "canceled" });
|
|
1349
1391
|
}
|
|
1350
1392
|
currentTiming.activeTurn = null;
|
|
1351
1393
|
clearTimingInterval();
|
|
@@ -1496,7 +1538,7 @@ const streamHandlers = {
|
|
|
1496
1538
|
// that the rendered markdown is in it (or the re-read failed: then the
|
|
1497
1539
|
// plain text pops), and the display if one was coming, so the links
|
|
1498
1540
|
// never swap in after the pop. The hold reveals by itself after
|
|
1499
|
-
// HOLD_MS whatever comes. The
|
|
1541
|
+
// HOLD_MS whatever comes. The stage has no hold.
|
|
1500
1542
|
if (display) await display;
|
|
1501
1543
|
turnDom.answerReady?.();
|
|
1502
1544
|
},
|
|
@@ -1611,10 +1653,10 @@ const streamHandlers = {
|
|
|
1611
1653
|
if (!turnRunning) showRecap(data.recap);
|
|
1612
1654
|
},
|
|
1613
1655
|
guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
|
|
1614
|
-
//
|
|
1656
|
+
// A notice during a turn is a row of its current step.
|
|
1615
1657
|
hook_notice: (data) => { if (!turnDom.notice(data)) showHookNotice(data); },
|
|
1616
|
-
// The loop asks again after an empty answer: a row of the empty step
|
|
1617
|
-
//
|
|
1658
|
+
// The loop asks again after an empty answer: a row of the empty step
|
|
1659
|
+
// (a reload puts it back from the snapshot's cards).
|
|
1618
1660
|
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
1619
1661
|
// The provider is asked again after an error, or the turn waits for
|
|
1620
1662
|
// plugins' setup: a note on the live timing line until it streams.
|
|
@@ -53,15 +53,22 @@ export function cardPlace(userBubbles, card) {
|
|
|
53
53
|
// (a row of its current step once the turn is drawn), and the ones that
|
|
54
54
|
// came during the running turn but aren't its own (an anytime command's,
|
|
55
55
|
// such as /btw: after the running turn once it is drawn, as they came).
|
|
56
|
+
// The running turn's own rows (a hook's notice, a retry) are left out:
|
|
57
|
+
// they come with the turn's parts (snapshotEvents).
|
|
56
58
|
export function splitSnapshotCards(cards) {
|
|
57
59
|
const list = Array.isArray(cards) ? cards : [];
|
|
58
60
|
return {
|
|
59
61
|
placed: list.filter((c) => !c.current && !c.during),
|
|
60
|
-
current: list.filter((c) => c.current),
|
|
62
|
+
current: list.filter((c) => c.current && isCard(c)),
|
|
61
63
|
during: list.filter((c) => !c.current && c.during),
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
// An entry of the snapshot's cards that is a card (not a notice or a row).
|
|
68
|
+
export function isCard(entry) {
|
|
69
|
+
return (entry?.type ?? "card") === "card";
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
// Where a snapshot's turn notice (a hook's notice during a turn, in_turn)
|
|
66
73
|
// goes in its reloaded turn's block: the step of its iteration (the
|
|
67
74
|
// iteration-th .gen, 0-based index here) and the key of the tool row it
|
|
@@ -25,7 +25,7 @@ export function noteHtml(note) {
|
|
|
25
25
|
|
|
26
26
|
// A plugin's steer (a nudge put into the running turn): one line that
|
|
27
27
|
// names who nudged the model, its text collapsed under it. +bubble+: on its
|
|
28
|
-
// own between bubbles (
|
|
28
|
+
// own between bubbles (no step holds it), not a row of a step.
|
|
29
29
|
export function steerRowHtml({ source, text } = {}, { bubble = false } = {}) {
|
|
30
30
|
const who = source ? `${source} nudged the model` : "nudged the model";
|
|
31
31
|
return `<details class="${bubble ? "bubble " : ""}steer-row"><summary>${escapeHtml(who)}</summary>` +
|
|
@@ -104,11 +104,6 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
104
104
|
.bubble.recap summary{cursor:pointer;color:var(--think-text);font-size:12px}
|
|
105
105
|
.bubble.recap .recap-body{white-space:pre-wrap;margin-top:6px}
|
|
106
106
|
.bubble.recap.stale{opacity:.7;border-left-style:dashed}
|
|
107
|
-
.bubble.activity{border-left:3px solid var(--accent);background:var(--bg-panel);padding:8px 12px}
|
|
108
|
-
.bubble.activity>summary{cursor:pointer;color:var(--text-muted);font-size:13px;font-weight:600;user-select:none;list-style:none}
|
|
109
|
-
.bubble.activity>summary::-webkit-details-marker{display:none}
|
|
110
|
-
.bubble.activity>summary::before{content:"▸ ";color:var(--accent)}
|
|
111
|
-
.bubble.activity[open]>summary::before{content:"▾ "}
|
|
112
107
|
.activity-body{margin-top:6px}
|
|
113
108
|
.activity-row{display:flex;gap:8px;align-items:baseline;font-size:13px;color:var(--text-2);padding:5px 2px;border-top:1px solid var(--border)}
|
|
114
109
|
.activity-row:first-child{border-top:none}
|
|
@@ -561,6 +556,8 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
561
556
|
#composeHint{display:none}
|
|
562
557
|
#infoBar{flex-wrap:nowrap;align-items:center;gap:6px;padding:6px 10px 8px}
|
|
563
558
|
#infoBar .preview-line{display:none}
|
|
559
|
+
/* No terminal on a phone to attach from. */
|
|
560
|
+
#infoBar .meta .copy-attach{display:none}
|
|
564
561
|
#infoBar .meta{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;white-space:nowrap}
|
|
565
562
|
#infoBar .meta::-webkit-scrollbar{display:none}
|
|
566
563
|
#infoBar .meta>*{flex-shrink:0}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// history, and in what order its pieces go there. No DOM, so `node --test`
|
|
4
4
|
// covers the rules; stage_view.js draws them.
|
|
5
5
|
import { toolName } from "./activity.js";
|
|
6
|
+
import { splitSentences } from "./sentences.js";
|
|
6
7
|
|
|
7
8
|
const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
|
|
8
9
|
const EDITS = new Set(["edit", "write", "memory_write"]);
|
|
@@ -14,6 +15,49 @@ export const QUIET_MS = 1500;
|
|
|
14
15
|
// A touch, wheel or scroll counts as use this long (phones have no hover).
|
|
15
16
|
export const TOUCH_MS = 4000;
|
|
16
17
|
|
|
18
|
+
// One headline sentence as plain text: the markdown a streaming answer
|
|
19
|
+
// carries (headings, quotes, bullets, table pipes, emphasis, code ticks,
|
|
20
|
+
// links) goes, and a line that is only syntax (a code fence, a table's
|
|
21
|
+
// separator row, a rule) is empty. A small pass, no renderer: a live line
|
|
22
|
+
// only needs to read well, and an unclosed "**" mid-stream goes too.
|
|
23
|
+
export function plainHeadline(line) {
|
|
24
|
+
let s = String(line ?? "").trim();
|
|
25
|
+
if (/^(```|~~~)/.test(s)) return "";
|
|
26
|
+
if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?$/.test(s)) return "";
|
|
27
|
+
if (/^([-*_])(\s*\1){2,}$/.test(s)) return "";
|
|
28
|
+
s = s.replace(/^(>\s*)+/, "").replace(/^#{1,6}\s+/, "").replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, "");
|
|
29
|
+
// A table row: a pipe at either end (a pipe mid-prose, "a || b", stays).
|
|
30
|
+
if (/^\||\|$/.test(s)) {
|
|
31
|
+
s = s.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()).filter(Boolean).join(" · ");
|
|
32
|
+
}
|
|
33
|
+
// Code spans keep their text as is; an unclosed tick just goes.
|
|
34
|
+
const code = [];
|
|
35
|
+
s = s.replace(/`+([^`]+)`+/g, (_, c) => `\u0000${code.push(c) - 1}\u0000`);
|
|
36
|
+
s = s
|
|
37
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)?/g, "$1")
|
|
38
|
+
.replace(/\[([^\]]*)\]\([^)]*\)?/g, "$1")
|
|
39
|
+
.replace(/<(https?:\/\/[^>\s]+)>/g, "$1")
|
|
40
|
+
.replace(/`+/g, "")
|
|
41
|
+
.replace(/(^|[^\w])__(?=\S)(.*?\S)__(?=$|[^\w])/g, "$1$2")
|
|
42
|
+
.replace(/(\*\*|~~)/g, "")
|
|
43
|
+
.replace(/(^|[^\w*])\*(?=\S)([^*]*?\S)\*(?=$|[^\w*])/g, "$1$2")
|
|
44
|
+
.replace(/(^|[^\w])_(?=\S)([^_]*?\S)_(?=$|[^\w])/g, "$1$2")
|
|
45
|
+
.replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
|
|
46
|
+
return s.replace(/\s+/g, " ").trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The step's newest narration sentence, as plain text: the end of a
|
|
50
|
+
// finished text is a boundary (its last line may have no period), a live
|
|
51
|
+
// one's not yet. A sentence that is only markdown syntax is skipped.
|
|
52
|
+
export function headlineOf(text, done) {
|
|
53
|
+
const { sentences, rest } = splitSentences(text, { atEnd: true });
|
|
54
|
+
const fragment = plainHeadline(rest);
|
|
55
|
+
if (done && fragment) return fragment;
|
|
56
|
+
const plain = sentences.map(plainHeadline).filter(Boolean);
|
|
57
|
+
if (plain.length) return plain[plain.length - 1];
|
|
58
|
+
return fragment.length > 160 ? fragment : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
17
61
|
// A tool's tick colour: read-like, edit, exec or other.
|
|
18
62
|
export function toolKind(name) {
|
|
19
63
|
if (READ_LIKE.has(name)) return "read";
|
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
// reload. Slots are patched in place, never rebuilt from HTML: a selection
|
|
12
12
|
// and annotate survive. The rules are pure in stage_model.js.
|
|
13
13
|
import { createTurnView } from "./turn_view.js";
|
|
14
|
-
import { handOffDue, handOffOrder, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
|
|
15
|
-
import { splitSentences } from "./sentences.js";
|
|
14
|
+
import { handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
|
|
16
15
|
import { formatDuration } from "./timing.js";
|
|
17
16
|
|
|
18
17
|
const COLLAPSE_KEY = "chi_stage_collapsed";
|
|
@@ -23,6 +22,10 @@ const SETTLE_CAP_MS = 8000;
|
|
|
23
22
|
const FOLD_MS = 420;
|
|
24
23
|
const FLASH_MS = 4000;
|
|
25
24
|
const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.continue:not(.resolved)";
|
|
25
|
+
// A running turn's card with actions (check-in's Nudge / Keep going / Stop)
|
|
26
|
+
// asks the user too, but the turn goes on: it waits only while the turn runs
|
|
27
|
+
// and never holds the hand-off (a turn cut short can leave it open).
|
|
28
|
+
const ASKS = ".plugin-card[data-in-turn] .card-action";
|
|
26
29
|
const MARKS = { answered: "✓", canceled: "■", failed: "✕" };
|
|
27
30
|
|
|
28
31
|
function node(tag, cls, parent = null) {
|
|
@@ -32,16 +35,6 @@ function node(tag, cls, parent = null) {
|
|
|
32
35
|
return el;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
// The step's newest narration sentence: the end of a finished text is a
|
|
36
|
-
// boundary (its last line may have no period), a live one's not yet.
|
|
37
|
-
function headlineOf(text, done) {
|
|
38
|
-
const { sentences, rest } = splitSentences(text, { atEnd: true });
|
|
39
|
-
const fragment = rest.replace(/\s+/g, " ").trim();
|
|
40
|
-
if (done && fragment) return fragment;
|
|
41
|
-
if (sentences.length) return sentences[sentences.length - 1];
|
|
42
|
-
return fragment.length > 160 ? fragment : "";
|
|
43
|
-
}
|
|
44
|
-
|
|
45
38
|
function readCollapsed() {
|
|
46
39
|
try { return localStorage.getItem(COLLAPSE_KEY) === "1"; } catch (_) { return false; }
|
|
47
40
|
}
|
|
@@ -153,6 +146,13 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
153
146
|
return !!(extras.querySelector(PENDING) || tail.querySelector(PENDING));
|
|
154
147
|
}
|
|
155
148
|
|
|
149
|
+
function asksCard() {
|
|
150
|
+
return !endKind && !!extras.querySelector(ASKS);
|
|
151
|
+
}
|
|
152
|
+
// A card answered or redrawn in place (its buttons gone) changes the
|
|
153
|
+
// status without a turn event.
|
|
154
|
+
new MutationObserver(() => scheduleRefresh()).observe(extras, { childList: true, subtree: true });
|
|
155
|
+
|
|
156
156
|
function refresh() {
|
|
157
157
|
rafPending = false;
|
|
158
158
|
const { turn, blockEl } = view.current();
|
|
@@ -169,12 +169,14 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
169
169
|
if (blockEl && blockEl.parentNode === cloudBody) blockEl.open = true;
|
|
170
170
|
const narration = gen?.text ? headlineOf(gen.text, gen.textDone || gen.closed) : "";
|
|
171
171
|
const thinking = thinkingLine && thinkingLine.step === turn.gens.length ? thinkingLine.text : "";
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
// A question blocks the turn: its card takes the live slots' room. An
|
|
173
|
+
// asking card's turn goes on: the status waits, the slots stay.
|
|
174
|
+
const blocked = pendingCard();
|
|
175
|
+
const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), ended: endKind });
|
|
174
176
|
|
|
175
177
|
stage.classList.toggle("plain", plain);
|
|
176
178
|
stage.classList.toggle("ended", !!endKind);
|
|
177
|
-
stage.classList.toggle("waiting",
|
|
179
|
+
stage.classList.toggle("waiting", blocked);
|
|
178
180
|
stage.dataset.phase = slots.phase;
|
|
179
181
|
mark.textContent = MARKS[slots.phase] || "";
|
|
180
182
|
phaseEl.textContent = slots.phase;
|
|
@@ -521,6 +523,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
521
523
|
turnEnded(args) {
|
|
522
524
|
const kept = view.turnEnded(args) || [];
|
|
523
525
|
if (!holding) return kept;
|
|
526
|
+
// The warn cards the extras kept in sight leave the turn with the
|
|
527
|
+
// block's (card.js leavesBlock): after the end line, as a reload has them.
|
|
528
|
+
const warn = [...extras.querySelectorAll(":scope > .plugin-card.warn")];
|
|
529
|
+
warn.forEach((el) => el.remove());
|
|
530
|
+
kept.push(...warn);
|
|
524
531
|
view.chronological();
|
|
525
532
|
endKind = args.kind;
|
|
526
533
|
endedAt = Date.now();
|
|
@@ -542,7 +549,9 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
542
549
|
return placed;
|
|
543
550
|
},
|
|
544
551
|
steer: (data) => view.steer(data),
|
|
545
|
-
card
|
|
552
|
+
// A warn card (a plugin's warning) stays in sight: false puts it in the
|
|
553
|
+
// extras (place), not in its step inside the closed cloud.
|
|
554
|
+
card: (el) => !el.classList.contains("warn") && view.card(el),
|
|
546
555
|
flush: () => view.flush(),
|
|
547
556
|
answerReady() {
|
|
548
557
|
settled = true;
|
|
@@ -564,7 +573,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
564
573
|
const stick = slot.scrollHeight - slot.scrollTop - slot.clientHeight < 24;
|
|
565
574
|
slot.appendChild(el);
|
|
566
575
|
slot.hidden = false;
|
|
567
|
-
if (stick || el.matches?.(PENDING)) slot.scrollTop = slot.scrollHeight;
|
|
576
|
+
if (stick || el.matches?.(PENDING) || el.querySelector?.(ASKS)) slot.scrollTop = slot.scrollHeight;
|
|
568
577
|
scheduleRefresh();
|
|
569
578
|
return true;
|
|
570
579
|
},
|
|
@@ -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. The turn index counts prompts, as timedTurnIndexes does.
|
|
91
|
+
// their own. The turn index counts prompts, as timedTurnIndexes does. The
|
|
92
|
+
// user's lines merged into the running turn (`merged`, the server's
|
|
93
|
+
// Steer::INPUT_KIND) stay in it: the turn's `merged` list.
|
|
92
94
|
export function turnGroups(items, timing) {
|
|
93
95
|
const groups = [];
|
|
94
96
|
let turnIndex = -1;
|
|
@@ -99,9 +101,11 @@ export function turnGroups(items, timing) {
|
|
|
99
101
|
} else if (item?.role === "steer") {
|
|
100
102
|
if (turn) turn.steers.push(i);
|
|
101
103
|
else groups.push({ kind: "steer", i });
|
|
104
|
+
} else if (item?.role === "user" && item.merged && turn) {
|
|
105
|
+
turn.merged.push(i);
|
|
102
106
|
} else if (item?.role === "user") {
|
|
103
107
|
turnIndex += 1;
|
|
104
|
-
turn = { kind: "turn", turnIndex, user: i, texts: [], steers: [] };
|
|
108
|
+
turn = { kind: "turn", turnIndex, user: i, texts: [], steers: [], merged: [] };
|
|
105
109
|
groups.push(turn);
|
|
106
110
|
} else if (item?.role === "assistant") {
|
|
107
111
|
if (turn) turn.texts.push(i);
|
|
@@ -110,7 +114,9 @@ export function turnGroups(items, timing) {
|
|
|
110
114
|
});
|
|
111
115
|
return groups.map((g) => {
|
|
112
116
|
if (g.kind !== "turn") return g;
|
|
113
|
-
|
|
117
|
+
const group = withSteers(turnGroup(g, items, timing), g.steers, items);
|
|
118
|
+
// A turn with no merged lines keeps its shape.
|
|
119
|
+
return g.merged.length ? { ...group, merged: g.merged } : group;
|
|
114
120
|
});
|
|
115
121
|
}
|
|
116
122
|
|
|
@@ -55,9 +55,18 @@ export function hookNoticeLabel(hook) {
|
|
|
55
55
|
return /^[a-z][a-z0-9-]*$/.test(name) ? name : "hook";
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// The row a step gets when the loop asks again after an empty answer
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
// The row a step gets when the loop asks again after an empty answer, or
|
|
59
|
+
// after a plugin cut the generation (stopped_by: the bundle).
|
|
60
|
+
export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy } = {}) {
|
|
61
|
+
const what = stoppedBy ? `cut by ${stoppedBy}` : "empty answer";
|
|
62
|
+
return `↻ ${what}, asking again (${attempt}/${of})`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The text of a turn's row from a snapshot entry (CardStore): a hook's
|
|
66
|
+
// notice as "<label>: text", the loop's asking-again row as its line.
|
|
67
|
+
export function noticeLine(entry = {}) {
|
|
68
|
+
if (entry.type === "empty_answer_retry") return emptyRetryLine(entry);
|
|
69
|
+
return `${hookNoticeLabel(entry.hook)}: ${entry.text || ""}`;
|
|
61
70
|
}
|
|
62
71
|
|
|
63
72
|
// The live turn's status while the provider is asked again after an error
|
|
@@ -211,6 +220,7 @@ export function promptOps(event, { myId, known, unmatchedMerge = false }) {
|
|
|
211
220
|
// parts in order), then the prompts queued behind it. app.js feeds these to
|
|
212
221
|
// its stream handlers, so a join renders exactly like watching live.
|
|
213
222
|
// `merged_input` is the one synthetic type: a prompt merged into the turn.
|
|
223
|
+
// A `notice` part carries its event as it came (TurnNotice).
|
|
214
224
|
export function snapshotEvents({ current_turn: turn = null, queued = [], started_at = undefined } = {}) {
|
|
215
225
|
const events = [];
|
|
216
226
|
if (turn) {
|
|
@@ -256,9 +266,12 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
|
|
|
256
266
|
closeText();
|
|
257
267
|
events.push({ type: "reminder_injected", reminders: part.reminders || [] });
|
|
258
268
|
break;
|
|
259
|
-
|
|
269
|
+
// One of the turn's rows (a hook's notice, an empty-answer retry, a
|
|
270
|
+
// question and its answer): the event itself, which the live
|
|
271
|
+
// handler draws where it drew it live.
|
|
272
|
+
case "notice":
|
|
260
273
|
closeText();
|
|
261
|
-
events.push({
|
|
274
|
+
if (part.event?.type) events.push({ ...part.event });
|
|
262
275
|
break;
|
|
263
276
|
// A plugin's steer: a steer-only merge, as live.
|
|
264
277
|
case "steer":
|