samagotchi 0.65.0 → 0.67.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +50 -28
  6. data/docs/cli.md +14 -2
  7. data/docs/configuration.md +101 -16
  8. data/docs/context.md +39 -2
  9. data/docs/desktop.md +90 -22
  10. data/docs/guardrails.md +8 -5
  11. data/docs/hooks.md +2 -2
  12. data/docs/internals/anthropic.md +156 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bundles.md +2 -0
  15. data/docs/internals/engine.md +75 -30
  16. data/docs/internals/gemma4-contract.md +1 -1
  17. data/docs/internals/llm-context-bench.md +5 -4
  18. data/docs/internals/prompt-caching.md +36 -13
  19. data/docs/memory.md +1 -1
  20. data/docs/plugins.md +289 -25
  21. data/docs/releasing.md +1 -1
  22. data/docs/sessions.md +16 -6
  23. data/docs/sub-agent.md +6 -1
  24. data/docs/testing.md +17 -3
  25. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  26. data/lib/samagotchi/bootstrap_command.rb +13 -3
  27. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  28. data/lib/samagotchi/bridge.rb +6 -3
  29. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/claude-peer/plugin.rb +40 -302
  31. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  33. data/lib/samagotchi/bundles/codex-peer/manifest.yml +22 -0
  34. data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
  35. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
  36. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  37. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
  38. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  39. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  40. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  44. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  45. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  46. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  47. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  48. data/lib/samagotchi/cache_confirm.rb +9 -6
  49. data/lib/samagotchi/commands/registry.rb +25 -8
  50. data/lib/samagotchi/config.rb +23 -8
  51. data/lib/samagotchi/context_absorber.rb +66 -13
  52. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  53. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  54. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  55. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  56. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  57. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  58. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  59. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  60. data/lib/samagotchi/desktop/macos.rb +84 -13
  61. data/lib/samagotchi/desktop_command.rb +14 -3
  62. data/lib/samagotchi/engine.rb +120 -107
  63. data/lib/samagotchi/engine_peers.rb +3 -0
  64. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  65. data/lib/samagotchi/guardrails/approval.rb +1 -1
  66. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  67. data/lib/samagotchi/guardrails/rules.rb +1 -1
  68. data/lib/samagotchi/guardrails/targets.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +151 -0
  70. data/lib/samagotchi/hooks/registry.rb +2 -2
  71. data/lib/samagotchi/host_keys.rb +147 -0
  72. data/lib/samagotchi/kernel_loop.rb +9 -1
  73. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  74. data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
  75. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  76. data/lib/samagotchi/llm/api_key.rb +23 -7
  77. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  78. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  79. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  80. data/lib/samagotchi/llm_context_override.rb +1 -0
  81. data/lib/samagotchi/peer_sources.rb +58 -0
  82. data/lib/samagotchi/plugin/api.rb +138 -156
  83. data/lib/samagotchi/plugin/context.rb +22 -1
  84. data/lib/samagotchi/plugin/loader.rb +5 -2
  85. data/lib/samagotchi/plugin/peer.rb +394 -0
  86. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  87. data/lib/samagotchi/plugin_tasks.rb +2 -2
  88. data/lib/samagotchi/row_refs.rb +1 -1
  89. data/lib/samagotchi/self_report.rb +5 -3
  90. data/lib/samagotchi/send_command.rb +41 -15
  91. data/lib/samagotchi/session.rb +18 -1
  92. data/lib/samagotchi/session_commands.rb +16 -1
  93. data/lib/samagotchi/session_continue.rb +58 -15
  94. data/lib/samagotchi/session_inbox.rb +0 -13
  95. data/lib/samagotchi/session_manager.rb +4 -1
  96. data/lib/samagotchi/session_setup.rb +14 -7
  97. data/lib/samagotchi/system_prompt.rb +16 -1
  98. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  99. data/lib/samagotchi/terminal_ui.rb +18 -2
  100. data/lib/samagotchi/tool_call_parser.rb +1 -1
  101. data/lib/samagotchi/tool_runner.rb +13 -1
  102. data/lib/samagotchi/toolkit.rb +246 -0
  103. data/lib/samagotchi/toolkit_binding.rb +138 -0
  104. data/lib/samagotchi/toolkit_command.rb +117 -0
  105. data/lib/samagotchi/tools/context_read.rb +14 -0
  106. data/lib/samagotchi/tools/execute.rb +12 -12
  107. data/lib/samagotchi/tools/registry.rb +43 -6
  108. data/lib/samagotchi/tools/task_create.rb +6 -6
  109. data/lib/samagotchi/tools/task_get.rb +13 -13
  110. data/lib/samagotchi/tools/task_list.rb +6 -6
  111. data/lib/samagotchi/tools/task_record.rb +52 -0
  112. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  113. data/lib/samagotchi/tools/task_stop.rb +6 -6
  114. data/lib/samagotchi/tools/task_wait.rb +12 -13
  115. data/lib/samagotchi/tools/tool_index.rb +454 -0
  116. data/lib/samagotchi/update_command.rb +9 -6
  117. data/lib/samagotchi/version.rb +1 -1
  118. data/lib/samagotchi/web/app.rb +28 -9
  119. data/lib/samagotchi/web/capabilities.rb +1 -1
  120. data/lib/samagotchi/web/public/activity.js +2 -2
  121. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  122. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  123. data/lib/samagotchi/web/public/app.js +52 -43
  124. data/lib/samagotchi/web/public/chains.js +1 -1
  125. data/lib/samagotchi/web/public/commands.js +6 -4
  126. data/lib/samagotchi/web/public/composer_size.js +2 -2
  127. data/lib/samagotchi/web/public/copy.js +1 -1
  128. data/lib/samagotchi/web/public/ctx.js +1 -1
  129. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  130. data/lib/samagotchi/web/public/families_view.js +1 -1
  131. data/lib/samagotchi/web/public/format.js +11 -7
  132. data/lib/samagotchi/web/public/images.js +25 -2
  133. data/lib/samagotchi/web/public/index.html +9 -0
  134. data/lib/samagotchi/web/public/info_bar.js +5 -3
  135. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  136. data/lib/samagotchi/web/public/notify.js +6 -4
  137. data/lib/samagotchi/web/public/question_card.js +1 -1
  138. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  139. data/lib/samagotchi/web/public/session_state.js +17 -0
  140. data/lib/samagotchi/web/public/session_title.js +2 -3
  141. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  142. data/lib/samagotchi/web/public/stage_model.js +2 -2
  143. data/lib/samagotchi/web/public/stream_handlers.js +13 -0
  144. data/lib/samagotchi/web/public/strip.js +3 -3
  145. data/lib/samagotchi/web/public/task_stop.js +3 -2
  146. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  147. data/lib/samagotchi/web/public/turn_events.js +1 -1
  148. data/lib/samagotchi/web/public/turn_html.js +2 -2
  149. data/lib/samagotchi/web/public/turn_model.js +2 -2
  150. data/lib/samagotchi/web/public/turn_view.js +1 -1
  151. data/lib/samagotchi/web/server.rb +0 -4
  152. data/lib/samagotchi/worker.rb +13 -8
  153. data/lib/samagotchi/worker_inbound.rb +36 -4
  154. data/lib/samagotchi/worker_wakes.rb +67 -7
  155. metadata +23 -1
