samagotchi 0.64.0 → 0.66.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +20 -3
  6. data/docs/cli.md +36 -2
  7. data/docs/configuration.md +177 -8
  8. data/docs/context.md +4 -1
  9. data/docs/internals/engine.md +19 -4
  10. data/docs/internals/prompt-caching.md +10 -2
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +163 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +13 -2
  15. data/docs/sub-agent.md +3 -1
  16. data/lib/samagotchi/bridge.rb +11 -1
  17. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
  21. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
  22. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  23. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
  24. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  25. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  28. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  31. data/lib/samagotchi/cache_confirm.rb +9 -6
  32. data/lib/samagotchi/client.rb +4 -2
  33. data/lib/samagotchi/commands/registry.rb +25 -8
  34. data/lib/samagotchi/config.rb +13 -5
  35. data/lib/samagotchi/engine.rb +151 -11
  36. data/lib/samagotchi/generation_phase.rb +17 -0
  37. data/lib/samagotchi/host_config.rb +27 -6
  38. data/lib/samagotchi/host_registry.rb +34 -11
  39. data/lib/samagotchi/idle_client.rb +62 -25
  40. data/lib/samagotchi/idle_recap.rb +5 -2
  41. data/lib/samagotchi/idle_target.rb +30 -7
  42. data/lib/samagotchi/kernel_loop.rb +9 -1
  43. data/lib/samagotchi/llm/adapters.rb +41 -0
  44. data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
  45. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  46. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  47. data/lib/samagotchi/llm/api_key.rb +26 -9
  48. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  49. data/lib/samagotchi/llm/dialect.rb +57 -0
  50. data/lib/samagotchi/llm/errors.rb +81 -12
  51. data/lib/samagotchi/llm/model_listing.rb +111 -0
  52. data/lib/samagotchi/llm/model_result.rb +9 -2
  53. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  54. data/lib/samagotchi/model_profile.rb +1 -1
  55. data/lib/samagotchi/plugin/api.rb +80 -7
  56. data/lib/samagotchi/plugin/context.rb +22 -1
  57. data/lib/samagotchi/plugin/loader.rb +3 -2
  58. data/lib/samagotchi/reasoning_history.rb +14 -4
  59. data/lib/samagotchi/recap_setup.rb +2 -1
  60. data/lib/samagotchi/self_report.rb +18 -28
  61. data/lib/samagotchi/send_command.rb +23 -7
  62. data/lib/samagotchi/session.rb +21 -3
  63. data/lib/samagotchi/session_commands.rb +16 -1
  64. data/lib/samagotchi/session_manager.rb +4 -1
  65. data/lib/samagotchi/session_metrics.rb +6 -11
  66. data/lib/samagotchi/session_setup.rb +14 -7
  67. data/lib/samagotchi/steer_cut.rb +4 -0
  68. data/lib/samagotchi/system_prompt.rb +16 -1
  69. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  70. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
  71. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  72. data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
  73. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  74. data/lib/samagotchi/terminal_ui.rb +18 -2
  75. data/lib/samagotchi/tool_runner.rb +13 -1
  76. data/lib/samagotchi/toolkit.rb +246 -0
  77. data/lib/samagotchi/toolkit_command.rb +117 -0
  78. data/lib/samagotchi/tools/registry.rb +43 -6
  79. data/lib/samagotchi/turn_note.rb +22 -5
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +14 -1
  82. data/lib/samagotchi/web/public/activity.js +3 -0
  83. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  84. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  85. data/lib/samagotchi/web/public/app.js +111 -20
  86. data/lib/samagotchi/web/public/commands.js +4 -2
  87. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  88. data/lib/samagotchi/web/public/index.html +38 -0
  89. data/lib/samagotchi/web/public/info_bar.js +5 -3
  90. data/lib/samagotchi/web/public/live_turn.js +8 -0
  91. data/lib/samagotchi/web/public/session_state.js +3 -0
  92. data/lib/samagotchi/web/public/stage_model.js +27 -7
  93. data/lib/samagotchi/web/public/stage_view.js +32 -10
  94. data/lib/samagotchi/web/public/stream_handlers.js +21 -2
  95. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  96. data/lib/samagotchi/web/public/turn_events.js +22 -8
  97. data/lib/samagotchi/web/public/turn_html.js +20 -4
  98. data/lib/samagotchi/web/public/turn_model.js +16 -9
  99. data/lib/samagotchi/web/public/turn_view.js +6 -4
  100. data/lib/samagotchi/worker.rb +2 -0
  101. metadata +17 -1
@@ -7,10 +7,15 @@
7
7
  // bubble rewrites its text every frame, which would collapse it by the time
8
8
  // a button is clicked.
9
9
  //
10
+ // A bundle's pills (chi.annotation) come after the presets: each prepares
11
+ // its command line with the selection (annotate_actions.js), at most 3
12
+ // inline, the rest behind "⋯".
13
+ //
10
14
  // The quoting rules are annotations.js'; this is the bar.
11
15
 
