samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,35 @@
1
+ // Context-window usage (%) from a stream event. `windowTokens` is the window
2
+ // the kernel resolved for this generation (:generation_started carries it as
3
+ // context_window_tokens); llama.cpp's stream payloads report token counts
4
+ // but no n_ctx, so without it only a precomputed pct or an n_ctx works.
5
+
6
+ function num(v) {
7
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
8
+ }
9
+
10
+ // Tokens in context after this chunk: prompt + generated so far.
11
+ function usedTokens(p) {
12
+ if (!p || typeof p !== "object") return null;
13
+ if (num(p.n_past)) return p.n_past;
14
+ if (num(p.usage?.total_tokens)) return p.usage.total_tokens;
15
+ const prompt = num(p.usage?.prompt_tokens) ?? num(p.tokens_evaluated) ?? num(p.timings?.prompt_n);
16
+ const completion = num(p.usage?.completion_tokens) ?? num(p.tokens_predicted) ?? num(p.timings?.predicted_n);
17
+ if (prompt === null && completion === null) return null;
18
+ return (prompt ?? 0) + (completion ?? 0);
19
+ }
20
+
21
+ export function extractCtxPct(data, windowTokens = null) {
22
+ if (!data || typeof data !== "object") return null;
23
+ if (num(data.ctx_pct) !== null) return data.ctx_pct;
24
+ if (num(data.est_pct) !== null) return data.est_pct;
25
+ if (num(data.ctxPct) !== null) return data.ctxPct;
26
+ // The kernel's :context_status event: { usage: { estimated_pct, ... }, bucket }.
27
+ if (num(data.usage?.estimated_pct) !== null) return data.usage.estimated_pct;
28
+ if (data.context_window_tokens && data.total_tokens) return (data.total_tokens / data.context_window_tokens) * 100;
29
+ const p = data.payload || data;
30
+ if (num(p.ctx_pct) !== null) return p.ctx_pct;
31
+ const window = num(p.n_ctx) || num(windowTokens);
32
+ const used = usedTokens(p);
33
+ if (!window || window <= 0 || used === null) return null;
34
+ return (used / window) * 100;
35
+ }
@@ -0,0 +1,256 @@
1
+ const DEFAULT_EVENT_TYPES = Object.freeze([
2
+ "generation_chunk",
3
+ "generation_started",
4
+ "generation_completed",
5
+ "turn_started",
6
+ "turn_completed",
7
+ "turn_canceled",
8
+ "turn_failed",
9
+ "turn_enqueued",
10
+ "input_merged",
11
+ "tool_call_completed",
12
+ "tool_call_started",
13
+ "context_status",
14
+ "used_memories_updated",
15
+ "pending_input_merged",
16
+ "reset",
17
+ "question_requested",
18
+ "question_answered",
19
+ "question_cancelled",
20
+ "recap_ready",
21
+ "prompt_restored",
22
+ "command_queued",
23
+ "command_ran",
24
+ "continue_offered",
25
+ "continue_resolved",
26
+ "reminder_injected",
27
+ "context_added",
28
+ "guardrail_warning",
29
+ "hook_notice",
30
+ "card",
31
+ "plugin_init_started",
32
+ "plugin_init_finished",
33
+ ]);
34
+
35
+ export function api(path, opts = {}) {
36
+ const { fetchImpl = globalThis.fetch } = opts;
37
+ return fetchImpl(path, {
38
+ headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
39
+ ...opts,
40
+ }).then(async (r) => {
41
+ const j = await r.json().catch(() => ({}));
42
+ if (!r.ok) {
43
+ const error = new Error(`${j.detail || j.error || r.statusText} (${r.status})`);
44
+ error.status = r.status;
45
+ throw error;
46
+ }
47
+ return j;
48
+ });
49
+ }
50
+
51
+ // opts.dir: the page's scope folder (scope.js); only that project's sessions.
52
+ export function listSessions(sort, order, { dir, ...opts } = {}) {
53
+ const scope = dir ? `&dir=${encodeURIComponent(dir)}` : "";
54
+ return api(
55
+ `/api/sessions?sort=${encodeURIComponent(sort)}&order=${encodeURIComponent(order)}${scope}`,
56
+ opts,
57
+ );
58
+ }
59
+
60
+ // opts.dir: the folder the chat starts in (else the server's cwd).
61
+ // dir: the folder the chat starts in; model: the model it starts on (as
62
+ // GET /api/models spells it); either left out means the server's default.
63
+ export function createSession(prompt, { dir, model, ...opts } = {}) {
64
+ const body = { prompt, ...(dir ? { dir } : {}), ...(model ? { model } : {}) };
65
+ return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
66
+ }
67
+
68
+ // A session with no first turn: a first message with images is sent into
69
+ // it once they are uploaded.
70
+ export function createIdleSession({ dir, model, ...opts } = {}) {
71
+ const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}) };
72
+ return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
73
+ }
74
+
75
+ // The models a new session can start on: {default, models: [{name, host, id}],
76
+ // warning?} (a host that failed is in the warning, the list has the rest).
77
+ export function listModels(opts = {}) {
78
+ return api("/api/models", opts);
79
+ }
80
+
81
+ // Upload one image (the raw file as the body); answers its ref.
82
+ export function uploadImage(id, file, name, opts = {}) {
83
+ return api(`/api/sessions/${id}/images?name=${encodeURIComponent(name || file?.name || "image")}`, {
84
+ method: "POST",
85
+ body: file,
86
+ headers: { "Content-Type": file?.type || "application/octet-stream" },
87
+ ...opts,
88
+ });
89
+ }
90
+
91
+ // opts.tail: the lighter answer for the end of a turn (the session, its
92
+ // timing and the last answer only), not the whole history.
93
+ // opts.parts: each saved message with what it did (thinking, tool calls
94
+ // and their output), for the turn view's reload.
95
+ export function getSession(id, { tail, parts, cards, ...opts } = {}) {
96
+ const query = cards ? "?cards=1" : tail ? "?tail=1" : parts ? "?parts=1" : "";
97
+ return api(`/api/sessions/${id}${query}`, opts);
98
+ }
99
+
100
+ // opts.clientId: this tab's id, so the events tell its prompts from others'.
101
+ // opts.images: refs ({file, name}) to images uploaded into the session.
102
+ export function sendTurn(id, prompt, { clientId, images, ...opts } = {}) {
103
+ const body = clientId ? { prompt, client_id: clientId } : { prompt };
104
+ if (images?.length) body.images = images;
105
+ return api(`/api/sessions/${id}/turn`, {
106
+ method: "POST",
107
+ body: JSON.stringify(body),
108
+ ...opts,
109
+ });
110
+ }
111
+
112
+ // A session command (/model, /models, !rollback, !cmd, /continue …) for the
113
+ // worker to run; its command_ran reaches every client.
114
+ export function sendCommand(id, line, { clientId, ...opts } = {}) {
115
+ return api(`/api/sessions/${id}/command`, {
116
+ method: "POST",
117
+ body: JSON.stringify(clientId ? { line, client_id: clientId } : { line }),
118
+ ...opts,
119
+ });
120
+ }
121
+
122
+ export function cancelTurn(id, opts = {}) {
123
+ return api(`/api/sessions/${id}/cancel`, {
124
+ method: "POST",
125
+ body: JSON.stringify({ reason: "user" }),
126
+ ...opts,
127
+ });
128
+ }
129
+
130
+ export function sendAnswer(id, answer, opts = {}) {
131
+ return api(`/api/sessions/${id}/answer`, {
132
+ method: "POST",
133
+ body: JSON.stringify(answer),
134
+ ...opts,
135
+ });
136
+ }
137
+
138
+ // Leave the pending question unanswered (the tool returns without one).
139
+ export function dismissQuestion(id, questionId, opts = {}) {
140
+ return api(`/api/sessions/${id}/question/dismiss`, {
141
+ method: "POST",
142
+ body: JSON.stringify({ id: questionId }),
143
+ ...opts,
144
+ });
145
+ }
146
+
147
+ export function stopSession(id, opts = {}) {
148
+ return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
149
+ }
150
+
151
+ // Deletes the session for good; the server stops a live worker first.
152
+ export function deleteSession(id, opts = {}) {
153
+ return api(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", ...opts });
154
+ }
155
+
156
+ // fromSeq: the snapshot's event id (`<seq>-<epoch>`: another worker answers it
157
+ // with a reset) or a plain event_seq.
158
+ export function openStream(id, fromSeq, handlers = {}, opts = {}) {
159
+ const { EventSourceImpl = globalThis.EventSource, onStreamError, onStreamClosed } = opts;
160
+ let cursor = "0";
161
+ if (Number.isInteger(fromSeq)) cursor = String(fromSeq);
162
+ else if (typeof fromSeq === "string" && fromSeq !== "") cursor = encodeURIComponent(fromSeq);
163
+ const es = new EventSourceImpl(`/api/sessions/${id}/stream?from_seq=${cursor}`);
164
+ let eventReceived = false;
165
+ for (const type of DEFAULT_EVENT_TYPES) {
166
+ const handler = handlers[type];
167
+ if (!handler) continue;
168
+ es.addEventListener(type, (raw) => {
169
+ eventReceived = true;
170
+ let data = {};
171
+ try {
172
+ data = raw.data ? JSON.parse(raw.data) : {};
173
+ } catch (_) {
174
+ data = { text: String(raw.data) };
175
+ }
176
+ handler(data, raw);
177
+ });
178
+ }
179
+ let live = true;
180
+ es.onerror = () => {
181
+ if (!eventReceived && live && typeof onStreamError === "function") {
182
+ live = false;
183
+ try { es.close(); } catch (_) {}
184
+ onStreamError();
185
+ } else if (eventReceived && live && typeof onStreamClosed === "function") {
186
+ // Don't let the browser reconnect on its own: while there is no
187
+ // worker the proxy answers 503 and the browser gives up (and the next
188
+ // worker would only answer the old Last-Event-ID with a reset). The
189
+ // caller re-reads the session instead.
190
+ live = false;
191
+ try { es.close(); } catch (_) {}
192
+ onStreamClosed();
193
+ }
194
+ };
195
+ return {
196
+ close() {
197
+ if (!live) return;
198
+ live = false;
199
+ try {
200
+ es.close();
201
+ } catch (_) {}
202
+ },
203
+ get live() {
204
+ return live;
205
+ },
206
+ get eventReceived() {
207
+ return eventReceived;
208
+ },
209
+ };
210
+ }
211
+
212
+ // The session list as a stream (GET /api/events): a `snapshot` frame first,
213
+ // then `session` (an upsert) and `session_gone` (a removal). onError fires
214
+ // only when the browser gave up before the first frame (a chi web without
215
+ // a hub answers 503, a bad ?dir 400): the caller falls back to fetching.
216
+ // Later errors are left to EventSource's own reconnect, and a reconnect
217
+ // always starts with a fresh snapshot.
218
+ // @return {{close(): void}}
219
+ export function openEvents(handlers = {}, opts = {}) {
220
+ const { EventSourceImpl = globalThis.EventSource, dir, onError } = opts;
221
+ const url = dir ? `/api/events?dir=${encodeURIComponent(dir)}` : "/api/events";
222
+ const es = new EventSourceImpl(url);
223
+ let frameReceived = false;
224
+ for (const type of ["snapshot", "session", "session_gone"]) {
225
+ const handler = handlers[type];
226
+ if (!handler) continue;
227
+ es.addEventListener(type, (raw) => {
228
+ frameReceived = true;
229
+ let data = {};
230
+ try {
231
+ data = raw.data ? JSON.parse(raw.data) : {};
232
+ } catch (_) {
233
+ data = {};
234
+ }
235
+ handler(data, raw);
236
+ });
237
+ }
238
+ let live = true;
239
+ es.onerror = () => {
240
+ // readyState 2 (CLOSED): the browser won't retry on its own.
241
+ if (frameReceived || !live || es.readyState !== 2) return;
242
+ live = false;
243
+ try { es.close(); } catch (_) {}
244
+ if (typeof onError === "function") onError();
245
+ };
246
+ return {
247
+ close() {
248
+ if (!live) return;
249
+ live = false;
250
+ try { es.close(); } catch (_) {}
251
+ },
252
+ get live() {
253
+ return live;
254
+ },
255
+ };
256
+ }
@@ -0,0 +1,232 @@
1
+ export const PREVIEW_CHARS = 40;
2
+ export const FIRST_PREVIEW_CHARS = 80;
3
+
4
+ export function escapeHtml(value) {
5
+ return String(value).replace(
6
+ /[&<>"]/g,
7
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]),
8
+ );
9
+ }
10
+
11
+ export function messageBodyHtml(message) {
12
+ if (message?.role === "assistant" && typeof message.html === "string") {
13
+ return { body: message.html, renderedMarkdown: true };
14
+ }
15
+ if (message?.role === "user") return { body: userBodyHtml(message.content), renderedMarkdown: false };
16
+ return { body: escapeHtml(message?.content), renderedMarkdown: false };
17
+ }
18
+
19
+ // A context note (background the user or another session pushed in; the
20
+ // model reads it, nobody asked it anything): who sent it, then its text.
21
+ export function noteHtml(note) {
22
+ const from = note?.label ? `note from ${note.label}` : "note";
23
+ return `<div class="note-line">${escapeHtml(from)}</div><div class="note-text">${escapeHtml(note?.content ?? "")}</div>`;
24
+ }
25
+
26
+ // A user message's body: runs of `>` lines become <blockquote>s, the rest
27
+ // stays escaped text. The bubble is pre-wrap, so the blank lines around a
28
+ // quote are dropped (the blockquote's margin spaces it instead).
29
+ export function userBodyHtml(content) {
30
+ const text = String(content ?? "").replace(/\r\n?/g, "\n");
31
+ const blocks = [];
32
+ for (const line of text.split("\n")) {
33
+ const m = /^ {0,3}> ?(.*)$/.exec(line);
34
+ const kind = m ? "quote" : "text";
35
+ const last = blocks[blocks.length - 1];
36
+ if (last?.kind === kind) last.lines.push(m ? m[1] : line);
37
+ else blocks.push({ kind, lines: [m ? m[1] : line] });
38
+ }
39
+ if (!blocks.some((b) => b.kind === "quote")) return escapeHtml(content ?? "");
40
+ return blocks
41
+ .map((b) => {
42
+ if (b.kind === "quote") return `<blockquote>${escapeHtml(b.lines.join("\n"))}</blockquote>`;
43
+ const body = b.lines.join("\n").replace(/^\s*\n/, "").replace(/\n\s*$/, "");
44
+ return body.trim() ? escapeHtml(body) : "";
45
+ })
46
+ .join("");
47
+ }
48
+
49
+ // The piece to append to a streamed body that holds +sofar+: while the body
50
+ // is empty its leading whitespace is dropped (a model's thinking starts with
51
+ // the "\n" after <think>, which would render as an empty first line).
52
+ export function leadTrimmed(sofar, piece) {
53
+ return sofar === "" ? String(piece || "").replace(/^[ \t\r\n]+/, "") : String(piece || "");
54
+ }
55
+
56
+ export function normalize(s) {
57
+ return String(s || "").replace(/\s+/g, " ").trim();
58
+ }
59
+
60
+ export function previewOf(lastPrompt) {
61
+ const normalized = normalize(lastPrompt);
62
+ if (!normalized) return "\u2014";
63
+ if (normalized.length > PREVIEW_CHARS) {
64
+ return `${normalized.slice(0, PREVIEW_CHARS)}\u2026`;
65
+ }
66
+ return normalized;
67
+ }
68
+
69
+ export function firstMessageOf(session, messages) {
70
+ // Prefer first user message content, else last_prompt / first_preview from server
71
+ if (Array.isArray(messages) && messages.length) {
72
+ const firstUser = messages.find((m) => m && (m.role === "user" || m.role === "assistant") && m.role === "user");
73
+ if (firstUser && firstUser.content) {
74
+ const n = normalize(firstUser.content);
75
+ if (n) return n.length > FIRST_PREVIEW_CHARS ? `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026` : n;
76
+ }
77
+ }
78
+ if (session) {
79
+ if (session.first_preview) {
80
+ const n = normalize(session.first_preview);
81
+ if (n) return n;
82
+ }
83
+ if (session.last_prompt) return previewOf(session.last_prompt);
84
+ // fallback to previewOf with first_preview logic length 80
85
+ const raw = session.preview || session.id || "";
86
+ const n = normalize(raw);
87
+ if (n) return n.length > FIRST_PREVIEW_CHARS ? `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026` : n;
88
+ }
89
+ return "\u2014";
90
+ }
91
+
92
+ export function previewForCard(session) {
93
+ // Unified card preview: first message (80) else previewOf(last_prompt)
94
+ const raw = session.first_preview || session.last_prompt || "";
95
+ const n = normalize(raw);
96
+ if (!n) return "\u2014";
97
+ if (n.length > FIRST_PREVIEW_CHARS) return `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026`;
98
+ return n;
99
+ }
100
+
101
+ const DELETE_PREVIEW_CHARS = 40;
102
+
103
+ // The confirm before deleting a session (the info bar's and a card's).
104
+ export function deleteConfirmText(session) {
105
+ const n = normalize(session.first_preview || session.last_prompt || "");
106
+ const preview = n.length > DELETE_PREVIEW_CHARS ? `${n.slice(0, DELETE_PREVIEW_CHARS)}\u2026` : n;
107
+ const shortId = session.short_id || String(session.id).slice(0, 8);
108
+ const lines = [
109
+ `Delete session ${shortId}${preview ? ` \u2014 "${preview}"` : ""}?`,
110
+ "Its history, notes and images are removed; this can't be undone.",
111
+ ];
112
+ if (session.owner === "worker") lines.push("Its worker is running and will be stopped first.");
113
+ return lines.join("\n");
114
+ }
115
+
116
+ // The badge on a session a process holds right now (the list's `owner`):
117
+ // a worker (shared: every UI can send), or a plain terminal chi (not shared).
118
+ export function ownerBadge(owner, id) {
119
+ if (owner === "worker") {
120
+ return { kind: "worker", text: "live", title: `A worker runs this session; a terminal joins with: chi --attach ${id}` };
121
+ }
122
+ if (owner === "tui") {
123
+ return { kind: "tui", text: "terminal", title: "A terminal chi owns this session and doesn't share it (chi --shared would)" };
124
+ }
125
+ return null;
126
+ }
127
+
128
+ // The chip on a delegated session (parent_id set): its parent's short id,
129
+ // with the parent's preview in the tooltip when the parent is known.
130
+ // @param parentId the session's parent_id
131
+ // @param parent the parent's summary from the list, if listed
132
+ export function delegatedBy(parentId, parent = null) {
133
+ if (!parentId) return null;
134
+ const short = String(parentId).slice(0, 8);
135
+ const what = parent ? previewForCard(parent) : "";
136
+ return { text: `\u21B3 ${short}`, title: `delegated by session ${short}${what ? `: ${what}` : ""}` };
137
+ }
138
+
139
+ // Whether the info bar offers "stop": only while a worker runs the session
140
+ // (the list says so, or this page streams from one a send just woke). A
141
+ // stopped or idle-exited session has nothing to stop, and a chi REPL's is
142
+ // refused by the server.
143
+ export function canStopSession({ owner, streaming }) {
144
+ if (owner === "tui") return false;
145
+ return owner === "worker" || !!streaming;
146
+ }
147
+
148
+ // The start page's line after the open session went away under the page
149
+ // (an unknown #/s/<id>, a delete in another tab). Plain text.
150
+ export function goneSessionNotice(id, why) {
151
+ return `Session ${String(id).slice(0, 8)} was ${why === "deleted" ? "deleted" : "not found"}.`;
152
+ }
153
+
154
+ // A session in the list after its live status changed (a turn started or
155
+ // ended): that is activity now, so its card's "N min ago" says so too, and
156
+ // the change came from a worker's stream, so the card says "live".
157
+ export function withLiveStatus(sess, status, now = Date.now()) {
158
+ const owner = sess.owner === "tui" ? "tui" : "worker";
159
+ return { ...sess, status, owner, updated_at: new Date(now).toISOString() };
160
+ }
161
+
162
+ // A session in the list after the page saw its worker go (the stream
163
+ // closed: killed, idle exit, stopped): no longer "live", and no turn runs.
164
+ // A terminal chi's session is left as is.
165
+ export function withoutWorker(sess) {
166
+ if (sess.owner !== "worker") return sess;
167
+ return { ...sess, owner: null, status: sess.status === "running" ? "idle" : sess.status };
168
+ }
169
+
170
+ // Where a saved recap goes in the history: before the first turn it doesn't
171
+ // cover, the +turnsSince+-th user bubble from the end; null (at the end) when
172
+ // it covers everything or the page shows fewer turns than that.
173
+ export function recapPlace(userBubbles, turnsSince) {
174
+ const n = Number(turnsSince) || 0;
175
+ if (n <= 0 || n > userBubbles.length) return null;
176
+ return userBubbles[userBubbles.length - n];
177
+ }
178
+
179
+ // The line a failed turn's bubble shows: a provider error's one-line
180
+ // summary, else the error's message and class.
181
+ export function failedTurnText(data) {
182
+ data = data || {};
183
+ const detail = data.summary || `${data.message || "error"}${data.error_class ? ` (${data.error_class})` : ""}`;
184
+ return `\u2715 turn failed: ${detail}`;
185
+ }
186
+
187
+ // Whether the server served another model than asked: names equal in any
188
+ // case, or one extending the other (":free" dropped, a date added), are the
189
+ // same model (ServedModel.differs? in Ruby).
190
+ export function servedModelDiffers(asked, served) {
191
+ const a = String(asked || "").trim().toLowerCase();
192
+ const s = String(served || "").trim().toLowerCase();
193
+ if (!a || !s) return false;
194
+ return !(a.startsWith(s) || s.startsWith(a));
195
+ }
196
+
197
+ // The session's model for the info bar: the served model with a marker when
198
+ // the server serves another one than +servedFor+ (the name asked).
199
+ export function modelLabel(model, served, servedFor) {
200
+ if (servedModelDiffers(servedFor, served)) {
201
+ return { text: `${served.slice(0, 40)} ⚠`, title: `served: ${served}; asked for ${model}`, mismatch: true };
202
+ }
203
+ const name = model || "";
204
+ return { text: name.slice(0, 40), title: name, mismatch: false };
205
+ }
206
+
207
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
208
+
209
+ // A card's "2 h ago": coarse on purpose, recomputed on each render (no
210
+ // ticking). A week or more back it is a short date; "" for no or bad input.
211
+ export function relativeTime(iso, now = Date.now()) {
212
+ const t = iso ? Date.parse(iso) : NaN;
213
+ if (Number.isNaN(t)) return "";
214
+ const secs = Math.max(0, (now - t) / 1000);
215
+ if (secs < 60) return "just now";
216
+ if (secs < 3600) return `${Math.floor(secs / 60)} min ago`;
217
+ if (secs < 86400) return `${Math.floor(secs / 3600)} h ago`;
218
+ if (secs < 2 * 86400) return "yesterday";
219
+ if (secs < 7 * 86400) return `${Math.floor(secs / 86400)} d ago`;
220
+ const d = new Date(t);
221
+ const date = `${d.getDate()} ${MONTHS[d.getMonth()]}`;
222
+ return d.getFullYear() === new Date(now).getFullYear() ? date : `${date} ${d.getFullYear()}`;
223
+ }
224
+
225
+ // The recap bubble's label: how many turns came after it (stale: a turn
226
+ // started while the page showed it, count unknown).
227
+ export function recapLabel(turnsSince, stale = false) {
228
+ const n = Number(turnsSince) || 0;
229
+ if (n === 1) return "recap · before the last turn";
230
+ if (n > 1) return `recap · before the last ${n} turns`;
231
+ return stale ? "recap · earlier" : "recap";
232
+ }
@@ -0,0 +1,78 @@
1
+ // The answer pop (D6): the promoted answer bubble is held at the live box's
2
+ // height (`.boxed` + an inline max-height, scrolled to its tail), then grows
3
+ // to its content while the content fades in, then becomes a plain bubble.
4
+ // One hold at a time: a new hold, and reset, finish the previous one first,
5
+ // and a safety timer un-boxes its own bubble whatever happens to the rest, so
6
+ // no bubble is left clipped for good.
7
+ //
8
+ // Timers, not transitionend: under reduced motion and in a hidden tab none
9
+ // fires. rAF only to follow the pop: a hidden tab has none, and finish()
10
+ // follows once more.
11
+ export const HOLD_MS = 1500;
12
+ export const REVEAL_MS = 400;
13
+ export const SAFETY_MS = HOLD_MS + REVEAL_MS + 1000;
14
+
15
+ export function createHold({ isNearBottom, follow, timers = globalThis }) {
16
+ // { bubble, timer, follow, revealing }
17
+ let held = null;
18
+
19
+ function unbox(bubble) {
20
+ bubble.classList.remove("boxed", "revealing");
21
+ bubble.removeAttribute("style");
22
+ }
23
+
24
+ // Hold +bubble+ at +maxHeight+ px until reveal() or HOLD_MS.
25
+ function start(bubble, maxHeight) {
26
+ finish();
27
+ bubble.classList.add("boxed");
28
+ bubble.style.maxHeight = `${maxHeight}px`;
29
+ bubble.scrollTop = bubble.scrollHeight;
30
+ held = {
31
+ bubble,
32
+ timer: timers.setTimeout(reveal, HOLD_MS),
33
+ follow: false,
34
+ };
35
+ timers.setTimeout(() => {
36
+ if (held?.bubble === bubble) finish();
37
+ else unbox(bubble);
38
+ }, SAFETY_MS);
39
+ }
40
+
41
+ // The pop: the held bubble grows to its content under the CSS transition;
42
+ // the class and the inline style go REVEAL_MS later (reload parity).
43
+ function reveal() {
44
+ if (!held || held.revealing) return;
45
+ timers.clearTimeout(held.timer);
46
+ const { bubble } = held;
47
+ held.revealing = true;
48
+ held.follow = isNearBottom();
49
+ bubble.scrollTop = 0;
50
+ bubble.style.maxHeight = `${bubble.scrollHeight}px`;
51
+ bubble.classList.add("revealing");
52
+ held.timer = timers.setTimeout(finish, REVEAL_MS);
53
+ // Following: the answer's end stays above the dock while it grows,
54
+ // frame by frame, not one jump at the end.
55
+ if (held.follow && timers.requestAnimationFrame) {
56
+ const pin = () => {
57
+ if (held?.bubble !== bubble) return;
58
+ follow();
59
+ timers.requestAnimationFrame(pin);
60
+ };
61
+ timers.requestAnimationFrame(pin);
62
+ }
63
+ }
64
+
65
+ // The held bubble as a plain answer bubble, at once.
66
+ function finish() {
67
+ if (!held) return;
68
+ // The safety timer stays: it un-boxes its bubble again, a no-op on a
69
+ // plain one.
70
+ timers.clearTimeout(held.timer);
71
+ const { bubble, follow: wasFollowing } = held;
72
+ held = null;
73
+ unbox(bubble);
74
+ if (wasFollowing) follow();
75
+ }
76
+
77
+ return { start, reveal, finish, get bubble() { return held?.bubble ?? null; } };
78
+ }
@@ -0,0 +1,77 @@
1
+ // Images in the web UI: pasted or dropped into the composer as chips, sent
2
+ // with a turn as refs (uploaded first), shown as thumbnails in the bubbles
3
+ // and tool rows, opened full size in a lightbox. No DOM here beyond what the
4
+ // callers pass, so it runs under `node --test`.
5
+ import { escapeHtml } from "./format.js";
6
+
7
+ // The image files a paste's or drop's DataTransfer carries.
8
+ export function imageFiles(dataTransfer) {
9
+ const files = [...(dataTransfer?.files || [])];
10
+ for (const item of dataTransfer?.items || []) {
11
+ if (item.kind !== "file") continue;
12
+ const file = item.getAsFile?.();
13
+ if (file && !files.includes(file)) files.push(file);
14
+ }
15
+ return files.filter((f) => /^image\//.test(f?.type || ""));
16
+ }
17
+
18
+ // A pasted screenshot is often called "image.png": number it so the chips
19
+ // (and what the model reads) tell them apart.
20
+ export function chipName(file, index) {
21
+ const name = String(file?.name || "").trim();
22
+ if (name && name !== "image.png") return name;
23
+ const ext = (String(file?.type || "").split("/")[1] || "png").replace("jpeg", "jpg");
24
+ return `pasted-${index + 1}.${ext}`;
25
+ }
26
+
27
+ // The text a turn sends: what was typed, or for images alone a line naming
28
+ // them (a turn always carries text).
29
+ export function turnText(prompt, chips) {
30
+ const text = String(prompt ?? "").trim();
31
+ if (text || !chips?.length) return text;
32
+ return chips.map((c) => `[image: ${c.name}]`).join(" ");
33
+ }
34
+
35
+ // Where the page loads a stored image from.
36
+ export function imageUrl(sessionId, ref) {
37
+ return `/api/sessions/${encodeURIComponent(sessionId)}/${String(ref?.file || "")
38
+ .split("/")
39
+ .map(encodeURIComponent)
40
+ .join("/")}`;
41
+ }
42
+
43
+ // Thumbnails for a bubble or a tool row; "" for none. `src` may be given
44
+ // (a blob: URL while the upload is on its way).
45
+ export function thumbsHtml(sessionId, images) {
46
+ const list = (images || []).filter((i) => i && (i.src || i.file));
47
+ if (!list.length) return "";
48
+ const thumbs = list.map((img) => {
49
+ const src = img.src || imageUrl(sessionId, img);
50
+ const size = img.width && img.height ? ` ${img.width}×${img.height}` : "";
51
+ const title = `${img.name || "image"}${size}`;
52
+ return `<img class="thumb" src="${escapeHtml(src)}" alt="${escapeHtml(title)}" title="${escapeHtml(title)}" loading="lazy">`;
53
+ });
54
+ return `<div class="thumbs">${thumbs.join("")}</div>`;
55
+ }
56
+
57
+ // The composer's chips: a thumbnail, the name and a remove button each.
58
+ export function chipsHtml(chips) {
59
+ return (chips || [])
60
+ .map(
61
+ (c, i) =>
62
+ `<span class="chip" data-index="${i}"><img src="${escapeHtml(c.src)}" alt=""><span class="chip-name">${escapeHtml(c.name)}</span>` +
63
+ `<button type="button" class="chip-remove" data-index="${i}" aria-label="remove ${escapeHtml(c.name)}">×</button></span>`,
64
+ )
65
+ .join("");
66
+ }
67
+
68
+ // The {file, name} refs a turn names.
69
+ export function turnRefs(chips) {
70
+ return (chips || []).filter((c) => c.ref).map((c) => ({ file: c.ref.file, name: c.name }));
71
+ }
72
+
73
+ // Chips again for a prompt the worker handed back (its images are stored
74
+ // already, so they need no upload).
75
+ export function restoredChips(sessionId, images) {
76
+ return (images || []).filter((i) => i && i.file).map((ref) => ({ name: ref.name || "image", ref, src: imageUrl(sessionId, ref) }));
77
+ }