samagotchi 0.70.0 → 0.72.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 +83 -1
- data/docs/architecture.md +16 -10
- data/docs/cli.md +7 -4
- data/docs/configuration.md +24 -14
- data/docs/hooks.md +1 -1
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/background-tasks.md +2 -2
- data/docs/internals/engine.md +38 -13
- data/docs/internals/llm-context-live.md +1 -1
- data/docs/internals/prompt-caching.md +6 -0
- data/docs/plugins.md +18 -7
- data/docs/sessions.md +4 -2
- data/docs/testing.md +12 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge.rb +5 -2
- data/lib/samagotchi/broadcast/scope_card.rb +2 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -7
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +2 -2
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +2 -2
- data/lib/samagotchi/bundles/coordinator/manifest.yml +8 -6
- data/lib/samagotchi/bundles/coordinator/plugin.rb +10 -0
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +7 -25
- data/lib/samagotchi/context_note.rb +2 -6
- data/lib/samagotchi/effective_model.rb +1 -1
- data/lib/samagotchi/engine.rb +91 -213
- data/lib/samagotchi/engine_peers.rb +2 -0
- data/lib/samagotchi/event_routes.rb +83 -0
- data/lib/samagotchi/hook_routes.rb +16 -37
- data/lib/samagotchi/host_config.rb +1 -1
- data/lib/samagotchi/host_keys.rb +0 -2
- data/lib/samagotchi/paths.rb +13 -0
- data/lib/samagotchi/plugin/api.rb +11 -5
- data/lib/samagotchi/plugin/peer.rb +5 -8
- data/lib/samagotchi/prompt_files.rb +2 -4
- data/lib/samagotchi/session.rb +2 -1
- data/lib/samagotchi/session_manager.rb +6 -16
- data/lib/samagotchi/session_model_settings.rb +227 -0
- data/lib/samagotchi/session_setup.rb +2 -1
- data/lib/samagotchi/sessions_command.rb +12 -3
- data/lib/samagotchi/system_prompt.rb +18 -13
- data/lib/samagotchi/terminal_ui/attached_loop.rb +36 -15
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
- data/lib/samagotchi/tool_activity.rb +20 -1
- data/lib/samagotchi/tool_declarations.rb +4 -0
- data/lib/samagotchi/tool_output_cut.rb +11 -2
- data/lib/samagotchi/toolkit.rb +60 -22
- data/lib/samagotchi/toolkit_binding.rb +7 -5
- data/lib/samagotchi/toolkit_command.rb +28 -5
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +32 -6
- data/lib/samagotchi/tools/list_sessions.rb +2 -2
- data/lib/samagotchi/tools/peers.rb +5 -2
- data/lib/samagotchi/tools/register_reminder.rb +1 -0
- data/lib/samagotchi/tools/registry.rb +13 -2
- data/lib/samagotchi/tools/task_wait.rb +4 -6
- data/lib/samagotchi/tools/tool_index.rb +15 -4
- data/lib/samagotchi/turn_note.rb +4 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +18 -17
- data/lib/samagotchi/web/message_parts.rb +1 -1
- data/lib/samagotchi/web/public/answer_refs.js +1 -0
- data/lib/samagotchi/web/public/app.js +20 -18
- data/lib/samagotchi/web/public/bubbles.js +1 -1
- data/lib/samagotchi/web/public/cards_place.js +6 -10
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/chip_popover.js +3 -1
- data/lib/samagotchi/web/public/composer.js +13 -6
- data/lib/samagotchi/web/public/context_chips.js +25 -12
- data/lib/samagotchi/web/public/data.js +5 -1
- data/lib/samagotchi/web/public/format.js +5 -0
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/sessions_list.js +2 -3
- data/lib/samagotchi/web/public/stage_frame.js +1 -1
- data/lib/samagotchi/web/public/stage_view.js +8 -2
- data/lib/samagotchi/web/public/stream_handlers.js +6 -3
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/toolkit_chip.js +3 -1
- data/lib/samagotchi/web/public/turn_html.js +55 -3
- data/lib/samagotchi/web/session_hub.rb +8 -0
- metadata +3 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -46,6 +46,7 @@ require_relative "editor"
|
|
|
46
46
|
require_relative "message_parts"
|
|
47
47
|
require_relative "session_summary"
|
|
48
48
|
require_relative "../log"
|
|
49
|
+
require_relative "../paths"
|
|
49
50
|
require_relative "../redacted_inspect"
|
|
50
51
|
|
|
51
52
|
module Samagotchi
|
|
@@ -896,9 +897,9 @@ module Samagotchi
|
|
|
896
897
|
# session part is what the page reads of it (id, status, used memory
|
|
897
898
|
# names, the model notes its prompt carried: the first turn's build or
|
|
898
899
|
# a /model records them). ?turn_id=: that turn's answer (a queued
|
|
899
|
-
# turn's page re-reads after the next one started).
|
|
900
|
-
#
|
|
901
|
-
#
|
|
900
|
+
# turn's page re-reads after the next one started). The timing's records
|
|
901
|
+
# are the newest turn's (#recent_timing), which the page merges by id
|
|
902
|
+
# (the page still sends ?recent=1, which is accepted and not needed).
|
|
902
903
|
def handle_tail_read(req, id)
|
|
903
904
|
return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
|
|
904
905
|
|
|
@@ -909,7 +910,14 @@ module Samagotchi
|
|
|
909
910
|
state = nil unless state.is_a?(Hash)
|
|
910
911
|
if state
|
|
911
912
|
status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
|
|
912
|
-
|
|
913
|
+
# No session file read here: the folder the answer's file refs must
|
|
914
|
+
# stay under is the worker's (its tail's working_directory), else the
|
|
915
|
+
# hub's session list entry's (a worker from before the field, or read
|
|
916
|
+
# through its snapshot); nil then without a hub or before it saw the
|
|
917
|
+
# session: refs aren't checked.
|
|
918
|
+
cwd = tail["working_directory"]
|
|
919
|
+
cwd = @hub&.working_directory(id) unless cwd.is_a?(String) && !cwd.empty?
|
|
920
|
+
messages = last_assistant_for_display([tail["answer"]].compact, cwd: cwd)
|
|
913
921
|
else
|
|
914
922
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
915
923
|
status = displayed_status(session)
|
|
@@ -919,9 +927,7 @@ module Samagotchi
|
|
|
919
927
|
end
|
|
920
928
|
session_json = { id: id, status: status, used_memory_names: Array(used), prompt_notes: Array(notes) }
|
|
921
929
|
live_metrics = state && state["metrics"]
|
|
922
|
-
|
|
923
|
-
# timing with the reply's). Remove this branch after the next release.
|
|
924
|
-
timing = req.params["recent"] == "1" ? recent_timing(id, live_metrics) : timing_payload(id, live_metrics: live_metrics)
|
|
930
|
+
timing = recent_timing(id, live_metrics)
|
|
925
931
|
json_response(200, {
|
|
926
932
|
tail: true,
|
|
927
933
|
session: session_json,
|
|
@@ -951,9 +957,9 @@ module Samagotchi
|
|
|
951
957
|
end
|
|
952
958
|
|
|
953
959
|
# The worker's light read (Bridge GET tail): {"session_state_snapshot",
|
|
954
|
-
# "answer", "cards", "event_seq", "event_id"}. A
|
|
955
|
-
# route answers 404 not_found: its GET snapshot,
|
|
956
|
-
# answer by AnswerTail). Anything else (no worker, a refused connect, a
|
|
960
|
+
# "working_directory", "answer", "cards", "event_seq", "event_id"}. A
|
|
961
|
+
# worker from before the route answers 404 not_found: its GET snapshot,
|
|
962
|
+
# in the same shape (the answer by AnswerTail; no working_directory). Anything else (no worker, a refused connect, a
|
|
957
963
|
# 500, a timeout) is nil: the caller reads the disk, with no second
|
|
958
964
|
# try at a worker that failed once.
|
|
959
965
|
# @return [Hash, nil]
|
|
@@ -1703,7 +1709,7 @@ module Samagotchi
|
|
|
1703
1709
|
turn_count: (live && turn_count(live_metrics)) || turns.size)
|
|
1704
1710
|
end
|
|
1705
1711
|
|
|
1706
|
-
# The timing a ?tail=1
|
|
1712
|
+
# The timing a ?tail=1 read sends: a live worker's recent
|
|
1707
1713
|
# records as its metrics hold them (the newest finished turn's and
|
|
1708
1714
|
# the unsaved ones, the running turn's finished calls), no
|
|
1709
1715
|
# analytics.json read; with no worker the disk's, trimmed to the
|
|
@@ -2000,12 +2006,7 @@ module Samagotchi
|
|
|
2000
2006
|
home_label(File.expand_path(default_state_dir))
|
|
2001
2007
|
end
|
|
2002
2008
|
|
|
2003
|
-
def home_label(dir)
|
|
2004
|
-
home = Dir.home
|
|
2005
|
-
return "~" if dir == home
|
|
2006
|
-
|
|
2007
|
-
dir.start_with?("#{home}/") ? "~#{dir.delete_prefix(home)}" : dir
|
|
2008
|
-
end
|
|
2009
|
+
def home_label(dir) = Paths.tilde(dir)
|
|
2009
2010
|
|
|
2010
2011
|
# <body> data for the page: where sessions are stored, where an
|
|
2011
2012
|
# all-view new chat starts (the server's cwd), and, when ?dir names a
|
|
@@ -53,7 +53,7 @@ module Samagotchi
|
|
|
53
53
|
# The row shows 300 chars and the rest on hover; a read of a big file
|
|
54
54
|
# would otherwise ride along with every reload. A longer one keeps its
|
|
55
55
|
# head and tail (ToolOutputCut.preview).
|
|
56
|
-
OUTPUT_MAX =
|
|
56
|
+
OUTPUT_MAX = ToolOutputCut::WEB_PREVIEW_CHARS
|
|
57
57
|
|
|
58
58
|
QWEN_THINK = %r{<think>(.*?)</think>}m
|
|
59
59
|
QWEN_OPEN_THINK = %r{\A(.*?)</think>}m # the template opened the block
|
|
@@ -67,6 +67,7 @@ export function applyFinalAnswer(messages, bubbles, normalize) {
|
|
|
67
67
|
if (typeof message.html === "string") {
|
|
68
68
|
bubble.classList.add("markdown");
|
|
69
69
|
bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
|
|
70
|
+
// The server's rendered markdown, sanitized there (Web::MarkdownRenderer#sanitize): set as it is, not templated.
|
|
70
71
|
bubble.innerHTML = message.html;
|
|
71
72
|
}
|
|
72
73
|
setAnswerRefs(bubble, message.display_refs);
|
|
@@ -38,12 +38,13 @@ import { MODEL_KEY } from "./model_pick.js";
|
|
|
38
38
|
import { emptySessionState, sessionStateFrom } from "./session_state.js";
|
|
39
39
|
import { createModelPicker } from "./model_picker.js";
|
|
40
40
|
import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
|
|
41
|
-
import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, withLiveStatus, withoutWorker } from "./format.js";
|
|
41
|
+
import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
|
|
42
42
|
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
43
43
|
import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
|
|
44
44
|
import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
|
|
45
45
|
import { createCardsPlace } from "./cards_place.js";
|
|
46
46
|
import { createStageView, FOLD_MS } from "./stage_view.js";
|
|
47
|
+
import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
|
|
47
48
|
import { CONTINUE_OPENER, chainNeighbours, isOpener, continueRefusal, continuedText, freshLink, openChildrenTemplate } from "./chains.js";
|
|
48
49
|
import { createTaskStopper } from "./task_stop.js";
|
|
49
50
|
import { appendQuote } from "./annotations.js";
|
|
@@ -313,8 +314,6 @@ const {
|
|
|
313
314
|
// The plugin cards and a snapshot's notices in the history (cards_place.js).
|
|
314
315
|
const cards = createCardsPlace({
|
|
315
316
|
historyEl,
|
|
316
|
-
appendToHistory: (el) => appendToHistory(el),
|
|
317
|
-
isNearBottom: () => isNearBottom(),
|
|
318
317
|
turnDom,
|
|
319
318
|
findInTurnRoots: (selector) => findInTurnRoots(selector),
|
|
320
319
|
selected: () => selected,
|
|
@@ -1063,7 +1062,7 @@ async function restartWorker() {
|
|
|
1063
1062
|
const result = await restartSession(id);
|
|
1064
1063
|
showToast(result.version ? `Restarted on chi ${result.version}` : "Restarting: the new worker is starting");
|
|
1065
1064
|
} catch (e) {
|
|
1066
|
-
showToast(
|
|
1065
|
+
showToast(cantText("restart", e), null, { sticky: true });
|
|
1067
1066
|
} finally {
|
|
1068
1067
|
restartingIds.delete(id);
|
|
1069
1068
|
updateInfoBar();
|
|
@@ -1482,6 +1481,8 @@ const composerCommands = createComposerCommands({
|
|
|
1482
1481
|
followWokenWorker: () => followWokenWorker(),
|
|
1483
1482
|
refreshHistory: () => refreshHistory(),
|
|
1484
1483
|
failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
|
|
1484
|
+
// As handleSendTurn says a turn's failure after a switch.
|
|
1485
|
+
failedElsewhere: (line, text) => alert(`${line}: ${text}`),
|
|
1485
1486
|
ownedByTui: () => {
|
|
1486
1487
|
current.owner = "tui";
|
|
1487
1488
|
updateComposerMode();
|
|
@@ -1742,7 +1743,6 @@ const centerEl = $("#center");
|
|
|
1742
1743
|
// A leaving stage (the hand-off's slide) no longer counts: the reserve eases
|
|
1743
1744
|
// down over the slide (index.html, #center.reserve-easing).
|
|
1744
1745
|
const stageEl = view === "stage" ? turnDom.root : null;
|
|
1745
|
-
const STAGE_GAP = 10;
|
|
1746
1746
|
function dockReserve() {
|
|
1747
1747
|
const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
|
|
1748
1748
|
return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
|
|
@@ -1760,7 +1760,7 @@ function updateReserve({ fromStage = false } = {}) {
|
|
|
1760
1760
|
const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
|
|
1761
1761
|
if (changed) centerEl.style.setProperty("--dock-h", reserve);
|
|
1762
1762
|
if (changed && follow) {
|
|
1763
|
-
if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce:
|
|
1763
|
+
if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: reducedMotion() });
|
|
1764
1764
|
else if (historyEl.dataset.following === undefined) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1765
1765
|
}
|
|
1766
1766
|
placeToast();
|
|
@@ -1864,12 +1864,11 @@ $("#infoStopBtn").addEventListener("click", async () => {
|
|
|
1864
1864
|
stoppingId = id;
|
|
1865
1865
|
try {
|
|
1866
1866
|
await stopSession(id);
|
|
1867
|
-
if (id === selected)
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
}
|
|
1867
|
+
if (id === selected) stoppedWorker(id);
|
|
1868
|
+
} catch (e) {
|
|
1869
|
+
// A refusal (a terminal holds it) or a worker that won't stop: said, as
|
|
1870
|
+
// a failed restart is.
|
|
1871
|
+
showToast(cantText(`stop ${id.slice(0, 8)}`, e), null, { sticky: true });
|
|
1873
1872
|
} finally {
|
|
1874
1873
|
stoppingId = null;
|
|
1875
1874
|
}
|
|
@@ -1963,6 +1962,14 @@ function markArchived(ids, archived) {
|
|
|
1963
1962
|
// and a batch share. The ids in the answer flip in the list at once. When
|
|
1964
1963
|
// the open session's worker was stopped (it, or a delegate of the archived
|
|
1965
1964
|
// one), its stream ending is a stop, not a lost worker.
|
|
1965
|
+
// The open session's worker was stopped from here (the stop button, an
|
|
1966
|
+
// archive): don't wait for its stream to notice.
|
|
1967
|
+
function stoppedWorker(id) {
|
|
1968
|
+
streamControl?.close();
|
|
1969
|
+
current.status = "stopped";
|
|
1970
|
+
workerGone(id);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1966
1973
|
// @return {ok: true, result} | {ok: false, error}
|
|
1967
1974
|
async function archiveOne(id, archive) {
|
|
1968
1975
|
// A live worker in the tree is stopped by the archive: the open
|
|
@@ -1972,12 +1979,7 @@ async function archiveOne(id, archive) {
|
|
|
1972
1979
|
try {
|
|
1973
1980
|
const result = archive ? await archiveSession(id) : await unarchiveSession(id);
|
|
1974
1981
|
markArchived(archive ? result.archived : result.unarchived, archive);
|
|
1975
|
-
if (mine && mine === selected && (result.stopped || []).includes(mine))
|
|
1976
|
-
// Its worker is gone: don't wait for its stream to notice.
|
|
1977
|
-
streamControl?.close();
|
|
1978
|
-
current.status = "stopped";
|
|
1979
|
-
workerGone(mine);
|
|
1980
|
-
}
|
|
1982
|
+
if (mine && mine === selected && (result.stopped || []).includes(mine)) stoppedWorker(mine);
|
|
1981
1983
|
return { ok: true, result };
|
|
1982
1984
|
} catch (error) {
|
|
1983
1985
|
return { ok: false, error };
|
|
@@ -285,7 +285,7 @@ export function createBubbles({
|
|
|
285
285
|
}
|
|
286
286
|
|
|
287
287
|
return {
|
|
288
|
-
removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
|
|
288
|
+
removeHintIfEmpty, placeFollowing, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
|
|
289
289
|
markRecapStale, showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
|
|
290
290
|
};
|
|
291
291
|
}
|
|
@@ -32,22 +32,20 @@ export function turnAnswerEl(turnEls) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// @param historyEl the history
|
|
35
|
-
// @param appendToHistory (el) puts +el+ at the history's end (or the stage's)
|
|
36
|
-
// @param isNearBottom () => whether the history follows its end
|
|
37
35
|
// @param turnDom the running turn's view (its card(el) takes a turn's card)
|
|
38
36
|
// @param findInTurnRoots (selector) => the matching elements in the history
|
|
39
37
|
// and the stage
|
|
40
38
|
// @param selected () => the open session's id
|
|
41
39
|
// @param clientId this tab's id in the session's events
|
|
42
40
|
// @param questionCards the history's question cards (question_cards.js)
|
|
43
|
-
// @param bubbles {removeHintIfEmpty, showGuardrailWarning, showHookNotice}
|
|
41
|
+
// @param bubbles {removeHintIfEmpty, placeFollowing, showGuardrailWarning, showHookNotice}
|
|
44
42
|
// (bubbles.js createBubbles)
|
|
45
43
|
// @param followWokenWorker () follows the worker a card's command woke
|
|
46
44
|
// @param api {getSession, sendCommand} (data.js)
|
|
47
45
|
// @return {cardElement(id), upsertCard(card, {before}), placeSnapshotCards(entries),
|
|
48
46
|
// refreshCardBodies(), onCardAction(event)}
|
|
49
47
|
export function createCardsPlace({
|
|
50
|
-
historyEl,
|
|
48
|
+
historyEl, turnDom, findInTurnRoots, selected, clientId, questionCards, bubbles,
|
|
51
49
|
followWokenWorker, api = { getSession, sendCommand },
|
|
52
50
|
}) {
|
|
53
51
|
function cardElement(id) {
|
|
@@ -73,10 +71,7 @@ export function createCardsPlace({
|
|
|
73
71
|
const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
|
|
74
72
|
const asks = (card.actions || []).some((a) => a && a.command);
|
|
75
73
|
if (card.in_turn && !before && !asks && turnDom.card(el)) return;
|
|
76
|
-
|
|
77
|
-
if (before) historyEl.insertBefore(el, before);
|
|
78
|
-
else appendToHistory(el);
|
|
79
|
-
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
74
|
+
bubbles.placeFollowing(el, before);
|
|
80
75
|
}
|
|
81
76
|
|
|
82
77
|
// A snapshot's cards and notices, each above the first turn after it; a
|
|
@@ -170,8 +165,9 @@ export function createCardsPlace({
|
|
|
170
165
|
button.disabled = true;
|
|
171
166
|
api.sendCommand(id, button.dataset.command, { clientId, card: true })
|
|
172
167
|
.then(() => {
|
|
173
|
-
// The command woke the worker: follow its stream if none is open
|
|
174
|
-
|
|
168
|
+
// The command woke the worker: follow its stream if none is open
|
|
169
|
+
// (and the page still shows that session).
|
|
170
|
+
if (selected() === id) followWokenWorker();
|
|
175
171
|
})
|
|
176
172
|
.catch((error) => {
|
|
177
173
|
err.textContent = error.message;
|
|
@@ -206,7 +206,7 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
|
|
|
206
206
|
export function continueRefusal(error, sessions = []) {
|
|
207
207
|
const body = error?.body || {};
|
|
208
208
|
if (error?.code === "continued" && body.next_id) return { kind: "open", id: body.next_id };
|
|
209
|
-
const text = String(error?.message || "refused")
|
|
209
|
+
const text = String(error?.message || "refused");
|
|
210
210
|
if (error?.code === "open_children" && Array.isArray(body.ids) && body.ids.length) {
|
|
211
211
|
const byId = new Map(sessions.map((s) => [s.id, s]));
|
|
212
212
|
const children = body.ids.map((id) => {
|
|
@@ -84,10 +84,12 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
|
|
|
84
84
|
show(data, false);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// The click goes on to the document: another chip's popover (or the
|
|
88
|
+
// context popover) open meanwhile closes there. This one's own closer
|
|
89
|
+
// leaves its chip's click alone.
|
|
87
90
|
el.addEventListener("click", (e) => {
|
|
88
91
|
const anchor = e.target.closest(anchorSel);
|
|
89
92
|
if (!anchor) return;
|
|
90
|
-
e.stopPropagation();
|
|
91
93
|
if (isOpen()) close();
|
|
92
94
|
else open(anchor);
|
|
93
95
|
});
|
|
@@ -60,20 +60,26 @@ export function routeSessionLine(prompt, { commands = [], images = 0, delivery =
|
|
|
60
60
|
// followWokenWorker() the command woke the worker: follow its stream
|
|
61
61
|
// refreshHistory()
|
|
62
62
|
// failed(line, text) a failed command's bubble
|
|
63
|
+
// failedElsewhere(line, text) a command to a session no longer open failed
|
|
63
64
|
// ownedByTui() a plain terminal holds the session
|
|
64
65
|
// composer: {busy(), clear(), done()} the composer's send button and text
|
|
65
66
|
export function createComposerCommands(deps) {
|
|
66
|
-
const { session, clientId, sendCommand, followWokenWorker, refreshHistory, failed, ownedByTui,
|
|
67
|
+
const { session, clientId, sendCommand, followWokenWorker, refreshHistory, failed, failedElsewhere, ownedByTui,
|
|
68
|
+
composer } = deps;
|
|
67
69
|
|
|
68
|
-
//
|
|
70
|
+
// The page may open another session while a command is in flight: what
|
|
71
|
+
// follows is about the session it went to (+id+), so the open one's
|
|
72
|
+
// worker, owner and history are left alone then.
|
|
69
73
|
async function send(line) {
|
|
74
|
+
const id = session();
|
|
70
75
|
composer.busy();
|
|
71
76
|
try {
|
|
72
|
-
await sendCommand(
|
|
77
|
+
await sendCommand(id, line, { clientId });
|
|
73
78
|
composer.clear();
|
|
74
79
|
refreshHistory();
|
|
75
|
-
followWokenWorker();
|
|
80
|
+
if (session() === id) followWokenWorker();
|
|
76
81
|
} catch (e) {
|
|
82
|
+
if (session() !== id) return failedElsewhere(line, e.message);
|
|
77
83
|
if (e.code === "owned_by_tui") {
|
|
78
84
|
ownedByTui();
|
|
79
85
|
return;
|
|
@@ -93,10 +99,11 @@ export function createComposerCommands(deps) {
|
|
|
93
99
|
if (!id) return false;
|
|
94
100
|
try {
|
|
95
101
|
await sendCommand(id, line, { clientId });
|
|
96
|
-
followWokenWorker();
|
|
102
|
+
if (session() === id) followWokenWorker();
|
|
97
103
|
return true;
|
|
98
104
|
} catch (e) {
|
|
99
|
-
failed(line, e.message);
|
|
105
|
+
if (session() === id) failed(line, e.message);
|
|
106
|
+
else failedElsewhere(line, e.message);
|
|
100
107
|
return false;
|
|
101
108
|
}
|
|
102
109
|
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
11
11
|
import { keepFocus } from "./keep_focus.js";
|
|
12
|
+
import { popoverPlace } from "./chip_popover.js";
|
|
13
|
+
import { cantText } from "./format.js";
|
|
12
14
|
|
|
13
15
|
// "now", "3m", "2h", "4d" since an ISO time; "" for none.
|
|
14
16
|
export function ageText(iso, now = Date.now()) {
|
|
@@ -177,13 +179,24 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
|
|
|
177
179
|
render(popoverTemplate(row, { text }), pop);
|
|
178
180
|
}
|
|
179
181
|
|
|
180
|
-
//
|
|
182
|
+
// At the chip as the session bar's chips place theirs (popoverPlace):
|
|
183
|
+
// above it, or below when the room above is short; capped to the room
|
|
184
|
+
// (it scrolls), inside the window with a 16px gutter.
|
|
181
185
|
function place(chip) {
|
|
182
|
-
const rect = chip.getBoundingClientRect();
|
|
183
186
|
const width = Math.min(380, win.innerWidth - 32);
|
|
184
187
|
pop.style.width = `${width}px`;
|
|
185
|
-
pop.style.
|
|
186
|
-
|
|
188
|
+
pop.style.maxHeight = "";
|
|
189
|
+
const at = popoverPlace(chip.getBoundingClientRect(), { width, height: pop.offsetHeight,
|
|
190
|
+
innerWidth: win.innerWidth, innerHeight: win.innerHeight });
|
|
191
|
+
pop.style.left = `${at.left}px`;
|
|
192
|
+
pop.style.top = at.top === undefined ? "" : `${at.top}px`;
|
|
193
|
+
pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
|
|
194
|
+
if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function placeAtOpenChip() {
|
|
198
|
+
const chip = openName && el.querySelector(".ctx-chip.open");
|
|
199
|
+
if (chip) place(chip);
|
|
187
200
|
}
|
|
188
201
|
|
|
189
202
|
function open(name, chip) {
|
|
@@ -231,7 +244,7 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
|
|
|
231
244
|
} catch (e) {
|
|
232
245
|
if (id !== sessionId || openName !== ADD) return;
|
|
233
246
|
// The same form again: what the user typed stays; the URL gets focus.
|
|
234
|
-
drawAdd({ busy: false, error:
|
|
247
|
+
drawAdd({ busy: false, error: e.message });
|
|
235
248
|
pop.querySelector('input[name="url"]')?.focus();
|
|
236
249
|
}
|
|
237
250
|
}
|
|
@@ -255,8 +268,10 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
|
|
|
255
268
|
if (!row) return;
|
|
256
269
|
text = data.text || "";
|
|
257
270
|
fill(row);
|
|
271
|
+
// The text makes it taller: placed again.
|
|
272
|
+
placeAtOpenChip();
|
|
258
273
|
} catch (e) {
|
|
259
|
-
toast(`
|
|
274
|
+
toast(cantText(`read ${name}`, e));
|
|
260
275
|
}
|
|
261
276
|
}
|
|
262
277
|
async function detach() {
|
|
@@ -271,14 +286,15 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
|
|
|
271
286
|
close();
|
|
272
287
|
await refresh();
|
|
273
288
|
} catch (e) {
|
|
274
|
-
toast(`
|
|
289
|
+
toast(cantText(`detach ${row.name}`, e));
|
|
275
290
|
}
|
|
276
291
|
}
|
|
277
292
|
|
|
278
293
|
el.addEventListener("click", (e) => {
|
|
279
294
|
const chip = e.target.closest(".ctx-chip");
|
|
280
295
|
if (!chip) return;
|
|
281
|
-
|
|
296
|
+
// On to the document: a session-bar chip's popover closes (the close
|
|
297
|
+
// below leaves clicks in +el+ alone).
|
|
282
298
|
if (chip.dataset.act === "add") {
|
|
283
299
|
if (openName === ADD) close();
|
|
284
300
|
else openAdd(chip);
|
|
@@ -301,10 +317,7 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
|
|
|
301
317
|
doc.addEventListener("keydown", (e) => {
|
|
302
318
|
if (e.key === "Escape" && openName) close();
|
|
303
319
|
});
|
|
304
|
-
win.addEventListener("resize",
|
|
305
|
-
const chip = openName && el.querySelector(".ctx-chip.open");
|
|
306
|
-
if (chip) place(chip);
|
|
307
|
-
});
|
|
320
|
+
win.addEventListener("resize", placeAtOpenChip);
|
|
308
321
|
win.addEventListener("focus", () => refresh());
|
|
309
322
|
// The ages move on by themselves.
|
|
310
323
|
win.setInterval(() => { if (rows.length) drawChips(); }, 60_000);
|
|
@@ -6,7 +6,11 @@ export function api(path, opts = {}) {
|
|
|
6
6
|
}).then(async (r) => {
|
|
7
7
|
const j = await r.json().catch(() => ({}));
|
|
8
8
|
if (!r.ok) {
|
|
9
|
-
|
|
9
|
+
// The server's words as they are: what the page shows the user. The
|
|
10
|
+
// status goes in only when it sent none (a proxy's bare 502, a crash's
|
|
11
|
+
// 500): then it is all there is to go on.
|
|
12
|
+
const words = j.detail || j.error;
|
|
13
|
+
const error = new Error(words || `${r.statusText || "HTTP error"} (${r.status})`);
|
|
10
14
|
error.status = r.status;
|
|
11
15
|
// The server's error code ({error: "owned_by_tui", detail}): what
|
|
12
16
|
// the page matches on, never the message's words.
|
|
@@ -168,6 +168,11 @@ export function goneSessionNotice(id, why) {
|
|
|
168
168
|
return `Session ${String(id).slice(0, 8)} was ${why === "deleted" ? "deleted" : "not found"}.`;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// A failed action's toast: what failed and why (api()'s error says why).
|
|
172
|
+
export function cantText(what, error) {
|
|
173
|
+
return `Can't ${what}: ${String(error?.message ?? error)}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
171
176
|
// A session in the list after its live status changed (a turn started or
|
|
172
177
|
// ended): that is activity now, so its card's "N min ago" says so too, and
|
|
173
178
|
// the change came from a worker's stream, so the card says "live".
|
|
@@ -162,6 +162,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
162
162
|
.activity-alias{color:var(--text-faint);white-space:nowrap}
|
|
163
163
|
.activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
164
164
|
.activity-output{margin:4px 0 2px var(--row-gutter);font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
|
|
165
|
+
.activity-output.tail{display:flex;flex-direction:column-reverse}
|
|
165
166
|
/* A row's status and tool name never wrap (a phone broke them letter by
|
|
166
167
|
letter); the params take what is left. The output goes on its own line
|
|
167
168
|
under the call instead of a column beside it: on a desktop it lines up
|
|
@@ -264,10 +264,9 @@ export function batchTargets(picked, shownIds, archive, sessionsById) {
|
|
|
264
264
|
});
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
// A refusal's reason as the card shows it: the server's words
|
|
268
|
-
// the api() status suffix.
|
|
267
|
+
// A refusal's reason as the card shows it: the server's words.
|
|
269
268
|
function refusalText(error) {
|
|
270
|
-
return String(error?.message || "refused")
|
|
269
|
+
return String(error?.message || "refused");
|
|
271
270
|
}
|
|
272
271
|
|
|
273
272
|
// Runs +act+ over +ids+ one at a time (a live session's stop waits on the
|
|
@@ -13,7 +13,7 @@ import { easeScrollTo } from "./scroll.js";
|
|
|
13
13
|
const GROW_MS = 180;
|
|
14
14
|
const GROW_EASE = "cubic-bezier(.2,.7,.3,1)";
|
|
15
15
|
// The room between the stage and the composer card (index.html's margin-bottom).
|
|
16
|
-
const STAGE_GAP = 10;
|
|
16
|
+
export const STAGE_GAP = 10;
|
|
17
17
|
|
|
18
18
|
export function reducedMotion() {
|
|
19
19
|
return matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
@@ -127,6 +127,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
127
127
|
let endedAt = 0;
|
|
128
128
|
let thinkingLine = null; // { step, text } from the live step's ticker
|
|
129
129
|
let flash = null; // { kind, text, until } a notice or nudge in the trail
|
|
130
|
+
let flashTimer = null; // its end's redraw
|
|
130
131
|
let collapsed = readCollapsed();
|
|
131
132
|
let cloudOpen = false;
|
|
132
133
|
// A ref's "show in turn" opened a collapsed stage (not kept).
|
|
@@ -252,7 +253,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
252
253
|
if (!what) return;
|
|
253
254
|
flash = { ...what, until: Date.now() + FLASH_MS };
|
|
254
255
|
scheduleRefresh();
|
|
255
|
-
|
|
256
|
+
clearTimeout(flashTimer);
|
|
257
|
+
flashTimer = setTimeout(scheduleRefresh, FLASH_MS + 50);
|
|
256
258
|
}
|
|
257
259
|
|
|
258
260
|
// The answer reads from its top (no tail pin in the stage): scrolled to
|
|
@@ -408,6 +410,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
408
410
|
promptSlot.classList.remove("full");
|
|
409
411
|
thinkingLine = null;
|
|
410
412
|
flash = null;
|
|
413
|
+
clearTimeout(flashTimer);
|
|
411
414
|
setCloud(false);
|
|
412
415
|
stage.classList.remove("plain", "ended", "waiting", "held-wait", "asking", "in-use");
|
|
413
416
|
stage.style.removeProperty("--live-held");
|
|
@@ -590,8 +593,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
590
593
|
// composer's delivery line).
|
|
591
594
|
current: () => view.current(),
|
|
592
595
|
phase: () => liveSlots(view.current().turn, { pending: pendingCard() || asksCard() }).phase,
|
|
596
|
+
// Another session opens, or this one renders again: a turn still
|
|
597
|
+
// sliding away is dropped (its history is about to be drawn anew),
|
|
598
|
+
// never moved into whatever history shows now.
|
|
593
599
|
reset() {
|
|
594
|
-
|
|
600
|
+
folding = null;
|
|
595
601
|
release();
|
|
596
602
|
},
|
|
597
603
|
// The stage's extras while it holds a turn (a card, a prompt for its
|
|
@@ -150,12 +150,15 @@ export function createStreamHandlers(deps) {
|
|
|
150
150
|
// An after_turn hook presented the answer (AnswerDisplay) or attached
|
|
151
151
|
// refs to it (AnswerRefs: refs, a count; display null when only refs):
|
|
152
152
|
// it comes after turn_completed, so re-read the answer once that read
|
|
153
|
-
// is done
|
|
153
|
+
// is done: the session's open then, so a switch meanwhile leaves the
|
|
154
|
+
// other session's answers alone.
|
|
154
155
|
answer_display: async (data) => {
|
|
156
|
+
const id = selected();
|
|
155
157
|
try {
|
|
156
158
|
await liveTurn.endRead;
|
|
157
|
-
if (!selected() || (!data.display && !data.refs)) return;
|
|
158
|
-
const d = await getSession(
|
|
159
|
+
if (!id || id !== selected() || (!data.display && !data.refs)) return;
|
|
160
|
+
const d = await getSession(id, { tail: true, recent: true });
|
|
161
|
+
if (id !== selected()) return;
|
|
159
162
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
160
163
|
} catch (_) {
|
|
161
164
|
} finally {
|
|
@@ -278,7 +278,7 @@ function noMatch(record) {
|
|
|
278
278
|
// every assistant message is one step. Its calls take the turn's records by
|
|
279
279
|
// tool, in call order (each the next record of its tool, consumed with the
|
|
280
280
|
// ones it skipped), not by iteration: an empty retry spends an iteration
|
|
281
|
-
// that left no message (as the TUI's join, attached_loop.rb
|
|
281
|
+
// that left no message (as the TUI's join, attached_loop.rb join_record).
|
|
282
282
|
// The last message is the answer when it has text and made no call (and the
|
|
283
283
|
// turn is no canceled multi-step one); its thinking stays behind as a step,
|
|
284
284
|
// as it does live.
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// worker's snapshot or a command_ran; the server's own reading of the
|
|
6
6
|
// session file has no lists). Left out is reachable (find_tools and
|
|
7
7
|
// call_tool reach them) and removed (only /toolkit add brings one back).
|
|
8
|
-
// Changing it is /toolkit (no popover yet).
|
|
8
|
+
// Changing it is /toolkit (no popover yet). A kit with a prompt text has
|
|
9
|
+
// it on the summary too (prompt), and the tooltip ends with it.
|
|
9
10
|
|
|
10
11
|
import { html, nothing } from "./vendor/lit-html.js";
|
|
11
12
|
|
|
@@ -24,6 +25,7 @@ export function chipModel(summary) {
|
|
|
24
25
|
parts.push(summary.left_out.length ? `Not loaded: ${summary.left_out.join(", ")}.` : "Nothing left out.");
|
|
25
26
|
}
|
|
26
27
|
parts.push("/toolkit lists them; /toolkit NAME, add TOOL or remove TOOL changes them from the next turn.");
|
|
28
|
+
if (summary.prompt) parts.push(`Prompt: ${summary.prompt}`);
|
|
27
29
|
return { text: `tools ${label}${summary.fallback ? " ?" : ""}`, title: parts.join(" ") };
|
|
28
30
|
}
|
|
29
31
|
|