12
16
  import { annotateBarLeft, annotationSource, quoteBlock } from "./annotations.js";
13
17
  import { presetNote } from "./annotate_presets.js";
18
+ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline } from "./annotate_actions.js";
14
19
 
15
20
  // @param presets the preset texts (annotate_presets.js parsePresets)
16
21
  // @param canAnnotate () => whether the open session takes annotations (one
@@ -21,11 +26,18 @@ import { presetNote } from "./annotate_presets.js";
21
26
  // until its step closes)
22
27
  // @param onQuote (text) runs with the quote (and a preset's note) to append
23
28
  // to the composer; the selection is cleared and the bar hidden first
29
+ // @param actions () => the open session's annotation actions
30
+ // (annotate_actions.js annotationActions), read on every selection
31
+ // @param running () => whether a turn runs (a send pill may wait for it)
32
+ // @param onAction (action, fields) runs with a clicked pill's action and the
33
+ // selection's {quote, text, source}; the selection is cleared and the bar
34
+ // hidden first
24
35
  // @param doc, win the page's document and window
25
36
  // @return {schedule(), hide(), el}; schedule() re-reads the selection on the
26
37
  // next frame (call it on scroll)
27
38
  export function createAnnotateBar({
28
- presets, canAnnotate, roots, isLive, onQuote, doc = globalThis.document, win = globalThis.window,
39
+ presets, canAnnotate, roots, isLive, onQuote, actions = () => [], running = () => false, onAction = () => {},
40
+ doc = globalThis.document, win = globalThis.window,
29
41
  }) {
30
42
  const bar = doc.createElement("div");
31
43
  bar.id = "annotateBar";
@@ -46,11 +58,63 @@ export function createAnnotateBar({
46
58
  btn.title = text;
47
59
  bar.appendChild(btn);
48
60
  });
61
+ // The plugin pills: a separator, the inline ones, then ⋯ and the list
62
+ // it opens. Redrawn only when the session's actions change.
63
+ const group = doc.createElement("span");
64
+ group.className = "annotate-actions";
65
+ group.hidden = true;
66
+ bar.appendChild(group);
49
67
  doc.body.appendChild(bar);
50
68
  let pendingQuote = null;
69
+ let pendingFields = null;
70
+ let drawn = [];
71
+ let moreList = null;
72
+
73
+ function drawActions(list) {
74
+ drawn = list;
75
+ moreList = null;
76
+ group.hidden = !list.length;
77
+ if (!list.length) return group.replaceChildren();
78
+ const sep = doc.createElement("span");
79
+ sep.className = "annotate-sep";
80
+ sep.setAttribute("aria-hidden", "true");
81
+ const pill = (action, i) => {
82
+ const btn = button(action.label, "annotate-action");
83
+ btn.dataset.action = String(i);
84
+ if (action.send) btn.dataset.sends = "";
85
+ return btn;
86
+ };
87
+ const { inline, more } = splitInline(list);
88
+ const nodes = [sep, ...inline.map(pill)];
89
+ if (more.length) {
90
+ const toggle = button("⋯", "annotate-more");
91
+ toggle.title = "More actions";
92
+ toggle.setAttribute("aria-label", "More actions");
93
+ moreList = doc.createElement("div");
94
+ moreList.className = "annotate-more-list";
95
+ moreList.hidden = true;
96
+ more.forEach((action, j) => moreList.appendChild(pill(action, inline.length + j)));
97
+ nodes.push(toggle, moreList);
98
+ }
99
+ group.replaceChildren(...nodes);
100
+ }
101
+
102
+ // Each pill's state for whether a turn runs (a send pill may wait).
103
+ function markActions() {
104
+ const now = running();
105
+ const all = [...group.children, ...(moreList ? moreList.children : [])];
106
+ for (const btn of all) {
107
+ if (btn.dataset?.action === undefined) continue;
108
+ const action = drawn[Number(btn.dataset.action)];
109
+ btn.disabled = !actionEnabled(action, now);
110
+ btn.title = actionTitle(action, now);
111
+ }
112
+ }
51
113
 
52
114
  function hide() {
53
115
  pendingQuote = null;
116
+ pendingFields = null;
117
+ if (moreList) moreList.hidden = true;
54
118
  bar.hidden = true;
55
119
  }
56
120
 
@@ -67,9 +131,14 @@ export function createAnnotateBar({
67
131
  // Clip to where the selection starts: one tool row, one bubble.
68
132
  const clipped = range.cloneRange();
69
133
  if (!source.root.contains(range.endContainer)) clipped.setEnd(source.root, source.root.childNodes.length);
70
- const block = quoteBlock(clipped.toString(), source);
134
+ const raw = clipped.toString();
135
+ const block = quoteBlock(raw, source);
71
136
  if (!block) return hide();
72
137
  pendingQuote = block;
138
+ pendingFields = selectionFields(raw, source);
139
+ const list = actions() || [];
140
+ if (!sameActions(list, drawn)) drawActions(list);
141
+ markActions();
73
142
  const rects = clipped.getClientRects();
74
143
  const rect = rects.length ? rects[rects.length - 1] : clipped.getBoundingClientRect();
75
144
  bar.hidden = false;
@@ -109,6 +178,19 @@ export function createAnnotateBar({
109
178
  bar.addEventListener("click", (e) => {
110
179
  const btn = e.target.closest("button");
111
180
  if (!btn || !pendingQuote) return;
181
+ if (btn.className === "annotate-more") {
182
+ moreList.hidden = !moreList.hidden;
183
+ return;
184
+ }
185
+ if (btn.dataset.action !== undefined) {
186
+ const action = drawn[Number(btn.dataset.action)];
187
+ if (!action || btn.disabled) return;
188
+ const fields = pendingFields;
189
+ win.getSelection()?.removeAllRanges();
190
+ hide();
191
+ onAction(action, fields);
192
+ return;
193
+ }
112
194
  const preset = btn.dataset.index === undefined ? null : presets[Number(btn.dataset.index)];
113
195
  const text = preset ? presetNote(pendingQuote, preset) : pendingQuote;
114
196
  win.getSelection()?.removeAllRanges();
@@ -46,6 +46,7 @@ import { createTaskStopper } from "./task_stop.js";
46
46
  import { appendQuote } from "./annotations.js";
47
47
  import { parsePresets } from "./annotate_presets.js";
48
48
  import { createAnnotateBar } from "./annotate_bar.js";
49
+ import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
49
50
  import { copyText, installCopy } from "./copy.js";
50
51
  import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
51
52
  import { createQuestionCards } from "./question_cards.js";
@@ -76,6 +77,10 @@ import { createComposerCommands, routeComposerLine, routeSessionLine } from "./c
76
77
  import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
77
78
  import { createStreamHandlers } from "./stream_handlers.js";
78
79
  import { createListsView } from "./lists_view.js";
80
+ import { createDeliveryControl, QUEUE_NEEDS_WORKER } from "./delivery_line.js";
81
+ import { liveSlots } from "./stage_model.js";
82
+ import { currentGen } from "./turn_model.js";
83
+ import { toolName } from "./activity.js";
79
84
 
80
85
  const $ = (s) => document.querySelector(s);
81
86
  const topStripEl = $("#topStrip");
@@ -251,6 +256,28 @@ const liveTurn = createLiveTurn({
251
256
  onTick: () => updateInfoBar(),
252
257
  });
253
258
 
259
+ // The composer's delivery line while the open session's turn runs
260
+ // (delivery_line.js): where a message typed now goes in, and the cut and
261
+ // queue links.
262
+ const deliveryControl = createDeliveryControl({
263
+ el: $("#deliveryLine"),
264
+ read: () => {
265
+ const turn = turnDom.current?.().turn || null;
266
+ const running = turn ? turn.gens.flatMap((g) => g.tools).reverse().find((r) => r.status === "running") : null;
267
+ return {
268
+ phase: turnDom.phase?.() || liveSlots(turn).phase,
269
+ gen: turn ? currentGen(turn) : null,
270
+ tool: running ? { name: toolName(running), startedAt: running.startedAt ?? null } : null,
271
+ cutAfter: liveTurn.cutAfter,
272
+ serverGen: liveTurn.serverGen,
273
+ canQueue: current.workerFeatures.includes("delivery"),
274
+ model: current.model,
275
+ };
276
+ },
277
+ composerEmpty: () => !promptEl.value.trim() && !chips.length,
278
+ send: (delivery) => handleSendTurn({ delivery }),
279
+ });
280
+
254
281
  // This tab's id in the session's events: tells its own prompts from the
255
282
  // ones other clients (an attached TUI, another tab, reminders) sent.
256
283
  const clientId = newClientId();
@@ -552,8 +579,9 @@ function updateComposerMode() {
552
579
  }
553
580
  const running = liveTurn.running && !!selected;
554
581
  cancelBtn.style.display = running ? "" : "none";
582
+ syncDeliveryLine();
555
583
  if (running) {
556
- setPlaceholder("Running… a message now steers this turn · Enter to send · Shift+Enter newline", "Steer the turn…");
584
+ setPlaceholder("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…");
557
585
  composeHintEl.textContent = SEND_HINT;
558
586
  actionBtn.textContent = "Send";
559
587
  emptyEl.classList.add("hidden");
@@ -574,7 +602,8 @@ function updateComposerMode() {
574
602
  actionBtn.disabled = true;
575
603
  emptyEl.classList.add("hidden");
576
604
  } else {
577
- setPlaceholder("Message chi… · Enter to send · Shift+Enter newline · ↑ history", "Message chi…");
605
+ // The keys are in the delivery line's row above.
606
+ setPlaceholder("Message chi…", "Message chi…");
578
607
  composeHintEl.textContent = SEND_HINT;
579
608
  actionBtn.textContent = "Send";
580
609
  actionBtn.disabled = false;
@@ -582,6 +611,14 @@ function updateComposerMode() {
582
611
  }
583
612
  }
584
613
 
614
+ // The delivery line's row in a session (delivery_line.js): the key hint
615
+ // while idle, where a message goes in while the turn runs.
616
+ function syncDeliveryLine() {
617
+ // A terminal owns the session: the composer is off, so is its row.
618
+ if (!selected || current.owner === "tui") deliveryControl.show("off");
619
+ else deliveryControl.show(liveTurn.running && current.owner !== "tui" ? "running" : "idle");
620
+ }
621
+
585
622
  function setTurnRunning(running) {
586
623
  liveTurn.running = !!running;
587
624
  updateComposerMode();
@@ -790,6 +827,9 @@ function renderSessionView(data, { kept = [] } = {}) {
790
827
  });
791
828
  // Marked replayed: a call's duration isn't timed from the replay's clock.
792
829
  replay.forEach((event) => streamHandlers[event.type]?.({ ...event, replayed: true }));
830
+ // A join mid-generation: the worker's word on it clocks the delivery line.
831
+ const gen = data.current_turn?.generation;
832
+ liveTurn.serverGen = gen ? { ...gen, at: Date.now() } : null;
793
833
  if (!data.current_turn) {
794
834
  setTurnRunning(current.status === "running");
795
835
  if (liveTurn.running && current.timing.activeTurn?.started_at) liveTurn.timing.start(current.timing.activeTurn.started_at, activeTurnId);
@@ -997,11 +1037,13 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
997
1037
  // prompts are echoed at send (data-own) and tagged by their turn_enqueued.
998
1038
 
999
1039
  // The bubble a reload draws (turn_html.js), with this page's own fields.
1000
- function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null } = {}) {
1040
+ // +delivery+: how it went into a running turn (next_step, cut, queue): its
1041
+ // queued badge says what it waits for.
1042
+ function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null } = {}) {
1001
1043
  const hint = historyEl.querySelector(".hint");
1002
1044
  if (hint) hint.remove();
1003
1045
  const div = elementOf(userBubbleTemplate({ content: prompt }, {
1004
- label, state, own, enqueuedId, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
1046
+ label, state, own, enqueuedId, delivery, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
1005
1047
  }));
1006
1048
  const follow = isNearBottom();
1007
1049
  appendToHistory(div);
@@ -1018,7 +1060,17 @@ function setBubbleState(bubble, state) {
1018
1060
  }
1019
1061
  bubble.classList.add(state);
1020
1062
  bubble.dataset.userState = state;
1021
- bubble.append(elementOf(stateBadgeTemplate(state)));
1063
+ bubble.append(elementOf(stateBadgeTemplate(state, bubble.dataset.delivery || null)));
1064
+ }
1065
+
1066
+ // A bubble's delivery changed (the ack's cut: off goes in at the next
1067
+ // step; a queue to a turn that ended meanwhile is just the next turn): its
1068
+ // badge follows.
1069
+ function setBubbleDelivery(bubble, delivery) {
1070
+ if ((bubble.dataset.delivery || null) === (delivery || null)) return;
1071
+ if (delivery) bubble.dataset.delivery = delivery;
1072
+ else delete bubble.dataset.delivery;
1073
+ if (bubble.dataset.userState) setBubbleState(bubble, bubble.dataset.userState);
1022
1074
  }
1023
1075
 
1024
1076
  function bubbleById(enqueuedId) {
@@ -1047,15 +1099,18 @@ function applyPromptOps(event) {
1047
1099
  if (op.op === "report") {
1048
1100
  liveTurn.unmatchedMerge = "report";
1049
1101
  } else if (op.op === "add") {
1050
- const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
1102
+ const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
1051
1103
  if (op.state === "started") started = bubble;
1052
1104
  if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
1053
1105
  } else if (op.op === "tag") {
1054
1106
  if (bubbleById(op.enqueuedId)) continue;
1055
1107
  const echo = untaggedOwnBubble(op.prompt);
1056
- if (echo) echo.dataset.enqueuedId = op.enqueuedId;
1108
+ if (echo) {
1109
+ echo.dataset.enqueuedId = op.enqueuedId;
1110
+ if (op.delivery) setBubbleDelivery(echo, op.delivery);
1111
+ }
1057
1112
  // Re-rendered from a snapshot: the echo is gone, the prompt still queued.
1058
- else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images });
1113
+ else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
1059
1114
  } else if (op.op === "start") {
1060
1115
  const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
1061
1116
  // A resync (a new worker woken by this page's send) re-rendered the
@@ -1422,6 +1477,8 @@ function updateInfoBar() {
1422
1477
  llmContext: current.llmContext,
1423
1478
  thinking: current.thinking,
1424
1479
  reasoningHistory: current.reasoningHistory,
1480
+ // The tools chip (toolkit_chip.js): null on full.
1481
+ toolkit: current.toolkit,
1425
1482
  // The model notes the session's prompt carried.
1426
1483
  notes: promptNotesChip(current.promptNotes),
1427
1484
  memories: current.usedMemories,
@@ -1683,10 +1740,14 @@ function handleCreate() {
1683
1740
  return handleSendTurn();
1684
1741
  }
1685
1742
 
1686
- async function handleSendTurn() {
1743
+ // +delivery+: the composer's choice for a running turn (Alt+Enter: queue;
1744
+ // a delivery-line link: cut or queue); else the armed one; a typed /cut or
1745
+ // /queue wins over both.
1746
+ async function handleSendTurn({ delivery: chosen = null } = {}) {
1687
1747
  if (current.owner === "tui") return;
1688
1748
  let prompt = turnText(promptEl.value, chips);
1689
1749
  if (!prompt) return;
1750
+ const armed = deliveryControl.take();
1690
1751
  // An image-only send's text is its placeholders: not for ↑.
1691
1752
  const typed = promptEl.value.trim() !== "";
1692
1753
  // The composer holds the line as typed until the ack (composerAfterAck).
@@ -1727,14 +1788,18 @@ async function handleSendTurn() {
1727
1788
  }
1728
1789
  if (route.command) return composerCommands.send(route.command);
1729
1790
  prompt = route.send;
1730
- const delivery = route.delivery;
1791
+ const running = liveTurn.running && !!selected;
1792
+ const delivery = route.delivery || (running ? chosen || armed : null);
1731
1793
  const hint = historyEl.querySelector(".hint");
1732
1794
  if (hint) hint.remove();
1733
1795
  // Sending moves a finished turn out of the stage at once (else the echo
1734
1796
  // would land among its rows); into a running one it is a queued row there.
1735
1797
  turnDom.handOff?.({ immediate: true });
1736
1798
  const sending = chips.slice();
1737
- const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending });
1799
+ // Into a running turn the echo says what it waits for (its delivery); a
1800
+ // line with images always waits for the turn's end (the worker queues it).
1801
+ const shownDelivery = running ? (sending.length ? "queue" : delivery || "next_step") : null;
1802
+ const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending, delivery: shownDelivery });
1738
1803
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
1739
1804
  if (!current.firstPreview || current.firstPreview==="—") {
1740
1805
  current.firstPreview = firstPreview(prompt);
@@ -1756,6 +1821,11 @@ async function handleSendTurn() {
1756
1821
  return;
1757
1822
  }
1758
1823
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
1824
+ // A cut the worker didn't take (no thinking-only step: cut off) goes in
1825
+ // at the next step; a queue whose turn ended meanwhile is the next turn.
1826
+ if (ack?.cut === "off" || (ack?.delivery === "next_step" && echo.dataset.delivery === "queue")) {
1827
+ setBubbleDelivery(echo, ack.cut === "off" ? "next_step" : null);
1828
+ }
1759
1829
  refreshHistory();
1760
1830
  // A turn that failed fast was restored before this ack: its prompt
1761
1831
  // (and images) go back into the composer, without replacing text typed
@@ -2118,10 +2188,20 @@ function historyKey(e) {
2118
2188
  promptEl.addEventListener("keydown", (e) => {
2119
2189
  if (commandList.handleKey(e)) return;
2120
2190
  if (historyKey(e)) return;
2121
- if (e.key === "Enter" && !e.shiftKey) {
2191
+ // Esc lets go of a choice armed on the delivery line.
2192
+ if (e.key === "Escape" && deliveryControl.disarm()) {
2193
+ e.preventDefault();
2194
+ return;
2195
+ }
2196
+ // An IME's Enter picks the composition, it doesn't send.
2197
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
2122
2198
  e.preventDefault();
2123
2199
  if (!selected) handleCreate();
2124
- else handleSendTurn();
2200
+ // Alt/Option+Enter: queued for after the running turn (D6), only to a
2201
+ // worker that queues (else it would steer): the text stays, a note says why.
2202
+ else if (e.altKey && liveTurn.running && !current.workerFeatures.includes("delivery")) {
2203
+ addCommandBubble({ label: null, line: promptEl.value.trim(), text: QUEUE_NEEDS_WORKER, failed: true });
2204
+ } else handleSendTurn(e.altKey ? { delivery: "queue" } : {});
2125
2205
  }
2126
2206
  });
2127
2207
  $("#infoStopBtn").addEventListener("click", async () => {
@@ -2302,18 +2382,29 @@ function showToast(text, action = null, { sticky = false } = {}) {
2302
2382
  }
2303
2383
 
2304
2384
  // Annotate (annotate_bar.js): a selection in the conversation offers
2305
- // "Annotate" and the presets, which append a quote to the composer.
2385
+ // "Annotate" and the presets, which append a quote to the composer, and the
2386
+ // session's plugins' pills (chi.annotation, annotate_actions.js), which fill
2387
+ // the composer with their command line and the quote, or send it at once.
2388
+ function fillComposer(text) {
2389
+ promptEl.value = text;
2390
+ fitPrompt();
2391
+ promptEl.focus();
2392
+ promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
2393
+ promptEl.scrollTop = promptEl.scrollHeight;
2394
+ }
2395
+
2306
2396
  const annotateBar = createAnnotateBar({
2307
2397
  presets: parsePresets(document.body.dataset.annotatePresets),
2308
2398
  canAnnotate: () => !!selected && current.owner !== "tui",
2309
2399
  roots: turnRoots,
2310
2400
  isLive: (root) => liveTurn.running && turnDom.containsLive(root),
2311
- onQuote: (text) => {
2312
- promptEl.value = appendQuote(promptEl.value, text);
2313
- fitPrompt();
2314
- promptEl.focus();
2315
- promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
2316
- promptEl.scrollTop = promptEl.scrollHeight;
2401
+ onQuote: (text) => fillComposer(appendQuote(promptEl.value, text)),
2402
+ actions: () => annotationActions(current.commands),
2403
+ running: () => liveTurn.running,
2404
+ onAction: (action, fields) => {
2405
+ const line = actionLine(action, fields);
2406
+ if (action.send) runSessionCommand(line);
2407
+ else fillComposer(composerWithAction(promptEl.value, action, line));
2317
2408
  },
2318
2409
  });
2319
2410
  historyEl.addEventListener("scroll", annotateBar.schedule);
@@ -10,7 +10,8 @@ import { anchorInsertIndex, commandAnchor, isShellCommandLine, keepsCommandBubbl
10
10
  // Whether a composer line in an open session is one of its commands
11
11
  // (+commands+: the session GET's listing), as the terminals decide
12
12
  // (Commands::Registry#lookup): a "!" line with something after the "!", or
13
- // a listed name alone or followed by a space; a terminal's own (/stats,
13
+ // a listed name alone or followed by whitespace (a newline too: an
14
+ // annotation action's line puts the quote under the name); a terminal's own (/stats,
14
15
  // /exit) too, which the page answers itself (webLocalReply). An unknown
15
16
  // "/word" (a path, a typo) goes to the model as a prompt, as in a terminal.
16
17
  export function sessionCommandLine(text, commands) {
@@ -19,7 +20,8 @@ export function sessionCommandLine(text, commands) {
19
20
  if (!line.startsWith("/")) return false;
20
21
  if (webLocalReply(line)) return true;
21
22
  return (Array.isArray(commands) ? commands : [])
22
- .some((c) => c && typeof c.name === "string" && (line === c.name || line.startsWith(`${c.name} `)));
23
+ .some((c) => c && typeof c.name === "string"
24
+ && (line === c.name || (line.startsWith(c.name) && /\s/.test(line[c.name.length]))));
23
25
  }
24
26
 
25
27
  // /cut TEXT and /queue TEXT: the UIs' own words for a delivery (listed
@@ -0,0 +1,228 @@
1
+ // The composer's delivery line (web, steer delivery P4w): a row above the
2
+ // text field in a session, always there so the composer never changes
3
+ // height (the stage above it would jump). Idle it holds the key hint; while
4
+ // the open session's turn runs it says where a message typed now goes in,
5
+ // and offers the other two deliveries as links:
6
+ //
7
+ // ↳ goes in after this thinking step · 2:31 · 31k chars cut in now · queue for after the turn
8
+ // ↳ goes in after this tool call (execute · 0:12) queue for after the turn
9
+ //
10
+ // Send (Enter, the button) is next_step; "queue for after the turn" (or
11
+ // Alt/Option+Enter) runs the message as a turn of its own after this one;
12
+ // "cut in now" (a click only: it throws the thinking away) cuts a
13
+ // thinking-only generation (the worker's SteerCut rule: once it has
14
+ // thought for the model's cut_after seconds). A link clicked with an empty
15
+ // composer arms its choice for the next Enter; Esc or a second click
16
+ // disarms it.
17
+ //
18
+ // deliveryLine is pure (node tests); deliveryLineTemplate is its lit view.
19
+ import { html, nothing, render } from "./vendor/lit-html.js";
20
+
21
+ export const QUEUE_LABEL = "queue for after the turn";
22
+ export const CUT_LABEL = "cut in now";
23
+ // A phone's row (≤ 600 px) is short: its text and chips.
24
+ export const QUEUE_SHORT = "queue";
25
+ export const CUT_SHORT = "cut in";
26
+ // Alt+Enter to a worker without the delivery feature: not sent (a queue
27
+ // would steer it), the text kept, this said.
28
+ export const QUEUE_NEEDS_WORKER = "Not sent: queueing needs a newer worker (it would steer the running turn). "
29
+ + "Restart it (chi sessions restart ID, or the update badge), then send again.";
30
+ // The idle row: the keys (the placeholder's hint before the row existed).
31
+ export const IDLE_HINT = "Enter to send · Shift+Enter newline · ↑ history";
32
+
33
+ // m:ss (h:mm:ss past an hour) of +seconds+; "" when unknown.
34
+ export function clockText(seconds) {
35
+ if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return "";
36
+ const total = Math.floor(seconds);
37
+ const h = Math.floor(total / 3600);
38
+ const m = Math.floor((total % 3600) / 60);
39
+ const s = String(total % 60).padStart(2, "0");
40
+ return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
41
+ }
42
+
43
+ // "812 chars", "31k chars"; "" for none.
44
+ export function charsText(count) {
45
+ if (!count) return "";
46
+ return count < 1000 ? `${count} chars` : `${Math.round(count / 1000)}k chars`;
47
+ }
48
+
49
+ // Seconds the current generation has run: from the page's own clock (its
50
+ // first thinking delta, thinkingAt, as the worker's cut counts; else its
51
+ // startedAt, set at generation_started), else from the worker's snapshot
52
+ // (+serverGen+: {thinking_only, age, at}; a page that joined mid-generation
53
+ // has no start of its own: age as of +at+, the page's clock when the
54
+ // snapshot came); null when neither.
55
+ export function genAge(gen, serverGen, now) {
56
+ const since = gen?.thinkingAt ?? gen?.startedAt;
57
+ if (since != null) return Math.max(0, (now - since) / 1000);
58
+ if (serverGen?.age != null && serverGen.at != null) return serverGen.age + Math.max(0, (now - serverGen.at) / 1000);
59
+ return null;
60
+ }
61
+
62
+ // Whether the generation has streamed only thinking: thinking, no visible
63
+ // text, no tool call (the worker's GenerationPhase, freshness aside: a
64
+ // stale cut is the worker's to refuse or hold, its ack says which). The
65
+ // worker's own word (+serverGen+) wins: it also sees tool-call chunks.
66
+ function thinkingOnly(gen, serverGen) {
67
+ if (!gen?.thinking || gen.text?.trim() || gen.tools?.length) return false;
68
+ return serverGen?.thinking_only !== false;
69
+ }
70
+
71
+ // What the line shows for the running turn:
72
+ // phase stage_model.js liveSlots(turn).phase
73
+ // gen the current gen (turn_model.js: thinking, text, tools, startedAt)
74
+ // tool the running call: {name, startedAt} (activity.js row), or null
75
+ // cutAfter the run's cut wait in seconds (turn_started / current_turn
76
+ // cut_after): 0 is cutting off, null unknown (an older worker)
77
+ // serverGen the snapshot's current_turn.generation with `at`, or null
78
+ // now the page's clock (ms)
79
+ // armed "cut" | "queue" | null: the choice the next Enter sends
80
+ // canQueue the worker takes delivery: queue (its "delivery" feature)
81
+ // model the session's model, for the cut-off reason
82
+ // → {text, short, cut: {enabled, shown, reason, label, short}, queue:
83
+ // {enabled, shown, label, short}, armed}: short is the phone's text;
84
+ // armed is what is still armed (a cut no longer possible disarms). With
85
+ // cutting off the cut link goes (the text says why); without the
86
+ // feature the queue link goes.
87
+ export function deliveryLine({ phase = "thinking", gen = null, tool = null, cutAfter = null, serverGen = null, now = Date.now(),
88
+ armed = null, canQueue = true, model = "" } = {}) {
89
+ const cutOff = cutAfter === 0 || (cutAfter != null && cutAfter < 0);
90
+ const thinking = phase === "thinking" && thinkingOnly(gen, serverGen);
91
+ const age = genAge(gen, serverGen, now);
92
+ let where;
93
+ let short;
94
+ let reason = "";
95
+ if (phase === "running a tool") {
96
+ const clock = clockText(tool?.startedAt != null ? (now - tool.startedAt) / 1000 : null);
97
+ where = `goes in after this tool call (${[tool?.name || "tool", clock].filter(Boolean).join(" · ")})`;
98
+ short = ["after this tool call", clock].filter(Boolean).join(" · ");
99
+ reason = "a tool is running: only a thinking step can be cut";
100
+ } else if (phase === "waiting for you") {
101
+ where = "goes in once the card above is answered";
102
+ short = "after the card's answer";
103
+ reason = "the turn waits for you";
104
+ } else if (phase === "writing" || phase === "writing the answer") {
105
+ where = "goes in after this answer; the turn may go on";
106
+ short = "after this answer";
107
+ reason = "the model is writing: only a thinking step can be cut";
108
+ } else if (thinking) {
109
+ where = ["goes in after this thinking step", clockText(age), charsText(gen.thinking.length)].filter(Boolean).join(" · ");
110
+ short = ["after this thinking", clockText(age)].filter(Boolean).join(" · ");
111
+ } else {
112
+ where = "goes in after this step";
113
+ short = "after this step";
114
+ reason = "nothing to cut until the model thinks";
115
+ }
116
+ if (cutOff) reason = `cutting is off for ${model || "this model"}`;
117
+ const cutEnabled = !cutOff && thinking;
118
+ let wait = "";
119
+ if (cutEnabled && cutAfter > 0 && age != null && age < cutAfter) wait = `cuts at about ${clockText(cutAfter)} if you choose ${CUT_LABEL}`;
120
+ const effective = (armed === "cut" && cutEnabled) || (armed === "queue" && canQueue) ? armed : null;
121
+ const parts = [cutOff ? reason : "", where, wait].filter(Boolean);
122
+ // A phone's short row leaves it out: its ticked chip says it.
123
+ if (effective) parts.push("Enter sends");
124
+ const tick = (label, which) => (effective === which ? `${label} \u2713` : label);
125
+ return {
126
+ text: `\u21B3 ${parts.join(" · ")}`,
127
+ short: `\u21B3 ${short}`,
128
+ cut: { enabled: cutEnabled, shown: !cutOff, reason: cutEnabled ? "" : reason, label: tick(CUT_LABEL, "cut"), short: tick(CUT_SHORT, "cut") },
129
+ queue: { enabled: canQueue, shown: canQueue, label: tick(QUEUE_LABEL, "queue"), short: tick(QUEUE_SHORT, "queue") },
130
+ armed: effective,
131
+ };
132
+ }
133
+
134
+ // The line's view: +line+ is deliveryLine's; +onCut+ / +onQueue+ the
135
+ // links' clicks. A disabled cut stays in place, its reason on hover.
136
+ export function deliveryLineTemplate(line, { onCut, onQueue } = {}) {
137
+ const link = (which, part, onClick) => html`<button type="button" class="delivery-link ${which}${line.armed === which ? " armed" : ""}"
138
+ ?disabled=${!part.enabled} title=${part.reason || nothing} aria-label=${part.label} aria-pressed=${line.armed === which ? "true" : "false"}
139
+ @mousedown=${(e) => e.preventDefault()} @click=${onClick}><span class="long">${part.label}</span><span class="short">${part.short}</span></button>`;
140
+ const links = [line.cut.shown ? link("cut", line.cut, onCut) : null, line.queue.shown ? link("queue", line.queue, onQueue) : null]
141
+ .filter(Boolean);
142
+ return html`<span class="delivery-where" title=${line.text}><span class="long">${line.text}</span><span class="short">${line.short}</span></span><span class="delivery-links">${links.map((l, i) =>
143
+ (i ? html`<span class="delivery-sep" aria-hidden="true">·</span>${l}` : l))}</span>`;
144
+ }
145
+
146
+ // The idle row: the key hint (none on a phone: no keyboard keys there).
147
+ export function idleTemplate() {
148
+ return html`<span class="delivery-where idle"><span class="long">${IDLE_HINT}</span><span class="short"></span></span>`;
149
+ }
150
+
151
+ // The row on the page: the key hint while idle, the line while the turn
152
+ // runs (its clock ticking every TICK_MS), nothing outside a session; the
153
+ // armed choice is kept here. No page globals: the page passes its hooks.
154
+ // read() deliveryLine's inputs but now and armed (the turn's
155
+ // phase, gen, tool, cutAfter, serverGen, canQueue, model)
156
+ // composerEmpty() nothing typed and no image chips: a link arms
157
+ // send(delivery) send the composer's line with +delivery+ ("cut", "queue")
158
+ export const TICK_MS = 500;
159
+
160
+ export function createDeliveryControl({ el, read, composerEmpty, send, now = () => Date.now(),
161
+ setTimer = (fn, ms) => setInterval(fn, ms), clearTimer = (t) => clearInterval(t) }) {
162
+ // "off" (no session: the row is gone), "idle" or "running".
163
+ let mode = "off";
164
+ let armed = null;
165
+ let timer = null;
166
+ let line = null;
167
+
168
+ const click = (which) => {
169
+ if (mode !== "running" || !line?.[which]?.enabled) return;
170
+ if (!composerEmpty()) {
171
+ armed = null;
172
+ send(which);
173
+ return;
174
+ }
175
+ armed = armed === which ? null : which;
176
+ refresh();
177
+ };
178
+
179
+ function refresh() {
180
+ if (mode === "off") return;
181
+ if (mode === "idle") {
182
+ render(idleTemplate(), el);
183
+ return;
184
+ }
185
+ line = deliveryLine({ ...read(), now: now(), armed });
186
+ // A choice no longer possible (a tool started) disarms.
187
+ armed = line.armed;
188
+ render(deliveryLineTemplate(line, { onCut: () => click("cut"), onQueue: () => click("queue") }), el);
189
+ }
190
+
191
+ return {
192
+ // +next+: "off", "idle" or "running".
193
+ show(next) {
194
+ if (next !== mode) {
195
+ mode = next;
196
+ el.hidden = mode === "off";
197
+ el.classList.toggle("running", mode === "running");
198
+ if (mode === "running") timer ??= setTimer(refresh, TICK_MS);
199
+ else {
200
+ clearTimer(timer);
201
+ timer = null;
202
+ armed = null;
203
+ line = null;
204
+ }
205
+ }
206
+ refresh();
207
+ },
208
+ refresh,
209
+ click,
210
+ armed: () => armed,
211
+ // The armed choice for this send, and disarmed.
212
+ take() {
213
+ const was = armed;
214
+ if (was) {
215
+ armed = null;
216
+ refresh();
217
+ }
218
+ return was;
219
+ },
220
+ disarm() {
221
+ if (!armed) return false;
222
+ armed = null;
223
+ refresh();
224
+ return true;
225
+ },
226
+ line: () => line,
227
+ };
228
+ }