samagotchi 0.16.0 → 0.18.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 +149 -1
- data/README.md +2 -0
- data/docs/architecture.md +38 -2
- data/docs/cli.md +37 -9
- data/docs/configuration.md +8 -6
- data/docs/guardrails.md +51 -8
- data/docs/hooks.md +4 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +41 -0
- data/docs/plugins.md +5 -2
- data/docs/sessions.md +8 -6
- data/docs/sub-agent.md +43 -4
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
- data/lib/samagotchi/bridge.rb +86 -14
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +121 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -0
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +73 -19
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +136 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +46 -6
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +137 -48
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +46 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +35 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +5 -4
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +189 -43
- data/lib/samagotchi/web/public/app.js +157 -51
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +12 -5
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/stage_view.js +13 -1
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +67 -12
- data/lib/samagotchi/web/public/turn_events.js +74 -3
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +58 -60
- data/lib/samagotchi/worker_sidecar.rb +7 -1
- metadata +10 -1
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
listSessions,
|
|
3
|
-
createSession,
|
|
4
3
|
getSession,
|
|
5
4
|
sendTurn,
|
|
6
5
|
cancelTurn,
|
|
@@ -37,14 +36,15 @@ import { createPromptHistory } from "./prompt_history.js";
|
|
|
37
36
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
38
37
|
import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
|
|
39
38
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
40
|
-
import { stripColumns, stripShowParts } from "./strip.js";
|
|
39
|
+
import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
|
|
41
40
|
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
|
|
42
41
|
import { createNotifications } from "./notifications.js";
|
|
42
|
+
import { updateNotice } from "./update_notice.js";
|
|
43
43
|
import {
|
|
44
|
-
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
44
|
+
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, 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, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine,
|
|
47
|
+
import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, startPageReply, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
48
48
|
|
|
49
49
|
const $ = (s) => document.querySelector(s);
|
|
50
50
|
const topStripEl = $("#topStrip");
|
|
@@ -102,6 +102,15 @@ function findInTurnRoots(selector) {
|
|
|
102
102
|
let current = emptySessionState();
|
|
103
103
|
let turnRunning = false;
|
|
104
104
|
let liveTurnTimingEl = null;
|
|
105
|
+
// The running turn's id (turn_started's turn_id, the id of its timing
|
|
106
|
+
// record), so a turn-end re-read finishes that turn's line with that
|
|
107
|
+
// turn's record, whatever started since.
|
|
108
|
+
let liveTurnId = null;
|
|
109
|
+
// The turns this page saw end: the records of its last full render, +1 at
|
|
110
|
+
// each turn's end, never fewer than a merge brought. The live line numbers
|
|
111
|
+
// the next one, so a turn queued behind another reads its own number while
|
|
112
|
+
// the other's re-read is still out.
|
|
113
|
+
let turnsEnded = 0;
|
|
105
114
|
// What the running turn waits on, after its live timing line: a provider
|
|
106
115
|
// retry (generation_retrying) or plugins' setup (plugin_init_wait); "" when
|
|
107
116
|
// it streams again.
|
|
@@ -170,6 +179,7 @@ async function refresh() {
|
|
|
170
179
|
function openSessionEvents() {
|
|
171
180
|
eventsControl = openEvents({
|
|
172
181
|
snapshot: (data) => {
|
|
182
|
+
noticeUpdate(data.version);
|
|
173
183
|
notifications.noticeSessions("snapshot", data);
|
|
174
184
|
allSessions = applySessionEvent(allSessions, "snapshot", data);
|
|
175
185
|
render();
|
|
@@ -200,6 +210,17 @@ function openSessionEvents() {
|
|
|
200
210
|
});
|
|
201
211
|
}
|
|
202
212
|
|
|
213
|
+
// chi web was upgraded while this tab stayed open (a snapshot on the
|
|
214
|
+
// reconnect names another version than the one that served the page):
|
|
215
|
+
// a toast offers the reload, never forces it.
|
|
216
|
+
let updateShownFor = null;
|
|
217
|
+
function noticeUpdate(served) {
|
|
218
|
+
const text = updateNotice(document.body.dataset.version, served, updateShownFor);
|
|
219
|
+
if (!text) return;
|
|
220
|
+
updateShownFor = served;
|
|
221
|
+
showToast(text, { label: "Reload", run: () => location.reload() }, { sticky: true });
|
|
222
|
+
}
|
|
223
|
+
|
|
203
224
|
// The selected session's card changed: its owner and, without a live
|
|
204
225
|
// stream, its status show in the info bar; a worker whose bridge is up
|
|
205
226
|
// (a turn another client sent woke one, or a replacement after a kill)
|
|
@@ -285,7 +306,7 @@ function cardHtml(s, { deletable = false } = {}) {
|
|
|
285
306
|
const ctx = cardCtxText(s.ctx_pct);
|
|
286
307
|
const ctxHtml = ctx ? `<span class="ctx" title="Context used after the last turn">${escapeHtml(ctx)}</span>` : "";
|
|
287
308
|
const waiting = waitingBadge(s);
|
|
288
|
-
const waitingHtml = waiting ? `<span class="attn
|
|
309
|
+
const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
|
|
289
310
|
const dotWord = waiting ? waiting.title : s.status;
|
|
290
311
|
return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
|
|
291
312
|
}
|
|
@@ -606,6 +627,7 @@ function clearSelection() {
|
|
|
606
627
|
resetSessionState();
|
|
607
628
|
clearTimingInterval();
|
|
608
629
|
liveTurnTimingEl = null;
|
|
630
|
+
liveTurnId = null;
|
|
609
631
|
setTurnRunning(false);
|
|
610
632
|
closeStream();
|
|
611
633
|
setMarkdownWarning(null);
|
|
@@ -659,6 +681,7 @@ function resetTurnView() {
|
|
|
659
681
|
closeStream();
|
|
660
682
|
clearTimingInterval();
|
|
661
683
|
liveTurnTimingEl = null;
|
|
684
|
+
liveTurnId = null;
|
|
662
685
|
questionCards.removeQuestion();
|
|
663
686
|
questionCards.forgetContinue();
|
|
664
687
|
unmatchedMerge = false;
|
|
@@ -716,6 +739,8 @@ function renderSessionView(data) {
|
|
|
716
739
|
current.dir = s.working_directory;
|
|
717
740
|
current.owner = s.owner || null;
|
|
718
741
|
current.timing = normalizeTiming(data.timing);
|
|
742
|
+
turnsEnded = current.timing.turnRecords.length;
|
|
743
|
+
const activeTurnId = current.timing.activeTurn?.id || null;
|
|
719
744
|
// The saved fill, so a reloaded or stopped session shows it before its
|
|
720
745
|
// next turn (a running turn's replay below streams over it).
|
|
721
746
|
const savedPct = savedCtxPct(data.timing?.context);
|
|
@@ -768,7 +793,10 @@ function renderSessionView(data) {
|
|
|
768
793
|
replay.forEach((event) => streamHandlers[event.type]?.(event));
|
|
769
794
|
if (!data.current_turn) {
|
|
770
795
|
setTurnRunning(current.status === "running");
|
|
771
|
-
if (turnRunning && current.timing.activeTurn?.started_at) startLiveTurnTiming(current.timing.activeTurn.started_at);
|
|
796
|
+
if (turnRunning && current.timing.activeTurn?.started_at) startLiveTurnTiming(current.timing.activeTurn.started_at, activeTurnId);
|
|
797
|
+
} else if (turnRunning) {
|
|
798
|
+
// The replayed turn_started carries no id: the running turn's record's.
|
|
799
|
+
liveTurnId = activeTurnId;
|
|
772
800
|
}
|
|
773
801
|
turnDom.flush();
|
|
774
802
|
snapshotCards.current.forEach((card) => upsertCard(card));
|
|
@@ -819,13 +847,18 @@ let turnEndRead = Promise.resolve();
|
|
|
819
847
|
let displayLanded = null;
|
|
820
848
|
async function readTurnEnd() {
|
|
821
849
|
if (!selected) return;
|
|
850
|
+
const id = selected;
|
|
851
|
+
// The line and the turn this read is for, before the await: a turn
|
|
852
|
+
// queued behind this one may start (a new live line) before it lands.
|
|
853
|
+
const line = { el: liveTurnTimingEl, turnId: liveTurnId };
|
|
822
854
|
try {
|
|
823
|
-
const d = await getSession(
|
|
855
|
+
const d = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
|
|
856
|
+
if (id !== selected) return;
|
|
824
857
|
if (d.session) {
|
|
825
858
|
if (Array.isArray(d.session.used_memory_names)) current.usedMemories = d.session.used_memory_names;
|
|
826
859
|
if (d.session.status) current.status = d.session.status;
|
|
827
|
-
|
|
828
|
-
|
|
860
|
+
if (!(await mergeTurnTiming(id, d.timing))) return;
|
|
861
|
+
finishTurnLine(line);
|
|
829
862
|
setMarkdownWarning(d.markdown_warning);
|
|
830
863
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
831
864
|
updateInfoBar();
|
|
@@ -914,6 +947,9 @@ function emptyAnswerEl(emptyAnswer) {
|
|
|
914
947
|
// left the collapsed block (card.js leavesBlock), where a reload puts them.
|
|
915
948
|
function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
|
|
916
949
|
setTurnRunning(false);
|
|
950
|
+
// Every ended turn has a record (completed, canceled, failed); a worker
|
|
951
|
+
// gone mid-turn is re-rendered (resync), which counts again.
|
|
952
|
+
if (kind !== "gone") turnsEnded += 1;
|
|
917
953
|
// A completed turn with no answer: its notice where the answer goes
|
|
918
954
|
// (the turn view places it), else after the turn; before the timing.
|
|
919
955
|
const endNote = emptyAnswer ? emptyAnswerEl(emptyAnswer) : null;
|
|
@@ -1357,11 +1393,12 @@ function clearTimingInterval() {
|
|
|
1357
1393
|
|
|
1358
1394
|
let liveTurnNumber = null;
|
|
1359
1395
|
|
|
1360
|
-
function startLiveTurnTiming(startedAt = new Date().toISOString()) {
|
|
1396
|
+
function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null) {
|
|
1361
1397
|
clearTimingInterval();
|
|
1362
|
-
current.timing.activeTurn = { started_at: startedAt };
|
|
1363
|
-
|
|
1364
|
-
|
|
1398
|
+
current.timing.activeTurn = turnId ? { id: turnId, started_at: startedAt } : { started_at: startedAt };
|
|
1399
|
+
liveTurnId = turnId || null;
|
|
1400
|
+
// The turns ended so far, so this one comes next.
|
|
1401
|
+
liveTurnNumber = turnsEnded + 1;
|
|
1365
1402
|
liveTurnNote = "";
|
|
1366
1403
|
liveTurnTimingEl = document.createElement("div");
|
|
1367
1404
|
liveTurnTimingEl.className = "turn-timing live";
|
|
@@ -1411,11 +1448,46 @@ function finishLiveTurnTiming(record = null, { canceled = false } = {}) {
|
|
|
1411
1448
|
updateInfoBar();
|
|
1412
1449
|
}
|
|
1413
1450
|
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1451
|
+
// A turn-end re-read's timing (?tail=1&recent=1: the newest turn's records
|
|
1452
|
+
// and turn_count) merged into the page's (mergeTiming); when the merge comes
|
|
1453
|
+
// up short, the whole timing (?timing=1). Never (re)starts live timing: a
|
|
1454
|
+
// turn that started meanwhile keeps its line and its own active turn.
|
|
1455
|
+
// @return false when the session changed meanwhile
|
|
1456
|
+
async function mergeTurnTiming(id, data) {
|
|
1457
|
+
const keepActive = () => (turnRunning ? current.timing.activeTurn : null);
|
|
1458
|
+
const merged = mergeTiming(current.timing, data);
|
|
1459
|
+
current.timing = { ...merged.timing, activeTurn: keepActive() };
|
|
1460
|
+
if (merged.short) {
|
|
1461
|
+
try {
|
|
1462
|
+
const full = await getSession(id, { timing: true });
|
|
1463
|
+
if (id !== selected) return false;
|
|
1464
|
+
current.timing = { ...normalizeTiming(full.timing), activeTurn: keepActive() };
|
|
1465
|
+
} catch (_) {
|
|
1466
|
+
if (id !== selected) return false;
|
|
1467
|
+
}
|
|
1418
1468
|
}
|
|
1469
|
+
turnsEnded = Math.max(turnsEnded, current.timing.turnRecords.length);
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Finish the timing line a turn-end read captured (+line+: {el, turnId})
|
|
1474
|
+
// with that turn's record: its place in the records and its duration (a
|
|
1475
|
+
// canceled one says so). No id (an older worker's events): the last
|
|
1476
|
+
// record. No record: the line stays as the turn's end left it. The ticker
|
|
1477
|
+
// stops only if that line is still the live one.
|
|
1478
|
+
function finishTurnLine(line) {
|
|
1479
|
+
const records = current.timing.turnRecords;
|
|
1480
|
+
const record = line.turnId ? records.find((r) => r.id === line.turnId) : records[records.length - 1];
|
|
1481
|
+
if (line.el === liveTurnTimingEl) {
|
|
1482
|
+
current.timing.activeTurn = null;
|
|
1483
|
+
clearTimingInterval();
|
|
1484
|
+
}
|
|
1485
|
+
const duration = Number(record?.duration_ms);
|
|
1486
|
+
if (line.el && record && Number.isFinite(duration)) {
|
|
1487
|
+
line.el.classList.remove("live");
|
|
1488
|
+
line.el.textContent = turnTimingText(records.indexOf(record) + 1, duration, { canceled: record.status === "canceled" });
|
|
1489
|
+
}
|
|
1490
|
+
updateInfoBar();
|
|
1419
1491
|
}
|
|
1420
1492
|
|
|
1421
1493
|
function updateFirstPeek() {
|
|
@@ -1539,7 +1611,7 @@ const streamHandlers = {
|
|
|
1539
1611
|
clearTimingInterval();
|
|
1540
1612
|
liveTurnTimingEl = null;
|
|
1541
1613
|
turnDom.turnStarted({ ...data, promptBubble });
|
|
1542
|
-
startLiveTurnTiming(data.started_at);
|
|
1614
|
+
startLiveTurnTiming(data.started_at, data.turn_id || null);
|
|
1543
1615
|
}),
|
|
1544
1616
|
turn_completed: async (data) => {
|
|
1545
1617
|
endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
|
|
@@ -1564,7 +1636,7 @@ const streamHandlers = {
|
|
|
1564
1636
|
try {
|
|
1565
1637
|
await turnEndRead;
|
|
1566
1638
|
if (!selected || !data.display) return;
|
|
1567
|
-
const d = await getSession(selected, { tail: true });
|
|
1639
|
+
const d = await getSession(selected, { tail: true, recent: true });
|
|
1568
1640
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
1569
1641
|
} catch (_) {
|
|
1570
1642
|
} finally {
|
|
@@ -1621,7 +1693,9 @@ const streamHandlers = {
|
|
|
1621
1693
|
updateInfoBar();
|
|
1622
1694
|
}
|
|
1623
1695
|
if (view.resync) await resync();
|
|
1624
|
-
|
|
1696
|
+
// A card's action that isn't anytime (the step-limit question's
|
|
1697
|
+
// /continue) has no bubble: nothing to remove.
|
|
1698
|
+
const queued = view.anytime ? commandBubble(view.commandId) : null;
|
|
1625
1699
|
if (view.hidden) queued?.remove();
|
|
1626
1700
|
else if (queued) drawCommandBubble(queued, view);
|
|
1627
1701
|
else addCommandBubble(view);
|
|
@@ -1701,11 +1775,13 @@ const streamHandlers = {
|
|
|
1701
1775
|
|
|
1702
1776
|
async function refreshTimingFromSession() {
|
|
1703
1777
|
if (!selected) return;
|
|
1778
|
+
const id = selected;
|
|
1779
|
+
const line = { el: liveTurnTimingEl, turnId: liveTurnId };
|
|
1704
1780
|
try {
|
|
1705
|
-
const data = await getSession(
|
|
1781
|
+
const data = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
|
|
1782
|
+
if (id !== selected) return;
|
|
1706
1783
|
if (data.session?.status) current.status = data.session.status;
|
|
1707
|
-
|
|
1708
|
-
finishLiveTurnTiming(current.timing.turnRecords[current.timing.turnRecords.length - 1]);
|
|
1784
|
+
if (await mergeTurnTiming(id, data.timing)) finishTurnLine(line);
|
|
1709
1785
|
} catch (_) {}
|
|
1710
1786
|
}
|
|
1711
1787
|
|
|
@@ -1732,38 +1808,27 @@ function addCreatedSession(created) {
|
|
|
1732
1808
|
render();
|
|
1733
1809
|
}
|
|
1734
1810
|
|
|
1735
|
-
// A first message: an idle session, then the turn
|
|
1736
|
-
// later one (handleSendTurn), so a failed first turn's prompt
|
|
1737
|
-
// into the composer too (sentIds).
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
const prompt = promptEl.value.trim();
|
|
1741
|
-
if (chips.length || (prompt && !isCommandLine(prompt))) return handleSendTurn();
|
|
1742
|
-
if (!prompt) return;
|
|
1743
|
-
actionBtn.disabled = true;
|
|
1744
|
-
try {
|
|
1745
|
-
const s = await createSession(prompt, { dir: scope.dir, model: chosenModel() });
|
|
1746
|
-
addCreatedSession(s);
|
|
1747
|
-
select(s.id);
|
|
1748
|
-
promptEl.value = "";
|
|
1749
|
-
fitPrompt();
|
|
1750
|
-
refreshHistory();
|
|
1751
|
-
} catch (e) {
|
|
1752
|
-
alert(e.message);
|
|
1753
|
-
} finally {
|
|
1754
|
-
actionBtn.disabled = false;
|
|
1755
|
-
}
|
|
1811
|
+
// A first message: an idle session, then the turn (or the command) into it
|
|
1812
|
+
// as for any later one (handleSendTurn), so a failed first turn's prompt
|
|
1813
|
+
// comes back into the composer too (sentIds).
|
|
1814
|
+
function handleCreate() {
|
|
1815
|
+
return handleSendTurn();
|
|
1756
1816
|
}
|
|
1757
1817
|
|
|
1758
1818
|
async function handleSendTurn() {
|
|
1759
|
-
if (!selected && !chips.length && isCommandLine(promptEl.value.trim())) return handleCreate();
|
|
1760
1819
|
if (current.owner === "tui") return;
|
|
1761
1820
|
const prompt = turnText(promptEl.value, chips);
|
|
1762
1821
|
if (!prompt) return;
|
|
1763
1822
|
// An image-only send's text is its placeholders: not for ↑.
|
|
1764
1823
|
const typed = promptEl.value.trim() !== "";
|
|
1765
|
-
if (!chips.length && isCommandLine(prompt)) return handleSendCommand(prompt);
|
|
1766
1824
|
if (!selected) {
|
|
1825
|
+
const local = startPageReply(prompt, { images: chips.length });
|
|
1826
|
+
if (local) {
|
|
1827
|
+
promptEl.value = "";
|
|
1828
|
+
fitPrompt();
|
|
1829
|
+
showNotice(local);
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1767
1832
|
actionBtn.disabled = true;
|
|
1768
1833
|
try {
|
|
1769
1834
|
const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt });
|
|
@@ -1776,6 +1841,19 @@ async function handleSendTurn() {
|
|
|
1776
1841
|
actionBtn.disabled = false;
|
|
1777
1842
|
}
|
|
1778
1843
|
}
|
|
1844
|
+
// A session command by the session's list (the new one's too: select
|
|
1845
|
+
// read it); an unknown /word is a prompt, as in a terminal — but a typo
|
|
1846
|
+
// of a command (/modle) gets the hint, not a turn.
|
|
1847
|
+
if (!chips.length && sessionCommandLine(prompt, current.commands)) return handleSendCommand(prompt);
|
|
1848
|
+
if (!chips.length) {
|
|
1849
|
+
const typo = unknownCommandHint(prompt, current.commands);
|
|
1850
|
+
if (typo) {
|
|
1851
|
+
addCommandBubble({ label: null, line: prompt, text: typo });
|
|
1852
|
+
promptEl.value = "";
|
|
1853
|
+
fitPrompt();
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1779
1857
|
const hint = historyEl.querySelector(".hint");
|
|
1780
1858
|
if (hint) hint.remove();
|
|
1781
1859
|
// Sending moves a finished turn out of the stage at once (else the echo
|
|
@@ -1795,6 +1873,15 @@ async function handleSendTurn() {
|
|
|
1795
1873
|
if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
|
|
1796
1874
|
}
|
|
1797
1875
|
const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed });
|
|
1876
|
+
if (ack?.command_id) {
|
|
1877
|
+
// The worker knew it as a command (its list was newer than ours):
|
|
1878
|
+
// its command_ran shows it, not a prompt bubble.
|
|
1879
|
+
echo.remove();
|
|
1880
|
+
promptEl.value = "";
|
|
1881
|
+
fitPrompt();
|
|
1882
|
+
if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1798
1885
|
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
1799
1886
|
refreshHistory();
|
|
1800
1887
|
// A turn that failed fast was restored before this ack: its prompt
|
|
@@ -2197,17 +2284,20 @@ async function setArchived(id, archive, { undo = true } = {}) {
|
|
|
2197
2284
|
showToast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
|
|
2198
2285
|
}
|
|
2199
2286
|
|
|
2287
|
+
// sticky: stays until its action or its ✕ (else gone after 6 s).
|
|
2200
2288
|
let toastTimer = null;
|
|
2201
|
-
function showToast(text, action = null) {
|
|
2289
|
+
function showToast(text, action = null, { sticky = false } = {}) {
|
|
2202
2290
|
const el = $("#toast");
|
|
2203
|
-
el.innerHTML = `<span>${escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}
|
|
2291
|
+
el.innerHTML = `<span>${escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}` +
|
|
2292
|
+
(sticky ? `<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss">✕</button>` : "");
|
|
2204
2293
|
el.hidden = false;
|
|
2205
2294
|
el.querySelector(".toast-action")?.addEventListener("click", () => {
|
|
2206
2295
|
el.hidden = true;
|
|
2207
2296
|
action.run();
|
|
2208
2297
|
});
|
|
2298
|
+
el.querySelector(".toast-close")?.addEventListener("click", () => { el.hidden = true; });
|
|
2209
2299
|
clearTimeout(toastTimer);
|
|
2210
|
-
toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
|
|
2300
|
+
if (!sticky) toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
|
|
2211
2301
|
}
|
|
2212
2302
|
|
|
2213
2303
|
$("#includeArchived").addEventListener("change", () => {
|
|
@@ -2247,11 +2337,27 @@ function stripHiddenSaved() {
|
|
|
2247
2337
|
return false;
|
|
2248
2338
|
}
|
|
2249
2339
|
}
|
|
2340
|
+
// A short window hides the strip by itself, so the stage keeps room for a
|
|
2341
|
+
// card that waits (its question and choices); the sessions pill brings it
|
|
2342
|
+
// back until the window is tall again. Not saved: the saved choice is the
|
|
2343
|
+
// user's own hide ▴ / show.
|
|
2344
|
+
const shortWindow = window.matchMedia(SHORT_WINDOW_QUERY);
|
|
2345
|
+
let stripShownWhileShort = false;
|
|
2346
|
+
function applyStripHidden() {
|
|
2347
|
+
document.body.classList.toggle("strip-hidden", stripAutoHidden({
|
|
2348
|
+
saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
|
|
2349
|
+
}));
|
|
2350
|
+
}
|
|
2250
2351
|
function setStripHidden(hidden) {
|
|
2251
2352
|
try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
|
|
2252
|
-
|
|
2353
|
+
stripShownWhileShort = !hidden && shortWindow.matches;
|
|
2354
|
+
applyStripHidden();
|
|
2253
2355
|
}
|
|
2254
|
-
|
|
2356
|
+
applyStripHidden();
|
|
2357
|
+
shortWindow.addEventListener("change", () => {
|
|
2358
|
+
if (!shortWindow.matches) stripShownWhileShort = false;
|
|
2359
|
+
applyStripHidden();
|
|
2360
|
+
});
|
|
2255
2361
|
$("#stripShow").addEventListener("click", () => setStripHidden(false));
|
|
2256
2362
|
|
|
2257
2363
|
$("#filter").addEventListener("input", renderAll);
|
|
@@ -23,17 +23,11 @@ export function listSessions(sort, order, { dir, ...opts } = {}) {
|
|
|
23
23
|
);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
// opts.dir: the folder the chat starts in (else the server's cwd).
|
|
27
|
-
// dir: the folder the chat starts in; model: the model it starts on (as
|
|
28
|
-
// GET /api/models spells it); either left out means the server's default.
|
|
29
|
-
export function createSession(prompt, { dir, model, ...opts } = {}) {
|
|
30
|
-
const body = { prompt, ...(dir ? { dir } : {}), ...(model ? { model } : {}) };
|
|
31
|
-
return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
|
|
32
|
-
}
|
|
33
|
-
|
|
34
26
|
// A session with no first turn: the page's first message is sent into it
|
|
35
|
-
// as a turn (with its images, once they are uploaded).
|
|
36
|
-
//
|
|
27
|
+
// as a turn (with its images, once they are uploaded). dir: the folder the
|
|
28
|
+
// chat starts in; model: the model it starts on (as GET /api/models spells
|
|
29
|
+
// it); either left out means the server's default. preview: that message,
|
|
30
|
+
// which names the session until its turn is saved.
|
|
37
31
|
export function createIdleSession({ dir, model, preview, ...opts } = {}) {
|
|
38
32
|
const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}) };
|
|
39
33
|
return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
|
|
@@ -61,11 +55,16 @@ export function uploadImage(id, file, name, opts = {}) {
|
|
|
61
55
|
}
|
|
62
56
|
|
|
63
57
|
// opts.tail: the lighter answer for the end of a turn (the session, its
|
|
64
|
-
// timing and the last answer only), not the whole history
|
|
58
|
+
// timing and the last answer only), not the whole history; with
|
|
59
|
+
// opts.recent its timing holds the newest turn's records and turn_count
|
|
60
|
+
// (mergeTiming), and opts.turnId asks for that turn's answer.
|
|
61
|
+
// opts.timing: the whole timing alone (when the merge came up short).
|
|
65
62
|
// opts.parts: each saved message with what it did (thinking, tool calls
|
|
66
63
|
// and their output), for the turn view's reload.
|
|
67
|
-
export function getSession(id, { tail, parts, cards, ...opts } = {}) {
|
|
68
|
-
|
|
64
|
+
export function getSession(id, { tail, recent, turnId, timing, parts, cards, ...opts } = {}) {
|
|
65
|
+
let query = cards ? "?cards=1" : tail ? "?tail=1" : timing ? "?timing=1" : parts ? "?parts=1" : "";
|
|
66
|
+
if (tail && recent) query += "&recent=1";
|
|
67
|
+
if (tail && turnId) query += `&turn_id=${encodeURIComponent(turnId)}`;
|
|
69
68
|
return api(`/api/sessions/${id}${query}`, opts);
|
|
70
69
|
}
|
|
71
70
|
|
|
@@ -224,6 +224,9 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
224
224
|
.continue-context{font-size:12px;color:var(--text-3);margin-bottom:8px;white-space:pre-wrap}
|
|
225
225
|
.continue-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
|
226
226
|
.continue-reason{flex:1;min-width:140px;background:var(--bg-inset);border:1px solid var(--border-strong);border-radius:8px;color:var(--text);padding:6px 8px}
|
|
227
|
+
/* The step-limit question (kind continue): the continue card's buttons inside a question card; the picked one stays marked once answered. */
|
|
228
|
+
.bubble.question.step-limit .continue-actions{margin-top:2px}
|
|
229
|
+
.bubble.question.step-limit .question-choice.selected{outline:2px solid var(--ok);outline-offset:1px;opacity:1}
|
|
227
230
|
.bubble.question.submitting{opacity:.7}
|
|
228
231
|
.bubble.question.answered{border-left-color:var(--ok)}
|
|
229
232
|
.bubble.question.cancelled{border-left-color:var(--text-muted);opacity:.75}
|
|
@@ -666,9 +669,11 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
666
669
|
.card.archived{opacity:.6}
|
|
667
670
|
.card.archived:hover{opacity:.85}
|
|
668
671
|
.card .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
|
|
669
|
-
#toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:13px}
|
|
672
|
+
#toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:13px}
|
|
670
673
|
#toast[hidden]{display:none}
|
|
671
674
|
#toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
|
|
675
|
+
#toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
|
|
676
|
+
#toast .toast-close:hover{color:var(--text)}
|
|
672
677
|
#allList{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;align-content:start;padding:2px}
|
|
673
678
|
.card.skeleton{pointer-events:none;border-style:dashed;opacity:.9;animation:skeleton-pulse 1.2s ease-in-out infinite}
|
|
674
679
|
.card.skeleton .line{height:10px;background:var(--bg-raised);border-radius:6px}
|
|
@@ -15,13 +15,15 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
|
|
|
15
15
|
// A finished turn shorter than this was watched: no "done".
|
|
16
16
|
export const DONE_MIN_SECONDS = 10;
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// The step-limit question (kind "continue": a turn ran out of iterations
|
|
19
|
+
// and asks whether to go on) has its own reason: "out of steps".
|
|
20
|
+
const questionReason = (q) => (q.kind === "approval" ? "approval" : q.kind === "continue" ? "continue" : "question");
|
|
19
21
|
|
|
20
22
|
// What a session waits on the user for right now, from one summary: an open
|
|
21
23
|
// question or approval, or a running turn's card with actions (check-in's
|
|
22
24
|
// Nudge / Keep going / Stop asks as a question does). The session cards
|
|
23
25
|
// mark and lift these; attentionFor notifies when one opens.
|
|
24
|
-
// @return null | {reason: "question"|"approval"|"card", id}
|
|
26
|
+
// @return null | {reason: "question"|"approval"|"continue"|"card", id}
|
|
25
27
|
export function waitingOn(summary) {
|
|
26
28
|
const q = summary?.pending_question;
|
|
27
29
|
if (q && q.id) return { reason: questionReason(q), id: q.id };
|
|
@@ -31,9 +33,13 @@ export function waitingOn(summary) {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
// How long a delegated child's new approval waits before it notifies: its
|
|
34
|
-
// parent relays it
|
|
35
|
-
//
|
|
36
|
-
|
|
36
|
+
// parent relays it when it waits on its children, and then the parent's
|
|
37
|
+
// card notifies instead (one bell, not two). A child the parent isn't
|
|
38
|
+
// waiting on directly is seen by its look at the others (DelegateRelay
|
|
39
|
+
// OTHERS_EVERY, 2 s), and the relay reaches the page with the hub's next
|
|
40
|
+
// scan (SessionHub SCAN_INTERVAL, 1 s): up to ~3 s. Only these approvals
|
|
41
|
+
// wait; a child's approval nobody relays rings this late.
|
|
42
|
+
export const RELAY_HOLD_MS = 3500;
|
|
37
43
|
|
|
38
44
|
// What changed between two summaries of one session that needs the user.
|
|
39
45
|
// @param prev the summary held before (null for a session not seen yet)
|
|
@@ -133,6 +139,7 @@ export function attentionText(session, attention) {
|
|
|
133
139
|
const body = {
|
|
134
140
|
question: "needs an answer",
|
|
135
141
|
approval: "needs approval",
|
|
142
|
+
continue: "hit its step limit",
|
|
136
143
|
card: "needs you",
|
|
137
144
|
failed: "turn failed",
|
|
138
145
|
done: `done in ${seconds} s`
|
|
@@ -8,6 +8,23 @@ export function isApproval(pq) {
|
|
|
8
8
|
return !!pq && pq.kind === "approval";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// The step-limit question (kind "continue"): a turn ran out of iterations.
|
|
12
|
+
// Its card has a button per option (Continue, Stop) and a reason that goes
|
|
13
|
+
// with Stop; it can't be dismissed.
|
|
14
|
+
export function isContinue(pq) {
|
|
15
|
+
return !!pq && pq.kind === "continue";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const CONTINUE_REASON_PLACEHOLDER = "Stop, because… (the model reads it)";
|
|
19
|
+
|
|
20
|
+
// Why a step-limit question closed unanswered (its question_cancelled reason).
|
|
21
|
+
const CONTINUE_CLOSED = {
|
|
22
|
+
dropped: "Dropped: a new prompt came",
|
|
23
|
+
answered: "Answered with /continue",
|
|
24
|
+
superseded: "Set aside for another question",
|
|
25
|
+
replaced: "Asked again",
|
|
26
|
+
};
|
|
27
|
+
|
|
11
28
|
// A delegate's approval relayed to this session's user (the approval
|
|
12
29
|
// relay): who asks (the chain, "ab12 → cd34" for a grandchild's) and its
|
|
13
30
|
// task, or null.
|
|
@@ -79,6 +96,7 @@ export function resultText(pq, { answer = null, cancelled = false, reason = "" }
|
|
|
79
96
|
const approval = isApproval(pq);
|
|
80
97
|
const relay = relayView(pq);
|
|
81
98
|
if (cancelled && relay && reason !== "dismissed") return relayClosedText(relay, reason);
|
|
99
|
+
if (isContinue(pq)) return continueResultText({ answer, cancelled, reason });
|
|
82
100
|
if (cancelled) {
|
|
83
101
|
const label = approval ? "Denied" : "Cancelled";
|
|
84
102
|
return reason ? `${label} (${reason})` : label;
|
|
@@ -97,6 +115,14 @@ export function resultText(pq, { answer = null, cancelled = false, reason = "" }
|
|
|
97
115
|
return parts.length ? `Answered: ${parts.join(" · ")}` : "Answered";
|
|
98
116
|
}
|
|
99
117
|
|
|
118
|
+
function continueResultText({ answer, cancelled, reason }) {
|
|
119
|
+
if (cancelled) return CONTINUE_CLOSED[reason] || (reason ? `Closed (${reason})` : "Closed");
|
|
120
|
+
const sel = answer && Array.isArray(answer.selected) ? answer.selected : [];
|
|
121
|
+
const freeform = answer && answer.freeform ? String(answer.freeform) : "";
|
|
122
|
+
if (sel[0] === "Continue") return "Continued";
|
|
123
|
+
return freeform ? `Stopped: ${freeform}` : "Stopped";
|
|
124
|
+
}
|
|
125
|
+
|
|
100
126
|
// Shorten a line of text for the card's summary (the collapsed one-liner).
|
|
101
127
|
export function truncate(text, n = 80) {
|
|
102
128
|
const s = String(text ?? "");
|
|
@@ -115,7 +141,8 @@ export function summaryText(pq, { answer = null, cancelled = false, reason = ""
|
|
|
115
141
|
const text = asked ? `${truncate(asked, 60)} → ${result}` : result;
|
|
116
142
|
return truncate(relay ? `${relay.who}: ${text}` : text, 120);
|
|
117
143
|
}
|
|
118
|
-
const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?`
|
|
144
|
+
const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?`
|
|
145
|
+
: isContinue(pq) ? "Step limit" : "Question";
|
|
119
146
|
return truncate(pq.header || label);
|
|
120
147
|
}
|
|
121
148
|
|