samagotchi 0.31.0 → 0.33.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/README.md +13 -0
- data/bin/chi +3 -0
- data/docs/cli.md +1 -0
- data/docs/configuration.md +3 -1
- data/docs/context.md +220 -0
- data/docs/guardrails.md +12 -4
- data/docs/hooks.md +7 -3
- data/docs/plugins.md +79 -2
- data/docs/releasing.md +3 -2
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +1 -15
- data/lib/samagotchi/bootstrap_command.rb +4 -1
- data/lib/samagotchi/bridge.rb +8 -3
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
- data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
- data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +6 -0
- data/lib/samagotchi/child_reports.rb +31 -12
- data/lib/samagotchi/client_id.rb +52 -0
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/context_absorber.rb +180 -0
- data/lib/samagotchi/context_command.rb +473 -0
- data/lib/samagotchi/context_fetch.rb +195 -0
- data/lib/samagotchi/context_note.rb +11 -5
- data/lib/samagotchi/context_poller.rb +100 -0
- data/lib/samagotchi/context_providers.rb +159 -0
- data/lib/samagotchi/context_sources.rb +462 -0
- data/lib/samagotchi/engine.rb +19 -11
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -9
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
- data/lib/samagotchi/guardrails/verdict.rb +21 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +22 -1
- data/lib/samagotchi/memory_bundle/installer.rb +43 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
- data/lib/samagotchi/plugin/attached_context.rb +100 -0
- data/lib/samagotchi/plugin/context.rb +22 -0
- data/lib/samagotchi/plugin/sessions.rb +2 -1
- data/lib/samagotchi/process_group.rb +160 -0
- data/lib/samagotchi/prompt_warmup.rb +20 -1
- data/lib/samagotchi/question_desk.rb +2 -4
- data/lib/samagotchi/relay_verifier.rb +2 -4
- data/lib/samagotchi/reply_wait.rb +9 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +7 -5
- data/lib/samagotchi/session_commands.rb +18 -1
- data/lib/samagotchi/session_manager.rb +7 -6
- data/lib/samagotchi/session_retention.rb +3 -0
- data/lib/samagotchi/steer.rb +36 -19
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -7
- data/lib/samagotchi/terminal_ui/formatting.rb +28 -8
- data/lib/samagotchi/terminal_ui.rb +2 -4
- data/lib/samagotchi/tool_activity.rb +8 -0
- data/lib/samagotchi/tool_declarations.rb +21 -0
- data/lib/samagotchi/tools/builtin_calls.rb +1 -2
- data/lib/samagotchi/tools/builtins.rb +5 -0
- data/lib/samagotchi/tools/context_read.rb +163 -0
- data/lib/samagotchi/tools/delegate.rb +2 -2
- data/lib/samagotchi/tools/execute.rb +10 -81
- data/lib/samagotchi/tools/task_runtime.rb +25 -38
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +111 -2
- data/lib/samagotchi/web/public/app.js +57 -28
- data/lib/samagotchi/web/public/composer_size.js +7 -6
- data/lib/samagotchi/web/public/context_chips.js +285 -0
- data/lib/samagotchi/web/public/data.js +24 -0
- data/lib/samagotchi/web/public/index.html +49 -3
- data/lib/samagotchi/web/public/notify.js +5 -3
- data/lib/samagotchi/web/public/question_card.js +3 -4
- data/lib/samagotchi/web/public/shown_texts.js +13 -0
- data/lib/samagotchi/web/public/stable_html.js +18 -0
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/turn_events.js +16 -3
- data/lib/samagotchi/web/public/turn_view.js +5 -2
- data/lib/samagotchi/worker.rb +154 -6
- metadata +18 -1
|
@@ -15,7 +15,14 @@ import {
|
|
|
15
15
|
uploadImage,
|
|
16
16
|
listModels,
|
|
17
17
|
fetchHistory,
|
|
18
|
+
listContext,
|
|
19
|
+
getContext,
|
|
20
|
+
detachContext,
|
|
21
|
+
addContext,
|
|
18
22
|
} from "./data.js";
|
|
23
|
+
import { createContextChips } from "./context_chips.js";
|
|
24
|
+
import { renderStable } from "./stable_html.js";
|
|
25
|
+
import { createShownTexts } from "./shown_texts.js";
|
|
19
26
|
import { MODEL_KEY } from "./model_pick.js";
|
|
20
27
|
import { emptySessionState } from "./session_state.js";
|
|
21
28
|
import { createModelPicker } from "./model_picker.js";
|
|
@@ -32,7 +39,7 @@ import { routeChunk } from "./chunk_router.js";
|
|
|
32
39
|
import { extractCtxPct, savedCtxPct, cardCtxText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
|
|
33
40
|
import { createQuestionCards } from "./question_cards.js";
|
|
34
41
|
import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
35
|
-
import { gripFloor, promptHeight } from "./composer_size.js";
|
|
42
|
+
import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
|
|
36
43
|
import { createCommandList } from "./command_list.js";
|
|
37
44
|
import { createPromptHistory } from "./prompt_history.js";
|
|
38
45
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
@@ -57,6 +64,14 @@ const composerEl = $("#composer");
|
|
|
57
64
|
const infoBarEl = $("#infoBar");
|
|
58
65
|
const firstPeekEl = $("#firstPeek");
|
|
59
66
|
const infoTextEl = $("#infoText");
|
|
67
|
+
// The open session's attached context, in its own row of the session bar
|
|
68
|
+
// (updateInfoBar redraws #infoText every tick; the chips keep their popover).
|
|
69
|
+
const contextChips = createContextChips({
|
|
70
|
+
el: $("#contextChips"),
|
|
71
|
+
api: { list: listContext, show: getContext, detach: detachContext, add: addContext },
|
|
72
|
+
escapeHtml,
|
|
73
|
+
toast: (text) => showToast(text),
|
|
74
|
+
});
|
|
60
75
|
const markdownWarningEl = $("#markdownWarning");
|
|
61
76
|
const promptEl = $("#prompt");
|
|
62
77
|
const actionBtn = $("#actionBtn");
|
|
@@ -74,7 +89,8 @@ let eventsControl = null;
|
|
|
74
89
|
let resyncsInFlight = 0;
|
|
75
90
|
let resyncAgain = false;
|
|
76
91
|
let allSessions = [];
|
|
77
|
-
|
|
92
|
+
// The running turn's texts on the page (the turn output dedupe).
|
|
93
|
+
const shownTexts = createShownTexts();
|
|
78
94
|
// The page's view of a turn (web.view, or ?view=stage|turn): the running
|
|
79
95
|
// turn pinned above the composer (the stage, stage_view.js), or one block
|
|
80
96
|
// per turn with its steps in the history (turn_view.js).
|
|
@@ -85,7 +101,7 @@ const viewArgs = {
|
|
|
85
101
|
historyEl,
|
|
86
102
|
appendToHistory: (el) => appendToHistory(el),
|
|
87
103
|
isNearBottom: () => isNearBottom(),
|
|
88
|
-
sawText: (k) =>
|
|
104
|
+
sawText: (k) => shownTexts.saw(k),
|
|
89
105
|
sessionId: () => selected,
|
|
90
106
|
// The stop-task button on a running task_wait, for a worker that has
|
|
91
107
|
// the route (its sidecar features).
|
|
@@ -552,18 +568,24 @@ function setPlaceholder(long, short) {
|
|
|
552
568
|
promptEl.placeholder = narrowScreen.matches ? short : long;
|
|
553
569
|
}
|
|
554
570
|
|
|
555
|
-
// The key hint under the composer (shown while it has focus
|
|
571
|
+
// The key hint under the composer (shown while it has focus; on the start
|
|
572
|
+
// page only: in a session the placeholder carries it, the hint row is gone).
|
|
556
573
|
const SEND_HINT = "Enter to send · Shift+Enter for a newline · ↑ history";
|
|
557
574
|
|
|
575
|
+
let hadSession = false;
|
|
558
576
|
// While a turn runs, Send still sends: the prompt merges into the turn at
|
|
559
577
|
// its next step (steering). Cancel is its own button then.
|
|
560
578
|
function updateComposerMode() {
|
|
561
|
-
|
|
579
|
+
// In a session the box is one line: its minimum follows the class.
|
|
580
|
+
if (document.body.classList.toggle("has-session", !!selected) !== hadSession) {
|
|
581
|
+
hadSession = !hadSession;
|
|
582
|
+
fitPrompt();
|
|
583
|
+
}
|
|
562
584
|
if (selected) modelPicker.close();
|
|
563
585
|
const running = turnRunning && !!selected;
|
|
564
586
|
cancelBtn.style.display = running ? "" : "none";
|
|
565
587
|
if (running) {
|
|
566
|
-
setPlaceholder("Running… a message now steers this turn
|
|
588
|
+
setPlaceholder("Running… a message now steers this turn · Enter to send · Shift+Enter newline", "Steer the turn…");
|
|
567
589
|
composeHintEl.textContent = SEND_HINT;
|
|
568
590
|
actionBtn.textContent = "Send";
|
|
569
591
|
emptyEl.classList.add("hidden");
|
|
@@ -578,13 +600,13 @@ function updateComposerMode() {
|
|
|
578
600
|
historyEl.innerHTML = "";
|
|
579
601
|
updateInfoBar();
|
|
580
602
|
} else if (current.owner === "tui") {
|
|
581
|
-
setPlaceholder("A terminal chi owns this session
|
|
603
|
+
setPlaceholder("A terminal chi owns this session · run it with chi --shared to send from here", "A terminal chi owns this session");
|
|
582
604
|
composeHintEl.textContent = "";
|
|
583
605
|
actionBtn.textContent = "Send";
|
|
584
606
|
actionBtn.disabled = true;
|
|
585
607
|
emptyEl.classList.add("hidden");
|
|
586
608
|
} else {
|
|
587
|
-
setPlaceholder("Message chi…", "Message chi…");
|
|
609
|
+
setPlaceholder("Message chi… · Enter to send · Shift+Enter newline · ↑ history", "Message chi…");
|
|
588
610
|
composeHintEl.textContent = SEND_HINT;
|
|
589
611
|
actionBtn.textContent = "Send";
|
|
590
612
|
actionBtn.disabled = false;
|
|
@@ -653,6 +675,7 @@ function clearSelection() {
|
|
|
653
675
|
setSessionHash(null);
|
|
654
676
|
document.querySelectorAll(".card").forEach((el) => el.classList.remove("active"));
|
|
655
677
|
resetSessionState();
|
|
678
|
+
contextChips.clear();
|
|
656
679
|
clearTimingInterval();
|
|
657
680
|
liveTurnTimingEl = null;
|
|
658
681
|
liveTurnId = null;
|
|
@@ -699,6 +722,7 @@ async function select(id) {
|
|
|
699
722
|
resetTurnView();
|
|
700
723
|
updateFirstPeek();
|
|
701
724
|
updateInfoBar();
|
|
725
|
+
contextChips.load(id);
|
|
702
726
|
updateComposerMode();
|
|
703
727
|
await resync();
|
|
704
728
|
promptEl.focus();
|
|
@@ -929,11 +953,7 @@ function applyFinalMarkdown(messages) {
|
|
|
929
953
|
}
|
|
930
954
|
|
|
931
955
|
function seedSeenFromDom() {
|
|
932
|
-
|
|
933
|
-
findInTurnRoots(".bubble").forEach((b) => {
|
|
934
|
-
const k = normalize(b.textContent);
|
|
935
|
-
if (k) seenContent.add(k);
|
|
936
|
-
});
|
|
956
|
+
shownTexts.seed(findInTurnRoots(".bubble").map((b) => normalize(b.textContent)));
|
|
937
957
|
}
|
|
938
958
|
|
|
939
959
|
// Sticky-follow scroll: only auto-scroll to the bottom while the user is
|
|
@@ -953,7 +973,7 @@ function atHistoryEnd() {
|
|
|
953
973
|
function appendChunk(text) {
|
|
954
974
|
if (!text) return;
|
|
955
975
|
const k = normalize(text);
|
|
956
|
-
if (!k ||
|
|
976
|
+
if (!k || shownTexts.shows(k)) return;
|
|
957
977
|
const follow = isNearBottom();
|
|
958
978
|
const hint = historyEl.querySelector(".hint");
|
|
959
979
|
if (hint) hint.remove();
|
|
@@ -1443,7 +1463,7 @@ function updateInfoBar() {
|
|
|
1443
1463
|
}
|
|
1444
1464
|
if (!selected) {
|
|
1445
1465
|
// The start page hides the info bar (the storage path is on #/sessions).
|
|
1446
|
-
infoTextEl
|
|
1466
|
+
renderStable(infoTextEl, "");
|
|
1447
1467
|
infoTextEl.title = "";
|
|
1448
1468
|
if (stopBtn) stopBtn.style.display = "none";
|
|
1449
1469
|
return;
|
|
@@ -1467,9 +1487,11 @@ function updateInfoBar() {
|
|
|
1467
1487
|
const attachCmd = `chi --attach ${selected}`;
|
|
1468
1488
|
const copied = Date.now() - attachCopiedAt < 2000;
|
|
1469
1489
|
const copyHtml = `<button type="button" class="copy-attach" title="Copy "${escapeHtml(attachCmd)}" to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
|
|
1470
|
-
const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}">${escapeHtml(
|
|
1490
|
+
const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
|
|
1471
1491
|
const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
|
|
1472
|
-
|
|
1492
|
+
// The session clock ticks every second while a turn runs: only its text
|
|
1493
|
+
// changes, the bar's other nodes (a tooltip, a button mid-press) stay.
|
|
1494
|
+
renderStable(infoTextEl, metaHtml + previewHtml, { ".session-time": timingText });
|
|
1473
1495
|
infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
|
|
1474
1496
|
}
|
|
1475
1497
|
|
|
@@ -1738,6 +1760,7 @@ const streamHandlers = {
|
|
|
1738
1760
|
// for a queued prompt among its rows.
|
|
1739
1761
|
turnDom.handOff?.({ immediate: true });
|
|
1740
1762
|
markRecapStale();
|
|
1763
|
+
shownTexts.turnStarted();
|
|
1741
1764
|
const promptBubble = applyPromptOps(data);
|
|
1742
1765
|
setTurnRunning(true);
|
|
1743
1766
|
setLiveStatus("running");
|
|
@@ -1844,7 +1867,11 @@ const streamHandlers = {
|
|
|
1844
1867
|
// Due reminders went into the turn (a reminder turn has no prompt bubble).
|
|
1845
1868
|
reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
|
|
1846
1869
|
// A context note joined the conversation (between turns; no turn runs).
|
|
1847
|
-
context_added: (data) =>
|
|
1870
|
+
context_added: (data) => {
|
|
1871
|
+
addNoteBubble({ label: data.label, content: data.text || "" });
|
|
1872
|
+
// An attached context source's note: its chip changed.
|
|
1873
|
+
if (data.context_source) contextChips.refresh();
|
|
1874
|
+
},
|
|
1848
1875
|
continue_offered: (data) => questionCards.renderContinue(data),
|
|
1849
1876
|
continue_resolved: (data) => questionCards.resolveContinue(data),
|
|
1850
1877
|
tool_call_started: (data) => turnDom.toolStarted(data),
|
|
@@ -2046,12 +2073,9 @@ async function handleSendTurn() {
|
|
|
2046
2073
|
// for its ack is never taken back (4.13), so drop it here. The prompt
|
|
2047
2074
|
// is still in the composer, as the echo was removed.
|
|
2048
2075
|
dropEarlyRestores(earlyRestores);
|
|
2049
|
-
// The chips stay (uploaded ones keep their ref) for another try
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
return;
|
|
2053
|
-
}
|
|
2054
|
-
if (/owned_by_tui|409/.test(e.message)) {
|
|
2076
|
+
// The chips stay (uploaded ones keep their ref) for another try: an
|
|
2077
|
+
// image refused (bad_image, too_large, bad_images) says why below.
|
|
2078
|
+
if (e.code === "owned_by_tui") {
|
|
2055
2079
|
current.owner = "tui";
|
|
2056
2080
|
updateComposerMode();
|
|
2057
2081
|
return;
|
|
@@ -2082,7 +2106,7 @@ async function handleSendCommand(line) {
|
|
|
2082
2106
|
// The command woke the worker: follow its stream if none is open.
|
|
2083
2107
|
followWokenWorker();
|
|
2084
2108
|
} catch (e) {
|
|
2085
|
-
if (
|
|
2109
|
+
if (e.code === "owned_by_tui") {
|
|
2086
2110
|
current.owner = "tui";
|
|
2087
2111
|
updateComposerMode();
|
|
2088
2112
|
return;
|
|
@@ -2143,7 +2167,7 @@ async function handleCancel() {
|
|
|
2143
2167
|
await cancelTurn(selected);
|
|
2144
2168
|
} catch (e) {
|
|
2145
2169
|
// The turn ended meanwhile: nothing left to cancel.
|
|
2146
|
-
if (
|
|
2170
|
+
if (e.code !== "not_running") alert(e.message);
|
|
2147
2171
|
} finally {
|
|
2148
2172
|
cancelBtn.disabled = false;
|
|
2149
2173
|
}
|
|
@@ -2238,10 +2262,15 @@ const GRIP_KEY = "chi_composer_floor";
|
|
|
2238
2262
|
let promptFloor = null;
|
|
2239
2263
|
try { promptFloor = Number(localStorage.getItem(GRIP_KEY)) || null; } catch (_) {}
|
|
2240
2264
|
|
|
2265
|
+
// The empty box: one line in a session, three on the start page.
|
|
2266
|
+
function promptMinPx() {
|
|
2267
|
+
return document.body.classList.contains("has-session") ? PROMPT_LINE_PX : PROMPT_MIN_PX;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2241
2270
|
function fitPrompt() {
|
|
2242
2271
|
const borders = promptEl.offsetHeight - promptEl.clientHeight;
|
|
2243
2272
|
promptEl.style.height = "0px";
|
|
2244
|
-
const { height, scrolls } = promptHeight({ contentPx: promptEl.scrollHeight + borders, floorPx: promptFloor, viewportPx: window.innerHeight });
|
|
2273
|
+
const { height, scrolls } = promptHeight({ contentPx: promptEl.scrollHeight + borders, floorPx: promptFloor, viewportPx: window.innerHeight, minPx: promptMinPx() });
|
|
2245
2274
|
promptEl.style.height = `${height}px`;
|
|
2246
2275
|
promptEl.style.overflowY = scrolls ? "auto" : "hidden";
|
|
2247
2276
|
}
|
|
@@ -2254,7 +2283,7 @@ composeGrip.addEventListener("pointerdown", (e) => {
|
|
|
2254
2283
|
const startY = e.clientY;
|
|
2255
2284
|
const startH = promptEl.offsetHeight;
|
|
2256
2285
|
const move = (ev) => {
|
|
2257
|
-
promptFloor = gripFloor(startH + (startY - ev.clientY), window.innerHeight);
|
|
2286
|
+
promptFloor = gripFloor(startH + (startY - ev.clientY), window.innerHeight, promptMinPx());
|
|
2258
2287
|
fitPrompt();
|
|
2259
2288
|
};
|
|
2260
2289
|
const up = () => {
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
// The composer's height. It grows with its text up to a cap; the grip on
|
|
2
2
|
// its top edge sets a taller floor (the cap then is at least that floor).
|
|
3
|
-
export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field
|
|
3
|
+
export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field: the start page
|
|
4
|
+
export const PROMPT_LINE_PX = 26; // one line (15 px × 1.45 + 4 padding): in a session
|
|
4
5
|
export const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
|
|
5
6
|
export const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
|
|
6
7
|
|
|
7
8
|
// contentPx: the text's height (scrollHeight plus borders); floorPx: the
|
|
8
|
-
// grip's height, or null.
|
|
9
|
-
export function promptHeight({ contentPx, floorPx = null, viewportPx }) {
|
|
10
|
-
const floor = Math.max(
|
|
9
|
+
// grip's height, or null; minPx: the empty box's height.
|
|
10
|
+
export function promptHeight({ contentPx, floorPx = null, viewportPx, minPx = PROMPT_MIN_PX }) {
|
|
11
|
+
const floor = Math.max(minPx, floorPx || 0);
|
|
11
12
|
const cap = Math.max(floor, Math.round(viewportPx * PROMPT_CAP_SHARE));
|
|
12
13
|
return { height: Math.min(Math.max(contentPx, floor), cap), scrolls: contentPx > cap };
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
// A grip drag: the floor it asks for, kept between the minimum and the
|
|
16
17
|
// grip's share of the window.
|
|
17
|
-
export function gripFloor(px, viewportPx) {
|
|
18
|
-
return Math.round(Math.min(Math.max(px,
|
|
18
|
+
export function gripFloor(px, viewportPx, minPx = PROMPT_MIN_PX) {
|
|
19
|
+
return Math.round(Math.min(Math.max(px, minPx), viewportPx * PROMPT_GRIP_SHARE));
|
|
19
20
|
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// The session bar's attached context (chi context): a chip per source the
|
|
2
|
+
// open session sees (name · age; a dot for a change the agent hasn't read,
|
|
3
|
+
// red for a failing refresh), and a popover with its summary, hint link,
|
|
4
|
+
// text and detach. The chips refresh when a context note arrives in the
|
|
5
|
+
// stream and when the page gets focus (no event of their own), and their
|
|
6
|
+
// ages tick every minute. A "+ URL" chip (when an installed bundle's
|
|
7
|
+
// provider can attach one: GET …/context's can_add_url) opens a form that
|
|
8
|
+
// attaches a URL (a GitHub PR); the web never adds a command.
|
|
9
|
+
|
|
10
|
+
// "now", "3m", "2h", "4d" since an ISO time; "" for none.
|
|
11
|
+
export function ageText(iso, now = Date.now()) {
|
|
12
|
+
const at = Date.parse(iso || "");
|
|
13
|
+
if (Number.isNaN(at)) return "";
|
|
14
|
+
const s = Math.max(0, Math.round((now - at) / 1000));
|
|
15
|
+
if (s < 60) return "now";
|
|
16
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
17
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
18
|
+
return `${Math.floor(s / 86400)}d`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A hint the popover may link to: http(s) only.
|
|
22
|
+
export function hintUrl(hint) {
|
|
23
|
+
const text = String(hint || "").trim();
|
|
24
|
+
return /^https?:\/\/\S+$/i.test(text) ? text : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// GET …/context's rows as chips: the muted ones left out. state: "error"
|
|
28
|
+
// (the last refresh failed), "unread" (a text the agent hasn't read),
|
|
29
|
+
// "empty" (no text yet) or "ok".
|
|
30
|
+
export function chipModel(rows, now = Date.now()) {
|
|
31
|
+
return (rows || []).filter((row) => row && !row.muted).map((row) => {
|
|
32
|
+
const state = row.error ? "error" : !row.has_text ? "empty" : row.unread ? "unread" : "ok";
|
|
33
|
+
const age = ageText(row.fetched_at, now);
|
|
34
|
+
const why = { error: `last refresh failed: ${row.error}`, unread: "changed; the agent hasn't read it yet",
|
|
35
|
+
empty: "no text yet", ok: "the agent has read the latest" }[state];
|
|
36
|
+
const title = [row.summary, why].filter(Boolean).join(" · ");
|
|
37
|
+
return { name: row.name, label: age ? `${row.name} · ${age}` : row.name, state, title };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// addUrl: end with the "+ URL" chip.
|
|
42
|
+
export function chipsHtml(chips, escapeHtml, { addUrl = false } = {}) {
|
|
43
|
+
return chips.map((chip) => `<button type="button" class="ctx-chip ${chip.state}" data-name="${escapeHtml(chip.name)}" ` +
|
|
44
|
+
`title="${escapeHtml(chip.title)}" aria-haspopup="dialog">` +
|
|
45
|
+
`${chip.state === "unread" ? '<span class="ctx-dot" aria-hidden="true"></span>' : ""}${escapeHtml(chip.label)}</button>`).join("") +
|
|
46
|
+
(addUrl ? '<button type="button" class="ctx-chip ctx-add" data-act="add" title="Attach a URL (a GitHub PR) the agent can read and hears about when it changes" aria-haspopup="dialog">+ URL</button>' : "");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The "+ URL" popover: the URL, an optional why, Attach; +url+ and +error+
|
|
50
|
+
// after a try that failed.
|
|
51
|
+
export function addFormHtml(escapeHtml, { url = "", error = null } = {}) {
|
|
52
|
+
return `<form class="ctx-add-form"><div class="ctx-pop-head"><strong>Attach a URL</strong>` +
|
|
53
|
+
`<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
|
|
54
|
+
`<div class="ctx-pop-meta">A link an installed bundle knows (github-pr: a pull request). The agent gets a note when it changes.</div>` +
|
|
55
|
+
`<input class="ctx-add-input" name="url" type="url" inputmode="url" placeholder="https://github.com/owner/repo/pull/123" value="${escapeHtml(url)}" required autocomplete="off">` +
|
|
56
|
+
`<input class="ctx-add-input" name="why" type="text" placeholder="why (optional)" maxlength="300" autocomplete="off">` +
|
|
57
|
+
(error ? `<div class="ctx-pop-error" role="alert">${escapeHtml(error)}</div>` : "") +
|
|
58
|
+
`<div class="ctx-pop-actions"><button type="submit" class="ctx-add-submit">Attach</button></div></form>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The popover's inside for one row (GET …/context's).
|
|
62
|
+
export function popoverHtml(row, escapeHtml, now = Date.now()) {
|
|
63
|
+
const kind = row.kind === "cmd" ? `runs every ${row.every_seconds ? `${row.every_seconds}s` : "few minutes"}` : "pushed";
|
|
64
|
+
const url = hintUrl(row.hint);
|
|
65
|
+
const hint = url
|
|
66
|
+
? `<a class="ctx-pop-hint" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>`
|
|
67
|
+
: (row.hint ? `<div class="ctx-pop-hint">${escapeHtml(row.hint)}</div>` : "");
|
|
68
|
+
const age = ageText(row.fetched_at, now);
|
|
69
|
+
const meta = [age ? (age === "now" ? "fetched just now" : `fetched ${age} ago`) : "no text yet",
|
|
70
|
+
row.unread ? "the agent hasn't read this change" : (row.has_text ? "the agent has read it" : "")]
|
|
71
|
+
.filter(Boolean).join(" · ");
|
|
72
|
+
const detach = row.scope === "project" ? "mute here" : "detach";
|
|
73
|
+
const detachTitle = row.scope === "project"
|
|
74
|
+
? "The project's source: this session stops seeing it (chi context unmute brings it back)"
|
|
75
|
+
: "Remove it from this session";
|
|
76
|
+
return `<div class="ctx-pop-head"><strong>${escapeHtml(row.name)}</strong>` +
|
|
77
|
+
`<span class="ctx-pop-kind">${escapeHtml(row.scope)} · ${escapeHtml(kind)}</span>` +
|
|
78
|
+
`<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
|
|
79
|
+
(row.why ? `<div class="ctx-pop-why">${escapeHtml(row.why)}</div>` : "") +
|
|
80
|
+
hint +
|
|
81
|
+
(row.summary ? `<div class="ctx-pop-summary">${escapeHtml(row.summary)}</div>` : "") +
|
|
82
|
+
`<div class="ctx-pop-meta">${escapeHtml(meta)}</div>` +
|
|
83
|
+
(row.error ? `<div class="ctx-pop-error">last refresh failed: ${escapeHtml(row.error)}</div>` : "") +
|
|
84
|
+
`<pre class="ctx-pop-text" hidden></pre>` +
|
|
85
|
+
`<div class="ctx-pop-actions">` +
|
|
86
|
+
(row.has_text ? `<button type="button" data-act="view">view text</button>` : "") +
|
|
87
|
+
`<button type="button" class="ctx-pop-detach" data-act="detach" title="${escapeHtml(detachTitle)}">${detach}</button></div>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The chips for the session bar. el: #contextChips; api: {list(id),
|
|
91
|
+
// show(id, name), detach(id, name), add(id, {url, why})}; escapeHtml;
|
|
92
|
+
// toast(text).
|
|
93
|
+
export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc = document, win = window }) {
|
|
94
|
+
let sessionId = null;
|
|
95
|
+
let rows = [];
|
|
96
|
+
let canAdd = false;
|
|
97
|
+
let openName = null;
|
|
98
|
+
let loadSeq = 0;
|
|
99
|
+
const pop = doc.createElement("div");
|
|
100
|
+
pop.id = "contextPopover";
|
|
101
|
+
pop.className = "ctx-popover";
|
|
102
|
+
pop.setAttribute("role", "dialog");
|
|
103
|
+
pop.hidden = true;
|
|
104
|
+
doc.body.appendChild(pop);
|
|
105
|
+
|
|
106
|
+
// openName ADD: the "+ URL" form is open.
|
|
107
|
+
const ADD = "\u0000add";
|
|
108
|
+
|
|
109
|
+
function render() {
|
|
110
|
+
const chips = chipModel(rows);
|
|
111
|
+
el.innerHTML = chipsHtml(chips, escapeHtml, { addUrl: canAdd });
|
|
112
|
+
el.hidden = chips.length === 0 && !canAdd;
|
|
113
|
+
// The "+ URL" chip alone sits inline, not on a row of its own.
|
|
114
|
+
el.classList.toggle("only-add", chips.length === 0 && canAdd);
|
|
115
|
+
if (openName === ADD) {
|
|
116
|
+
if (canAdd) el.querySelector(".ctx-add")?.classList.add("open");
|
|
117
|
+
else close();
|
|
118
|
+
} else if (openName && !chips.some((chip) => chip.name === openName)) close();
|
|
119
|
+
else if (openName) el.querySelector(`.ctx-chip[data-name="${openName}"]`)?.classList.add("open");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function refresh() {
|
|
123
|
+
const id = sessionId;
|
|
124
|
+
if (!id) return;
|
|
125
|
+
const seq = ++loadSeq;
|
|
126
|
+
try {
|
|
127
|
+
const data = await api.list(id);
|
|
128
|
+
if (seq !== loadSeq || id !== sessionId) return;
|
|
129
|
+
rows = data.sources || [];
|
|
130
|
+
canAdd = data.can_add_url === true;
|
|
131
|
+
} catch {
|
|
132
|
+
// A session the server doesn't know (gone, or an older chi web): no chips.
|
|
133
|
+
if (seq !== loadSeq || id !== sessionId) return;
|
|
134
|
+
rows = [];
|
|
135
|
+
canAdd = false;
|
|
136
|
+
}
|
|
137
|
+
render();
|
|
138
|
+
if (openName && openName !== ADD) {
|
|
139
|
+
const row = rows.find((r) => r.name === openName);
|
|
140
|
+
if (row && pop.querySelector(".ctx-pop-text")?.hidden !== false) fill(row);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function load(id) {
|
|
145
|
+
sessionId = id;
|
|
146
|
+
rows = [];
|
|
147
|
+
canAdd = false;
|
|
148
|
+
close();
|
|
149
|
+
render();
|
|
150
|
+
return refresh();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function clear() {
|
|
154
|
+
sessionId = null;
|
|
155
|
+
rows = [];
|
|
156
|
+
canAdd = false;
|
|
157
|
+
close();
|
|
158
|
+
render();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function fill(row) {
|
|
162
|
+
pop.innerHTML = popoverHtml(row, escapeHtml);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Above the chip, inside the window with a 16px gutter.
|
|
166
|
+
function place(chip) {
|
|
167
|
+
const rect = chip.getBoundingClientRect();
|
|
168
|
+
const width = Math.min(380, win.innerWidth - 32);
|
|
169
|
+
pop.style.width = `${width}px`;
|
|
170
|
+
pop.style.left = `${Math.max(16, Math.min(rect.left, win.innerWidth - width - 16))}px`;
|
|
171
|
+
pop.style.bottom = `${Math.max(16, win.innerHeight - rect.top + 8)}px`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function open(name, chip) {
|
|
175
|
+
const row = rows.find((r) => r.name === name);
|
|
176
|
+
if (!row) return;
|
|
177
|
+
openName = name;
|
|
178
|
+
fill(row);
|
|
179
|
+
pop.hidden = false;
|
|
180
|
+
place(chip);
|
|
181
|
+
el.querySelectorAll(".ctx-chip").forEach((c) => c.classList.toggle("open", c.dataset.name === name));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function openAdd(chip, state = {}) {
|
|
185
|
+
openName = ADD;
|
|
186
|
+
pop.innerHTML = addFormHtml(escapeHtml, state);
|
|
187
|
+
pop.hidden = false;
|
|
188
|
+
place(chip);
|
|
189
|
+
el.querySelectorAll(".ctx-chip").forEach((c) => c.classList.toggle("open", c === chip));
|
|
190
|
+
pop.querySelector('input[name="url"]')?.focus();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function submitAdd(form) {
|
|
194
|
+
const id = sessionId;
|
|
195
|
+
const url = form.elements.url.value.trim();
|
|
196
|
+
const why = form.elements.why.value.trim();
|
|
197
|
+
if (!id || !url) return;
|
|
198
|
+
const button = form.querySelector(".ctx-add-submit");
|
|
199
|
+
if (button) button.disabled = true;
|
|
200
|
+
try {
|
|
201
|
+
const data = await api.add(id, { url, why: why || undefined });
|
|
202
|
+
if (id !== sessionId) return;
|
|
203
|
+
close();
|
|
204
|
+
await refresh();
|
|
205
|
+
toast(`Attached ${data.name}: the agent gets a note before your next message`);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
if (id !== sessionId || openName !== ADD) return;
|
|
208
|
+
const chip = el.querySelector(".ctx-add");
|
|
209
|
+
if (chip) openAdd(chip, { url, error: String(e.message).replace(/ \(\d+\)$/, "") });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function close() {
|
|
214
|
+
openName = null;
|
|
215
|
+
pop.hidden = true;
|
|
216
|
+
el.querySelectorAll(".ctx-chip.open").forEach((c) => c.classList.remove("open"));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function viewText() {
|
|
220
|
+
const name = openName;
|
|
221
|
+
const pre = pop.querySelector(".ctx-pop-text");
|
|
222
|
+
if (!name || !pre) return;
|
|
223
|
+
try {
|
|
224
|
+
const data = await api.show(sessionId, name);
|
|
225
|
+
if (name !== openName) return;
|
|
226
|
+
pre.textContent = data.text || "";
|
|
227
|
+
pre.hidden = false;
|
|
228
|
+
pop.querySelector('[data-act="view"]')?.remove();
|
|
229
|
+
} catch (e) {
|
|
230
|
+
toast(`Can't read ${name}: ${String(e.message).replace(/ \(\d+\)$/, "")}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function detach() {
|
|
235
|
+
const row = rows.find((r) => r.name === openName);
|
|
236
|
+
if (!row) return;
|
|
237
|
+
const ask = row.scope === "project"
|
|
238
|
+
? `Mute ${row.name} for this session? It is the project's: other sessions keep it.`
|
|
239
|
+
: `Detach ${row.name} from this session?`;
|
|
240
|
+
if (!win.confirm(ask)) return;
|
|
241
|
+
try {
|
|
242
|
+
await api.detach(sessionId, row.name);
|
|
243
|
+
close();
|
|
244
|
+
await refresh();
|
|
245
|
+
} catch (e) {
|
|
246
|
+
toast(`Can't detach ${row.name}: ${String(e.message).replace(/ \(\d+\)$/, "")}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
el.addEventListener("click", (e) => {
|
|
251
|
+
const chip = e.target.closest(".ctx-chip");
|
|
252
|
+
if (!chip) return;
|
|
253
|
+
e.stopPropagation();
|
|
254
|
+
if (chip.dataset.act === "add") {
|
|
255
|
+
if (openName === ADD) close();
|
|
256
|
+
else openAdd(chip);
|
|
257
|
+
} else if (openName === chip.dataset.name) close();
|
|
258
|
+
else open(chip.dataset.name, chip);
|
|
259
|
+
});
|
|
260
|
+
pop.addEventListener("submit", (e) => {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
submitAdd(e.target);
|
|
263
|
+
});
|
|
264
|
+
pop.addEventListener("click", (e) => {
|
|
265
|
+
const act = e.target.closest("[data-act]")?.dataset.act;
|
|
266
|
+
if (act === "close") close();
|
|
267
|
+
else if (act === "view") viewText();
|
|
268
|
+
else if (act === "detach") detach();
|
|
269
|
+
});
|
|
270
|
+
doc.addEventListener("click", (e) => {
|
|
271
|
+
if (openName && !pop.contains(e.target) && !el.contains(e.target)) close();
|
|
272
|
+
});
|
|
273
|
+
doc.addEventListener("keydown", (e) => {
|
|
274
|
+
if (e.key === "Escape" && openName) close();
|
|
275
|
+
});
|
|
276
|
+
win.addEventListener("resize", () => {
|
|
277
|
+
const chip = openName && el.querySelector(".ctx-chip.open");
|
|
278
|
+
if (chip) place(chip);
|
|
279
|
+
});
|
|
280
|
+
win.addEventListener("focus", () => refresh());
|
|
281
|
+
// The ages move on by themselves.
|
|
282
|
+
win.setInterval(() => { if (rows.length) render(); }, 60_000);
|
|
283
|
+
|
|
284
|
+
return { load, clear, refresh, close, get rows() { return rows; } };
|
|
285
|
+
}
|
|
@@ -8,6 +8,9 @@ export function api(path, opts = {}) {
|
|
|
8
8
|
if (!r.ok) {
|
|
9
9
|
const error = new Error(`${j.detail || j.error || r.statusText} (${r.status})`);
|
|
10
10
|
error.status = r.status;
|
|
11
|
+
// The server's error code ({error: "owned_by_tui", detail}): what
|
|
12
|
+
// the page matches on, never the message's words.
|
|
13
|
+
error.code = j.error || null;
|
|
11
14
|
throw error;
|
|
12
15
|
}
|
|
13
16
|
return j;
|
|
@@ -251,3 +254,24 @@ export function openEvents(handlers = {}, opts = {}) {
|
|
|
251
254
|
},
|
|
252
255
|
};
|
|
253
256
|
}
|
|
257
|
+
|
|
258
|
+
// A session's attached context (chi context): the sources it sees, one
|
|
259
|
+
// with its text, and detach (a session's) or mute here (a project's).
|
|
260
|
+
export function listContext(id, opts = {}) {
|
|
261
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/context`, opts);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function getContext(id, name, opts = {}) {
|
|
265
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/context/${encodeURIComponent(name)}`, opts);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// A URL an installed bundle's provider attaches: {url, why} → {name}.
|
|
269
|
+
export function addContext(id, body, opts = {}) {
|
|
270
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/context`, {
|
|
271
|
+
method: "POST", body: JSON.stringify(body), ...opts,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function detachContext(id, name, opts = {}) {
|
|
276
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/context/${encodeURIComponent(name)}`, { method: "DELETE", ...opts });
|
|
277
|
+
}
|