@@ -0,0 +1,109 @@
1
+ // Annotation actions: a bundle's pills in the Annotate bar (chi.annotation,
2
+ // docs/plugins.md). Each prepares a command line of its plugin with the
3
+ // selection filled into its template under it:
4
+ //
5
+ // /claude validate
6
+ // From your thinking:
7
+ // > the quoted line
8
+ //
9
+ // and fills the composer with it (the note goes under), or sends it at once
10
+ // (send: true). The pills ride on the session's command listing (the
11
+ // worker's snapshot), so a muted bundle's go with its command. Pure: no DOM.
12
+ // A module of its own: static JS is cached at unversioned URLs, and a new
13
+ // export on an old module would blank the page.
14
+
15
+ import { quoteBlock } from "./annotations.js";
16
+
17
+ // How many plugin pills the bar shows; the rest go behind "⋯".
18
+ const INLINE_ACTIONS = 3;
19
+
20
+ // The session's commands' annotations, flattened in listing order: {label,
21
+ // line, template, send, command, anytime, source}. anytime: the command
22
+ // runs mid-turn (a send pill needs it while a turn runs).
23
+ export function annotationActions(commands) {
24
+ const out = [];
25
+ for (const c of Array.isArray(commands) ? commands : []) {
26
+ if (!c || typeof c.name !== "string" || !Array.isArray(c.annotations)) continue;
27
+ for (const a of c.annotations) {
28
+ if (!a || typeof a.label !== "string" || !a.label || typeof a.line !== "string" || !a.line) continue;
29
+ out.push({
30
+ label: a.label,
31
+ line: a.line,
32
+ template: typeof a.template === "string" && a.template ? a.template : "{quote}",
33
+ send: a.send === true,
34
+ command: c.name,
35
+ anytime: c.mid_turn === "anytime" || c.anytime === true,
36
+ source: typeof c.source === "string" ? c.source : "",
37
+ });
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ // Whether two action lists draw the same pills (the bar redraws only on a
44
+ // change).
45
+ export function sameActions(a, b) {
46
+ return JSON.stringify(a) === JSON.stringify(b);
47
+ }
48
+
49
+ // The selection as templates read it: {quote} the labelled block Annotate
50
+ // makes (annotations.js quoteBlock), {text} the selection alone with the
51
+ // same trimming (CRLF → LF, each line's trailing blanks, blank lines at
52
+ // either end; indentation kept), {source} where it came from (answer,
53
+ // thinking, step, tool, user). null when it holds no text.
54
+ export function selectionFields(raw, source = {}) {
55
+ const quote = quoteBlock(raw, source);
56
+ if (!quote) return null;
57
+ const lines = String(raw ?? "").replace(/\r\n?/g, "\n").split("\n").map((l) => l.replace(/\s+$/, ""));
58
+ while (lines.length && !lines[0]) lines.shift();
59
+ while (lines.length && !lines[lines.length - 1]) lines.pop();
60
+ return { quote, text: lines.join("\n"), source: source.kind || "answer" };
61
+ }
62
+
63
+ // +template+ with {quote}, {text} and {source} filled from +fields+; any
64
+ // other {x} stays as written.
65
+ export function fillTemplate(template, fields) {
66
+ return String(template ?? "").replace(/\{(quote|text|source)\}/g, (_, key) => String(fields?.[key] ?? ""));
67
+ }
68
+
69
+ // The line the pill prepares: its command line, the filled template under it.
70
+ export function actionLine(action, fields) {
71
+ return `${action.line}\n${fillTemplate(action.template, fields)}`;
72
+ }
73
+
74
+ // The composer's text after a fill pill (+line+: actionLine's):
75
+ // - empty: the line;
76
+ // - it already starts with the same command line: the filled part is
77
+ // appended (several selections collect under one command);
78
+ // - another draft: the line on top, the draft under it, after a blank line
79
+ // (nothing leaves chi without a click on Send).
80
+ export function composerWithAction(value, action, line) {
81
+ const draft = String(value ?? "");
82
+ if (!draft.trim()) return line;
83
+ const filled = line.slice(action.line.length + 1);
84
+ if (draft.trimStart().split("\n")[0].trim() === action.line) {
85
+ const before = draft.replace(/\s+$/, "");
86
+ return `${before}\n\n${filled}`;
87
+ }
88
+ return `${line.replace(/\s+$/, "")}\n\n${draft.replace(/^\s*\n/, "")}`;
89
+ }
90
+
91
+ // Whether a pill can be clicked: a fill pill always; a send pill while a
92
+ // turn runs only when its command runs mid-turn (anytime), else it would come
93
+ // back busy.
94
+ export function actionEnabled(action, running) {
95
+ return !action.send || !running || action.anytime;
96
+ }
97
+
98
+ // A pill's tooltip: its label and bundle, that a send pill sends at once,
99
+ // and why it is disabled.
100
+ export function actionTitle(action, running) {
101
+ const base = action.source ? `${action.label} (${action.source})` : action.label;
102
+ if (!actionEnabled(action, running)) return `${base}: ${action.command} waits for the turn to end`;
103
+ return action.send ? `${base}: sends at once` : base;
104
+ }
105
+
106
+ // The pills shown inline and the ones behind "⋯".
107
+ export function splitInline(actions, max = INLINE_ACTIONS) {
108
+ return { inline: actions.slice(0, max), more: actions.slice(max) };
109
+ }
@@ -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();
@@ -36,7 +36,7 @@ import { createShownTexts } from "./shown_texts.js";
36
36
  import { MODEL_KEY } from "./model_pick.js";
37
37
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
38
38
  import { createModelPicker } from "./model_picker.js";
39
- import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText } from "./images.js";
39
+ import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
40
40
  import { normalize, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
41
41
  import { createTurnView, stepText, turnHistoryNodes } from "./turn_view.js";
42
42
  import { BADGES, elementOf, emptyAnswerTemplate, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
@@ -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";
@@ -290,13 +291,6 @@ const questionCards = createQuestionCards({
290
291
  removeHintIfEmpty: () => removeHintIfEmpty(),
291
292
  askingText: () => turnDom.askingText(),
292
293
  });
293
- // The event id (or, from an older worker, the event_seq) the selected session
294
- // was rendered at (null: no live worker then), where a stream opened later
295
- // has to start.
296
- let selectSeq = null;
297
- // The stream was opened from 0 (a wake with no cursor to go on) and no
298
- // new turn has started on it yet: see followWokenWorker.
299
- let streamFromGuess = false;
300
294
  // The enqueued_ids this page sent (from the /turn acks): only these come back
301
295
  // into the composer when their turn fails.
302
296
  const sentIds = new Set();
@@ -382,8 +376,9 @@ function openSessionEvents() {
382
376
  // never forcing it) or when a newer chi is installed than chi web runs.
383
377
  // Once per notice per tab: every reconnect's snapshot says it again.
384
378
  const versions = { served: null, installed: null };
385
- // The session whose Restart is under way (its button says so).
386
- let restartingId = null;
379
+ // The sessions whose Restart is under way (their button says so): one
380
+ // session's restart never blocks another's.
381
+ const restartingIds = new Set();
387
382
  const versionNoticesShown = new Set();
388
383
  function noticeVersions(data) {
389
384
  if (typeof data.version === "string") versions.served = data.version;
@@ -766,8 +761,8 @@ async function resync({ endText = null } = {}) {
766
761
  renderSessionView(data, { kept });
767
762
  // Preview is read-only: a worker is woken only by POST /turn, so attach
768
763
  // only when a live bridge exists.
769
- selectSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
770
- if (selectSeq !== null) startStream(id, selectSeq);
764
+ current.streamSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
765
+ if (current.streamSeq !== null) startStream(id, current.streamSeq);
771
766
  else if (endText) addEndBubble(endText);
772
767
  } finally {
773
768
  resyncsInFlight -= 1;
@@ -1374,8 +1369,7 @@ function removeRecap() {
1374
1369
  }
1375
1370
 
1376
1371
  // The info bar's "copy chi --attach": the full id, which the page shows
1377
- // only short; "copied ✓" for 2 s.
1378
- let attachCopiedAt = 0;
1372
+ // only short; "copied ✓" for 2 s (current.attachCopiedAt: that session's).
1379
1373
  infoTextEl.addEventListener("click", (e) => {
1380
1374
  if (e.target.closest(".worker-restart")) restartWorker();
1381
1375
  // Pushed as openAllView does, so the view's back returns here.
@@ -1386,8 +1380,9 @@ infoTextEl.addEventListener("click", (e) => {
1386
1380
  });
1387
1381
  infoTextEl.addEventListener("click", async (e) => {
1388
1382
  if (!e.target.closest(".copy-attach") || !selected) return;
1389
- if (!(await copyText(`chi --attach ${selected}`))) return;
1390
- attachCopiedAt = Date.now();
1383
+ const id = selected;
1384
+ if (!(await copyText(`chi --attach ${id}`)) || id !== selected) return;
1385
+ current.attachCopiedAt = Date.now();
1391
1386
  updateInfoBar();
1392
1387
  setTimeout(updateInfoBar, 2000);
1393
1388
  });
@@ -1404,10 +1399,10 @@ function currentWorkerBadge() {
1404
1399
  // follows it (its bridge_up), the composer's draft stays as it is.
1405
1400
  async function restartWorker() {
1406
1401
  const badge = currentWorkerBadge();
1407
- if (!badge || !badge.restart || restartingId) return;
1402
+ if (!badge || !badge.restart || restartingIds.has(selected)) return;
1408
1403
  if (!confirm(restartConfirmText(current.workerVersion, badge.newest))) return;
1409
1404
  const id = selected;
1410
- restartingId = id;
1405
+ restartingIds.add(id);
1411
1406
  updateInfoBar();
1412
1407
  try {
1413
1408
  const result = await restartSession(id);
@@ -1415,7 +1410,7 @@ async function restartWorker() {
1415
1410
  } catch (e) {
1416
1411
  showToast(`Can't restart: ${String(e.message).replace(/ \(\d+\)$/, "")}`, null, { sticky: true });
1417
1412
  } finally {
1418
- restartingId = null;
1413
+ restartingIds.delete(id);
1419
1414
  updateInfoBar();
1420
1415
  }
1421
1416
  }
@@ -1464,7 +1459,7 @@ function updateInfoBar() {
1464
1459
  // text alone, the bar's other nodes (a tooltip, a button mid-press) stay.
1465
1460
  renderInfoBar(infoTextEl, {
1466
1461
  id: selected,
1467
- copied: Date.now() - attachCopiedAt < 2000,
1462
+ copied: Date.now() - current.attachCopiedAt < 2000,
1468
1463
  status: current.status,
1469
1464
  model: modelLabel(current.model, ...(current.served || [])),
1470
1465
  // A link of a session chain: ← the previous link, → the next one (each
@@ -1476,6 +1471,8 @@ function updateInfoBar() {
1476
1471
  llmContext: current.llmContext,
1477
1472
  thinking: current.thinking,
1478
1473
  reasoningHistory: current.reasoningHistory,
1474
+ // The tools chip (toolkit_chip.js): null on full.
1475
+ toolkit: current.toolkit,
1479
1476
  // The model notes the session's prompt carried.
1480
1477
  notes: promptNotesChip(current.promptNotes),
1481
1478
  memories: current.usedMemories,
@@ -1485,7 +1482,7 @@ function updateInfoBar() {
1485
1482
  ctx: { text: ctxBarText(current.ctxPct, current.ctxWindowSource), tip: ctxTip },
1486
1483
  speed: speedTextNow ? { text: speedTextNow, tip: `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}` } : null,
1487
1484
  clock: sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`,
1488
- worker: badge ? { badge, busy: restartingId === selected } : null,
1485
+ worker: badge ? { badge, busy: restartingIds.has(selected) } : null,
1489
1486
  });
1490
1487
  updateSessionTitle();
1491
1488
  }
@@ -1574,12 +1571,12 @@ function setUsedMemories(names) {
1574
1571
  // worker have been up already, turn_started says so (replaysDrawnTurn).
1575
1572
  function followWokenWorker() {
1576
1573
  if (streamControl?.live) return;
1577
- startStream(selected, selectSeq ?? 0);
1578
- streamFromGuess = selectSeq === null;
1574
+ startStream(selected, current.streamSeq ?? 0);
1575
+ current.streamFromGuess = current.streamSeq === null;
1579
1576
  }
1580
1577
 
1581
1578
  function startStream(id, fromSeq) {
1582
- streamFromGuess = false;
1579
+ current.streamFromGuess = false;
1583
1580
  if (streamControl) {
1584
1581
  streamControl.close();
1585
1582
  streamControl = null;
@@ -1649,8 +1646,8 @@ const streamHandlers = createStreamHandlers({
1649
1646
  earlyRestores,
1650
1647
  stream: {
1651
1648
  takeGuess: () => {
1652
- const guess = streamFromGuess;
1653
- streamFromGuess = false;
1649
+ const guess = current.streamFromGuess;
1650
+ current.streamFromGuess = false;
1654
1651
  return guess;
1655
1652
  },
1656
1653
  restart: () => {
@@ -1803,18 +1800,18 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1803
1800
  updateInfoBar();
1804
1801
  }
1805
1802
  actionBtn.disabled = true;
1803
+ // The session the line goes to: the page may switch to another while an
1804
+ // image uploads or the turn is sent, and what follows stays with this one.
1806
1805
  const sentTo = selected;
1807
1806
  try {
1808
- for (const chip of sending) {
1809
- if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
1810
- }
1811
- const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
1807
+ await uploadChips(sending, sentTo, { upload: uploadImage });
1808
+ const ack = await sendTurn(sentTo, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
1812
1809
  if (ack?.command_id) {
1813
1810
  // The worker knew it as a command (its list was newer than ours):
1814
1811
  // its command_ran shows it, not a prompt bubble.
1815
1812
  echo.remove();
1816
1813
  clearComposer();
1817
- followWokenWorker();
1814
+ if (sentTo === selected) followWokenWorker();
1818
1815
  return;
1819
1816
  }
1820
1817
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
@@ -1831,7 +1828,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1831
1828
  promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
1832
1829
  fitPrompt();
1833
1830
  clearChips();
1834
- if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
1831
+ if (restored?.images?.length) setChips(restoredChips(sentTo, restored.images));
1835
1832
  // Its turn_enqueued usually tagged the echo already; the ack covers a
1836
1833
  // worker that wasn't live (no turn_enqueued then).
1837
1834
  if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
@@ -1840,17 +1837,18 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1840
1837
  // A live stream carries this turn already; restarting it would drop the
1841
1838
  // events in between. Without one, POST /turn just woke a worker: its
1842
1839
  // events start after the seq the session was rendered at (0 for a new
1843
- // worker process).
1844
- followWokenWorker();
1840
+ // worker process). A switch since then opened the other session's own.
1841
+ if (sentTo === selected) followWokenWorker();
1845
1842
  } catch (e) {
1846
1843
  echo.remove();
1847
1844
  // The turn never reached the worker's queue: an early restore waiting
1848
1845
  // for its ack is never taken back (4.13), so drop it here. The prompt
1849
1846
  // is still in the composer, as the echo was removed.
1850
1847
  dropEarlyRestores(earlyRestores);
1851
- // The chips stay (uploaded ones keep their ref) for another try: an
1848
+ // The chips stay (uploaded ones keep their ref, to sentTo) for another try: an
1852
1849
  // image refused (bad_image, too_large, bad_images) says why below.
1853
1850
  if (e.code === "owned_by_tui") {
1851
+ if (sentTo !== selected) return alert(e.message);
1854
1852
  current.owner = "tui";
1855
1853
  updateComposerMode();
1856
1854
  return;
@@ -1859,7 +1857,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1859
1857
  // says so, and the line stays in the composer.
1860
1858
  if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
1861
1859
  // A /queue to a worker that can't queue (it would steer): its words, the text kept.
1862
- if (e.code === "delivery_unavailable") return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1860
+ if (e.code === "delivery_unavailable" && sentTo === selected) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1863
1861
  alert(e.message);
1864
1862
  } finally {
1865
1863
  actionBtn.disabled = current.owner === "tui";
@@ -2379,18 +2377,29 @@ function showToast(text, action = null, { sticky = false } = {}) {
2379
2377
  }
2380
2378
 
2381
2379
  // Annotate (annotate_bar.js): a selection in the conversation offers
2382
- // "Annotate" and the presets, which append a quote to the composer.
2380
+ // "Annotate" and the presets, which append a quote to the composer, and the
2381
+ // session's plugins' pills (chi.annotation, annotate_actions.js), which fill
2382
+ // the composer with their command line and the quote, or send it at once.
2383
+ function fillComposer(text) {
2384
+ promptEl.value = text;
2385
+ fitPrompt();
2386
+ promptEl.focus();
2387
+ promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
2388
+ promptEl.scrollTop = promptEl.scrollHeight;
2389
+ }
2390
+
2383
2391
  const annotateBar = createAnnotateBar({
2384
2392
  presets: parsePresets(document.body.dataset.annotatePresets),
2385
2393
  canAnnotate: () => !!selected && current.owner !== "tui",
2386
2394
  roots: turnRoots,
2387
2395
  isLive: (root) => liveTurn.running && turnDom.containsLive(root),
2388
- onQuote: (text) => {
2389
- promptEl.value = appendQuote(promptEl.value, text);
2390
- fitPrompt();
2391
- promptEl.focus();
2392
- promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
2393
- promptEl.scrollTop = promptEl.scrollHeight;
2396
+ onQuote: (text) => fillComposer(appendQuote(promptEl.value, text)),
2397
+ actions: () => annotationActions(current.commands),
2398
+ running: () => liveTurn.running,
2399
+ onAction: (action, fields) => {
2400
+ const line = actionLine(action, fields);
2401
+ if (action.send) runSessionCommand(line);
2402
+ else fillComposer(composerWithAction(promptEl.value, action, line));
2394
2403
  },
2395
2404
  });
2396
2405
  historyEl.addEventListener("scroll", annotateBar.schedule);
@@ -141,7 +141,7 @@ export function allViewChains(fams, all, { query = "", includeArchived = false }
141
141
 
142
142
  // The links a family's chain list shows: the chain but its head, newest
143
143
  // first, each {session, day} (day: its 1-based place in the chain).
144
- export function chainOthers(f) {
144
+ function chainOthers(f) {
145
145
  const links = f?.chain?.links || [];
146
146
  return links.map((session, i) => ({ session, day: i + 1 }))
147
147
  .filter(({ session }) => session.id !== f.head.id)
@@ -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
@@ -151,8 +153,8 @@ export function continueLine(choice, reason = "") {
151
153
  // dropped (styled as failed). A change that restarts the prompt cache asks
152
154
  // first (CacheConfirm): `confirm` is the line that applies it, and this
153
155
  // tab's own bubble (`own`) gets an Apply button that sends it.
154
- export const QUEUED_TEXT = "queued: runs after this turn";
155
- export const APPLY_TEXT = "Apply (restarts the cache)";
156
+ const QUEUED_TEXT = "queued: runs after this turn";
157
+ const APPLY_TEXT = "Apply (restarts the cache)";
156
158
 
157
159
  export function commandView(event, myId) {
158
160
  const own = isOwn(event.client_id, myId);
@@ -2,8 +2,8 @@
2
2
  // its top edge sets a taller floor (the cap then is at least that floor).
3
3
  export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field: the start page
4
4
  export const PROMPT_LINE_PX = 26; // one line (15 px × 1.45 + 4 padding): in a session
5
- export const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
6
- export const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
5
+ const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
6
+ const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
7
7
 
8
8
  // contentPx: the text's height (scrollHeight plus borders); floorPx: the
9
9
  // grip's height, or null; minPx: the empty box's height.
@@ -99,7 +99,7 @@ function makeButton(doc, kind) {
99
99
 
100
100
  // Buttons on every bubble and code block in +root+ (and on +root+ itself)
101
101
  // that has none yet.
102
- export function decorate(root) {
102
+ function decorate(root) {
103
103
  const doc = root.ownerDocument;
104
104
  const bubbles = [...root.querySelectorAll(BUBBLES)];
105
105
  if (root.matches(BUBBLES)) bubbles.push(root);
@@ -13,7 +13,7 @@ function num(v) {
13
13
 
14
14
  // What a fill counts against: the window, or the budget when one is set
15
15
  // and smaller; null without a window.
16
- export function countedTokens(windowTokens, budgetTokens = null) {
16
+ function countedTokens(windowTokens, budgetTokens = null) {
17
17
  const window = num(windowTokens);
18
18
  if (!window || window <= 0) return null;
19
19
  const budget = num(budgetTokens);
@@ -21,8 +21,8 @@ import { html, nothing, render } from "./vendor/lit-html.js";
21
21
  export const QUEUE_LABEL = "queue for after the turn";
22
22
  export const CUT_LABEL = "cut in now";
23
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";
24
+ const QUEUE_SHORT = "queue";
25
+ const CUT_SHORT = "cut in";
26
26
  // Alt+Enter to a worker without the delivery feature: not sent (a queue
27
27
  // would steer it), the text kept, this said.
28
28
  export const QUEUE_NEEDS_WORKER = "Not sent: queueing needs a newer worker (it would steer the running turn). "
@@ -144,7 +144,7 @@ export function deliveryLineTemplate(line, { onCut, onQueue } = {}) {
144
144
  }
145
145
 
146
146
  // The idle row: the key hint (none on a phone: no keyboard keys there).
147
- export function idleTemplate() {
147
+ function idleTemplate() {
148
148
  return html`<span class="delivery-where idle"><span class="long">${IDLE_HINT}</span><span class="short"></span></span>`;
149
149
  }
150
150
 
@@ -155,7 +155,7 @@ export function idleTemplate() {
155
155
  // phase, gen, tool, cutAfter, serverGen, canQueue, model)
156
156
  // composerEmpty() nothing typed and no image chips: a link arms
157
157
  // send(delivery) send the composer's line with +delivery+ ("cut", "queue")
158
- export const TICK_MS = 500;
158
+ const TICK_MS = 500;
159
159
 
160
160
  export function createDeliveryControl({ el, read, composerEmpty, send, now = () => Date.now(),
161
161
  setTimer = (fn, ms) => setInterval(fn, ms), clearTimer = (t) => clearInterval(t) }) {
@@ -71,7 +71,7 @@ export function familyRowsTemplate(f, { activeId = null, matchIds = null, archiv
71
71
  }
72
72
 
73
73
  // The widest window index.html lays out as a phone (@media (max-width:600px)).
74
- export const PHONE_MAX_PX = 600;
74
+ const PHONE_MAX_PX = 600;
75
75
 
76
76
  // The strip's family popover: one #familyPop in body (position: fixed, so
77
77
  // the strip's sideways scroll on a phone doesn't clip it), under the chip
@@ -1,7 +1,7 @@
1
1
  import { waitingOn } from "./notify.js";
2
2
 
3
3
  export const PREVIEW_CHARS = 40;
4
- export const FIRST_PREVIEW_CHARS = 80;
4
+ const FIRST_PREVIEW_CHARS = 80;
5
5
 
6
6
  export function escapeHtml(value) {
7
7
  return String(value).replace(
@@ -37,20 +37,24 @@ export function normalize(s) {
37
37
  return String(s || "").replace(/\s+/g, " ").trim();
38
38
  }
39
39
 
40
+ // +text+ in at most +n+ characters: whole when it fits, else cut with "…"
41
+ // as its last (the kept part's trailing spaces dropped).
42
+ export function clip(text, n) {
43
+ const s = String(text ?? "");
44
+ return s.length > n ? `${s.slice(0, n - 1).trimEnd()}\u2026` : s;
45
+ }
46
+
40
47
  export function previewOf(lastPrompt) {
41
48
  const normalized = normalize(lastPrompt);
42
49
  if (!normalized) return "\u2014";
43
- if (normalized.length > PREVIEW_CHARS) {
44
- return `${normalized.slice(0, PREVIEW_CHARS)}\u2026`;
45
- }
46
- return normalized;
50
+ return clip(normalized, PREVIEW_CHARS + 1);
47
51
  }
48
52
 
49
53
  // A session's first message as the info bar's title: whitespace collapsed,
50
54
  // cut at FIRST_PREVIEW_CHARS with an ellipsis ("" for none).
51
55
  export function firstPreview(text) {
52
56
  const n = normalize(text);
53
- return n.length > FIRST_PREVIEW_CHARS ? `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026` : n;
57
+ return clip(n, FIRST_PREVIEW_CHARS + 1);
54
58
  }
55
59
 
56
60
  export function previewForCard(session) {
@@ -66,7 +70,7 @@ const DELETE_PREVIEW_CHARS = 40;
66
70
  // "stopped if it's still running", never promised.
67
71
  export function deleteConfirmText(session) {
68
72
  const n = normalize(session.first_preview || session.last_prompt || "");
69
- const preview = n.length > DELETE_PREVIEW_CHARS ? `${n.slice(0, DELETE_PREVIEW_CHARS)}\u2026` : n;
73
+ const preview = clip(n, DELETE_PREVIEW_CHARS + 1);
70
74
  const shortId = session.short_id || String(session.id).slice(0, 8);
71
75
  const lines = [
72
76
  `Delete session ${shortId}${preview ? ` \u2014 "${preview}"` : ""}?`,