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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +13 -0
  4. data/bin/chi +3 -0
  5. data/docs/cli.md +1 -0
  6. data/docs/configuration.md +3 -1
  7. data/docs/context.md +220 -0
  8. data/docs/guardrails.md +12 -4
  9. data/docs/hooks.md +7 -3
  10. data/docs/plugins.md +79 -2
  11. data/docs/releasing.md +3 -2
  12. data/lib/samagotchi/answer_command.rb +2 -1
  13. data/lib/samagotchi/archive_store.rb +1 -15
  14. data/lib/samagotchi/bootstrap_command.rb +4 -1
  15. data/lib/samagotchi/bridge.rb +8 -3
  16. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  17. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  18. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  19. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  20. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  21. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/self_map.md +6 -0
  26. data/lib/samagotchi/child_reports.rb +31 -12
  27. data/lib/samagotchi/client_id.rb +52 -0
  28. data/lib/samagotchi/config.rb +6 -0
  29. data/lib/samagotchi/context_absorber.rb +180 -0
  30. data/lib/samagotchi/context_command.rb +473 -0
  31. data/lib/samagotchi/context_fetch.rb +195 -0
  32. data/lib/samagotchi/context_note.rb +11 -5
  33. data/lib/samagotchi/context_poller.rb +100 -0
  34. data/lib/samagotchi/context_providers.rb +159 -0
  35. data/lib/samagotchi/context_sources.rb +462 -0
  36. data/lib/samagotchi/engine.rb +19 -11
  37. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  38. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -9
  39. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  40. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  41. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  42. data/lib/samagotchi/log_line.rb +1 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -1
  44. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  45. data/lib/samagotchi/memory_bundle/manifest.rb +42 -4
  46. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  47. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  48. data/lib/samagotchi/plugin/context.rb +22 -0
  49. data/lib/samagotchi/plugin/sessions.rb +2 -1
  50. data/lib/samagotchi/process_group.rb +160 -0
  51. data/lib/samagotchi/prompt_warmup.rb +20 -1
  52. data/lib/samagotchi/question_desk.rb +2 -4
  53. data/lib/samagotchi/relay_verifier.rb +2 -4
  54. data/lib/samagotchi/reply_wait.rb +9 -4
  55. data/lib/samagotchi/send_command.rb +2 -1
  56. data/lib/samagotchi/session.rb +7 -5
  57. data/lib/samagotchi/session_commands.rb +18 -1
  58. data/lib/samagotchi/session_manager.rb +7 -6
  59. data/lib/samagotchi/session_retention.rb +3 -0
  60. data/lib/samagotchi/steer.rb +36 -19
  61. data/lib/samagotchi/system_prompt.rb +1 -1
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -7
  63. data/lib/samagotchi/terminal_ui/formatting.rb +28 -8
  64. data/lib/samagotchi/terminal_ui.rb +2 -4
  65. data/lib/samagotchi/tool_activity.rb +8 -0
  66. data/lib/samagotchi/tool_declarations.rb +21 -0
  67. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  68. data/lib/samagotchi/tools/builtins.rb +5 -0
  69. data/lib/samagotchi/tools/context_read.rb +163 -0
  70. data/lib/samagotchi/tools/delegate.rb +2 -2
  71. data/lib/samagotchi/tools/execute.rb +10 -81
  72. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/web/app.rb +111 -2
  75. data/lib/samagotchi/web/public/app.js +57 -28
  76. data/lib/samagotchi/web/public/composer_size.js +7 -6
  77. data/lib/samagotchi/web/public/context_chips.js +285 -0
  78. data/lib/samagotchi/web/public/data.js +24 -0
  79. data/lib/samagotchi/web/public/index.html +49 -3
  80. data/lib/samagotchi/web/public/notify.js +5 -3
  81. data/lib/samagotchi/web/public/question_card.js +3 -4
  82. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  83. data/lib/samagotchi/web/public/stable_html.js +18 -0
  84. data/lib/samagotchi/web/public/timing.js +7 -3
  85. data/lib/samagotchi/web/public/turn_events.js +16 -3
  86. data/lib/samagotchi/web/public/turn_view.js +5 -2
  87. data/lib/samagotchi/worker.rb +154 -6
  88. 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
- let seenContent = new Set();
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) => seenContent.add(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
- document.body.classList.toggle("has-session", !!selected);
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 (Enter to send)", "Running… a message steers it");
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 and doesn't share it — run it with chi --shared to send from here", "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
- seenContent = new Set();
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 || seenContent.has(k)) return;
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.innerHTML = "";
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 &quot;${escapeHtml(attachCmd)}&quot; 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((current.dir||"").slice(0,48))}</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">${escapeHtml(timingText)}</span>` : ""}${workerBadgeHtml()}</div>`;
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
- infoTextEl.innerHTML = metaHtml + previewHtml;
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) => addNoteBubble({ label: data.label, content: data.text || "" }),
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
- if (/bad_image|too_large/.test(e.message)) {
2051
- alert(e.message);
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 (/owned_by_tui|409/.test(e.message)) {
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 (!/not_running|409/.test(e.message)) alert(e.message);
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(PROMPT_MIN_PX, floorPx || 0);
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, PROMPT_MIN_PX), viewportPx * PROMPT_GRIP_SHARE));
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
+ }