samagotchi 0.71.0 → 0.73.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 +70 -1
- data/docs/architecture.md +15 -9
- data/docs/cli.md +12 -5
- data/docs/configuration.md +23 -13
- data/docs/hooks.md +5 -3
- 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 +5 -3
- data/docs/testing.md +10 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -3
- 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 +7 -1
- data/lib/samagotchi/session_chain.rb +24 -0
- data/lib/samagotchi/session_continue.rb +3 -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/system_prompt.rb +18 -13
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -14
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
- data/lib/samagotchi/tool_activity.rb +1 -0
- data/lib/samagotchi/tool_declarations.rb +4 -0
- data/lib/samagotchi/toolkit.rb +55 -24
- 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/execute.rb +1 -15
- 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 +1 -1
- data/lib/samagotchi/tools/task_runtime.rb +69 -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 +40 -13
- data/lib/samagotchi/web/public/app.js +53 -28
- data/lib/samagotchi/web/public/bubbles.js +5 -3
- data/lib/samagotchi/web/public/cards_place.js +6 -10
- data/lib/samagotchi/web/public/chains.js +22 -2
- 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 +14 -1
- data/lib/samagotchi/web/public/format.js +5 -0
- data/lib/samagotchi/web/public/index.html +39 -2
- data/lib/samagotchi/web/public/notice_links.js +55 -0
- data/lib/samagotchi/web/public/question_cards.js +33 -1
- 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_slots.js +35 -3
- data/lib/samagotchi/web/public/stage_view.js +17 -6
- 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_view.js +3 -2
- data/lib/samagotchi/web/session_hub.rb +8 -0
- metadata +4 -1
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -198,8 +198,11 @@ module Samagotchi
|
|
|
198
198
|
|
|
199
199
|
# The hidden nudge before a retry of an empty answer (EmptyAnswerRetry):
|
|
200
200
|
# the model never sees its empty generation, so it is told what happened.
|
|
201
|
+
# It asks to go on with the task, like cut_retry: "answer now" made a
|
|
202
|
+
# mid-task model stop and report before its last steps.
|
|
201
203
|
def empty_retry
|
|
202
|
-
retry_nudge(message("your last reply had no visible answer.
|
|
204
|
+
retry_nudge(message("your last reply had no visible answer. Continue the task with your next tool call, " \
|
|
205
|
+
"or answer if it is done."))
|
|
203
206
|
end
|
|
204
207
|
|
|
205
208
|
# The hidden nudge before a retry of a generation a plugin cut
|
data/lib/samagotchi/version.rb
CHANGED
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
|
|
@@ -65,6 +66,9 @@ module Samagotchi
|
|
|
65
66
|
BRIDGE_WAIT_TIMEOUT = 10.0
|
|
66
67
|
# An uploaded image may be this big before it is downscaled.
|
|
67
68
|
MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024
|
|
69
|
+
# A POST /api/render body may be this big: a decision message is a few
|
|
70
|
+
# KB, and the cap bounds the work a page can ask for.
|
|
71
|
+
MAX_RENDER_BYTES = 64 * 1024
|
|
68
72
|
SESSION_ID_RE = /\A[0-9A-Za-z-]{1,64}\z/
|
|
69
73
|
IMAGE_NAME_RE = /\A[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z/
|
|
70
74
|
IMAGE_TYPES = { "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif",
|
|
@@ -157,6 +161,7 @@ module Samagotchi
|
|
|
157
161
|
["GET", %r{\A/api/models\z}, :handle_models],
|
|
158
162
|
["GET", %r{\A/api/history\z}, :handle_history],
|
|
159
163
|
["GET", %r{\A/api/events\z}, :handle_events],
|
|
164
|
+
["POST", %r{\A/api/render\z}, :handle_render],
|
|
160
165
|
["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
|
|
161
166
|
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
|
|
162
167
|
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
|
|
@@ -678,8 +683,9 @@ module Samagotchi
|
|
|
678
683
|
# refused beside it (a create takes no reasoning_history). It runs on
|
|
679
684
|
# model (or model_name) when given, else on the previous link's: one
|
|
680
685
|
# whose host is gone falls back to the default, and the 201's
|
|
681
|
-
# model_warning (null when none) says so.
|
|
682
|
-
#
|
|
686
|
+
# model_warning (null when none) says so. Its opener is the text the
|
|
687
|
+
# page prefills (SessionChain.opener_for; null: the page's constant).
|
|
688
|
+
# A model it can't start on is 400 invalid_model, nothing archived.
|
|
683
689
|
# 409 continued (with next_id, the link that continues it already: the
|
|
684
690
|
# page opens that one), open_children (ids), folder_gone, busy, scratch
|
|
685
691
|
# or owned_by_tui.
|
|
@@ -706,7 +712,8 @@ module Samagotchi
|
|
|
706
712
|
[session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
|
|
707
713
|
record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
|
|
708
714
|
json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved,
|
|
709
|
-
model_warning: session.model_warning
|
|
715
|
+
model_warning: session.model_warning,
|
|
716
|
+
opener: session.opener))
|
|
710
717
|
end
|
|
711
718
|
|
|
712
719
|
# SessionManager.continue_session, its refusals as responses: [session,
|
|
@@ -909,7 +916,14 @@ module Samagotchi
|
|
|
909
916
|
state = nil unless state.is_a?(Hash)
|
|
910
917
|
if state
|
|
911
918
|
status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
|
|
912
|
-
|
|
919
|
+
# No session file read here: the folder the answer's file refs must
|
|
920
|
+
# stay under is the worker's (its tail's working_directory), else the
|
|
921
|
+
# hub's session list entry's (a worker from before the field, or read
|
|
922
|
+
# through its snapshot); nil then without a hub or before it saw the
|
|
923
|
+
# session: refs aren't checked.
|
|
924
|
+
cwd = tail["working_directory"]
|
|
925
|
+
cwd = @hub&.working_directory(id) unless cwd.is_a?(String) && !cwd.empty?
|
|
926
|
+
messages = last_assistant_for_display([tail["answer"]].compact, cwd: cwd)
|
|
913
927
|
else
|
|
914
928
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
915
929
|
status = displayed_status(session)
|
|
@@ -949,9 +963,9 @@ module Samagotchi
|
|
|
949
963
|
end
|
|
950
964
|
|
|
951
965
|
# The worker's light read (Bridge GET tail): {"session_state_snapshot",
|
|
952
|
-
# "answer", "cards", "event_seq", "event_id"}. A
|
|
953
|
-
# route answers 404 not_found: its GET snapshot,
|
|
954
|
-
# answer by AnswerTail). Anything else (no worker, a refused connect, a
|
|
966
|
+
# "working_directory", "answer", "cards", "event_seq", "event_id"}. A
|
|
967
|
+
# worker from before the route answers 404 not_found: its GET snapshot,
|
|
968
|
+
# in the same shape (the answer by AnswerTail; no working_directory). Anything else (no worker, a refused connect, a
|
|
955
969
|
# 500, a timeout) is nil: the caller reads the disk, with no second
|
|
956
970
|
# try at a worker that failed once.
|
|
957
971
|
# @return [Hash, nil]
|
|
@@ -998,6 +1012,24 @@ module Samagotchi
|
|
|
998
1012
|
html || "<pre>#{CGI.escapeHTML(body)}</pre>"
|
|
999
1013
|
end
|
|
1000
1014
|
|
|
1015
|
+
# POST /api/render {text}: the page's markdown read for a text it
|
|
1016
|
+
# already shows (the stage's asking text, a resolved question card's
|
|
1017
|
+
# reply), session-free. {html: nil} when markdown is off or
|
|
1018
|
+
# commonmarker is missing: the page keeps the raw text.
|
|
1019
|
+
def handle_render(req)
|
|
1020
|
+
too_large = error_response(413, "too_large", "a text to render may be up to #{MAX_RENDER_BYTES / 1024} KB")
|
|
1021
|
+
return too_large if req.content_length.to_i > MAX_RENDER_BYTES
|
|
1022
|
+
|
|
1023
|
+
raw = request_body(req, MAX_RENDER_BYTES + 1).to_s
|
|
1024
|
+
return too_large if raw.bytesize > MAX_RENDER_BYTES
|
|
1025
|
+
|
|
1026
|
+
body = parse_json(raw)
|
|
1027
|
+
return error_response(400, "invalid_json", "give {text}") unless body.is_a?(Hash) && body["text"].is_a?(String)
|
|
1028
|
+
|
|
1029
|
+
html = @markdown_renderer.render(body["text"]) if @markdown_renderer.available?
|
|
1030
|
+
json_response(200, { html: html })
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1001
1033
|
def handle_question_answer(req, id)
|
|
1002
1034
|
session = @session_class.load(id, state_dir: default_state_dir) rescue nil
|
|
1003
1035
|
return error_response(404, "not_found", "Session not found: #{id}") unless session
|
|
@@ -1998,12 +2030,7 @@ module Samagotchi
|
|
|
1998
2030
|
home_label(File.expand_path(default_state_dir))
|
|
1999
2031
|
end
|
|
2000
2032
|
|
|
2001
|
-
def home_label(dir)
|
|
2002
|
-
home = Dir.home
|
|
2003
|
-
return "~" if dir == home
|
|
2004
|
-
|
|
2005
|
-
dir.start_with?("#{home}/") ? "~#{dir.delete_prefix(home)}" : dir
|
|
2006
|
-
end
|
|
2033
|
+
def home_label(dir) = Paths.tilde(dir)
|
|
2007
2034
|
|
|
2008
2035
|
# <body> data for the page: where sessions are stored, where an
|
|
2009
2036
|
# all-view new chat starts (the server's cwd), and, when ?dir names a
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
getContext,
|
|
21
21
|
detachContext,
|
|
22
22
|
addContext,
|
|
23
|
+
renderText,
|
|
23
24
|
} from "./data.js";
|
|
24
25
|
import { createContextChips } from "./context_chips.js";
|
|
25
26
|
import {
|
|
@@ -38,13 +39,14 @@ import { MODEL_KEY } from "./model_pick.js";
|
|
|
38
39
|
import { emptySessionState, sessionStateFrom } from "./session_state.js";
|
|
39
40
|
import { createModelPicker } from "./model_picker.js";
|
|
40
41
|
import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
|
|
41
|
-
import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, withLiveStatus, withoutWorker } from "./format.js";
|
|
42
|
+
import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
|
|
42
43
|
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
43
44
|
import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
|
|
44
45
|
import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
|
|
45
46
|
import { createCardsPlace } from "./cards_place.js";
|
|
46
47
|
import { createStageView, FOLD_MS } from "./stage_view.js";
|
|
47
|
-
import {
|
|
48
|
+
import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
|
|
49
|
+
import { chainNeighbours, continueRefusal, continuedText, freshLink, openChildrenTemplate, openerText, openTitle } from "./chains.js";
|
|
48
50
|
import { createTaskStopper } from "./task_stop.js";
|
|
49
51
|
import { appendQuote } from "./annotations.js";
|
|
50
52
|
import { parsePresets } from "./annotate_presets.js";
|
|
@@ -230,8 +232,25 @@ const viewArgs = {
|
|
|
230
232
|
taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
|
|
231
233
|
};
|
|
232
234
|
const turnDom = view === "stage"
|
|
233
|
-
? createStageView({
|
|
235
|
+
? createStageView({
|
|
236
|
+
...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl,
|
|
237
|
+
renderMarkdown, linkRefs: linkRefsIn,
|
|
238
|
+
})
|
|
234
239
|
: createTurnView(viewArgs);
|
|
240
|
+
// A text the page already shows as its markdown HTML (POST /api/render,
|
|
241
|
+
// sanitised there), or null: markdown off here (no request), or a failed
|
|
242
|
+
// read (the raw text stays).
|
|
243
|
+
function renderMarkdown(text) {
|
|
244
|
+
if (pageCapabilities.display !== true) return Promise.resolve(null);
|
|
245
|
+
return renderText(text)
|
|
246
|
+
.then((d) => (typeof d?.html === "string" && d.html ? d.html : null))
|
|
247
|
+
.catch(() => null);
|
|
248
|
+
}
|
|
249
|
+
// The `path:line` refs in one rendered text (the stage's asking slot, a
|
|
250
|
+
// kept reply), as linkTextRefs links the answers'.
|
|
251
|
+
function linkRefsIn(el) {
|
|
252
|
+
linkRefs(el, touchedFiles(turnRoots()), current.dir);
|
|
253
|
+
}
|
|
235
254
|
// Where a running turn's rows can be: the history, and the stage while it
|
|
236
255
|
// holds a turn (lookups by id, copy, annotate, card actions).
|
|
237
256
|
function turnRoots() {
|
|
@@ -292,6 +311,8 @@ const questionCards = createQuestionCards({
|
|
|
292
311
|
scrollToEnd: () => { historyEl.scrollTop = historyEl.scrollHeight; },
|
|
293
312
|
removeHintIfEmpty: () => removeHintIfEmpty(),
|
|
294
313
|
askingText: () => turnDom.askingText(),
|
|
314
|
+
renderMarkdown,
|
|
315
|
+
linkRefs: linkRefsIn,
|
|
295
316
|
});
|
|
296
317
|
// The history's bubbles (bubbles.js): output, end lines, every client's
|
|
297
318
|
// prompts, the recap, warnings and notices, command and note bubbles.
|
|
@@ -313,8 +334,6 @@ const {
|
|
|
313
334
|
// The plugin cards and a snapshot's notices in the history (cards_place.js).
|
|
314
335
|
const cards = createCardsPlace({
|
|
315
336
|
historyEl,
|
|
316
|
-
appendToHistory: (el) => appendToHistory(el),
|
|
317
|
-
isNearBottom: () => isNearBottom(),
|
|
318
337
|
turnDom,
|
|
319
338
|
findInTurnRoots: (selector) => findInTurnRoots(selector),
|
|
320
339
|
selected: () => selected,
|
|
@@ -1063,7 +1082,7 @@ async function restartWorker() {
|
|
|
1063
1082
|
const result = await restartSession(id);
|
|
1064
1083
|
showToast(result.version ? `Restarted on chi ${result.version}` : "Restarting: the new worker is starting");
|
|
1065
1084
|
} catch (e) {
|
|
1066
|
-
showToast(
|
|
1085
|
+
showToast(cantText("restart", e), null, { sticky: true });
|
|
1067
1086
|
} finally {
|
|
1068
1087
|
restartingIds.delete(id);
|
|
1069
1088
|
updateInfoBar();
|
|
@@ -1108,12 +1127,12 @@ function updateInfoBar() {
|
|
|
1108
1127
|
|
|
1109
1128
|
// The open session's title, once on screen (session_title.js): its strip
|
|
1110
1129
|
// card says it when the strip is shown and has one; else the top bar. The
|
|
1111
|
-
// browser tab names it too. It follows the card's: a chain link
|
|
1112
|
-
//
|
|
1130
|
+
// browser tab names it too. It follows the card's (openTitle): a chain link
|
|
1131
|
+
// reads its card's title (cardTitle), whatever opener it was sent.
|
|
1113
1132
|
function updateSessionTitle() {
|
|
1114
1133
|
const shown = selected ? lists.sessionsById().get(selected) : null;
|
|
1115
1134
|
const first = current.firstPreview || "";
|
|
1116
|
-
const raw = !selected ? "" : (shown
|
|
1135
|
+
const raw = !selected ? "" : openTitle({ shown, first, cardTitle: (s) => lists.cardTitle(s) });
|
|
1117
1136
|
const title = raw === "\u2014" ? "" : raw; // "—": no first message yet
|
|
1118
1137
|
const text = topBarTitle({
|
|
1119
1138
|
title,
|
|
@@ -1482,6 +1501,8 @@ const composerCommands = createComposerCommands({
|
|
|
1482
1501
|
followWokenWorker: () => followWokenWorker(),
|
|
1483
1502
|
refreshHistory: () => refreshHistory(),
|
|
1484
1503
|
failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
|
|
1504
|
+
// As handleSendTurn says a turn's failure after a switch.
|
|
1505
|
+
failedElsewhere: (line, text) => alert(`${line}: ${text}`),
|
|
1485
1506
|
ownedByTui: () => {
|
|
1486
1507
|
current.owner = "tui";
|
|
1487
1508
|
updateComposerMode();
|
|
@@ -1742,7 +1763,6 @@ const centerEl = $("#center");
|
|
|
1742
1763
|
// A leaving stage (the hand-off's slide) no longer counts: the reserve eases
|
|
1743
1764
|
// down over the slide (index.html, #center.reserve-easing).
|
|
1744
1765
|
const stageEl = view === "stage" ? turnDom.root : null;
|
|
1745
|
-
const STAGE_GAP = 10;
|
|
1746
1766
|
function dockReserve() {
|
|
1747
1767
|
const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
|
|
1748
1768
|
return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
|
|
@@ -1760,7 +1780,7 @@ function updateReserve({ fromStage = false } = {}) {
|
|
|
1760
1780
|
const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
|
|
1761
1781
|
if (changed) centerEl.style.setProperty("--dock-h", reserve);
|
|
1762
1782
|
if (changed && follow) {
|
|
1763
|
-
if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce:
|
|
1783
|
+
if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: reducedMotion() });
|
|
1764
1784
|
else if (historyEl.dataset.following === undefined) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1765
1785
|
}
|
|
1766
1786
|
placeToast();
|
|
@@ -1864,12 +1884,11 @@ $("#infoStopBtn").addEventListener("click", async () => {
|
|
|
1864
1884
|
stoppingId = id;
|
|
1865
1885
|
try {
|
|
1866
1886
|
await stopSession(id);
|
|
1867
|
-
if (id === selected)
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
}
|
|
1887
|
+
if (id === selected) stoppedWorker(id);
|
|
1888
|
+
} catch (e) {
|
|
1889
|
+
// A refusal (a terminal holds it) or a worker that won't stop: said, as
|
|
1890
|
+
// a failed restart is.
|
|
1891
|
+
showToast(cantText(`stop ${id.slice(0, 8)}`, e), null, { sticky: true });
|
|
1873
1892
|
} finally {
|
|
1874
1893
|
stoppingId = null;
|
|
1875
1894
|
}
|
|
@@ -1889,7 +1908,8 @@ $("#infoArchiveBtn").addEventListener("click", () => {
|
|
|
1889
1908
|
// ── Continue (session chains) ───────────────────────────────────────────────
|
|
1890
1909
|
// Continue → starts the next link of the open session's chain (the server
|
|
1891
1910
|
// waits up to 30 s for its recap, archives it, starts the new one with the
|
|
1892
|
-
// recap as a note) and opens it with the opener in the composer, not sent
|
|
1911
|
+
// recap as a note) and opens it with the opener in the composer, not sent
|
|
1912
|
+
// (fillOpener: the previous link's own first message when it continues one).
|
|
1893
1913
|
// A link continued already opens that next link; its open delegates move
|
|
1894
1914
|
// to the new link (the toast counts them); a refusal for open delegates
|
|
1895
1915
|
// that can't move names them, each a link.
|
|
@@ -1915,7 +1935,7 @@ async function continueSelected() {
|
|
|
1915
1935
|
return;
|
|
1916
1936
|
}
|
|
1917
1937
|
await select(next);
|
|
1918
|
-
if (selected === next) fillOpener();
|
|
1938
|
+
if (selected === next) fillOpener(created);
|
|
1919
1939
|
showToast(continuedText(id, created), null, { sticky });
|
|
1920
1940
|
} catch (e) {
|
|
1921
1941
|
const refusal = continueRefusal(e, allSessions);
|
|
@@ -1933,10 +1953,12 @@ async function continueSelected() {
|
|
|
1933
1953
|
}
|
|
1934
1954
|
}
|
|
1935
1955
|
|
|
1936
|
-
// The new link's composer: the opener
|
|
1937
|
-
|
|
1956
|
+
// The new link's composer: the opener (openerText: the previous link's own,
|
|
1957
|
+
// else the constant), unless a draft is there (it stays). +created+: the
|
|
1958
|
+
// continue's 201.
|
|
1959
|
+
function fillOpener(created) {
|
|
1938
1960
|
if (promptEl.value.trim() || chips.length) return;
|
|
1939
|
-
promptEl.value =
|
|
1961
|
+
promptEl.value = openerText(created);
|
|
1940
1962
|
fitPrompt();
|
|
1941
1963
|
promptEl.focus();
|
|
1942
1964
|
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
@@ -1963,6 +1985,14 @@ function markArchived(ids, archived) {
|
|
|
1963
1985
|
// and a batch share. The ids in the answer flip in the list at once. When
|
|
1964
1986
|
// the open session's worker was stopped (it, or a delegate of the archived
|
|
1965
1987
|
// one), its stream ending is a stop, not a lost worker.
|
|
1988
|
+
// The open session's worker was stopped from here (the stop button, an
|
|
1989
|
+
// archive): don't wait for its stream to notice.
|
|
1990
|
+
function stoppedWorker(id) {
|
|
1991
|
+
streamControl?.close();
|
|
1992
|
+
current.status = "stopped";
|
|
1993
|
+
workerGone(id);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1966
1996
|
// @return {ok: true, result} | {ok: false, error}
|
|
1967
1997
|
async function archiveOne(id, archive) {
|
|
1968
1998
|
// A live worker in the tree is stopped by the archive: the open
|
|
@@ -1972,12 +2002,7 @@ async function archiveOne(id, archive) {
|
|
|
1972
2002
|
try {
|
|
1973
2003
|
const result = archive ? await archiveSession(id) : await unarchiveSession(id);
|
|
1974
2004
|
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
|
-
}
|
|
2005
|
+
if (mine && mine === selected && (result.stopped || []).includes(mine)) stoppedWorker(mine);
|
|
1981
2006
|
return { ok: true, result };
|
|
1982
2007
|
} catch (error) {
|
|
1983
2008
|
return { ok: false, error };
|
|
@@ -9,6 +9,7 @@ import { BADGES, elementOf, noteBubbleTemplate, stateBadgeTemplate, steerRowTemp
|
|
|
9
9
|
import { thumbsTemplate } from "./images.js";
|
|
10
10
|
import { appendAboveLiveTiming, STOPPED_LINE_CLASS } from "./timing.js";
|
|
11
11
|
import { keepFollowing } from "./scroll.js";
|
|
12
|
+
import { linkedLine } from "./notice_links.js";
|
|
12
13
|
import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
|
|
13
14
|
import { drawCommandBubble } from "./commands.js";
|
|
14
15
|
|
|
@@ -53,10 +54,11 @@ export function guardrailWarningTemplate(text, label = "guardrails") {
|
|
|
53
54
|
|
|
54
55
|
// A hook's line to the user (event[:notify]), in the warning colour for
|
|
55
56
|
// level warn; +cls+ adds a kind's class (llm_context_edited's). +bubble+:
|
|
56
|
-
// one of the history's own, else a row of a turn's step.
|
|
57
|
+
// one of the history's own, else a row of a turn's step. Its http(s) URLs
|
|
58
|
+
// are links (notice_links.js).
|
|
57
59
|
export function hookNoticeTemplate({ line, level = null, cls = null, title = null }, { bubble = true } = {}) {
|
|
58
60
|
const klass = (bubble ? "bubble hook-notice" : "hook-notice") + (level === "warn" ? " warn" : "") + (cls ? ` ${cls}` : "");
|
|
59
|
-
return html`<div class=${klass} title=${title || nothing}>${line}</div>`;
|
|
61
|
+
return html`<div class=${klass} title=${title || nothing}>${linkedLine(line)}</div>`;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
// A hook notice's line: its own (a snapshot's notice), else "<hook>: text".
|
|
@@ -285,7 +287,7 @@ export function createBubbles({
|
|
|
285
287
|
}
|
|
286
288
|
|
|
287
289
|
return {
|
|
288
|
-
removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
|
|
290
|
+
removeHintIfEmpty, placeFollowing, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
|
|
289
291
|
markRecapStale, showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
|
|
290
292
|
};
|
|
291
293
|
}
|
|
@@ -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;
|
|
@@ -13,9 +13,18 @@ import { archivedBadge, rowBadgesTemplate, rowDotTemplate } from "./families_vie
|
|
|
13
13
|
import { html, nothing } from "./vendor/lit-html.js";
|
|
14
14
|
|
|
15
15
|
// The opener a new link's composer is filled with (never sent: the user
|
|
16
|
-
// edits or sends it)
|
|
16
|
+
// edits or sends it) when the continue brings none of its own.
|
|
17
17
|
export const CONTINUE_OPENER = "Continue where we left off.";
|
|
18
18
|
|
|
19
|
+
// What a new link's composer is filled with: the continue's own opener
|
|
20
|
+
// (+created+, the 201: the previous link's first typed message, when that
|
|
21
|
+
// link continues one), else CONTINUE_OPENER. So whatever the user sent on
|
|
22
|
+
// the last link comes back on the next one.
|
|
23
|
+
export function openerText(created) {
|
|
24
|
+
const own = created?.opener;
|
|
25
|
+
return typeof own === "string" && own.trim() ? own : CONTINUE_OPENER;
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
// Whether +text+ (a title or a first message) is the opener and nothing
|
|
20
29
|
// else, trimmed: "Continue where we left off. Then fix X" is a title of its own.
|
|
21
30
|
export function isOpener(text) {
|
|
@@ -41,6 +50,17 @@ export function chainTitle(s, byId) {
|
|
|
41
50
|
return isOpener(at.first_preview || at.last_prompt) ? own : previewForCard(at);
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
// The open session's title (app.js updateSessionTitle: the top bar and the
|
|
54
|
+
// tab) from +first+, its first message: it follows its card. A chain link
|
|
55
|
+
// (+shown+, its summary, has continues) reads +cardTitle+(shown) whatever
|
|
56
|
+
// its first message: a web link inherits its chain's title, and its first
|
|
57
|
+
// message is an opener (the constant or the user's own). Else +first+,
|
|
58
|
+
// unless it is the opener. +shown+ null: not listed yet, +first+.
|
|
59
|
+
export function openTitle({ shown, first, cardTitle }) {
|
|
60
|
+
if (shown && (shown.continues || isOpener(first))) return cardTitle(shown);
|
|
61
|
+
return first;
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
const createdMs = (s) => {
|
|
45
65
|
const ms = Date.parse(s?.created_at || "");
|
|
46
66
|
return Number.isNaN(ms) ? 0 : ms;
|
|
@@ -206,7 +226,7 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
|
|
|
206
226
|
export function continueRefusal(error, sessions = []) {
|
|
207
227
|
const body = error?.body || {};
|
|
208
228
|
if (error?.code === "continued" && body.next_id) return { kind: "open", id: body.next_id };
|
|
209
|
-
const text = String(error?.message || "refused")
|
|
229
|
+
const text = String(error?.message || "refused");
|
|
210
230
|
if (error?.code === "open_children" && Array.isArray(body.ids) && body.ids.length) {
|
|
211
231
|
const byId = new Map(sessions.map((s) => [s.id, s]));
|
|
212
232
|
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.
|
|
@@ -116,6 +120,15 @@ export function sendCommand(id, line, { clientId, card = false, ...opts } = {})
|
|
|
116
120
|
});
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
// A text's markdown as sanitised HTML ({ html }; null with markdown off).
|
|
124
|
+
export function renderText(text, opts = {}) {
|
|
125
|
+
return api("/api/render", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
body: JSON.stringify({ text }),
|
|
128
|
+
...opts,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
119
132
|
export function cancelTurn(id, opts = {}) {
|
|
120
133
|
return api(`/api/sessions/${id}/cancel`, {
|
|
121
134
|
method: "POST",
|